feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.turbo/
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Signage
|
||||||
|
|
||||||
|
Ekklesia's visual programming, timeline, synchronization, and display-delivery module.
|
||||||
|
|
||||||
|
Signage decides **what is on a screen, when, and on which screens together**. It natively supports
|
||||||
|
static images; every other kind of content reaches a screen because another Ekklesia module
|
||||||
|
registered a content provider and contributed a live React renderer for it. Signage never learns what
|
||||||
|
an announcement, a Bible passage, or a trivia question is.
|
||||||
|
|
||||||
|
This repository is a standalone, independently versioned Ekklesia module package. It is not a fork of
|
||||||
|
Ekklesia and vendors none of its SDKs.
|
||||||
|
|
||||||
|
- Module id: `signage`
|
||||||
|
- Package: `@ekklesia/signage`
|
||||||
|
- Manifest version: 7
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Version 0.1.0 is feature-complete for its declared scope and unreleased. What exists today:
|
||||||
|
|
||||||
|
- The module package, manifest (v7), four capabilities, six permissions, ten operator settings, and
|
||||||
|
one logical storage bucket.
|
||||||
|
- Eleven tables and a single initial migration, declared non-destructive.
|
||||||
|
- Native image ingest: one image per streamed request, 25 MiB, content-addressed, with display and
|
||||||
|
thumbnail renditions and temporary object keys removed after any unsuccessful upload.
|
||||||
|
- The content provider contract and registry, a bounded and sanitized call path, and Signage's own
|
||||||
|
built-in image provider registered through that same capability.
|
||||||
|
- Timelines, provider source slots, pinned slots, channel groups, synchronized audience channels,
|
||||||
|
authoritative playback with absolute instants, activation and scheduling, and viewer
|
||||||
|
registration, assignment, heartbeat, and health.
|
||||||
|
- A management-UI artifact and a full-viewport display artifact, both built through the canonical
|
||||||
|
Ekklesia module UI pipeline and mounted through the platform's browser ABI.
|
||||||
|
- Two permanent scene layers with a crossfade that never fades the outgoing slide, provider
|
||||||
|
renderers mounted as browser contributions, the built-in "Content unavailable" slate, deliberate
|
||||||
|
blackout, and offline continuation from this display's own persisted state.
|
||||||
|
|
||||||
|
Nothing here is deployed, and no cutover from the legacy Announcements container has happened or is
|
||||||
|
planned by this repository. Video, audio, MQTT, Home Assistant, Companion, and a notification outbox
|
||||||
|
are out of scope; so is any content module — Signage ships with no announcements, scripture, or
|
||||||
|
trivia, and cannot render any until one is installed.
|
||||||
|
|
||||||
|
### Offline cold start is not yet possible
|
||||||
|
|
||||||
|
A display that is **already mounted** keeps playing with no network: its schedule, playback state,
|
||||||
|
provider payloads, and media are Signage's own, persisted by this module in IndexedDB and Cache
|
||||||
|
Storage. Core's service worker caches module *code*, not Signage domain data.
|
||||||
|
|
||||||
|
A display that is **restarted while Ekklesia is completely unreachable** cannot mount Signage at all.
|
||||||
|
Mounting requires an authenticated module descriptor, and Core does not yet cache one. True offline
|
||||||
|
cold start depends on a Core-provided authorized offline display descriptor or lease; until that
|
||||||
|
exists, this module does not claim it. See ADR 0006.
|
||||||
|
|
||||||
|
## Developing against Ekklesia
|
||||||
|
|
||||||
|
Signage is developed **inside the authoritative Ekklesia workspace**, checked out at the path it will
|
||||||
|
eventually be installed at:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <ekklesia> ekklesia
|
||||||
|
git clone <this repository> ekklesia/modules/signage
|
||||||
|
cd ekklesia
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
`modules/*` is an Ekklesia workspace glob, so the checkout is picked up with no registry edit,
|
||||||
|
no second copy of Ekklesia, and no vendored SDK. `@ekklesia/module-sdk`, `@ekklesia/module-ui-sdk`,
|
||||||
|
`@ekklesia/design-tokens`, `@ekklesia/ui`, and `@ekklesia/module-ui-build` all resolve through the
|
||||||
|
workspace as `workspace:*` dependencies.
|
||||||
|
|
||||||
|
Two consequences worth knowing:
|
||||||
|
|
||||||
|
- Ekklesia uses a shared workspace lockfile, so installing here writes an importer entry for
|
||||||
|
`modules/signage` into Ekklesia's `pnpm-lock.yaml`. That entry belongs to whoever maintains the
|
||||||
|
Ekklesia checkout; it is not committed from this repository.
|
||||||
|
- Signage requires the Ekklesia platform prerequisites for shared design tokens, module stylesheets,
|
||||||
|
browser contributions, bounded streaming request bodies, and display offline code
|
||||||
|
(ADRs 0039–0043). An Ekklesia checkout without them cannot build or validate this module.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
Run from the **Ekklesia repository root**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @ekklesia/signage build # tsc + the canonical module UI build
|
||||||
|
pnpm --filter @ekklesia/signage test # unit and browser-artifact tests
|
||||||
|
pnpm --filter @ekklesia/signage typecheck # node, browser, and browser-test projects
|
||||||
|
pnpm --filter @ekklesia/signage lint
|
||||||
|
|
||||||
|
pnpm boundaries # four-layer boundary enforcement
|
||||||
|
pnpm modules:validate # static manifest, entrypoint, and schema validation
|
||||||
|
pnpm migrations:validate # migration journal, policy, ordering, and checksums
|
||||||
|
pnpm verify # the full Docker-free gate
|
||||||
|
```
|
||||||
|
|
||||||
|
Signage's tests are its own: `pnpm test` here runs `vitest` against this package's `vitest.config.ts`,
|
||||||
|
which includes `.test.tsx` as well as `.test.ts`. The workspace-root config includes only `.test.ts`,
|
||||||
|
so a browser test added here without that config would be silently skipped.
|
||||||
|
|
||||||
|
Never run `pnpm migrations:apply` against a persistent database while developing this module. Signage
|
||||||
|
reads no environment variable, constructs no database client, configures no object storage, hardcodes
|
||||||
|
no Ekklesia URL, opens no socket of its own, and applies no migration: every one of those reaches it
|
||||||
|
through the host.
|
||||||
|
|
||||||
|
### Changing the schema
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @ekklesia/signage migration:generate # drizzle-kit, into drizzle/
|
||||||
|
```
|
||||||
|
|
||||||
|
`tests/schema.test.ts` pins the checksum of the generated SQL, so a regenerated migration fails the
|
||||||
|
suite until the pin is updated deliberately. `drizzle/meta/_ekklesia.json` declares the migration
|
||||||
|
non-destructive; `pnpm migrations:validate` enforces that.
|
||||||
|
|
||||||
|
### Verifying what only a browser can show
|
||||||
|
|
||||||
|
Automated DOM assertions cannot prove a crossfade, a letterboxed image, or a slate. Those are
|
||||||
|
verified by driving the **built** artifacts in a real Chromium against a stub host and reading the
|
||||||
|
frames: native fill, both letterboxed fits, a contributed renderer, a frozen mid-crossfade composite,
|
||||||
|
the slate, a blackout, the offline indicator, every management tab, and the narrow-screen gate. The
|
||||||
|
harness is deliberately not committed — it stubs a host and would rot into a second, wrong
|
||||||
|
description of the platform ABI.
|
||||||
|
|
||||||
|
## What Signage owns, and what providers own
|
||||||
|
|
||||||
|
| Signage owns | A content provider owns |
|
||||||
|
| --- | --- |
|
||||||
|
| Native image assets and their renditions | Its own domain content and tables |
|
||||||
|
| Saved timelines, repeating patterns, and slots | Which of its content is available now |
|
||||||
|
| Channel groups and synchronized audience channels | Selecting the next item from one of its sources |
|
||||||
|
| Slot timing and authoritative playback | Repeat and no-repeat policy |
|
||||||
|
| Timeline activation and scheduling | Audience-specific variants and fallback |
|
||||||
|
| Viewer registration, assignment, heartbeat, health | Its own live React presentation |
|
||||||
|
| Transitions, offline continuation, playback controls | Dynamic change within one slide |
|
||||||
|
| The management and display UI | Its own internal multi-stage behaviour |
|
||||||
|
|
||||||
|
## Licence and scope
|
||||||
|
|
||||||
|
Deployment, the module store, a theme editor, a mobile management UI, video and audio, MQTT, Home
|
||||||
|
Assistant, Companion, and any migration of legacy Announcements data are explicitly out of scope for
|
||||||
|
this repository.
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# 0001: Signage owns timing; a provider owns content
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-27
|
||||||
|
- Related: Ekklesia [ADR 0006](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia)
|
||||||
|
(versioned capabilities), [ADR 0041](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia)
|
||||||
|
(browser contributions)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Church screens show announcements, scripture, trivia, service times, giving prompts, and photographs.
|
||||||
|
The obvious design — one module that knows about all of them — is the design the legacy Announcements
|
||||||
|
container has, and it is why adding trivia to that product means changing that product.
|
||||||
|
|
||||||
|
Signage exists to make the opposite true: adding trivia to the screens means installing a Trivia
|
||||||
|
module, and Signage never changes.
|
||||||
|
|
||||||
|
That only works if the line between the two is drawn somewhere principled. Drawn in the wrong place,
|
||||||
|
Signage ends up with an `announcementId` column, or a provider ends up owning a timer and two
|
||||||
|
displays drift apart.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Signage owns everything about *when*. A provider owns everything about *what*.**
|
||||||
|
|
||||||
|
| Signage decides | A provider decides |
|
||||||
|
| --- | --- |
|
||||||
|
| Which slot is on screen, starting exactly when, until exactly when | Which of its items is next in a given selection scope |
|
||||||
|
| That a slot's turn has come and a selection must be made | Whether that item may repeat, and when its pool is exhausted |
|
||||||
|
| That every channel in a group advances together | What the item looks like, in its own React renderer |
|
||||||
|
| How long a slot lasts when nothing overrides it | Its own default duration, and its own availability window |
|
||||||
|
| That a resolved selection is persisted, revised, and replayed identically | What is inside the payload it hands back |
|
||||||
|
|
||||||
|
Concretely:
|
||||||
|
|
||||||
|
- Signage calls a provider; a provider never calls Signage's playback. A provider can *notify*
|
||||||
|
Signage that something changed (invalidation) but cannot start, stop, skip, extend, or reorder
|
||||||
|
anything.
|
||||||
|
- Signage supplies a **stable opaque selection-scope id** and asks for the next item. The provider
|
||||||
|
advances its own cursor within that scope, in its own tables. Signage stores no cursor of the
|
||||||
|
provider's, and the provider stores no timing of Signage's.
|
||||||
|
- Signage persists the *result* — item identity, revision, payload per audience, duration,
|
||||||
|
availability — so that the same slot renders identically for a viewer that joined ten seconds later,
|
||||||
|
survives a restart, and can be replayed by `previous` without asking the provider again.
|
||||||
|
- Signage has no column, field, route, or type naming an announcement, a passage, a question, a
|
||||||
|
campus, or a service. A payload is opaque JSON on the way through and opaque JSON at rest.
|
||||||
|
|
||||||
|
## The one exception, and why it is not one
|
||||||
|
|
||||||
|
Native images are Signage's own content: it stores them, processes them, and renders them. That looks
|
||||||
|
like an exception to the rule until you see how it is built — as **Signage's own built-in content
|
||||||
|
provider**, `signage:native-images`, implementing the same registration shape an external provider
|
||||||
|
implements, resolving through the same code path, and persisting into the same resolved-selection
|
||||||
|
table.
|
||||||
|
|
||||||
|
There is therefore exactly one selection pipeline, not two. A native image slot and a Trivia slot
|
||||||
|
differ in which provider answers, and in nothing else. Had native images been special-cased, every
|
||||||
|
later feature — history, offline continuation, audience variants, `previous` — would have had to be
|
||||||
|
written twice and would have drifted.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A provider module can be written, installed, versioned, and removed without Signage knowing it
|
||||||
|
exists. Signage never enumerates provider modules and declares no dependency on one.
|
||||||
|
- A church that never installs Trivia has a Signage that is not missing anything; a timeline simply
|
||||||
|
has no Trivia slot.
|
||||||
|
- Provider failure cannot corrupt playback, because playback state was never the provider's to hold.
|
||||||
|
What a failure costs is at most the freshness of one slot's content.
|
||||||
|
- Signage cannot offer a feature that requires understanding content — "show announcements sorted by
|
||||||
|
campus" is a Trivia-shaped request that belongs to whichever module owns campuses.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **One module that knows every content type.** Rejected: it is the legacy design, and every new kind
|
||||||
|
of content is a change to the module every screen depends on.
|
||||||
|
- **Providers push content into Signage's tables.** Rejected: two writers to one timeline, no way to
|
||||||
|
attribute a bad row, and a provider outage that leaves stale rows nobody owns.
|
||||||
|
- **Providers own their own timing and Signage only composes screens.** Rejected outright: two
|
||||||
|
independent clocks cannot hold two televisions on the same slide, which is the single thing a
|
||||||
|
synchronized channel group exists to guarantee.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# 0002: The content-provider contract, and what it deliberately cannot express
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-27
|
||||||
|
- Related: [0001](0001-signage-owns-timing-providers-own-content.md),
|
||||||
|
[0003](0003-timelines-slots-and-selection-scopes.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`signage.content-providers` is the seam every future content module reaches Signage through. It is
|
||||||
|
the one contract in this repository that will have implementations Signage's authors never see, so
|
||||||
|
its shape has to be right before there is a second implementation rather than after.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
A provider module resolves the capability during its own `start` and registers once. Registration
|
||||||
|
returns a handle; the handle is released during `stop`. Signage keeps registrations **in memory
|
||||||
|
only** — a registration is a running fact, not a stored one, so a provider that is uninstalled leaves
|
||||||
|
no row behind claiming it exists.
|
||||||
|
|
||||||
|
### What a registration carries
|
||||||
|
|
||||||
|
- `providerId`, `providerVersion` — stable identity, chosen by the provider.
|
||||||
|
- `contractVersion` — which version of *this* contract the provider was written against. Signage
|
||||||
|
refuses a registration outside its supported range rather than guessing.
|
||||||
|
- `renderer: { contributionId, contributionVersion }` — the browser renderer this provider's content
|
||||||
|
is drawn by. Signage never imports it; it hands the identity to its own display, which asks the
|
||||||
|
host to mount it (ADR 0041). A provider whose renderer the platform did not publish is registered
|
||||||
|
and reported as unrenderable rather than refused, because publication is the platform's decision
|
||||||
|
and can change without the provider changing.
|
||||||
|
- `defaultDurationMs` — how long this provider's content is shown when no slot overrides it.
|
||||||
|
- `listSources(query)` — the sources an operator may put in a timeline. Management discovery only.
|
||||||
|
- `listItems(query)` — optional. Specific items, so an operator can pin one.
|
||||||
|
- `resolveNext(request)` — **the only call that advances anything.**
|
||||||
|
- `refresh(request)` — re-resolve one already-selected item without advancing.
|
||||||
|
|
||||||
|
### What Signage supplies, and nothing more
|
||||||
|
|
||||||
|
```text
|
||||||
|
selectionScopeId stable, opaque, and the same string every time this source occurs in this timeline
|
||||||
|
sourceId which of the provider's own sources is being asked
|
||||||
|
timelineId which timeline is running
|
||||||
|
channelGroupId which synchronized group is asking
|
||||||
|
audienceKeys every audience channel in that group, in a stable order
|
||||||
|
at the instant being resolved for, as an absolute ISO 8601 instant
|
||||||
|
timezone the installation's IANA zone, for a provider whose availability is calendar-shaped
|
||||||
|
```
|
||||||
|
|
||||||
|
Signage does not supply a principal, a session, a database, storage, a URL, or a permission. A
|
||||||
|
provider is already running inside the same trusted process; what it needs from Signage is *when*,
|
||||||
|
and that is the whole list.
|
||||||
|
|
||||||
|
### What a provider returns
|
||||||
|
|
||||||
|
One **logical** selection — never one per channel:
|
||||||
|
|
||||||
|
```text
|
||||||
|
itemId the provider's own stable identity for the content
|
||||||
|
itemRevision changes whenever the payload changes and the item stays the same thing
|
||||||
|
durationMs? overrides the provider's default for this one item
|
||||||
|
availableUntil? when this selection stops being valid, as an absolute instant
|
||||||
|
withdrawn? this item is explicitly gone; remove it at the next safe boundary
|
||||||
|
presentation? opaque presentation values the provider itself supplies to its own renderer
|
||||||
|
variants one payload per audience key, plus one default
|
||||||
|
```
|
||||||
|
|
||||||
|
The default variant is required. An audience-specific variant is optional, and a provider that has
|
||||||
|
none simply returns its default — which is what makes "in-person" and "online" a provider feature
|
||||||
|
rather than a Signage one.
|
||||||
|
|
||||||
|
### Three distinct failure answers, deliberately
|
||||||
|
|
||||||
|
| The provider says | Signage does |
|
||||||
|
| --- | --- |
|
||||||
|
| Returns `null` | Nothing is available for this source right now. **Skip the slot immediately.** No error, no slate, no gap. |
|
||||||
|
| Returns `{ withdrawn: true }` | This item is explicitly gone. Drop it at the next safe reconciliation boundary. |
|
||||||
|
| Throws, rejects, or exceeds its deadline | A temporary failure. **Keep the last known good selection, mark it stale**, and never reset the slot. |
|
||||||
|
|
||||||
|
Collapsing these into one would be the whole design's biggest mistake: an empty Trivia pool would
|
||||||
|
show an error, and a provider restart would clear the screen.
|
||||||
|
|
||||||
|
### Bounded, and sanitized
|
||||||
|
|
||||||
|
Every provider call is wrapped: a deadline (`providerTimeoutMilliseconds`), a `try`/`catch`, and a
|
||||||
|
result validated before it is believed. A malformed result is rejected with a stable
|
||||||
|
`SIGNAGE_PROVIDER_*` code carrying no provider value, no payload, and no caught exception. A provider
|
||||||
|
cannot crash the Signage runtime, corrupt playback, or put its own words on a screen or in a log.
|
||||||
|
|
||||||
|
### Invalidation is a notification, not a command
|
||||||
|
|
||||||
|
`handle.invalidate({ ... })` tells Signage that something it already resolved may have changed.
|
||||||
|
Signage decides what to do: refresh the affected selections, bump their revision, and broadcast. The
|
||||||
|
provider cannot say *when*, cannot skip a slot, and cannot extend a deadline. This is what lets a
|
||||||
|
cancelled event update a mounted React renderer within a second while its slot's authoritative
|
||||||
|
deadline stays exactly where it was.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A provider is a small object of pure functions over its own data. It needs no timer, no socket, no
|
||||||
|
knowledge of channels, and no Signage table.
|
||||||
|
- Signage's provider code is the same for every provider, including its own built-in image provider.
|
||||||
|
- A provider written against contract v1 keeps working when v2 exists, because v2 will be a new
|
||||||
|
version a provider opts into by declaring it.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Let providers write resolved selections directly.** Rejected: two writers, no attribution, and
|
||||||
|
stale rows after an uninstall.
|
||||||
|
- **One `resolve(at)` call with no scope.** Rejected: five Announcement slots in one pattern must
|
||||||
|
share one cursor, and without a scope id a provider cannot tell "the next one" from "the first one".
|
||||||
|
- **Per-channel resolution.** Rejected: it makes audience variants a race. One logical selection
|
||||||
|
resolved once, with variants derived from it, is what keeps two channels on the same slide.
|
||||||
|
- **An error means "skip".** Rejected: a provider that is briefly down would silently rewrite the
|
||||||
|
running order, and nobody would ever find out.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# 0003: Timelines are ordered patterns; a source instance is a selection scope
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-27
|
||||||
|
- Related: [0002](0002-content-provider-contract.md), [0004](0004-channel-groups-and-one-clock.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
An operator building a Sunday-morning loop wants something like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Announcements Announcements Announcements Trivia Announcements Announcements Scripture ↻
|
||||||
|
```
|
||||||
|
|
||||||
|
Five Announcement slots. They must not all show the same announcement, and they must not each start a
|
||||||
|
separate rotation that happens to begin at the same place. They are five *turns* of one rotation.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Three concepts, and no more.
|
||||||
|
|
||||||
|
### A timeline is an ordered, repeating pattern
|
||||||
|
|
||||||
|
`signage_timelines` holds a name, an optional eligibility window, and a revision. It holds no cursor,
|
||||||
|
no clock, and no channel: a timeline is a *program*, and running one is what a channel group does.
|
||||||
|
The same timeline may be activated on two groups at once and they will not interfere.
|
||||||
|
|
||||||
|
### A slot is a position in that pattern
|
||||||
|
|
||||||
|
`signage_timeline_slots` rows are `(timeline_id, position)` with `position` **unique and contiguous**
|
||||||
|
from zero. Contiguity is enforced inside the transaction that changes it, never by renumbering on
|
||||||
|
read: a gap would make "the next slot" ambiguous exactly when playback needs it least.
|
||||||
|
|
||||||
|
A slot's identity is its `id`, which is stable across every reorder. Playback history, resolved
|
||||||
|
selections, and a display's cursor all reference the id, so dragging a slot up the list never
|
||||||
|
rewrites what already happened.
|
||||||
|
|
||||||
|
Each slot is one of two kinds:
|
||||||
|
|
||||||
|
- **`provider-source`** — "ask this source for the next item when this slot's turn comes".
|
||||||
|
- **`pinned-item`** — "show exactly this item". A native Signage image is a pinned item of the
|
||||||
|
built-in image provider, which is why the two need no separate code path (ADR 0001).
|
||||||
|
|
||||||
|
Every slot may carry an `enabled` flag, a duration override, its own eligibility window, and — for
|
||||||
|
native images only — presentation overrides.
|
||||||
|
|
||||||
|
### A source instance is a selection scope
|
||||||
|
|
||||||
|
`signage_provider_sources` is the piece that makes the five-Announcement pattern work. One row is
|
||||||
|
"the Announcements module's `active-slides` source, as used by this timeline", and it carries a stable
|
||||||
|
opaque `selection_scope` string.
|
||||||
|
|
||||||
|
Every slot referencing that row passes the **same** scope id to `resolveNext`. The provider advances
|
||||||
|
its own cursor within that scope, so the five occurrences walk through five different announcements
|
||||||
|
and the sixth wraps according to *the provider's* repeat policy — which Signage neither implements
|
||||||
|
nor knows about.
|
||||||
|
|
||||||
|
Two source rows for the same provider source are two independent rotations, which is exactly how an
|
||||||
|
operator expresses "a fast loop here and a slow one there".
|
||||||
|
|
||||||
|
Signage generates the scope id and stores it. It is opaque to the provider in the sense that its
|
||||||
|
shape means nothing; it is stable in the sense that it survives edits, restarts, and reorders. It is
|
||||||
|
**not** derived from the timeline id or the slot id, because either would silently reset a
|
||||||
|
provider's rotation when an operator renamed or reordered something.
|
||||||
|
|
||||||
|
### Eligibility is `[start, end)`, in absolute instants
|
||||||
|
|
||||||
|
Both a timeline and a slot may carry a window. It is half-open: active exactly at `start`, inactive
|
||||||
|
exactly at `end`, which is the only convention under which two adjacent windows neither overlap nor
|
||||||
|
leave a gap.
|
||||||
|
|
||||||
|
Every stored instant is absolute (`timestamptz`). The installation's IANA zone is an operator setting
|
||||||
|
used to *write and read* those instants for a human, never to interpret an ambiguous local timestamp
|
||||||
|
at run time. A daylight-saving transition therefore cannot move a slot.
|
||||||
|
|
||||||
|
Signage decides whether a source *participates*; the provider decides what is *available*. Leaving
|
||||||
|
Trivia out of a service means using a timeline without a Trivia slot, or disabling that slot — and
|
||||||
|
Signage still never learns what Trivia is.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Reordering, renaming, duplicating, and disabling are cheap and safe: none of them touch a scope id
|
||||||
|
or a selection's identity.
|
||||||
|
- A timeline with every slot disabled, or every slot ineligible, is a legitimate state — it resolves
|
||||||
|
to nothing, and the display shows its slate rather than an error.
|
||||||
|
- An operator can express "three announcements, then trivia, then two more announcements" without any
|
||||||
|
concept of grouping, nesting, or weighting. The pattern *is* the expression.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Slot position as identity.** Rejected: every reorder would rewrite history and break a display's
|
||||||
|
cursor mid-service.
|
||||||
|
- **Scope derived from `(timeline, provider, source)`.** Rejected: it silently merges two source rows
|
||||||
|
an operator deliberately created as separate rotations, and changes meaning when a timeline is
|
||||||
|
duplicated.
|
||||||
|
- **A `repeat: n` count on one slot instead of repeated slots.** Rejected: the pattern is the thing an
|
||||||
|
operator reads, and a count hides where in the loop the repeats actually fall.
|
||||||
|
- **Local wall-clock schedules.** Rejected: one ambiguous hour every autumn, resolved silently, on the
|
||||||
|
screens a congregation is looking at.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 0004: A channel group is one clock; an audience channel is one rendering of it
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-27
|
||||||
|
- Related: [0002](0002-content-provider-contract.md), [0005](0005-authoritative-playback.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A church wants the lobby screens, the sanctuary side screens, and the online stream overlay to be on
|
||||||
|
the same slide at the same moment — but the online audience should see "watching from home" wording
|
||||||
|
where the room sees "welcome in". That is one program with two renderings, not two programs.
|
||||||
|
|
||||||
|
It also wants the nursery hallway to run a completely different loop. That *is* two programs.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**A channel group owns exactly one authoritative timeline clock.** It has one active timeline, one
|
||||||
|
current slot, one start instant, one deadline, one pause state, one blackout state, and one pending
|
||||||
|
activation. Everything a group's channels do, they do together.
|
||||||
|
|
||||||
|
**A channel is an audience within a group.** Each has a `key` (`default`, `in-person`, `online`, …),
|
||||||
|
and exactly one channel per group is the default. Channels differ in exactly one way: which variant of
|
||||||
|
a resolved selection they render.
|
||||||
|
|
||||||
|
- Signage resolves **one logical selection per slot occurrence**, for the whole group.
|
||||||
|
- The provider is asked once, with every audience key the group has.
|
||||||
|
- Each channel renders the variant matching its key, or the default variant if the provider offered
|
||||||
|
none.
|
||||||
|
|
||||||
|
A truly independent clock requires another channel group. There is no per-channel pause, no
|
||||||
|
per-channel skip, and no per-channel timeline — and no route that could express one.
|
||||||
|
|
||||||
|
**One default channel is created automatically**, and no other. `in-person` and `online` are examples
|
||||||
|
in a document, not rows in a migration: a church that never needs a second audience never sees one.
|
||||||
|
|
||||||
|
**A viewer never chooses its channel.** A display registers with its persistent viewer identity and
|
||||||
|
joins the default channel of the default group; an administrator moves it from the management
|
||||||
|
dashboard. A public display that could pick its own channel would let anyone who reaches the page
|
||||||
|
select what a screen shows.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Two viewers that connected minutes apart render the same slot with the same deadline, because both
|
||||||
|
read the same persisted absolute instants rather than counting from when they arrived.
|
||||||
|
- Adding an audience is additive: the provider starts returning a second variant, and the default
|
||||||
|
channel is unaffected.
|
||||||
|
- A provider that returns no variant for `online` is not a failure and produces no warning. Returning
|
||||||
|
the default is the contract.
|
||||||
|
- "Pause the lobby but not the sanctuary" is impossible by construction, and correctly so — those are
|
||||||
|
two groups.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **One clock per channel, synchronized by convention.** Rejected: two clocks drift, and no amount of
|
||||||
|
correction makes two televisions in one room agree.
|
||||||
|
- **Per-channel timelines with a shared cursor.** Rejected: the cursor is only meaningful against one
|
||||||
|
pattern; two patterns of different length make "the same slot" undefined.
|
||||||
|
- **Let the display pick its audience from a query parameter.** Rejected: it is an unauthenticated
|
||||||
|
content selector on a screen in a public hallway.
|
||||||
|
- **Create `in-person` and `online` channels on install.** Rejected: it presumes a broadcast ministry
|
||||||
|
every church does not have, and an unused channel is a thing an operator has to understand and
|
||||||
|
ignore forever.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# 0005: Playback is absolute instants reconciled forward, never a countdown
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-27
|
||||||
|
- Related: [0004](0004-channel-groups-and-one-clock.md),
|
||||||
|
[0006](0006-display-rendering-and-offline-continuation.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The proven behaviour this preserves comes from the legacy Announcements `v2` playback engine, which
|
||||||
|
ran unattended church screens through outages, restarts, and mid-service edits. Its three hard-won
|
||||||
|
properties are worth restating, because they are the ones a naive implementation loses:
|
||||||
|
|
||||||
|
1. **Absolute instants, not remaining time.** A slide has a `startedAt` and a `deadlineAt`; a timer is
|
||||||
|
only ever a hint about when to look again.
|
||||||
|
2. **Reconcile forward from the previous authoritative deadline**, never from "now". Otherwise a
|
||||||
|
delayed callback shortens nothing and lengthens everything, and drift accumulates all day.
|
||||||
|
3. **Catch up arithmetically.** A screen that was off for a week must not replay a week of slides.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### One serialized service per channel group
|
||||||
|
|
||||||
|
Each group has exactly one playback service owning its persisted state and its timers. Every mutation
|
||||||
|
— a timer firing, an operator command, a provider invalidation — runs serialized against that group,
|
||||||
|
inside one explicit transaction, and produces at most one revision increment. Two concurrent
|
||||||
|
commands cannot interleave into a state neither asked for.
|
||||||
|
|
||||||
|
`revision` increases **only on semantic change**. An exact no-op — resuming something already
|
||||||
|
running, activating the timeline already active — commits nothing and broadcasts nothing.
|
||||||
|
|
||||||
|
### Reconciliation, not simulation
|
||||||
|
|
||||||
|
`reconcile(group, at)` is the single entry point. It reads persisted state and walks it forward to
|
||||||
|
`at`:
|
||||||
|
|
||||||
|
- If the current slot's deadline is still in the future, **nothing happens**. This is what makes
|
||||||
|
restart recovery show the same item it was showing, rather than choosing a new one.
|
||||||
|
- Otherwise the boundary is the *previous deadline*, not `at`. The next slot starts exactly when the
|
||||||
|
last one ended, so a callback that fired 40 ms late costs 40 ms once instead of 40 ms per slide
|
||||||
|
forever.
|
||||||
|
- Eligibility and expiration boundaries are honoured at their exact instants, and a currently showing
|
||||||
|
slot can only be cut short by *its own* window, never by another slot's.
|
||||||
|
|
||||||
|
### Catch-up is bounded by boundaries, not by missed slides
|
||||||
|
|
||||||
|
When the gap is long, Signage advances the slot cursor **arithmetically**: it computes the effective
|
||||||
|
duration of each enabled, eligible slot from what it already knows — the slot override, or the
|
||||||
|
provider's registered default, or the native-image default — sums one pass of the pattern, and skips
|
||||||
|
whole passes with a division. Only the slot it actually lands on is resolved.
|
||||||
|
|
||||||
|
That is the property that keeps a provider's rotation sane: catching up a week does **not** call
|
||||||
|
`resolveNext` ten thousand times and does not consume a provider's no-repeat pool. Fast-forwarding
|
||||||
|
stops at the first eligibility boundary in the interval, exactly as `v2` did, so a schedule change
|
||||||
|
inside the outage is still honoured.
|
||||||
|
|
||||||
|
### Transport
|
||||||
|
|
||||||
|
| Control | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| Pause | Freeze the current slot. Persist the remainder; clear start and deadline. |
|
||||||
|
| Resume | `deadline = now + remainder`; `start = deadline − duration`, so the reconstructed timeline stays consistent. |
|
||||||
|
| Static hold | Show one native image while **preserving the normal cursor and remainder** underneath. Releasing it resumes from that remainder. |
|
||||||
|
| Next / Previous | Move over **actually resolved playback**: `previous` re-shows the previous resolved selection from history, not "the slot before this one". |
|
||||||
|
| Blackout | Cover the output with black. The hidden timeline keeps running, keeps its deadlines, and keeps resolving. |
|
||||||
|
| Reload | Refresh provider availability and re-resolve upcoming selections **without disturbing the current frame or its deadline**. |
|
||||||
|
| Activate now / at next boundary | Immediate, or queued as `pending` and applied at the next safe slot boundary. |
|
||||||
|
|
||||||
|
A provider's internal React behaviour may change anything it likes during a slot; it may not extend
|
||||||
|
that slot's authoritative deadline. The deadline was set by Signage before the renderer mounted.
|
||||||
|
|
||||||
|
### Realtime carries revisions, not content
|
||||||
|
|
||||||
|
Signage publishes compact authoritative state — group id, revision, slot identity, absolute
|
||||||
|
start/deadline, transport flags — and nothing large. Timelines, histories, and renderer payloads are
|
||||||
|
fetched over authorized HTTP, keyed by revision, so a client that sees a revision it does not have
|
||||||
|
refetches rather than waiting. That closes the GET-then-subscribe race without a second transport,
|
||||||
|
and keeps every publication far inside the platform's payload ceiling.
|
||||||
|
|
||||||
|
Signage introduces no socket, no broker, and no second realtime transport. It publishes through the
|
||||||
|
platform's module realtime contract and nothing else.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Server authority is unambiguous: on reconnect, the server's state wins immediately, and a client's
|
||||||
|
local cursor is discarded rather than reconciled.
|
||||||
|
- Timers are never load-bearing. Losing one costs a late transition, not a wrong one.
|
||||||
|
- Two displays that connected minutes apart agree, because they both read the same absolute instants.
|
||||||
|
- A long outage costs one reconciliation, not one per missed slide.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **A countdown per slide.** Rejected: it drifts, and it cannot be restored after a restart.
|
||||||
|
- **Reconcile from `now`.** Rejected: every late callback permanently lengthens the loop.
|
||||||
|
- **Replay every missed slide to catch up.** Rejected: it burns a provider's rotation and can take
|
||||||
|
longer than the outage.
|
||||||
|
- **Publish full state over realtime.** Rejected: a large timeline exceeds the payload ceiling, and a
|
||||||
|
screen that missed one message would be wrong until the next one.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# 0006: Two permanent layers, real readiness, and an offline continuation that never becomes authority
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-27
|
||||||
|
- Related: [0005](0005-authoritative-playback.md), Ekklesia
|
||||||
|
[ADR 0043](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (display offline code)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The legacy `v1` renderer rebuilt image elements per slide and faded the outgoing frame to
|
||||||
|
transparent, which let the black page background show through the middle of every transition. `v2`
|
||||||
|
fixed it with two permanent layers and never fading the outgoing one, and that fix is the single most
|
||||||
|
visible quality difference on a real television.
|
||||||
|
|
||||||
|
Signage additionally has to transition between things that are not images at all: a provider's live
|
||||||
|
React renderer, mounted through the platform's contribution ABI.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Two permanent scene layers, and one direction of fade
|
||||||
|
|
||||||
|
The display owns exactly two full-screen layers, created once and never rebuilt. For every
|
||||||
|
transition:
|
||||||
|
|
||||||
|
1. Resolve and mount the incoming content in the **hidden** layer — a native image, or a contributed
|
||||||
|
renderer mounted through `context.contributions.mount(...)`.
|
||||||
|
2. Wait for **actual readiness**: `img.decode()` for an image, and the renderer's own `mount`
|
||||||
|
resolution for a contribution, which the contribution ABI defines as asynchronous precisely so a
|
||||||
|
renderer can say "not yet".
|
||||||
|
3. Discard completion belonging to a stale generation. A newer transition always wins.
|
||||||
|
4. Keep the outgoing layer **fully opaque** the entire time.
|
||||||
|
5. Raise the prepared incoming layer above it at opacity zero.
|
||||||
|
6. Fade **only the incoming layer** in.
|
||||||
|
7. Unmount and release the old layer after the fade completes.
|
||||||
|
|
||||||
|
Both layers are never faded at once, so the black root can never contribute to the composite.
|
||||||
|
|
||||||
|
### Failure keeps the last good frame
|
||||||
|
|
||||||
|
If incoming content cannot be decoded or mounted: keep the frame that is already on screen, record a
|
||||||
|
bounded structured error, mark **that selection** locally unusable, and continue. A broken item is
|
||||||
|
never retried in a tight loop, and one broken item never blanks a screen.
|
||||||
|
|
||||||
|
An individually empty provider slot is different, and is skipped immediately with no error at all —
|
||||||
|
that is the provider saying "nothing right now", which is ordinary.
|
||||||
|
|
||||||
|
Only when there is neither renderable timeline content nor a cached good frame does the display show
|
||||||
|
its built-in slate: restrained, non-flashing bars and the words *Content unavailable*, carrying no
|
||||||
|
exception, URL, payload, credential, database detail, or identifier. Detail belongs in Signage's
|
||||||
|
sanitized management telemetry, where an already-authorized operator can read it.
|
||||||
|
|
||||||
|
A deliberate blackout is not a failure and never reaches the slate: it is pure black.
|
||||||
|
|
||||||
|
### The management preview is the same code
|
||||||
|
|
||||||
|
The Dashboard's live preview mounts the identical renderer host, the identical native-image
|
||||||
|
presentation resolver, and the identical layer transition. It differs in exactly two ways: it does not
|
||||||
|
register a viewer, and it sends no heartbeat. A preview that reimplemented the transition would drift
|
||||||
|
from what the wall actually shows, which makes it worse than no preview.
|
||||||
|
|
||||||
|
### Offline continuation, and its honest limit
|
||||||
|
|
||||||
|
Once mounted, a display survives losing the host. Signage persists its own domain state — viewer
|
||||||
|
identity, the last authoritative snapshot, resolved selections and revisions, audience payloads, the
|
||||||
|
timing anchor, transport state, renderer contribution identity, and the most recent safe error — in
|
||||||
|
**IndexedDB**, and native image bytes in **Cache Storage**. Ordinary HTTP cache is not an asset
|
||||||
|
manager and is not used as one.
|
||||||
|
|
||||||
|
While offline the display continues deterministically from the last authoritative anchor, applying
|
||||||
|
cached scheduling and expiration, preserving pause, blackout, static hold, and remaining duration,
|
||||||
|
skipping explicitly expired or withdrawn content, keeping the last good frame when expected content
|
||||||
|
is unusable, and never inventing provider data. Catch-up is arithmetic, mirroring the server's own
|
||||||
|
rule, so a long outage does not replay linearly.
|
||||||
|
|
||||||
|
**A local cursor is never reported back as authority.** On reconnect the server's state wins
|
||||||
|
immediately; incoming artifacts and data are staged before the current frame is replaced, no missed
|
||||||
|
slides are replayed, and superseded payloads and image bytes are pruned only after the new snapshot
|
||||||
|
is usable.
|
||||||
|
|
||||||
|
### The limit this cannot fix, stated rather than papered over
|
||||||
|
|
||||||
|
**A cold browser start while Ekklesia is unreachable cannot mount Signage at all.** The platform's
|
||||||
|
display service worker caches the shell and the declared code, deliberately not the display
|
||||||
|
descriptor — a descriptor is authorization-bearing, and caching one would let a revoked screen keep
|
||||||
|
showing content. Signage cannot and must not work around that: everything above applies to a display
|
||||||
|
that is **already mounted** when its network fails, which is the overwhelmingly common case (a router
|
||||||
|
reboot, an uplink drop, a switch power-cycle).
|
||||||
|
|
||||||
|
True offline cold start requires something only Core can provide: an authorized offline display
|
||||||
|
descriptor or lease with its own expiry and revocation. That is tracked as an Ekklesia prerequisite,
|
||||||
|
not as Signage work, and this repository does not claim the capability it does not have.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Transitions are visually identical for images and for live React renderers, because readiness is
|
||||||
|
awaited rather than assumed for both.
|
||||||
|
- A provider renderer that throws costs one slide, not the screen.
|
||||||
|
- The preview and the wall cannot disagree.
|
||||||
|
- A screen that reboots during an outage shows Ekklesia's own offline state and recovers by itself
|
||||||
|
when connectivity returns, but shows no Signage content until it can resolve its descriptor.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Crossfade both layers.** Rejected: it is the `v1` bug, and it is visible on every transition.
|
||||||
|
- **Treat `onload`, or a fixed delay, as readiness.** Rejected: a large image paints mid-fade, and a
|
||||||
|
renderer that is still fetching a font shows a flash of the wrong thing.
|
||||||
|
- **Cache the display descriptor so a cold start works offline.** Rejected: it is a stored permission
|
||||||
|
decision, and a revoked screen would keep showing content. This is the platform's rule, and Signage
|
||||||
|
respecting it is the point.
|
||||||
|
- **Use the HTTP cache for image bytes.** Rejected: no eviction control, no way to prune superseded
|
||||||
|
renditions deliberately, and no way to know what is actually available offline.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# 0007: A native image is content-addressed, archived once, and never baked
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-27
|
||||||
|
- Related: [0001](0001-signage-owns-timing-providers-own-content.md), Ekklesia
|
||||||
|
[ADR 0030](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (host-provided storage),
|
||||||
|
[ADR 0042](https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia) (streaming bodies)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Native images are the only content Signage owns, and the one thing it must do well with no other
|
||||||
|
module installed. A church's actual usage is an operator dragging twenty exported slides onto a page,
|
||||||
|
some of them the same file they uploaded last week.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Identity is the content hash
|
||||||
|
|
||||||
|
An asset's identity is the **SHA-256 of the uploaded bytes**, and the database enforces it as unique.
|
||||||
|
Uploading the same bytes twice returns the existing asset instead of storing a second copy, and two
|
||||||
|
operators uploading the same file — even simultaneously — produce one asset and one set of stored
|
||||||
|
objects: the loser of that race finds the winner's asset and reports success, because the image the
|
||||||
|
operator uploaded is stored.
|
||||||
|
|
||||||
|
Object keys are scoped to the asset (`assets/<asset-id>/…`), and until that asset's row commits they
|
||||||
|
are **temporary**: nothing references them, nothing can select them, and a failed ingest deletes
|
||||||
|
every one it wrote. Scoping them to the asset rather than to a throwaway upload id is what makes a
|
||||||
|
successful upload cost exactly one write of each object instead of two — the commit is the database
|
||||||
|
row, not a second copy of 25 MiB.
|
||||||
|
|
||||||
|
### One file per request, streamed, bounded
|
||||||
|
|
||||||
|
Uploads use `bodyMode: "streamed"` with a **25 MiB** Signage limit, inside the platform's 32 MiB
|
||||||
|
absolute ceiling. The unit is deliberately one file: a dashboard offering twenty images issues twenty
|
||||||
|
individually bounded requests, each of which can fail cheaply, rather than one request whose failure
|
||||||
|
wastes everything already sent. Nothing in Signage can express an aggregate upload.
|
||||||
|
|
||||||
|
Because a `413` can arrive *after* the handler starts in streamed mode, **nothing is written anywhere
|
||||||
|
until the whole request has been read and validated**. An upload that is aborted, oversized,
|
||||||
|
malformed, or unsupported has therefore written zero objects and zero rows. Once validation passes,
|
||||||
|
the objects are written to the temporary asset-scoped keys above and the row commits last; any
|
||||||
|
failure in between removes every object that attempt wrote. A partial write is never promoted to an
|
||||||
|
asset.
|
||||||
|
|
||||||
|
### Accepted, and refused
|
||||||
|
|
||||||
|
Accepted: still PNG, JPEG, and WebP. Refused: SVG, GIF and any animation, PDF, audio, video,
|
||||||
|
multipage input, anything malformed, anything over 25 MiB, and anything whose decoded size exceeds
|
||||||
|
**40 million pixels** — the last because decoded pixels, not file size, are what a decompression bomb
|
||||||
|
actually costs.
|
||||||
|
|
||||||
|
Format is decided by **decoding the bytes**, never by the filename or the declared content type.
|
||||||
|
|
||||||
|
### Three stored objects, and what each is for
|
||||||
|
|
||||||
|
| Object | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| Original | Archived **byte-for-byte, unmodified**. It is the church's file; Signage is not entitled to rewrite it. |
|
||||||
|
| Display WebP | Inside-fit within 1920×1080, quality 82. What a television actually renders. |
|
||||||
|
| Thumbnail WebP | Inside-fit within 480×270, quality 72. What the library grid renders. |
|
||||||
|
|
||||||
|
Both renditions are auto-oriented, **never cropped**, **never upscaled**, stripped of metadata, and
|
||||||
|
flattened against black. The original's real dimensions, orientation, and useful metadata stay in
|
||||||
|
PostgreSQL, where they are queryable without touching storage.
|
||||||
|
|
||||||
|
### Presentation is resolved at render time, never baked
|
||||||
|
|
||||||
|
A 16:9 image (within a documented tolerance of 0.02) fills the frame on its own: no blurred
|
||||||
|
duplicate, no pillarbox, no floating-card shadow. Anything else is shown **whole** with
|
||||||
|
`object-fit: contain` over a blurred, solid, or black background, optionally darkened, with a
|
||||||
|
foreground shadow.
|
||||||
|
|
||||||
|
None of that is written into a stored rendition. Changing a slot's background from blurred to black
|
||||||
|
is a database update, not a re-encode, and the same rendition serves both. These controls apply to
|
||||||
|
native images only — an external provider owns its rendering completely.
|
||||||
|
|
||||||
|
### Keys are persisted; URLs are not
|
||||||
|
|
||||||
|
Signage stores **logical object keys** and asks the host for a fresh read URL when something is about
|
||||||
|
to be delivered. A read URL is a bearer capability with an expiry in it; storing one in a table would
|
||||||
|
be storing a credential that stops working. Signage never configures, names, or identifies a storage
|
||||||
|
service.
|
||||||
|
|
||||||
|
### Deletion is reference-aware
|
||||||
|
|
||||||
|
Deleting an asset referenced by a timeline slot, by a pinned item, or by a live static hold is refused
|
||||||
|
with a sanitized code naming what refers to it. An asset with a null expiration is permanent; local
|
||||||
|
age never invents one. Rendition identity is immutable — a rendition is replaced by uploading
|
||||||
|
different bytes, which by definition produces a different asset.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Twenty uploads are twenty independent, individually recoverable requests, and the dashboard stays
|
||||||
|
responsive because none of them blocks another.
|
||||||
|
- Re-uploading last week's file costs one hash and one row read.
|
||||||
|
- A partially failed ingest leaves a temporary object that is removed, never an asset in a state
|
||||||
|
playback could select.
|
||||||
|
- Changing presentation is instant and lossless, because nothing was baked.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **A generated UUID as identity.** Rejected: it makes deduplication impossible and retries dangerous.
|
||||||
|
- **Trust the declared content type.** Rejected: it is attacker-controlled and routinely wrong even
|
||||||
|
when it is not.
|
||||||
|
- **Bake the background and shadow into the rendition.** Rejected: every presentation change becomes a
|
||||||
|
re-encode, and the archived original stops being the source of truth.
|
||||||
|
- **One multi-file upload request.** Rejected: one bad byte wastes every byte already sent, and the
|
||||||
|
platform's ceiling is per request for exactly that reason.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Signage architecture decision records
|
||||||
|
|
||||||
|
These record the durable choices behind Signage itself. Ekklesia's own ADRs remain authoritative for
|
||||||
|
everything the platform owns — manifests, capabilities, permissions, storage, realtime, browser
|
||||||
|
artifacts, contributions, and request bodies — and are never restated here.
|
||||||
|
|
||||||
|
- Filenames use a four-digit sequence followed by a short kebab-case title.
|
||||||
|
- Status is `Proposed`, `Accepted`, `Superseded`, or `Rejected`.
|
||||||
|
- Accepted ADRs are immutable except for status and cross-reference corrections.
|
||||||
|
- A changed decision receives a new ADR that links to and supersedes the old one.
|
||||||
|
|
||||||
|
## Index
|
||||||
|
|
||||||
|
- [0001: Signage owns timing; a provider owns content](0001-signage-owns-timing-providers-own-content.md) — Accepted
|
||||||
|
- [0002: The content-provider contract, and what it deliberately cannot express](0002-content-provider-contract.md) — Accepted
|
||||||
|
- [0003: Timelines are ordered patterns; a source instance is a selection scope](0003-timelines-slots-and-selection-scopes.md) — Accepted
|
||||||
|
- [0004: A channel group is one clock; an audience channel is one rendering of it](0004-channel-groups-and-one-clock.md) — Accepted
|
||||||
|
- [0005: Playback is absolute instants reconciled forward, never a countdown](0005-authoritative-playback.md) — Accepted
|
||||||
|
- [0006: Two permanent layers, real readiness, and an offline continuation that never becomes authority](0006-display-rendering-and-offline-continuation.md) — Accepted
|
||||||
|
- [0007: A native image is content-addressed, archived once, and never baked](0007-native-images.md) — Accepted
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
# Writing a Signage content provider
|
||||||
|
|
||||||
|
Signage decides **when** something is on screen. Your module decides **what**.
|
||||||
|
|
||||||
|
This document is the published contract for `signage.content-providers` version **1.0.0**, and for
|
||||||
|
the renderer input Signage hands your browser contribution. It is written to be implementable from
|
||||||
|
here alone — you will not import a line of Signage's code, because Ekklesia modules are independently
|
||||||
|
installed peers and `pnpm boundaries` refuses a module that imports another module.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What you declare
|
||||||
|
|
||||||
|
In your own `ekklesia.module.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"modules": { "required": { "signage": "^0.1.0" } },
|
||||||
|
"capabilities": {
|
||||||
|
"required": {
|
||||||
|
"signage.content-providers": "^1.0.0",
|
||||||
|
"signage.slide-renderer": "^1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"id": "announcements.signage-slide",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"capability": "signage.slide-renderer",
|
||||||
|
"capabilityRange": "^1.0.0",
|
||||||
|
"artifact": "./dist/contributions/signage-slide/index.js"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The module dependency is what makes the platform start Signage before you and stop you before
|
||||||
|
Signage — so your registration always has something to register with, and your unregistration always
|
||||||
|
happens first.
|
||||||
|
|
||||||
|
## 2. What you register
|
||||||
|
|
||||||
|
During your own `start` hook:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const signage = context.capabilities.resolve("signage.content-providers");
|
||||||
|
if (!signage) {
|
||||||
|
return; // Signage is not installed. Nothing else about your module should care.
|
||||||
|
}
|
||||||
|
const registry = signage.implementation as SignageContentProviders;
|
||||||
|
this.handle = registry.register({
|
||||||
|
providerId: "announcements",
|
||||||
|
providerVersion: "1.0.0",
|
||||||
|
contractVersion: "1.0.0",
|
||||||
|
renderer: { contributionId: "announcements.signage-slide", contributionVersion: "1.0.0" },
|
||||||
|
defaultDurationMs: 10_000,
|
||||||
|
listSources,
|
||||||
|
listItems,
|
||||||
|
resolveNext,
|
||||||
|
refresh,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
And release it in `stop`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
this.handle?.unregister();
|
||||||
|
```
|
||||||
|
|
||||||
|
`unregister` is idempotent, which matters: your `stop` hook runs **before** rollback if your own
|
||||||
|
`start` throws partway, and again during reverse-order shutdown.
|
||||||
|
|
||||||
|
### The types, to copy into your own module
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type SignageJsonValue =
|
||||||
|
| boolean | number | string | null
|
||||||
|
| readonly SignageJsonValue[]
|
||||||
|
| { readonly [key: string]: SignageJsonValue };
|
||||||
|
|
||||||
|
interface SignageContentProviders {
|
||||||
|
readonly contractVersion: string;
|
||||||
|
register(registration: SignageProviderRegistration): SignageProviderHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SignageProviderHandle {
|
||||||
|
unregister(): void;
|
||||||
|
invalidate(change?: { sourceId?: string; itemId?: string }): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SignageProviderRegistration {
|
||||||
|
providerId: string; // stable, yours, `[\w][\w.:-]*`, ≤ 200 chars
|
||||||
|
providerVersion: string; // semantic version
|
||||||
|
contractVersion: string; // "1.0.0"
|
||||||
|
renderer: { contributionId: string; contributionVersion: string };
|
||||||
|
defaultDurationMs: number; // 1000 … 3_600_000
|
||||||
|
listSources(request: DiscoveryRequest): Awaitable<readonly SourceDescriptor[]>;
|
||||||
|
listItems?(request: DiscoveryRequest & { sourceId?: string }): Awaitable<readonly ItemDescriptor[]>;
|
||||||
|
resolveNext(request: ResolveRequest): Awaitable<Resolution | null>;
|
||||||
|
refresh(request: RefreshRequest): Awaitable<Resolution | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscoveryRequest { at: string; timezone: string }
|
||||||
|
|
||||||
|
interface SourceDescriptor { id: string; label: string; description?: string }
|
||||||
|
interface ItemDescriptor { id: string; label: string; description?: string; availableUntil?: string | null }
|
||||||
|
|
||||||
|
interface ResolveRequest {
|
||||||
|
selectionScopeId: string; // stable and opaque — see §3
|
||||||
|
sourceId: string;
|
||||||
|
timelineId: string;
|
||||||
|
channelGroupId: string;
|
||||||
|
audienceKeys: readonly string[];
|
||||||
|
at: string; // absolute ISO 8601 instant
|
||||||
|
timezone: string; // IANA zone
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshRequest extends Omit<ResolveRequest, "sourceId" | "selectionScopeId"> {
|
||||||
|
itemId: string;
|
||||||
|
sourceId?: string;
|
||||||
|
selectionScopeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Resolution {
|
||||||
|
itemId: string;
|
||||||
|
itemRevision: string; // changes when the payload changes, item unchanged
|
||||||
|
durationMs?: number; // 1000 … 3_600_000
|
||||||
|
availableUntil?: string | null; // absolute ISO 8601 instant
|
||||||
|
withdrawn?: boolean;
|
||||||
|
presentation?: SignageJsonValue; // yours, for your own renderer
|
||||||
|
variants: readonly { audienceKey: string | null; payload: SignageJsonValue }[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. The selection scope is the whole idea
|
||||||
|
|
||||||
|
An operator's timeline might be:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Announcements Announcements Announcements Trivia Announcements Announcements Scripture ↻
|
||||||
|
```
|
||||||
|
|
||||||
|
Those five Announcement slots are five **turns of one rotation**, not five rotations. Signage
|
||||||
|
expresses that by giving every slot that references one configured source instance the same
|
||||||
|
`selectionScopeId`, and asking `resolveNext` once per turn.
|
||||||
|
|
||||||
|
- The scope id is **stable**: it survives edits, reorders, renames, and restarts.
|
||||||
|
- It is **opaque**: its shape means nothing, so never parse it.
|
||||||
|
- It is **not** derived from the timeline or slot id, because either would silently reset your
|
||||||
|
rotation when an operator dragged a row.
|
||||||
|
|
||||||
|
Keep your cursor, your "already shown in this pass" set, and anything else you need **in your own
|
||||||
|
tables, keyed by scope id**. Signage stores none of it and will never tell you what it thinks your
|
||||||
|
cursor is.
|
||||||
|
|
||||||
|
Two configured source instances of the same source are two independent rotations. That is an
|
||||||
|
operator deliberately asking for one.
|
||||||
|
|
||||||
|
## 4. Repeat policy is yours
|
||||||
|
|
||||||
|
Signage implements none of it:
|
||||||
|
|
||||||
|
| Your module | Your policy |
|
||||||
|
| --- | --- |
|
||||||
|
| Announcements | Cycle the active set forever, wrapping where you choose |
|
||||||
|
| Trivia | Never repeat until every currently available question has appeared |
|
||||||
|
| Scripture | Never repeat until the reading pool is exhausted |
|
||||||
|
|
||||||
|
Signage asks "what is next in this scope?" and shows what you hand back.
|
||||||
|
|
||||||
|
## 5. Three answers, and why they stay three
|
||||||
|
|
||||||
|
| Return | Meaning | What Signage does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `null` | Nothing available for this source right now | **Skips the slot immediately.** No error, no slate, no gap. |
|
||||||
|
| `{ withdrawn: true, … }` | This item is explicitly gone | Removes it at the next safe reconciliation boundary, never mid-slide. |
|
||||||
|
| throw / reject / exceed the deadline | A temporary failure | **Keeps the last known good selection and marks it stale.** The slot is not reset and playback does not stop. |
|
||||||
|
|
||||||
|
Collapsing these is the single most damaging mistake a provider can make. An empty question pool that
|
||||||
|
throws becomes an error on a lobby wall; a genuine outage that returns `null` silently rewrites the
|
||||||
|
running order and nobody finds out.
|
||||||
|
|
||||||
|
Every call is bounded by a deadline (an installation setting, 2 seconds by default). A call that
|
||||||
|
never settles is a temporary failure — Signage does not wait, and does not cancel your work.
|
||||||
|
|
||||||
|
## 6. One selection, many audiences
|
||||||
|
|
||||||
|
A channel group has one clock and one or more audience channels (`default`, and optionally
|
||||||
|
`in-person`, `online`, …). Signage asks you **once** per slot occurrence, with every audience key,
|
||||||
|
and you return **one logical item** with a variant per audience:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
return {
|
||||||
|
itemId: "advent-service",
|
||||||
|
itemRevision: "3",
|
||||||
|
variants: [
|
||||||
|
{ audienceKey: null, payload: { headline: "Join us Sunday, 5pm" } },
|
||||||
|
{ audienceKey: "online", payload: { headline: "Streaming Sunday, 5pm" } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- The **default variant is required** — `null` or the literal `"default"`.
|
||||||
|
- An audience with no variant of its own renders the default. That fallback is the contract, not a
|
||||||
|
convenience, and a provider with nothing audience-specific simply returns one variant.
|
||||||
|
- Resolving once is what keeps every channel on the same slide with the same deadline. There is no
|
||||||
|
per-channel resolution and no way to ask for one.
|
||||||
|
|
||||||
|
## 7. Invalidation updates a slide without restarting it
|
||||||
|
|
||||||
|
When your content changes — an event is cancelled, a countdown ticks past a threshold, a service time
|
||||||
|
is corrected — tell Signage:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
this.handle.invalidate({ itemId: "advent-service" });
|
||||||
|
```
|
||||||
|
|
||||||
|
Signage decides what to do: it re-resolves the affected selection through `refresh`, and if your
|
||||||
|
`itemRevision` changed it stores the new payloads, bumps the selection's revision, and broadcasts.
|
||||||
|
A mounted React renderer receives new input **in place**, with the slot's authoritative deadline
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
This is a **notification, not a command**. You cannot skip a slot, extend a deadline, reorder a
|
||||||
|
timeline, or make something appear. That is deliberate: one provider must not be able to take over a
|
||||||
|
channel group.
|
||||||
|
|
||||||
|
## 8. What Signage validates, and rejects
|
||||||
|
|
||||||
|
Everything crossing the boundary, before it is believed:
|
||||||
|
|
||||||
|
- A registration whose id, version, renderer identity, duration, or functions are not the shape above
|
||||||
|
is refused with `SIGNAGE_PROVIDER_REGISTRATION_INVALID` — and a `contractVersion` outside `1.x` with
|
||||||
|
`SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED`, so an operator can see that your module needs an update
|
||||||
|
rather than that it is broken.
|
||||||
|
- A resolution is refused with `SIGNAGE_PROVIDER_RESULT_INVALID` when it has no default variant, a
|
||||||
|
duplicate audience key, more than 16 variants, payloads totalling over 32 KiB, a duration outside
|
||||||
|
1 s … 1 h, a non-absolute `availableUntil`, or a payload that is not genuinely finite JSON — a
|
||||||
|
`Date`, a function, `NaN`, `undefined`, a class instance, or a cycle.
|
||||||
|
- Every rejection carries a stable code and **nothing of your value**: not your payload, not your
|
||||||
|
exception, not your message. Signage will not put your words on a screen or in a log.
|
||||||
|
|
||||||
|
None of this is a sandbox, and it is not described as one. Your module is trusted code in the same
|
||||||
|
process. This is the difference between your bug staying your bug and a malformed value reaching a
|
||||||
|
television.
|
||||||
|
|
||||||
|
## 9. Your renderer
|
||||||
|
|
||||||
|
Your browser contribution is the actual live renderer for your content. Signage mounts it through the
|
||||||
|
platform's contribution ABI and treats it as completely opaque.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default defineModuleContribution({
|
||||||
|
id: "announcements.signage-slide",
|
||||||
|
version: "1.0.0",
|
||||||
|
styles,
|
||||||
|
mount(container, context) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const input = context.input as SignageSlideRendererInput;
|
||||||
|
root.render(<Slide input={input} />);
|
||||||
|
return {
|
||||||
|
update(next) { root.render(<Slide input={next as SignageSlideRendererInput} />); },
|
||||||
|
unmount() { root.unmount(); },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`mount` is asynchronous by contract. **Resolve it only when you are genuinely ready to be seen** — a
|
||||||
|
font loaded, an image decoded, a layout measured. Signage waits for that promise before it fades your
|
||||||
|
layer in, which is what makes a transition into a live renderer look like a transition into an image.
|
||||||
|
|
||||||
|
### The input, version 1.0.0
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface SignageSlideRendererInput {
|
||||||
|
contract: "signage.slide-renderer";
|
||||||
|
version: string; // "1.0.0"
|
||||||
|
providerId: string;
|
||||||
|
itemId: string;
|
||||||
|
itemRevision: string;
|
||||||
|
audienceKey: string;
|
||||||
|
payload: unknown; // yours, exactly as you returned it
|
||||||
|
presentation: unknown; // yours, or null
|
||||||
|
timing: {
|
||||||
|
startedAtMs: number; // absolute, server clock
|
||||||
|
deadlineAtMs: number;
|
||||||
|
durationMs: number;
|
||||||
|
paused: boolean;
|
||||||
|
remainingMs: number | null; // when paused
|
||||||
|
};
|
||||||
|
clock: { serverNowMs: number; localNowMs: number };
|
||||||
|
generation: string; // changes whenever anything above changed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use the clock anchor, not `Date.now()` alone.** A television's clock is routinely wrong by hours.
|
||||||
|
The authoritative now is:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const serverNow = input.clock.serverNowMs + (Date.now() - input.clock.localNowMs);
|
||||||
|
```
|
||||||
|
|
||||||
|
That stays correct however wrong the device's clock is, and it is what makes a countdown on two
|
||||||
|
screens agree.
|
||||||
|
|
||||||
|
### What your renderer must and must not do
|
||||||
|
|
||||||
|
Must:
|
||||||
|
|
||||||
|
- Be ready asynchronously, and update in place through `update` without remounting.
|
||||||
|
- Handle timing changes: paused, resumed, re-anchored after a reconnect.
|
||||||
|
- Unmount deterministically, releasing every timer, listener, and framework root.
|
||||||
|
- Survive repeated mount/unmount without leaking.
|
||||||
|
- Render sensibly for an input shape older or newer than the one you developed against.
|
||||||
|
|
||||||
|
Must not:
|
||||||
|
|
||||||
|
- Reach the network, a database, storage, or a credential. **You are given none of them**, on
|
||||||
|
purpose: mounting your renderer grants your module nothing, and everything you need arrives in
|
||||||
|
`payload` because Signage is the party that was authorized to fetch it.
|
||||||
|
- Assume your module's management UI is mounted, or that any other renderer exists.
|
||||||
|
- Extend the slot. `deadlineAtMs` was decided before you mounted.
|
||||||
|
- Depend on an external internet service. A renderer must work from its cached artifact and its
|
||||||
|
cached payload, because a display's network is the first thing that fails.
|
||||||
|
|
||||||
|
A provider that genuinely needs cloud access needs a **later** contract version. Version 1 will not
|
||||||
|
be weakened to allow it.
|
||||||
|
|
||||||
|
## 10. A checklist before you ship
|
||||||
|
|
||||||
|
- [ ] `providerId` is stable and will never change for this module.
|
||||||
|
- [ ] Cursor and no-repeat state live in your own tables, keyed by `selectionScopeId`.
|
||||||
|
- [ ] `resolveNext` returns `null` for "nothing right now" and throws only for a genuine failure.
|
||||||
|
- [ ] `refresh` returns `{ withdrawn: true }` for content that is genuinely gone.
|
||||||
|
- [ ] Every resolution has a default variant.
|
||||||
|
- [ ] Payloads are finite JSON, well under 32 KiB for all variants together.
|
||||||
|
- [ ] `itemRevision` changes exactly when the payload changes.
|
||||||
|
- [ ] `invalidate` is called when your content changes, and never used to try to control playback.
|
||||||
|
- [ ] Your renderer resolves `mount` only when it is ready, implements `update`, and cleans up.
|
||||||
|
- [ ] Your renderer needs no network.
|
||||||
|
- [ ] `unregister` is called from `stop`, and calling it twice is safe.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: "postgresql",
|
||||||
|
schema: "./src/schema/tables.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
migrations: {
|
||||||
|
prefix: "index",
|
||||||
|
},
|
||||||
|
breakpoints: true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
CREATE TABLE "signage_channel_groups" (
|
||||||
|
"id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"revision" integer DEFAULT 1 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "signage_channels" (
|
||||||
|
"id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"group_id" uuid NOT NULL,
|
||||||
|
"key" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"is_default" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "signage_channels_key_check" CHECK ("signage_channels"."key" ~ '^[a-z][a-z0-9]*(-[a-z0-9]+)*$')
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "signage_image_assets" (
|
||||||
|
"id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"content_sha256" text NOT NULL,
|
||||||
|
"display_name" text NOT NULL,
|
||||||
|
"original_filename" text NOT NULL,
|
||||||
|
"original_object_key" text NOT NULL,
|
||||||
|
"original_content_type" text NOT NULL,
|
||||||
|
"original_byte_size" bigint NOT NULL,
|
||||||
|
"width" integer NOT NULL,
|
||||||
|
"height" integer NOT NULL,
|
||||||
|
"encoded_width" integer NOT NULL,
|
||||||
|
"encoded_height" integer NOT NULL,
|
||||||
|
"orientation" integer,
|
||||||
|
"has_alpha" boolean DEFAULT false NOT NULL,
|
||||||
|
"status" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"failure_code" text,
|
||||||
|
"expires_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "signage_image_assets_status_check" CHECK ("signage_image_assets"."status" in ('pending', 'ready', 'failed')),
|
||||||
|
CONSTRAINT "signage_image_assets_dimensions_check" CHECK ("signage_image_assets"."width" > 0 and "signage_image_assets"."height" > 0 and "signage_image_assets"."encoded_width" > 0 and "signage_image_assets"."encoded_height" > 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "signage_image_renditions" (
|
||||||
|
"id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"asset_id" uuid NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"object_key" text NOT NULL,
|
||||||
|
"content_type" text NOT NULL,
|
||||||
|
"content_sha256" text NOT NULL,
|
||||||
|
"byte_size" bigint NOT NULL,
|
||||||
|
"width" integer NOT NULL,
|
||||||
|
"height" integer NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "signage_image_renditions_kind_check" CHECK ("signage_image_renditions"."kind" in ('display', 'thumbnail'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "signage_playback_states" (
|
||||||
|
"group_id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"active_timeline_id" uuid,
|
||||||
|
"pending_timeline_id" uuid,
|
||||||
|
"pending_requested_at" timestamp with time zone,
|
||||||
|
"current_selection_id" uuid,
|
||||||
|
"current_slot_id" uuid,
|
||||||
|
"slot_started_at" timestamp with time zone,
|
||||||
|
"slot_deadline_at" timestamp with time zone,
|
||||||
|
"paused_remaining_ms" integer,
|
||||||
|
"paused" boolean DEFAULT false NOT NULL,
|
||||||
|
"blackout" boolean DEFAULT false NOT NULL,
|
||||||
|
"static_asset_id" uuid,
|
||||||
|
"scheduled_timeline_id" uuid,
|
||||||
|
"scheduled_activation_at" timestamp with time zone,
|
||||||
|
"revision" integer DEFAULT 1 NOT NULL,
|
||||||
|
"next_sequence" bigint DEFAULT 1 NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "signage_playback_states_remaining_check" CHECK ("signage_playback_states"."paused_remaining_ms" is null or "signage_playback_states"."paused_remaining_ms" >= 0),
|
||||||
|
CONSTRAINT "signage_playback_states_window_check" CHECK ("signage_playback_states"."slot_started_at" is null or "signage_playback_states"."slot_deadline_at" is null or "signage_playback_states"."slot_started_at" <= "signage_playback_states"."slot_deadline_at")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "signage_provider_sources" (
|
||||||
|
"id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"timeline_id" uuid NOT NULL,
|
||||||
|
"provider_id" text NOT NULL,
|
||||||
|
"source_id" text NOT NULL,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"selection_scope" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "signage_selection_variants" (
|
||||||
|
"id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"selection_id" uuid NOT NULL,
|
||||||
|
"audience_key" text NOT NULL,
|
||||||
|
"is_default" boolean DEFAULT false NOT NULL,
|
||||||
|
"payload" jsonb NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "signage_selections" (
|
||||||
|
"id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"group_id" uuid NOT NULL,
|
||||||
|
"sequence" bigint NOT NULL,
|
||||||
|
"timeline_id" uuid,
|
||||||
|
"slot_id" uuid,
|
||||||
|
"selection_scope" text,
|
||||||
|
"provider_id" text NOT NULL,
|
||||||
|
"item_id" text NOT NULL,
|
||||||
|
"item_revision" text NOT NULL,
|
||||||
|
"renderer_contribution_id" text,
|
||||||
|
"renderer_contribution_version" text,
|
||||||
|
"presentation" jsonb,
|
||||||
|
"duration_ms" integer NOT NULL,
|
||||||
|
"resolved_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"started_at" timestamp with time zone,
|
||||||
|
"deadline_at" timestamp with time zone,
|
||||||
|
"available_until" timestamp with time zone,
|
||||||
|
"withdrawn" boolean DEFAULT false NOT NULL,
|
||||||
|
"stale" boolean DEFAULT false NOT NULL,
|
||||||
|
"revision" integer DEFAULT 1 NOT NULL,
|
||||||
|
CONSTRAINT "signage_selections_duration_check" CHECK ("signage_selections"."duration_ms" > 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "signage_timeline_slots" (
|
||||||
|
"id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"timeline_id" uuid NOT NULL,
|
||||||
|
"position" integer NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"source_id" uuid,
|
||||||
|
"provider_id" text,
|
||||||
|
"item_id" text,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"duration_override_ms" integer,
|
||||||
|
"eligible_from" timestamp with time zone,
|
||||||
|
"eligible_until" timestamp with time zone,
|
||||||
|
"background_mode" text,
|
||||||
|
"background_color" text,
|
||||||
|
"background_darkened" boolean,
|
||||||
|
"blur_pixels" integer,
|
||||||
|
"foreground_shadow" boolean,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "signage_timeline_slots_position_check" CHECK ("signage_timeline_slots"."position" >= 0),
|
||||||
|
CONSTRAINT "signage_timeline_slots_kind_check" CHECK ("signage_timeline_slots"."kind" in ('provider-source', 'pinned-item')),
|
||||||
|
CONSTRAINT "signage_timeline_slots_reference_check" CHECK (("signage_timeline_slots"."kind" = 'provider-source' and "signage_timeline_slots"."source_id" is not null and "signage_timeline_slots"."provider_id" is null and "signage_timeline_slots"."item_id" is null)
|
||||||
|
or ("signage_timeline_slots"."kind" = 'pinned-item' and "signage_timeline_slots"."source_id" is null and "signage_timeline_slots"."provider_id" is not null and "signage_timeline_slots"."item_id" is not null)),
|
||||||
|
CONSTRAINT "signage_timeline_slots_duration_check" CHECK ("signage_timeline_slots"."duration_override_ms" is null or "signage_timeline_slots"."duration_override_ms" > 0),
|
||||||
|
CONSTRAINT "signage_timeline_slots_eligibility_check" CHECK ("signage_timeline_slots"."eligible_from" is null or "signage_timeline_slots"."eligible_until" is null or "signage_timeline_slots"."eligible_from" < "signage_timeline_slots"."eligible_until"),
|
||||||
|
CONSTRAINT "signage_timeline_slots_background_check" CHECK ("signage_timeline_slots"."background_mode" is null or "signage_timeline_slots"."background_mode" in ('blurred', 'solid', 'black'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "signage_timelines" (
|
||||||
|
"id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"eligible_from" timestamp with time zone,
|
||||||
|
"eligible_until" timestamp with time zone,
|
||||||
|
"revision" integer DEFAULT 1 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "signage_timelines_eligibility_check" CHECK ("signage_timelines"."eligible_from" is null or "signage_timelines"."eligible_until" is null or "signage_timelines"."eligible_from" < "signage_timelines"."eligible_until")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "signage_viewers" (
|
||||||
|
"id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"viewer_key" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"device_id" text,
|
||||||
|
"channel_id" uuid,
|
||||||
|
"last_seen_at" timestamp with time zone,
|
||||||
|
"reported_timeline_id" uuid,
|
||||||
|
"reported_selection_id" uuid,
|
||||||
|
"reported_provider_id" text,
|
||||||
|
"reported_contribution_id" text,
|
||||||
|
"reported_playback_revision" integer,
|
||||||
|
"offline_ready" boolean DEFAULT false NOT NULL,
|
||||||
|
"cached_asset_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"last_error_code" text,
|
||||||
|
"last_error_detail" text,
|
||||||
|
"last_error_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "signage_channels" ADD CONSTRAINT "signage_channels_group_id_signage_channel_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."signage_channel_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "signage_image_renditions" ADD CONSTRAINT "signage_image_renditions_asset_id_signage_image_assets_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."signage_image_assets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "signage_playback_states" ADD CONSTRAINT "signage_playback_states_group_id_signage_channel_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."signage_channel_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "signage_playback_states" ADD CONSTRAINT "signage_playback_states_active_timeline_id_signage_timelines_id_fk" FOREIGN KEY ("active_timeline_id") REFERENCES "public"."signage_timelines"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "signage_playback_states" ADD CONSTRAINT "signage_playback_states_pending_timeline_id_signage_timelines_id_fk" FOREIGN KEY ("pending_timeline_id") REFERENCES "public"."signage_timelines"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "signage_playback_states" ADD CONSTRAINT "signage_playback_states_static_asset_id_signage_image_assets_id_fk" FOREIGN KEY ("static_asset_id") REFERENCES "public"."signage_image_assets"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "signage_playback_states" ADD CONSTRAINT "signage_playback_states_scheduled_timeline_id_signage_timelines_id_fk" FOREIGN KEY ("scheduled_timeline_id") REFERENCES "public"."signage_timelines"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "signage_provider_sources" ADD CONSTRAINT "signage_provider_sources_timeline_id_signage_timelines_id_fk" FOREIGN KEY ("timeline_id") REFERENCES "public"."signage_timelines"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "signage_selection_variants" ADD CONSTRAINT "signage_selection_variants_selection_id_signage_selections_id_fk" FOREIGN KEY ("selection_id") REFERENCES "public"."signage_selections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "signage_selections" ADD CONSTRAINT "signage_selections_group_id_signage_channel_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."signage_channel_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "signage_selections" ADD CONSTRAINT "signage_selections_slot_id_signage_timeline_slots_id_fk" FOREIGN KEY ("slot_id") REFERENCES "public"."signage_timeline_slots"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "signage_timeline_slots" ADD CONSTRAINT "signage_timeline_slots_timeline_id_signage_timelines_id_fk" FOREIGN KEY ("timeline_id") REFERENCES "public"."signage_timelines"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "signage_timeline_slots" ADD CONSTRAINT "signage_timeline_slots_source_id_signage_provider_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."signage_provider_sources"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "signage_viewers" ADD CONSTRAINT "signage_viewers_channel_id_signage_channels_id_fk" FOREIGN KEY ("channel_id") REFERENCES "public"."signage_channels"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "signage_channel_groups_name_idx" ON "signage_channel_groups" USING btree ("name");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "signage_channels_group_key_key" ON "signage_channels" USING btree ("group_id","key");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "signage_channels_group_default_key" ON "signage_channels" USING btree ("group_id") WHERE "signage_channels"."is_default";--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "signage_image_assets_content_sha256_key" ON "signage_image_assets" USING btree ("content_sha256");--> statement-breakpoint
|
||||||
|
CREATE INDEX "signage_image_assets_status_idx" ON "signage_image_assets" USING btree ("status");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "signage_image_renditions_asset_kind_key" ON "signage_image_renditions" USING btree ("asset_id","kind");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "signage_provider_sources_selection_scope_key" ON "signage_provider_sources" USING btree ("selection_scope");--> statement-breakpoint
|
||||||
|
CREATE INDEX "signage_provider_sources_timeline_idx" ON "signage_provider_sources" USING btree ("timeline_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "signage_provider_sources_provider_idx" ON "signage_provider_sources" USING btree ("provider_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "signage_selection_variants_selection_audience_key" ON "signage_selection_variants" USING btree ("selection_id","audience_key");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "signage_selections_group_sequence_key" ON "signage_selections" USING btree ("group_id","sequence");--> statement-breakpoint
|
||||||
|
CREATE INDEX "signage_selections_group_resolved_idx" ON "signage_selections" USING btree ("group_id","resolved_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "signage_selections_provider_item_idx" ON "signage_selections" USING btree ("provider_id","item_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "signage_timeline_slots_position_key" ON "signage_timeline_slots" USING btree ("timeline_id","position");--> statement-breakpoint
|
||||||
|
CREATE INDEX "signage_timeline_slots_source_idx" ON "signage_timeline_slots" USING btree ("source_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "signage_timelines_name_idx" ON "signage_timelines" USING btree ("name");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "signage_viewers_viewer_key_key" ON "signage_viewers" USING btree ("viewer_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "signage_viewers_channel_idx" ON "signage_viewers" USING btree ("channel_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "signage_viewers_last_seen_idx" ON "signage_viewers" USING btree ("last_seen_at");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"moduleId": "signage",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"tag": "0000_initial",
|
||||||
|
"destructive": {
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787887037559,
|
||||||
|
"tag": "0000_initial",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
{
|
||||||
|
"manifestVersion": 7,
|
||||||
|
"id": "signage",
|
||||||
|
"name": "Signage",
|
||||||
|
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"compatibility": {
|
||||||
|
"platform": "^0.1.0",
|
||||||
|
"moduleSdk": "^0.1.0"
|
||||||
|
},
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"id": "signage.content-providers",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "signage.slide-renderer",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "signage.playback",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "signage.displays",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"id": "signage.view",
|
||||||
|
"baseline": "restricted",
|
||||||
|
"description": "View Signage timelines, images, channels, provider health, and display health."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "signage.assets.manage",
|
||||||
|
"baseline": "restricted",
|
||||||
|
"description": "Upload, rename, expire, and delete Signage's own native images."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "signage.timelines.manage",
|
||||||
|
"baseline": "restricted",
|
||||||
|
"description": "Create, edit, reorder, duplicate, schedule, and delete saved timelines."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "signage.playback.control",
|
||||||
|
"baseline": "restricted",
|
||||||
|
"description": "Activate timelines and operate live playback transport for a channel group."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "signage.displays.manage",
|
||||||
|
"baseline": "restricted",
|
||||||
|
"description": "Create channel groups and audience channels, and assign or rename viewers."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "signage.display.view",
|
||||||
|
"baseline": "restricted",
|
||||||
|
"description": "Render the Signage full-viewport display and report its own health."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration": [
|
||||||
|
{
|
||||||
|
"key": "timezone",
|
||||||
|
"type": "string",
|
||||||
|
"default": "America/Chicago",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 64,
|
||||||
|
"description": "IANA time zone this installation's Signage schedules are written and displayed in."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "nativeImageDurationSeconds",
|
||||||
|
"type": "integer",
|
||||||
|
"default": 7,
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 3600,
|
||||||
|
"description": "How long a native Signage image is shown when no slot override applies."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "crossfadeMilliseconds",
|
||||||
|
"type": "integer",
|
||||||
|
"default": 1000,
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 10000,
|
||||||
|
"description": "How long a display fades an incoming slide over the outgoing one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "defaultBackgroundMode",
|
||||||
|
"type": "enum",
|
||||||
|
"values": ["blurred", "solid", "black"],
|
||||||
|
"default": "blurred",
|
||||||
|
"description": "How a native image that is not 16:9 is backed when a slot says nothing."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "defaultBackgroundColor",
|
||||||
|
"type": "string",
|
||||||
|
"default": "#000000",
|
||||||
|
"minLength": 7,
|
||||||
|
"maxLength": 7,
|
||||||
|
"description": "The solid background colour used when the background mode is solid."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "defaultBackgroundDarkened",
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Whether a native image's background is darkened behind the foreground by default."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "defaultBlurPixels",
|
||||||
|
"type": "integer",
|
||||||
|
"default": 20,
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 120,
|
||||||
|
"description": "Blur radius applied to a blurred native-image background."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "providerTimeoutMilliseconds",
|
||||||
|
"type": "integer",
|
||||||
|
"default": 2000,
|
||||||
|
"minimum": 100,
|
||||||
|
"maximum": 15000,
|
||||||
|
"description": "How long Signage waits for one content-provider call before treating it as failed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "playbackHistoryLimit",
|
||||||
|
"type": "integer",
|
||||||
|
"default": 50,
|
||||||
|
"minimum": 2,
|
||||||
|
"maximum": 500,
|
||||||
|
"description": "How many resolved selections per channel group are retained as playback history."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "viewerHeartbeatWriteSeconds",
|
||||||
|
"type": "integer",
|
||||||
|
"default": 30,
|
||||||
|
"minimum": 5,
|
||||||
|
"maximum": 600,
|
||||||
|
"description": "Shortest interval between two persisted heartbeat writes for one steady viewer."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storage": {
|
||||||
|
"buckets": [
|
||||||
|
{
|
||||||
|
"id": "native-images",
|
||||||
|
"description": "Archived original uploads and generated renditions for Signage's native images."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"entrypoints": {
|
||||||
|
"server": "./dist/server/index.js",
|
||||||
|
"schema": "./dist/schema/index.js",
|
||||||
|
"migrations": "./drizzle",
|
||||||
|
"ui": "./dist/ui/index.js",
|
||||||
|
"display": {
|
||||||
|
"artifact": "./dist/display/index.js",
|
||||||
|
"permission": "signage.display.view"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/signage",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Ekklesia's visual programming, timeline, synchronization, and display-delivery module.",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"drizzle",
|
||||||
|
"ekklesia.module.json"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/server/index.d.ts",
|
||||||
|
"import": "./dist/server/index.js"
|
||||||
|
},
|
||||||
|
"./server": {
|
||||||
|
"types": "./dist/server/index.d.ts",
|
||||||
|
"import": "./dist/server/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui",
|
||||||
|
"lint": "biome lint .",
|
||||||
|
"migration:generate": "drizzle-kit generate --config=drizzle.config.ts",
|
||||||
|
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/signage",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json && tsc --noEmit --project tsconfig.ui-test.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ekklesia/design-tokens": "workspace:*",
|
||||||
|
"@ekklesia/module-sdk": "workspace:*",
|
||||||
|
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||||
|
"@ekklesia/ui": "workspace:*",
|
||||||
|
"drizzle-orm": "0.45.2",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8",
|
||||||
|
"sharp": "0.35.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.5.3",
|
||||||
|
"@ekklesia/module-ui-build": "workspace:*",
|
||||||
|
"@ekklesia/typescript-config": "workspace:*",
|
||||||
|
"@types/node": "24.13.3",
|
||||||
|
"@types/react": "19.2.18",
|
||||||
|
"@types/react-dom": "19.2.4",
|
||||||
|
"drizzle-kit": "0.31.10",
|
||||||
|
"happy-dom": "20.0.11",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"vitest": "4.1.10"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "module"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ReactElement, ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The narrowest viewport the Signage management UI is designed for.
|
||||||
|
*
|
||||||
|
* Signage's dashboard is a timeline editor, a slot table, a live preview, and a display roster shown
|
||||||
|
* together. Compressing that into a phone would not make it usable, it would make it a different
|
||||||
|
* product — the mobile application this release deliberately does not build. So below this width the
|
||||||
|
* operator is told plainly where to go instead of being handed a broken layout.
|
||||||
|
*
|
||||||
|
* The switch is a CSS media query rather than a measured width in JavaScript: both branches are
|
||||||
|
* always in the DOM and always in the accessibility tree, so a screen reader and a test find the
|
||||||
|
* same content regardless of how the environment reports its viewport.
|
||||||
|
*/
|
||||||
|
export const SIGNAGE_MIN_DESKTOP_WIDTH_PX = 1024;
|
||||||
|
|
||||||
|
export function DesktopOnly({ children }: { readonly children: ReactNode }): ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="signage-narrow-notice" data-testid="signage-narrow-notice" role="note">
|
||||||
|
<h2 className="signage-narrow-notice__title">Signage needs a desktop screen</h2>
|
||||||
|
<p className="signage-narrow-notice__body">
|
||||||
|
{`Open the Ekklesia Dashboard on a display at least ${SIGNAGE_MIN_DESKTOP_WIDTH_PX} pixels wide to manage timelines, images, channels, and playback.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="signage-desktop" data-testid="signage-desktop">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import {
|
||||||
|
advanceCursor,
|
||||||
|
nextTimingBoundary,
|
||||||
|
type TimingSlot,
|
||||||
|
type TimingTimeline,
|
||||||
|
} from "../shared/playback-timing.js";
|
||||||
|
import type { SceneSelection } from "./scene-request.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic local continuation of the last authoritative state (ADR 0006).
|
||||||
|
*
|
||||||
|
* This is **resilience, never a second authority**. It reproduces the server's own documented
|
||||||
|
* semantics using the *same* arithmetic module the server reconciles with, so a screen that has been
|
||||||
|
* disconnected for an hour is showing what the server would have said, not what a second
|
||||||
|
* implementation guessed.
|
||||||
|
*
|
||||||
|
* Three rules bound what it may do:
|
||||||
|
*
|
||||||
|
* - **It never invents provider data.** It replays selections the server already resolved and this
|
||||||
|
* display already cached. A slot it has no cached answer for holds the last good frame rather than
|
||||||
|
* fabricating one.
|
||||||
|
* - **It never reports its own cursor back as authority.** Nothing here is sent anywhere.
|
||||||
|
* - **It catches up arithmetically.** A long outage costs one evaluation, not one per missed slide.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface OfflineTimeline {
|
||||||
|
readonly id: string;
|
||||||
|
readonly revision: number;
|
||||||
|
readonly eligibleFromMs: number | null;
|
||||||
|
readonly eligibleUntilMs: number | null;
|
||||||
|
readonly slots: readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly position: number;
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly eligibleFromMs: number | null;
|
||||||
|
readonly eligibleUntilMs: number | null;
|
||||||
|
readonly estimatedDurationMs: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything a display persists so it can keep going. Structured, JSON-safe, and bounded. */
|
||||||
|
export interface OfflineSnapshot {
|
||||||
|
readonly version: 1;
|
||||||
|
readonly channel: { readonly id: string; readonly key: string; readonly groupId: string };
|
||||||
|
readonly crossfadeMs: number;
|
||||||
|
readonly timeline: OfflineTimeline | null;
|
||||||
|
readonly playback: {
|
||||||
|
readonly revision: number;
|
||||||
|
readonly transport: string;
|
||||||
|
readonly blackout: boolean;
|
||||||
|
readonly remainingMs: number | null;
|
||||||
|
readonly currentSelectionId: string | null;
|
||||||
|
readonly currentSlotId: string | null;
|
||||||
|
readonly startedAtMs: number | null;
|
||||||
|
readonly deadlineAtMs: number | null;
|
||||||
|
readonly pendingTimelineId: string | null;
|
||||||
|
};
|
||||||
|
readonly selections: readonly SceneSelection[];
|
||||||
|
readonly staticHold: SceneSelection | null;
|
||||||
|
/** The two clocks at the moment this was captured. A television's own clock is routinely wrong, so
|
||||||
|
* every later evaluation is done on the *server's* timeline, reconstructed from this pair. */
|
||||||
|
readonly anchor: { readonly serverNowMs: number; readonly localNowMs: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OfflineResolution =
|
||||||
|
/** Show this selection, with this window. */
|
||||||
|
| {
|
||||||
|
readonly kind: "selection";
|
||||||
|
readonly selection: SceneSelection;
|
||||||
|
readonly startedAtMs: number;
|
||||||
|
readonly deadlineAtMs: number;
|
||||||
|
}
|
||||||
|
/** The held static image. */
|
||||||
|
| { readonly kind: "static"; readonly selection: SceneSelection }
|
||||||
|
/** Frozen: whatever is on screen stays, with its remainder preserved. */
|
||||||
|
| { readonly kind: "paused"; readonly selection: SceneSelection | undefined }
|
||||||
|
/** Keep the last good frame. Something is genuinely unknown — a queued activation whose timeline
|
||||||
|
* this display never received, or a slot with no cached answer. Holding is the honest response. */
|
||||||
|
| { readonly kind: "hold"; readonly reason: string }
|
||||||
|
/** The cached timeline really has nothing eligible. Black is correct. */
|
||||||
|
| { readonly kind: "empty" };
|
||||||
|
|
||||||
|
/** The server's clock, reconstructed from the anchor. Correct however far off the device's is. */
|
||||||
|
export function serverNowFrom(snapshot: OfflineSnapshot, localNowMs: number): number {
|
||||||
|
return snapshot.anchor.serverNowMs + (localNowMs - snapshot.anchor.localNowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingTimelineOf(timeline: OfflineTimeline): TimingTimeline {
|
||||||
|
const slots: TimingSlot[] = timeline.slots.map((slot) => ({
|
||||||
|
eligibleFromMs: slot.eligibleFromMs,
|
||||||
|
eligibleUntilMs: slot.eligibleUntilMs,
|
||||||
|
enabled: slot.enabled,
|
||||||
|
estimatedDurationMs: slot.estimatedDurationMs,
|
||||||
|
id: slot.id,
|
||||||
|
position: slot.position,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
eligibleFromMs: timeline.eligibleFromMs,
|
||||||
|
eligibleUntilMs: timeline.eligibleUntilMs,
|
||||||
|
id: timeline.id,
|
||||||
|
slots,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a cached selection may still be shown. Expired and explicitly withdrawn content is
|
||||||
|
* skipped; a null expiry is permanent, and local age never invents one. */
|
||||||
|
function isUsable(selection: SceneSelection, atMs: number, unusable: ReadonlySet<string>): boolean {
|
||||||
|
if (unusable.has(selection.id) || selection.withdrawn) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return selection.availableUntilMs === null || atMs < selection.availableUntilMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The most recent cached answer for one slot. History is newest-first, so the first match is the
|
||||||
|
* one that most recently played there. */
|
||||||
|
function cachedForSlot(
|
||||||
|
snapshot: OfflineSnapshot,
|
||||||
|
slotId: string,
|
||||||
|
atMs: number,
|
||||||
|
unusable: ReadonlySet<string>,
|
||||||
|
): SceneSelection | undefined {
|
||||||
|
return snapshot.selections.find(
|
||||||
|
(selection) => selection.slotId === slotId && isUsable(selection, atMs, unusable),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfflineEvaluation {
|
||||||
|
readonly resolution: OfflineResolution;
|
||||||
|
/** When to look again, or `null` when nothing is scheduled to change. */
|
||||||
|
readonly nextBoundaryMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves what a disconnected display should be showing at `atMs`, on the server's clock.
|
||||||
|
*
|
||||||
|
* Pure and deterministic: the same snapshot and the same instant always give the same answer, which
|
||||||
|
* is what lets two screens that lost the same network stay on the same slide.
|
||||||
|
*/
|
||||||
|
export function resolveOffline(input: {
|
||||||
|
readonly snapshot: OfflineSnapshot;
|
||||||
|
readonly atMs: number;
|
||||||
|
readonly unusableSelectionIds?: ReadonlySet<string>;
|
||||||
|
}): OfflineEvaluation {
|
||||||
|
const { snapshot, atMs } = input;
|
||||||
|
const unusable = input.unusableSelectionIds ?? new Set<string>();
|
||||||
|
|
||||||
|
if (snapshot.staticHold !== null) {
|
||||||
|
// A hold is a deliberate operator decision, and local age never releases one. Only an explicit
|
||||||
|
// expiry on the held image does.
|
||||||
|
if (isUsable(snapshot.staticHold, atMs, unusable)) {
|
||||||
|
return {
|
||||||
|
nextBoundaryMs: null,
|
||||||
|
resolution: { kind: "static", selection: snapshot.staticHold },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.playback.transport === "paused") {
|
||||||
|
const current = snapshot.selections.find(
|
||||||
|
(selection) => selection.id === snapshot.playback.currentSelectionId,
|
||||||
|
);
|
||||||
|
// Frozen means frozen: the remainder is preserved and nothing advances.
|
||||||
|
return { nextBoundaryMs: null, resolution: { kind: "paused", selection: current } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.playback.pendingTimelineId !== null) {
|
||||||
|
const deadline = snapshot.playback.deadlineAtMs;
|
||||||
|
if (deadline !== null && atMs >= deadline) {
|
||||||
|
// The next boundary belongs to a timeline this display never received. Going further into the
|
||||||
|
// old one would show known-wrong content, so the last good frame stays until the server can
|
||||||
|
// speak again.
|
||||||
|
return { nextBoundaryMs: null, resolution: { kind: "hold", reason: "pending-activation" } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeline = snapshot.timeline;
|
||||||
|
if (!timeline || timeline.slots.length === 0) {
|
||||||
|
return { nextBoundaryMs: null, resolution: { kind: "empty" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const timing = timingTimelineOf(timeline);
|
||||||
|
let cursor = {
|
||||||
|
deadlineAtMs: snapshot.playback.deadlineAtMs,
|
||||||
|
slotId: snapshot.playback.currentSlotId,
|
||||||
|
startedAtMs: snapshot.playback.startedAtMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bounded by the number of slots: a slot with no cached answer is skipped exactly as the server
|
||||||
|
// skips a provider with nothing available, and one pass proves there is nothing to show.
|
||||||
|
const attempts = Math.max(1, timeline.slots.length);
|
||||||
|
for (let attempt = 0; attempt <= attempts; attempt += 1) {
|
||||||
|
const advance = advanceCursor(timing, cursor, atMs);
|
||||||
|
|
||||||
|
if (advance.kind === "empty") {
|
||||||
|
return {
|
||||||
|
nextBoundaryMs: nextTimingBoundary(timing, cursor, atMs),
|
||||||
|
resolution: { kind: "empty" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (advance.kind === "hold") {
|
||||||
|
const current = snapshot.selections.find(
|
||||||
|
(selection) => selection.id === snapshot.playback.currentSelectionId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
current &&
|
||||||
|
cursor.startedAtMs !== null &&
|
||||||
|
cursor.deadlineAtMs !== null &&
|
||||||
|
isUsable(current, atMs, unusable)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
nextBoundaryMs: nextTimingBoundary(timing, cursor, atMs),
|
||||||
|
resolution: {
|
||||||
|
deadlineAtMs: cursor.deadlineAtMs,
|
||||||
|
kind: "selection",
|
||||||
|
selection: current,
|
||||||
|
startedAtMs: cursor.startedAtMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
nextBoundaryMs: nextTimingBoundary(timing, cursor, atMs),
|
||||||
|
resolution: { kind: "hold", reason: "no-cached-selection" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = cachedForSlot(snapshot, advance.slotId, atMs, unusable);
|
||||||
|
if (cached) {
|
||||||
|
const nextCursor = {
|
||||||
|
deadlineAtMs: advance.deadlineAtMs,
|
||||||
|
slotId: advance.slotId,
|
||||||
|
startedAtMs: advance.startedAtMs,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
nextBoundaryMs: nextTimingBoundary(timing, nextCursor, atMs),
|
||||||
|
resolution: {
|
||||||
|
deadlineAtMs: advance.deadlineAtMs,
|
||||||
|
kind: "selection",
|
||||||
|
selection: cached,
|
||||||
|
startedAtMs: advance.startedAtMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cached answer for this slot. Skip it exactly as the server skips an empty provider slot,
|
||||||
|
// and keep walking — inventing content would be worse than moving on.
|
||||||
|
cursor = {
|
||||||
|
deadlineAtMs: advance.startedAtMs,
|
||||||
|
slotId: advance.slotId,
|
||||||
|
startedAtMs: advance.startedAtMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextBoundaryMs: nextTimingBoundary(timing, cursor, atMs),
|
||||||
|
resolution: { kind: "hold", reason: "no-cached-selection" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the snapshot a display persists. Bounded and JSON-safe by construction. */
|
||||||
|
export function snapshotFrom(input: {
|
||||||
|
readonly channel: OfflineSnapshot["channel"];
|
||||||
|
readonly crossfadeMs: number;
|
||||||
|
readonly timeline: OfflineTimeline | null;
|
||||||
|
readonly playback: OfflineSnapshot["playback"];
|
||||||
|
readonly selections: readonly SceneSelection[];
|
||||||
|
readonly staticHold: SceneSelection | null;
|
||||||
|
readonly serverNowMs: number;
|
||||||
|
readonly localNowMs: number;
|
||||||
|
}): OfflineSnapshot {
|
||||||
|
return {
|
||||||
|
anchor: { localNowMs: input.localNowMs, serverNowMs: input.serverNowMs },
|
||||||
|
channel: input.channel,
|
||||||
|
crossfadeMs: input.crossfadeMs,
|
||||||
|
playback: input.playback,
|
||||||
|
// A delivery URL is a bearer capability with an expiry in it. Persisting one would store a
|
||||||
|
// credential that stops working; the bytes live in the media cache under a stable key instead.
|
||||||
|
selections: input.selections.map(withoutDeliveryUrls),
|
||||||
|
staticHold: input.staticHold === null ? null : withoutDeliveryUrls(input.staticHold),
|
||||||
|
timeline: input.timeline,
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutDeliveryUrls(selection: SceneSelection): SceneSelection {
|
||||||
|
const { media: _media, ...rest } = selection as SceneSelection & { media?: unknown };
|
||||||
|
return rest as SceneSelection;
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
SIGNAGE_SLIDE_RENDERER_CONTRACT,
|
||||||
|
SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
|
||||||
|
type SignageSlideRendererInput,
|
||||||
|
signageRendererGeneration,
|
||||||
|
} from "../shared/renderer-input.js";
|
||||||
|
import type { SceneRequest } from "./scene.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turning one resolved selection into something the stage can show.
|
||||||
|
*
|
||||||
|
* The one decision here worth naming is the **key**, and it is narrower than it first looks: a slide's
|
||||||
|
* identity is the selection and the audience, and *nothing else*. A payload revision, a pause, a
|
||||||
|
* re-anchored clock, and a fresh delivery signature are all things that happen to a slide already on
|
||||||
|
* screen — they become an `update`, which is the whole reason a cancelled event can change a
|
||||||
|
* mounted React tree instead of restarting its slot and every animation in it.
|
||||||
|
*
|
||||||
|
* The renderer still learns that something changed: `generation` inside its input *does* carry the
|
||||||
|
* revision and the paused flag, so a renderer keying internal state on it sees the change without
|
||||||
|
* the stage remounting anything.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The subset of a channel state's selection this needs. Structural on purpose: the display and the
|
||||||
|
* management preview build the same request from the same shape. */
|
||||||
|
export interface SceneSelection {
|
||||||
|
readonly id: string;
|
||||||
|
readonly revision: number;
|
||||||
|
/** Which slot this occurrence came from. `null` for a static hold, and for history whose slot an
|
||||||
|
* operator has since deleted. */
|
||||||
|
readonly slotId: string | null;
|
||||||
|
/** When this selection stops being valid. `null` is permanent; local age never invents one. */
|
||||||
|
readonly availableUntilMs: number | null;
|
||||||
|
readonly withdrawn: boolean;
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly itemId: string;
|
||||||
|
readonly itemRevision: string;
|
||||||
|
readonly durationMs: number;
|
||||||
|
readonly startedAtMs: number | null;
|
||||||
|
readonly deadlineAtMs: number | null;
|
||||||
|
readonly payload: unknown;
|
||||||
|
readonly presentation: unknown;
|
||||||
|
readonly renderer:
|
||||||
|
| { readonly kind: "native-image" }
|
||||||
|
| {
|
||||||
|
readonly kind: "contribution";
|
||||||
|
readonly contributionId: string;
|
||||||
|
readonly contributionVersion: string;
|
||||||
|
}
|
||||||
|
| { readonly kind: "unavailable" };
|
||||||
|
readonly media?: {
|
||||||
|
readonly display: { readonly url: string };
|
||||||
|
readonly thumbnail: { readonly url: string };
|
||||||
|
};
|
||||||
|
readonly scene?: {
|
||||||
|
readonly fit: "fill" | "contain";
|
||||||
|
readonly backgroundMode: "blurred" | "solid" | "black" | "none";
|
||||||
|
readonly backgroundColor: string;
|
||||||
|
readonly backgroundImage: boolean;
|
||||||
|
readonly blurPixels: number;
|
||||||
|
readonly darkenOpacity: number;
|
||||||
|
readonly shadow: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneContext {
|
||||||
|
readonly audienceKey: string;
|
||||||
|
readonly paused: boolean;
|
||||||
|
readonly remainingMs: number | null;
|
||||||
|
/** The server's clock at the moment this state was received, and the device's at the same instant.
|
||||||
|
* A television's own clock is routinely wrong; this pair is what makes a renderer's countdown
|
||||||
|
* correct anyway. */
|
||||||
|
readonly serverNowMs: number;
|
||||||
|
readonly localNowMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sceneKeyFor(selection: SceneSelection, context: SceneContext): string {
|
||||||
|
return `${selection.id}:${context.audienceKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rendererInputFor(
|
||||||
|
selection: SceneSelection,
|
||||||
|
context: SceneContext,
|
||||||
|
): SignageSlideRendererInput {
|
||||||
|
return {
|
||||||
|
audienceKey: context.audienceKey,
|
||||||
|
clock: { localNowMs: context.localNowMs, serverNowMs: context.serverNowMs },
|
||||||
|
contract: SIGNAGE_SLIDE_RENDERER_CONTRACT,
|
||||||
|
generation: signageRendererGeneration({
|
||||||
|
audienceKey: context.audienceKey,
|
||||||
|
paused: context.paused,
|
||||||
|
revision: selection.revision,
|
||||||
|
selectionId: selection.id,
|
||||||
|
}),
|
||||||
|
itemId: selection.itemId,
|
||||||
|
itemRevision: selection.itemRevision,
|
||||||
|
payload: selection.payload,
|
||||||
|
presentation: selection.presentation,
|
||||||
|
providerId: selection.providerId,
|
||||||
|
timing: {
|
||||||
|
deadlineAtMs: selection.deadlineAtMs ?? context.serverNowMs + selection.durationMs,
|
||||||
|
durationMs: selection.durationMs,
|
||||||
|
paused: context.paused,
|
||||||
|
remainingMs: context.paused ? context.remainingMs : null,
|
||||||
|
startedAtMs: selection.startedAtMs ?? context.serverNowMs,
|
||||||
|
},
|
||||||
|
version: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a native-image scene from bytes this display already has.
|
||||||
|
*
|
||||||
|
* Separate from {@link sceneRequestFor} because offline is a different question: a persisted
|
||||||
|
* selection deliberately carries **no** delivery URL — one is a bearer capability with an expiry —
|
||||||
|
* so the only thing that can paint it is the local copy, and if there is none there is nothing to
|
||||||
|
* show.
|
||||||
|
*/
|
||||||
|
export function nativeSceneRequestFor(
|
||||||
|
selection: SceneSelection,
|
||||||
|
context: SceneContext,
|
||||||
|
localUrl: string,
|
||||||
|
): SceneRequest | undefined {
|
||||||
|
if (selection.renderer.kind !== "native-image" || selection.scene === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
alt: "",
|
||||||
|
key: sceneKeyFor(selection, context),
|
||||||
|
kind: "native-image",
|
||||||
|
scene: selection.scene,
|
||||||
|
url: localUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a scene, or returns `undefined` when this selection cannot be shown here.
|
||||||
|
*
|
||||||
|
* `undefined` is not a failure: a native image whose delivery URL is missing, or a contributed
|
||||||
|
* renderer the platform did not publish, is content this display genuinely cannot render — and the
|
||||||
|
* caller keeps its last good frame rather than blanking the screen.
|
||||||
|
*/
|
||||||
|
export function sceneRequestFor(
|
||||||
|
selection: SceneSelection,
|
||||||
|
context: SceneContext,
|
||||||
|
options: { readonly canMountContribution: (contributionId: string) => boolean },
|
||||||
|
): SceneRequest | undefined {
|
||||||
|
const key = sceneKeyFor(selection, context);
|
||||||
|
|
||||||
|
if (selection.renderer.kind === "native-image") {
|
||||||
|
const url = selection.media?.display.url;
|
||||||
|
if (url === undefined || selection.scene === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
alt: "",
|
||||||
|
key,
|
||||||
|
kind: "native-image",
|
||||||
|
scene: selection.scene,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.renderer.kind !== "contribution") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!options.canMountContribution(selection.renderer.contributionId)) {
|
||||||
|
// The contributing module is absent, disabled, or outside Signage's compatible range. That is
|
||||||
|
// ordinary rather than exceptional (ADR 0041) and the display simply cannot draw this item.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
contributionId: selection.renderer.contributionId,
|
||||||
|
input: rendererInputFor(selection, context),
|
||||||
|
key,
|
||||||
|
kind: "contribution",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
import type { SignageNativeScene } from "../shared/native-images.js";
|
||||||
|
import type { SignageSlideRendererInput } from "../shared/renderer-input.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two-layer scene stage (ADR 0006).
|
||||||
|
*
|
||||||
|
* The legacy `v1` renderer rebuilt image elements per slide and faded the outgoing frame to
|
||||||
|
* transparent, which let the black page background show through the middle of every transition. That
|
||||||
|
* is the single most visible defect this file exists to prevent, and the rule that prevents it is
|
||||||
|
* one sentence: **the outgoing layer is never faded.**
|
||||||
|
*
|
||||||
|
* Two layers are created once and never rebuilt. For every transition the incoming content is
|
||||||
|
* prepared in the hidden layer and *actually awaited* — an image decode, or a contributed renderer's
|
||||||
|
* own `mount` promise — then raised above the outgoing layer at opacity zero and faded in. The
|
||||||
|
* outgoing layer stays fully opaque underneath until the fade completes.
|
||||||
|
*
|
||||||
|
* Everything the stage touches arrives as an option: the document, the clock, the timer, how an
|
||||||
|
* image is decoded, and how a contribution is mounted. That is what lets the transition itself be
|
||||||
|
* exercised rather than assumed, and what lets the management preview reuse this exact code without
|
||||||
|
* pretending to be a viewer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** What a layer is asked to show. */
|
||||||
|
export type SceneRequest =
|
||||||
|
| {
|
||||||
|
readonly kind: "native-image";
|
||||||
|
/** Identity, so the stage can tell "the same slide, updated" from "a different slide". */
|
||||||
|
readonly key: string;
|
||||||
|
readonly url: string;
|
||||||
|
readonly alt: string;
|
||||||
|
readonly scene: SignageNativeScene;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: "contribution";
|
||||||
|
readonly key: string;
|
||||||
|
readonly contributionId: string;
|
||||||
|
readonly input: SignageSlideRendererInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** What the stage needs of a mounted contribution. Structurally the platform's
|
||||||
|
* `MountedModuleContribution`, restated so this file imports no SDK and stays testable. */
|
||||||
|
export interface MountedRenderer {
|
||||||
|
update?(input: unknown): PromiseLike<void> | void;
|
||||||
|
unmount?(): PromiseLike<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneStageOptions {
|
||||||
|
readonly root: Element;
|
||||||
|
/** The starting crossfade. It is an operator setting that arrives with channel state, so it is
|
||||||
|
* also settable afterwards — a stage is created before the first state is fetched. */
|
||||||
|
readonly crossfadeMs: number;
|
||||||
|
/** Mounts one contributed renderer into a container the stage owns. */
|
||||||
|
readonly mountContribution?: (options: {
|
||||||
|
readonly container: Element;
|
||||||
|
readonly contributionId: string;
|
||||||
|
readonly input: SignageSlideRendererInput;
|
||||||
|
}) => Promise<MountedRenderer>;
|
||||||
|
/** How an image is awaited. A browser's `decode()` by default; a test supplies its own. */
|
||||||
|
readonly decodeImage?: (image: HTMLImageElement) => Promise<void>;
|
||||||
|
/** How the crossfade is waited out. A test supplies a synchronous one. */
|
||||||
|
readonly setTimer?: (handler: () => void, delayMs: number) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SceneOutcome =
|
||||||
|
/** The scene is now on screen. */
|
||||||
|
| { readonly kind: "shown" }
|
||||||
|
/** The scene was already on screen; only its input changed, and the renderer was updated in place
|
||||||
|
* without a transition. This is what lets a cancelled event change a slide without restarting it. */
|
||||||
|
| { readonly kind: "updated" }
|
||||||
|
/** A newer transition won while this one was preparing. Nothing changed. */
|
||||||
|
| { readonly kind: "superseded" }
|
||||||
|
/** The content could not be prepared. **The last good frame is still on screen.** */
|
||||||
|
| { readonly kind: "failed"; readonly code: string };
|
||||||
|
|
||||||
|
export interface SceneStage {
|
||||||
|
show(request: SceneRequest): Promise<SceneOutcome>;
|
||||||
|
/** Covers the output with pure black. The caller's timeline keeps running underneath. */
|
||||||
|
setBlackout(on: boolean): void;
|
||||||
|
/** The built-in broadcast slate. Shown only when there is nothing renderable *and* no good frame. */
|
||||||
|
setSlate(on: boolean): void;
|
||||||
|
/** What is on screen now, or `undefined` if nothing ever was. */
|
||||||
|
currentKey(): string | undefined;
|
||||||
|
setCrossfade(milliseconds: number): void;
|
||||||
|
destroy(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Layer {
|
||||||
|
readonly element: HTMLElement;
|
||||||
|
readonly background: HTMLElement;
|
||||||
|
readonly backgroundImage: HTMLImageElement;
|
||||||
|
readonly darken: HTMLElement;
|
||||||
|
readonly foreground: HTMLImageElement;
|
||||||
|
readonly rendererHost: HTMLElement;
|
||||||
|
key: string | undefined;
|
||||||
|
renderer: MountedRenderer | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLayer(document: Document, index: number): Layer {
|
||||||
|
const element = document.createElement("div");
|
||||||
|
element.className = "signage-layer";
|
||||||
|
element.dataset.layer = String(index);
|
||||||
|
element.style.opacity = "0";
|
||||||
|
|
||||||
|
const background = document.createElement("div");
|
||||||
|
background.className = "signage-layer__background";
|
||||||
|
const backgroundImage = document.createElement("img");
|
||||||
|
backgroundImage.className = "signage-layer__background-image";
|
||||||
|
backgroundImage.alt = "";
|
||||||
|
const darken = document.createElement("div");
|
||||||
|
darken.className = "signage-layer__darken";
|
||||||
|
const foreground = document.createElement("img");
|
||||||
|
foreground.className = "signage-layer__foreground";
|
||||||
|
const rendererHost = document.createElement("div");
|
||||||
|
rendererHost.className = "signage-layer__renderer";
|
||||||
|
|
||||||
|
element.append(background, backgroundImage, darken, foreground, rendererHost);
|
||||||
|
return {
|
||||||
|
background,
|
||||||
|
backgroundImage,
|
||||||
|
darken,
|
||||||
|
element,
|
||||||
|
foreground,
|
||||||
|
key: undefined,
|
||||||
|
renderer: undefined,
|
||||||
|
rendererHost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSceneStage(options: SceneStageOptions): SceneStage {
|
||||||
|
const document = options.root.ownerDocument;
|
||||||
|
if (!document) {
|
||||||
|
throw new Error("A Signage scene stage needs a document.");
|
||||||
|
}
|
||||||
|
const decodeImage =
|
||||||
|
options.decodeImage ??
|
||||||
|
((image: HTMLImageElement) =>
|
||||||
|
typeof image.decode === "function" ? image.decode() : Promise.resolve());
|
||||||
|
const setTimer =
|
||||||
|
options.setTimer ?? ((handler: () => void, delayMs: number) => setTimeout(handler, delayMs));
|
||||||
|
|
||||||
|
const layers: [Layer, Layer] = [createLayer(document, 0), createLayer(document, 1)];
|
||||||
|
// The stage owns the two layers and nothing else. Blackout and the slate are **states of the
|
||||||
|
// root**, reported as attributes: whoever owns this root renders them, so there is exactly one
|
||||||
|
// slate implementation and this file never duplicates it.
|
||||||
|
const root = options.root as HTMLElement;
|
||||||
|
root.dataset.blackout = "false";
|
||||||
|
root.dataset.slate = "false";
|
||||||
|
root.prepend(layers[1].element);
|
||||||
|
root.prepend(layers[0].element);
|
||||||
|
|
||||||
|
let currentIndex = 0;
|
||||||
|
let generation = 0;
|
||||||
|
let destroyed = false;
|
||||||
|
let crossfadeMs = options.crossfadeMs;
|
||||||
|
|
||||||
|
async function releaseRenderer(layer: Layer): Promise<void> {
|
||||||
|
const renderer = layer.renderer;
|
||||||
|
layer.renderer = undefined;
|
||||||
|
if (!renderer?.unmount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await renderer.unmount();
|
||||||
|
} catch {
|
||||||
|
// A renderer whose cleanup throws must not stop the stage. The layer is reused either way,
|
||||||
|
// and the DOM it owned is cleared below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearLayer(layer: Layer): Promise<void> {
|
||||||
|
await releaseRenderer(layer);
|
||||||
|
layer.foreground.removeAttribute("src");
|
||||||
|
layer.backgroundImage.removeAttribute("src");
|
||||||
|
layer.rendererHost.replaceChildren();
|
||||||
|
layer.key = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepares the hidden layer, refusing to leave anything behind for a generation that has already
|
||||||
|
* been superseded.
|
||||||
|
*
|
||||||
|
* The token is not a nicety: two transitions can be in flight at once — one waiting on a slow
|
||||||
|
* decode while a newer one arrives — and both target the same hidden layer. Without checking the
|
||||||
|
* token *after* every await, a slow mount resolving late would overwrite the newer transition's
|
||||||
|
* renderer handle and leak the renderer it replaced.
|
||||||
|
*/
|
||||||
|
async function prepare(layer: Layer, request: SceneRequest, token: number): Promise<void> {
|
||||||
|
await clearLayer(layer);
|
||||||
|
layer.element.dataset.sceneKey = request.key;
|
||||||
|
|
||||||
|
if (request.kind === "native-image") {
|
||||||
|
const scene = request.scene;
|
||||||
|
layer.element.dataset.fit = scene.fit;
|
||||||
|
layer.background.style.backgroundColor =
|
||||||
|
scene.backgroundMode === "solid" ? scene.backgroundColor : "#000000";
|
||||||
|
if (scene.backgroundImage) {
|
||||||
|
layer.backgroundImage.style.display = "block";
|
||||||
|
layer.backgroundImage.style.filter = `blur(${scene.blurPixels}px)`;
|
||||||
|
layer.backgroundImage.setAttribute("src", request.url);
|
||||||
|
} else {
|
||||||
|
layer.backgroundImage.style.display = "none";
|
||||||
|
}
|
||||||
|
layer.darken.style.opacity = String(scene.darkenOpacity);
|
||||||
|
layer.foreground.style.display = "block";
|
||||||
|
layer.foreground.style.objectFit = scene.fit === "fill" ? "cover" : "contain";
|
||||||
|
layer.foreground.style.filter = scene.shadow
|
||||||
|
? "drop-shadow(0 0.6vh 1.6vh rgba(0, 0, 0, 0.45))"
|
||||||
|
: "none";
|
||||||
|
layer.foreground.alt = request.alt;
|
||||||
|
layer.foreground.setAttribute("src", request.url);
|
||||||
|
// The element that will actually be composited must be **decodable**, not merely present in
|
||||||
|
// the DOM. `onload` is not enough: a large image paints mid-fade.
|
||||||
|
await decodeImage(layer.foreground);
|
||||||
|
if (scene.backgroundImage) {
|
||||||
|
await decodeImage(layer.backgroundImage);
|
||||||
|
}
|
||||||
|
if (token !== generation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
layer.key = request.key;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.mountContribution) {
|
||||||
|
throw new Error("This stage cannot mount contributed renderers.");
|
||||||
|
}
|
||||||
|
layer.foreground.removeAttribute("src");
|
||||||
|
layer.foreground.style.display = "none";
|
||||||
|
layer.backgroundImage.style.display = "none";
|
||||||
|
layer.darken.style.opacity = "0";
|
||||||
|
layer.background.style.backgroundColor = "#000000";
|
||||||
|
// Asynchronous by contract: the renderer resolves when it is genuinely ready to be seen, and the
|
||||||
|
// fade below does not begin until it does.
|
||||||
|
const mounted = await options.mountContribution({
|
||||||
|
container: layer.rendererHost,
|
||||||
|
contributionId: request.contributionId,
|
||||||
|
input: request.input,
|
||||||
|
});
|
||||||
|
if (token !== generation) {
|
||||||
|
// A newer transition owns this layer now. Release what was just mounted rather than storing a
|
||||||
|
// handle over the one that transition is using.
|
||||||
|
try {
|
||||||
|
await mounted.unmount?.();
|
||||||
|
} catch {
|
||||||
|
// Nothing to do: the layer belongs to someone else, and its own cleanup will run.
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
layer.renderer = mounted;
|
||||||
|
layer.key = request.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
if (ms <= 0) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
// Nothing is cleared here: a timer that has fired needs no cancelling, and clearing one from
|
||||||
|
// inside its own handler is what breaks when a caller supplies a synchronous timer.
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimer(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentKey: () => layers[currentIndex]?.key,
|
||||||
|
|
||||||
|
async destroy(): Promise<void> {
|
||||||
|
destroyed = true;
|
||||||
|
generation += 1;
|
||||||
|
await Promise.all([clearLayer(layers[0]), clearLayer(layers[1])]);
|
||||||
|
layers[0].element.remove();
|
||||||
|
layers[1].element.remove();
|
||||||
|
},
|
||||||
|
|
||||||
|
setBlackout(on: boolean): void {
|
||||||
|
// Pure black, over everything, and nothing else changes. The timeline underneath keeps its
|
||||||
|
// deadlines and keeps resolving, so ending a blackout lands where it would have.
|
||||||
|
root.dataset.blackout = on ? "true" : "false";
|
||||||
|
},
|
||||||
|
|
||||||
|
setCrossfade(milliseconds: number): void {
|
||||||
|
crossfadeMs = Math.max(0, milliseconds);
|
||||||
|
},
|
||||||
|
|
||||||
|
setSlate(on: boolean): void {
|
||||||
|
root.dataset.slate = on ? "true" : "false";
|
||||||
|
},
|
||||||
|
|
||||||
|
async show(request: SceneRequest): Promise<SceneOutcome> {
|
||||||
|
if (destroyed) {
|
||||||
|
return { kind: "superseded" };
|
||||||
|
}
|
||||||
|
const outgoing = layers[currentIndex] as Layer;
|
||||||
|
|
||||||
|
// Already on screen. If it is a contributed renderer, hand it the new input in place: the
|
||||||
|
// whole point of `update` is a long-running screen changing content without restarting
|
||||||
|
// animations, reloading media, or resetting its slot.
|
||||||
|
if (outgoing.key === request.key) {
|
||||||
|
if (request.kind === "contribution" && outgoing.renderer?.update) {
|
||||||
|
try {
|
||||||
|
await outgoing.renderer.update(request.input);
|
||||||
|
return { kind: "updated" };
|
||||||
|
} catch {
|
||||||
|
return { code: "RENDERER_UPDATE_FAILED", kind: "failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { kind: "updated" };
|
||||||
|
}
|
||||||
|
|
||||||
|
generation += 1;
|
||||||
|
const token = generation;
|
||||||
|
const incomingIndex = currentIndex === 0 ? 1 : 0;
|
||||||
|
const incoming = layers[incomingIndex] as Layer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prepare(incoming, request, token);
|
||||||
|
} catch {
|
||||||
|
// The last good frame stays exactly where it is. A broken slide costs one slide.
|
||||||
|
await clearLayer(incoming);
|
||||||
|
return {
|
||||||
|
code: request.kind === "native-image" ? "SLIDE_DECODE_FAILED" : "RENDERER_MOUNT_FAILED",
|
||||||
|
kind: "failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authoritative state may have moved on while this was preparing. Completion from a stale
|
||||||
|
// generation is discarded rather than shown — and deliberately without clearing the layer,
|
||||||
|
// which by now belongs to the newer transition.
|
||||||
|
if (destroyed || token !== generation) {
|
||||||
|
return { kind: "superseded" };
|
||||||
|
}
|
||||||
|
|
||||||
|
incoming.element.style.transitionDuration = "0ms";
|
||||||
|
incoming.element.style.opacity = "0";
|
||||||
|
incoming.element.style.zIndex = "2";
|
||||||
|
outgoing.element.style.zIndex = "1";
|
||||||
|
// Guarantees the browser observes opacity 0 before the transition starts, so the incoming
|
||||||
|
// layer animates instead of snapping.
|
||||||
|
void incoming.element.offsetHeight;
|
||||||
|
incoming.element.style.transitionDuration = `${Math.max(0, crossfadeMs)}ms`;
|
||||||
|
incoming.element.style.opacity = "1";
|
||||||
|
incoming.element.dataset.visible = "true";
|
||||||
|
// The outgoing layer is **not** touched. Fading both is what let the black root show through.
|
||||||
|
|
||||||
|
await delay(crossfadeMs);
|
||||||
|
|
||||||
|
if (destroyed || token !== generation) {
|
||||||
|
// A newer transition won while this one was fading; it owns the layers now.
|
||||||
|
return { kind: "superseded" };
|
||||||
|
}
|
||||||
|
|
||||||
|
currentIndex = incomingIndex;
|
||||||
|
outgoing.element.dataset.visible = "false";
|
||||||
|
outgoing.element.style.transitionDuration = "0ms";
|
||||||
|
outgoing.element.style.opacity = "0";
|
||||||
|
await clearLayer(outgoing);
|
||||||
|
return { kind: "shown" };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The built-in slate a Signage display shows when it has nothing it can honestly render: no timeline
|
||||||
|
* content, and no cached good frame to keep.
|
||||||
|
*
|
||||||
|
* It is deliberately unmistakable and deliberately uninformative. Restrained broadcast-style bars
|
||||||
|
* and four words tell anyone in the room that the screen is working and the content is not — while
|
||||||
|
* showing no exception, URL, provider payload, credential, database detail, or internal identifier.
|
||||||
|
* Full error detail belongs only in Signage's sanitized management telemetry, where an operator who
|
||||||
|
* is already authorized can read it.
|
||||||
|
*
|
||||||
|
* Nothing here animates. A lobby television showing a flashing diagnostic for an hour is worse than
|
||||||
|
* one showing a still frame, and an unattended screen is exactly the case this exists for.
|
||||||
|
*
|
||||||
|
* A deliberate blackout is **not** this: that renders pure black, and never reaches here.
|
||||||
|
*/
|
||||||
|
export function SignageSlate(): ReactElement {
|
||||||
|
return (
|
||||||
|
<section aria-live="polite" className="signage-slate" data-testid="signage-slate">
|
||||||
|
<div aria-hidden="true" className="signage-slate__bars">
|
||||||
|
<span className="signage-slate__bar signage-slate__bar--one" />
|
||||||
|
<span className="signage-slate__bar signage-slate__bar--two" />
|
||||||
|
<span className="signage-slate__bar signage-slate__bar--three" />
|
||||||
|
<span className="signage-slate__bar signage-slate__bar--four" />
|
||||||
|
<span className="signage-slate__bar signage-slate__bar--five" />
|
||||||
|
<span className="signage-slate__bar signage-slate__bar--six" />
|
||||||
|
</div>
|
||||||
|
<p className="signage-slate__message">Content unavailable</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* A display's own local storage: **IndexedDB for structured state, Cache Storage for image bytes**
|
||||||
|
* (ADR 0006).
|
||||||
|
*
|
||||||
|
* Ordinary HTTP cache is deliberately not used as the asset manager. It offers no eviction control,
|
||||||
|
* no way to prune a superseded rendition on purpose, and no way to answer the one question a display
|
||||||
|
* has to answer honestly — *what can I actually render if the network is gone?*
|
||||||
|
*
|
||||||
|
* Everything here is behind a small interface with an in-memory implementation, because a display's
|
||||||
|
* offline behaviour is the part that must be tested and IndexedDB is the part a test runner cannot
|
||||||
|
* reliably host. Both implementations satisfy the same contract; the browser one is thin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One key-value store of structured, JSON-serializable state. */
|
||||||
|
export interface SignageLocalStore {
|
||||||
|
read<Value>(key: string): Promise<Value | undefined>;
|
||||||
|
write(key: string, value: unknown): Promise<void>;
|
||||||
|
delete(key: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Image bytes a display can still paint with no network.
|
||||||
|
*
|
||||||
|
* Everything is addressed by a **stable content key**, never by the delivery URL. A delivery URL is
|
||||||
|
* signed and short-lived: it changes on every refresh of the same bytes, so a cache keyed on one
|
||||||
|
* would store the same image over and over and find none of it after a reconnect. The key is derived
|
||||||
|
* from identity that does not change — the provider, the item, and the item's revision — so the
|
||||||
|
* bytes a display cached an hour ago are the bytes it can still paint now.
|
||||||
|
*/
|
||||||
|
export interface SignageMediaCache {
|
||||||
|
/** Fetches from `url` and stores under `key`, returning a local URL to render from. */
|
||||||
|
store(key: string, url: string): Promise<string | undefined>;
|
||||||
|
/** A local URL for previously stored bytes, or `undefined` if they were never stored. */
|
||||||
|
find(key: string): Promise<string | undefined>;
|
||||||
|
/** Removes every key not in the keep set. Called only after a new snapshot is usable, so a prune
|
||||||
|
* can never leave a screen with less than it had. */
|
||||||
|
prune(keep: readonly string[]): Promise<void>;
|
||||||
|
count(): Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stable key a native image's bytes are cached under. Deliberately built from identity, never
|
||||||
|
* from a URL. */
|
||||||
|
export function signageMediaKey(input: {
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly itemId: string;
|
||||||
|
readonly itemRevision: string;
|
||||||
|
readonly rendition: string;
|
||||||
|
}): string {
|
||||||
|
return `signage-media/${encodeURIComponent(input.providerId)}/${encodeURIComponent(input.itemId)}/${encodeURIComponent(input.itemRevision)}/${input.rendition}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SIGNAGE_LOCAL_DATABASE = "ekklesia-signage";
|
||||||
|
export const SIGNAGE_LOCAL_STORE = "state";
|
||||||
|
export const SIGNAGE_MEDIA_CACHE = "ekklesia-signage-media-v1";
|
||||||
|
|
||||||
|
export function createMemoryStore(): SignageLocalStore {
|
||||||
|
const values = new Map<string, unknown>();
|
||||||
|
return {
|
||||||
|
delete(key: string): Promise<void> {
|
||||||
|
values.delete(key);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
read<Value>(key: string): Promise<Value | undefined> {
|
||||||
|
return Promise.resolve(values.get(key) as Value | undefined);
|
||||||
|
},
|
||||||
|
write(key: string, value: unknown): Promise<void> {
|
||||||
|
// Structured state only. Round-tripping through JSON here is what keeps a `Date`, a function,
|
||||||
|
// or a cycle from being written now and failing to come back later.
|
||||||
|
values.set(key, JSON.parse(JSON.stringify(value)) as unknown);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMemoryMediaCache(): SignageMediaCache {
|
||||||
|
const stored = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
count: () => Promise.resolve(stored.size),
|
||||||
|
find: (key: string) => Promise.resolve(stored.get(key)),
|
||||||
|
prune(keep: readonly string[]): Promise<void> {
|
||||||
|
const retained = new Set(keep);
|
||||||
|
for (const key of [...stored.keys()]) {
|
||||||
|
if (!retained.has(key)) {
|
||||||
|
stored.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
store(key: string, url: string): Promise<string | undefined> {
|
||||||
|
stored.set(key, url);
|
||||||
|
return Promise.resolve(url);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The IndexedDB-backed store a real display uses. Falls back to memory where IndexedDB is absent —
|
||||||
|
* a private window, a policy that blocks storage — because a display that cannot persist must still
|
||||||
|
* render, online. */
|
||||||
|
export function createBrowserStore(scope: typeof globalThis = globalThis): SignageLocalStore {
|
||||||
|
const maybeIndexedDb = (scope as { indexedDB?: IDBFactory }).indexedDB;
|
||||||
|
if (!maybeIndexedDb) {
|
||||||
|
return createMemoryStore();
|
||||||
|
}
|
||||||
|
const indexedDb: IDBFactory = maybeIndexedDb;
|
||||||
|
|
||||||
|
function open(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDb.open(SIGNAGE_LOCAL_DATABASE, 1);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const database = request.result;
|
||||||
|
if (!database.objectStoreNames.contains(SIGNAGE_LOCAL_STORE)) {
|
||||||
|
database.createObjectStore(SIGNAGE_LOCAL_STORE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(new Error("Signage could not open its local store."));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transact<Result>(
|
||||||
|
mode: IDBTransactionMode,
|
||||||
|
run: (store: IDBObjectStore) => IDBRequest,
|
||||||
|
): Promise<Result | undefined> {
|
||||||
|
let database: IDBDatabase;
|
||||||
|
try {
|
||||||
|
database = await open();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return new Promise<Result | undefined>((resolve) => {
|
||||||
|
try {
|
||||||
|
const transaction = database.transaction(SIGNAGE_LOCAL_STORE, mode);
|
||||||
|
const request = run(transaction.objectStore(SIGNAGE_LOCAL_STORE));
|
||||||
|
request.onsuccess = () => resolve(request.result as Result | undefined);
|
||||||
|
request.onerror = () => resolve(undefined);
|
||||||
|
transaction.oncomplete = () => database.close();
|
||||||
|
} catch {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async delete(key: string): Promise<void> {
|
||||||
|
await transact("readwrite", (store) => store.delete(key));
|
||||||
|
},
|
||||||
|
read<Value>(key: string): Promise<Value | undefined> {
|
||||||
|
return transact<Value>("readonly", (store) => store.get(key));
|
||||||
|
},
|
||||||
|
async write(key: string, value: unknown): Promise<void> {
|
||||||
|
await transact("readwrite", (store) => store.put(value, key));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Cache Storage-backed media cache a real display uses. Absent Cache Storage — an insecure
|
||||||
|
* origin in development, a policy that refuses — a display works exactly as it did before: online. */
|
||||||
|
export function createBrowserMediaCache(scope: typeof globalThis = globalThis): SignageMediaCache {
|
||||||
|
const maybeCaches = (scope as { caches?: CacheStorage }).caches;
|
||||||
|
const fetchFn = (scope as { fetch?: typeof fetch }).fetch;
|
||||||
|
if (!maybeCaches || !fetchFn) {
|
||||||
|
return createMemoryMediaCache();
|
||||||
|
}
|
||||||
|
const caches: CacheStorage = maybeCaches;
|
||||||
|
|
||||||
|
async function open(): Promise<Cache | undefined> {
|
||||||
|
try {
|
||||||
|
return await caches.open(SIGNAGE_MEDIA_CACHE);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache Storage keys on a request URL, so a stable key becomes a stable absolute URL on this
|
||||||
|
* origin. It is never fetched; it exists only to be a key. */
|
||||||
|
function cacheRequest(key: string): string {
|
||||||
|
const origin =
|
||||||
|
(scope as { location?: { origin?: string } }).location?.origin ?? "https://signage.invalid";
|
||||||
|
return `${origin}/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function localUrl(cache: Cache, url: string): Promise<string | undefined> {
|
||||||
|
const response = await cache.match(url);
|
||||||
|
if (!response) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const blob = await response.blob();
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async count(): Promise<number> {
|
||||||
|
const cache = await open();
|
||||||
|
if (!cache) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return (await cache.keys()).length;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async find(key: string): Promise<string | undefined> {
|
||||||
|
const cache = await open();
|
||||||
|
return cache ? localUrl(cache, cacheRequest(key)) : undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
async prune(keep: readonly string[]): Promise<void> {
|
||||||
|
const cache = await open();
|
||||||
|
if (!cache) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const retained = new Set(keep.map((key) => cacheRequest(key)));
|
||||||
|
try {
|
||||||
|
for (const request of await cache.keys()) {
|
||||||
|
if (!retained.has(request.url)) {
|
||||||
|
await cache.delete(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A prune that fails leaves a screen with more than it needs, which is the safe direction.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async store(key: string, url: string): Promise<string | undefined> {
|
||||||
|
const cache = await open();
|
||||||
|
if (!cache) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetchFn(url);
|
||||||
|
// Only a complete, successful response is stored. A `404` or a redirect replayed later as a
|
||||||
|
// slide would be worse than having nothing cached.
|
||||||
|
if (!response.ok || response.status !== 200) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Stored under the *stable* key, so the next refresh's freshly signed URL finds these
|
||||||
|
// bytes instead of downloading them again.
|
||||||
|
await cache.put(cacheRequest(key), response.clone());
|
||||||
|
return localUrl(cache, cacheRequest(key));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* The upload queue behind the image library.
|
||||||
|
*
|
||||||
|
* The shape of it is the whole point: **one image, one request**, several at a time but never all at
|
||||||
|
* once. Twenty slides an operator dropped on the page become twenty individually bounded requests,
|
||||||
|
* each of which the server can refuse cheaply, rather than one enormous one whose failure wastes
|
||||||
|
* every byte already sent (ADR 0007). And because only a few are in flight, the dashboard stays
|
||||||
|
* responsive and the API's memory stays bounded while a batch runs.
|
||||||
|
*
|
||||||
|
* Pure and injectable: the queue takes a function that uploads one file and knows nothing about HTTP,
|
||||||
|
* so its ordering, concurrency, and failure behaviour are exercised directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UploadStatus = "queued" | "uploading" | "done" | "failed" | "rejected";
|
||||||
|
|
||||||
|
export interface UploadItem {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly size: number;
|
||||||
|
readonly status: UploadStatus;
|
||||||
|
/** A sanitized code from the server, or one the queue itself decided before sending. */
|
||||||
|
readonly code?: string;
|
||||||
|
readonly message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadFile {
|
||||||
|
readonly name: string;
|
||||||
|
readonly size: number;
|
||||||
|
readonly type: string;
|
||||||
|
slice?(start?: number, end?: number): Blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadQueueOptions {
|
||||||
|
readonly concurrency: number;
|
||||||
|
readonly maxBytes: number;
|
||||||
|
readonly maxFiles: number;
|
||||||
|
/** Uploads exactly one file. Rejecting is a failure for that file and nothing else. */
|
||||||
|
readonly upload: (file: UploadFile) => Promise<void>;
|
||||||
|
readonly onChange: (items: readonly UploadItem[]) => void;
|
||||||
|
readonly newId?: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadQueue {
|
||||||
|
/** Adds files and starts working. Returns immediately; progress arrives through `onChange`. */
|
||||||
|
enqueue(files: readonly UploadFile[]): void;
|
||||||
|
items(): readonly UploadItem[];
|
||||||
|
/** Resolves when nothing is left in flight. */
|
||||||
|
drain(): Promise<void>;
|
||||||
|
clearFinished(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formats the two rejections the queue itself makes, so an operator is told before a byte is sent
|
||||||
|
* rather than after the server refuses. */
|
||||||
|
export const UPLOAD_REJECTIONS = Object.freeze({
|
||||||
|
SIGNAGE_IMAGE_TOO_LARGE: "This file is larger than Signage accepts.",
|
||||||
|
SIGNAGE_UPLOAD_BATCH_TOO_LARGE: "Too many files at once. Add them in smaller batches.",
|
||||||
|
SIGNAGE_UPLOAD_UNSUPPORTED_TYPE: "Signage accepts PNG, JPEG, and WebP images.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ACCEPTED_TYPES: ReadonlySet<string> = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||||||
|
|
||||||
|
export function createUploadQueue(options: UploadQueueOptions): UploadQueue {
|
||||||
|
const newId = options.newId ?? (() => Math.random().toString(36).slice(2));
|
||||||
|
let items: UploadItem[] = [];
|
||||||
|
const pending: { item: UploadItem; file: UploadFile }[] = [];
|
||||||
|
let active = 0;
|
||||||
|
let idle: Promise<void> = Promise.resolve();
|
||||||
|
let settleIdle: (() => void) | undefined;
|
||||||
|
|
||||||
|
function publish(): void {
|
||||||
|
options.onChange([...items]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replace(id: string, changes: Partial<UploadItem>): void {
|
||||||
|
items = items.map((item) => (item.id === id ? { ...item, ...changes } : item));
|
||||||
|
publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pump(): void {
|
||||||
|
while (active < options.concurrency && pending.length > 0) {
|
||||||
|
const next = pending.shift();
|
||||||
|
if (!next) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
active += 1;
|
||||||
|
replace(next.item.id, { status: "uploading" });
|
||||||
|
void options
|
||||||
|
.upload(next.file)
|
||||||
|
.then(() => replace(next.item.id, { status: "done" }))
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
// One bad file fails alone. The rest of the batch keeps going, which is the entire reason
|
||||||
|
// a batch is many requests rather than one.
|
||||||
|
const failure = error as { failure?: { code?: string; message?: string } };
|
||||||
|
replace(next.item.id, {
|
||||||
|
code: failure.failure?.code ?? "SIGNAGE_REQUEST_FAILED",
|
||||||
|
message: failure.failure?.message ?? "Signage could not be reached.",
|
||||||
|
status: "failed",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
active -= 1;
|
||||||
|
if (pending.length > 0) {
|
||||||
|
pump();
|
||||||
|
} else if (active === 0) {
|
||||||
|
settleIdle?.();
|
||||||
|
settleIdle = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clearFinished(): void {
|
||||||
|
items = items.filter((item) => item.status === "queued" || item.status === "uploading");
|
||||||
|
publish();
|
||||||
|
},
|
||||||
|
|
||||||
|
drain(): Promise<void> {
|
||||||
|
if (active === 0 && pending.length === 0) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
idle = new Promise<void>((resolve) => {
|
||||||
|
settleIdle = resolve;
|
||||||
|
});
|
||||||
|
return idle;
|
||||||
|
},
|
||||||
|
|
||||||
|
enqueue(files: readonly UploadFile[]): void {
|
||||||
|
const accepted = files.slice(0, options.maxFiles);
|
||||||
|
const overflow = files.slice(options.maxFiles);
|
||||||
|
|
||||||
|
for (const file of accepted) {
|
||||||
|
const item: UploadItem = {
|
||||||
|
id: newId(),
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
status: "queued",
|
||||||
|
};
|
||||||
|
items = [...items, item];
|
||||||
|
// Two rejections happen here, before anything is sent: a file too large for Signage, and a
|
||||||
|
// type it does not accept. The server checks both again — it must, because a browser is not
|
||||||
|
// a security boundary — but telling an operator now costs them nothing.
|
||||||
|
if (file.size > options.maxBytes) {
|
||||||
|
items = items.map((candidate) =>
|
||||||
|
candidate.id === item.id
|
||||||
|
? {
|
||||||
|
...candidate,
|
||||||
|
code: "SIGNAGE_IMAGE_TOO_LARGE",
|
||||||
|
message: UPLOAD_REJECTIONS.SIGNAGE_IMAGE_TOO_LARGE,
|
||||||
|
status: "rejected",
|
||||||
|
}
|
||||||
|
: candidate,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (file.type !== "" && !ACCEPTED_TYPES.has(file.type)) {
|
||||||
|
items = items.map((candidate) =>
|
||||||
|
candidate.id === item.id
|
||||||
|
? {
|
||||||
|
...candidate,
|
||||||
|
code: "SIGNAGE_UPLOAD_UNSUPPORTED_TYPE",
|
||||||
|
message: UPLOAD_REJECTIONS.SIGNAGE_UPLOAD_UNSUPPORTED_TYPE,
|
||||||
|
status: "rejected",
|
||||||
|
}
|
||||||
|
: candidate,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pending.push({ file, item });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of overflow) {
|
||||||
|
items = [
|
||||||
|
...items,
|
||||||
|
{
|
||||||
|
code: "SIGNAGE_UPLOAD_BATCH_TOO_LARGE",
|
||||||
|
id: newId(),
|
||||||
|
message: UPLOAD_REJECTIONS.SIGNAGE_UPLOAD_BATCH_TOO_LARGE,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
status: "rejected",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
publish();
|
||||||
|
pump();
|
||||||
|
},
|
||||||
|
|
||||||
|
items: () => [...items],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,676 @@
|
|||||||
|
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
|
import {
|
||||||
|
type OfflineSnapshot,
|
||||||
|
resolveOffline,
|
||||||
|
serverNowFrom,
|
||||||
|
snapshotFrom,
|
||||||
|
} from "../browser/offline.js";
|
||||||
|
import { createSceneStage, type SceneStage } from "../browser/scene.js";
|
||||||
|
import {
|
||||||
|
nativeSceneRequestFor,
|
||||||
|
type SceneSelection,
|
||||||
|
sceneRequestFor,
|
||||||
|
} from "../browser/scene-request.js";
|
||||||
|
import {
|
||||||
|
createBrowserMediaCache,
|
||||||
|
createBrowserStore,
|
||||||
|
type SignageLocalStore,
|
||||||
|
type SignageMediaCache,
|
||||||
|
signageMediaKey,
|
||||||
|
} from "../browser/storage.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display: what it fetches, what it shows, and what it reports.
|
||||||
|
*
|
||||||
|
* The order of operations is the product. Server state arrives, the scene is prepared in the hidden
|
||||||
|
* layer and *awaited*, and only then does the incoming layer fade in over an outgoing layer that was
|
||||||
|
* never touched. When content cannot be prepared, the last good frame stays exactly where it is and
|
||||||
|
* the failure becomes a bounded, sanitized code in the next heartbeat — never anything on screen.
|
||||||
|
*
|
||||||
|
* Everything the platform provides arrives through the host context: the API client, realtime, and
|
||||||
|
* the renderers other modules contributed. Nothing here builds a URL, opens a socket, or reaches for
|
||||||
|
* a credential.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VIEWER_KEY_STORAGE = "viewer-key";
|
||||||
|
const SNAPSHOT_STORAGE = "last-known-good";
|
||||||
|
/** A safety-net refetch. Realtime drives every transition, but a display that missed one message
|
||||||
|
* must not sit on a stale frame until the next; and a delivery URL has a finite life. */
|
||||||
|
const REFRESH_INTERVAL_MS = 4 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface DisplayChannelStateView {
|
||||||
|
readonly serverNowMs: number;
|
||||||
|
readonly channel: { readonly id: string; readonly key: string; readonly groupId: string };
|
||||||
|
readonly crossfadeMs: number;
|
||||||
|
readonly playback: {
|
||||||
|
readonly revision: number;
|
||||||
|
readonly transport: string;
|
||||||
|
readonly blackout: boolean;
|
||||||
|
readonly remainingMs: number | null;
|
||||||
|
readonly selectionRevision: number | null;
|
||||||
|
readonly currentSelectionId: string | null;
|
||||||
|
readonly currentSlotId: string | null;
|
||||||
|
readonly activeTimelineId: string | null;
|
||||||
|
readonly pendingTimelineId: string | null;
|
||||||
|
readonly startedAtMs: number | null;
|
||||||
|
readonly deadlineAtMs: number | null;
|
||||||
|
};
|
||||||
|
readonly timeline: OfflineSnapshot["timeline"];
|
||||||
|
readonly current: SceneSelection | null;
|
||||||
|
readonly staticHold: SceneSelection | null;
|
||||||
|
readonly selections: readonly SceneSelection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisplaySessionView {
|
||||||
|
readonly viewerId: string;
|
||||||
|
readonly channelId: string;
|
||||||
|
readonly channelKey: string;
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly heartbeatIntervalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What an operator can see about this screen, and what a test asserts on. Sanitized by
|
||||||
|
* construction: identity, counts, and a code. */
|
||||||
|
export interface DisplayDiagnostics {
|
||||||
|
readonly viewerId: string | undefined;
|
||||||
|
readonly channelId: string | undefined;
|
||||||
|
readonly renderedKey: string | undefined;
|
||||||
|
readonly renderedSelectionId: string | undefined;
|
||||||
|
readonly slate: boolean;
|
||||||
|
readonly blackout: boolean;
|
||||||
|
readonly online: boolean;
|
||||||
|
/** Whether this display is currently continuing from its own persisted state rather than from the
|
||||||
|
* server. Reported so an operator can see it; never sent back as authority. */
|
||||||
|
readonly offline: boolean;
|
||||||
|
readonly offlineReady: boolean;
|
||||||
|
readonly cachedAssetCount: number;
|
||||||
|
readonly unusableSelectionIds: readonly string[];
|
||||||
|
readonly lastError: { readonly code: string; readonly detail: string | null } | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisplayControllerOptions {
|
||||||
|
readonly context: ModuleUiHostContext;
|
||||||
|
readonly root: Element;
|
||||||
|
readonly now?: () => number;
|
||||||
|
readonly setTimer?: (handler: () => void, delayMs: number) => unknown;
|
||||||
|
readonly clearTimer?: (handle: unknown) => void;
|
||||||
|
readonly store?: SignageLocalStore;
|
||||||
|
readonly media?: SignageMediaCache;
|
||||||
|
readonly decodeImage?: (image: HTMLImageElement) => Promise<void>;
|
||||||
|
readonly newViewerKey?: () => string;
|
||||||
|
readonly onDiagnostics?: (diagnostics: DisplayDiagnostics) => void;
|
||||||
|
/** Suppresses viewer registration and heartbeats. The management preview uses this: it renders
|
||||||
|
* exactly what a wall renders, and must not appear in the display roster while doing so. */
|
||||||
|
readonly telemetry?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisplayController {
|
||||||
|
start(): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
/** Applies one channel state. Exposed so a test — and the preview — can drive it directly. */
|
||||||
|
apply(state: DisplayChannelStateView): Promise<void>;
|
||||||
|
diagnostics(): DisplayDiagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDisplayController(options: DisplayControllerOptions): DisplayController {
|
||||||
|
const now = options.now ?? (() => Date.now());
|
||||||
|
const setTimer =
|
||||||
|
options.setTimer ??
|
||||||
|
((handler: () => void, delayMs: number) => {
|
||||||
|
return setTimeout(handler, delayMs);
|
||||||
|
});
|
||||||
|
const clearTimer = options.clearTimer ?? ((handle: unknown) => clearTimeout(handle as never));
|
||||||
|
const store = options.store ?? createBrowserStore();
|
||||||
|
const media = options.media ?? createBrowserMediaCache();
|
||||||
|
const telemetry = options.telemetry !== false;
|
||||||
|
|
||||||
|
let stage: SceneStage | undefined;
|
||||||
|
let session: DisplaySessionView | undefined;
|
||||||
|
let subscription: { close(): void } | undefined;
|
||||||
|
let heartbeatHandle: unknown;
|
||||||
|
let refreshHandle: unknown;
|
||||||
|
let stopped = false;
|
||||||
|
let online = false;
|
||||||
|
let renderedSelectionId: string | undefined;
|
||||||
|
let cachedAssetCount = 0;
|
||||||
|
let lastError: { code: string; detail: string | null } | undefined;
|
||||||
|
let appliedRevision = -1;
|
||||||
|
let appliedSelectionRevision: number | null = null;
|
||||||
|
let lastState: DisplayChannelStateView | undefined;
|
||||||
|
let snapshot: OfflineSnapshot | undefined;
|
||||||
|
let offlineActive = false;
|
||||||
|
let offlineHandle: unknown;
|
||||||
|
/** Selections this display has tried and failed to render. A broken item is skipped rather than
|
||||||
|
* retried in a tight loop, which is what keeps one bad slide from becoming a flicker. */
|
||||||
|
const unusable = new Set<string>();
|
||||||
|
|
||||||
|
function report(): void {
|
||||||
|
// A quiet, honest state on the root, which the display renders as a small corner indicator. A
|
||||||
|
// screen whose switch was rebooted must not blank a lobby wall to say so.
|
||||||
|
(options.root as HTMLElement).dataset.offline = offlineActive ? "true" : "false";
|
||||||
|
options.onDiagnostics?.(diagnostics());
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnostics(): DisplayDiagnostics {
|
||||||
|
return {
|
||||||
|
blackout: lastState?.playback.blackout ?? snapshot?.playback.blackout ?? false,
|
||||||
|
cachedAssetCount,
|
||||||
|
channelId: session?.channelId,
|
||||||
|
lastError,
|
||||||
|
offline: offlineActive,
|
||||||
|
offlineReady:
|
||||||
|
snapshot !== undefined && cachedAssetCount >= 0 && snapshot.selections.length > 0,
|
||||||
|
online,
|
||||||
|
renderedKey: stage?.currentKey(),
|
||||||
|
renderedSelectionId,
|
||||||
|
slate: stage?.currentKey() === undefined,
|
||||||
|
unusableSelectionIds: [...unusable],
|
||||||
|
viewerId: session?.viewerId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordError(code: string, detail: string | null = null): void {
|
||||||
|
lastError = { code, detail };
|
||||||
|
report();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewerKey(): Promise<string> {
|
||||||
|
const existing = await store.read<string>(VIEWER_KEY_STORAGE);
|
||||||
|
if (typeof existing === "string" && existing.length >= 8) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
// A display's identity is its own and persists across reboots, so a television that restarts is
|
||||||
|
// recognized as the same screen rather than filling the roster with ghosts.
|
||||||
|
const created =
|
||||||
|
options.newViewerKey?.() ??
|
||||||
|
`viewer-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
||||||
|
await store.write(VIEWER_KEY_STORAGE, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caches a native image's bytes under a **stable** key, and prefers the cached copy when it
|
||||||
|
* exists. A delivery URL is signed and changes on every refresh; the bytes do not. */
|
||||||
|
async function localUrlFor(selection: SceneSelection): Promise<string | undefined> {
|
||||||
|
if (selection.renderer.kind !== "native-image") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const key = signageMediaKey({
|
||||||
|
itemId: selection.itemId,
|
||||||
|
itemRevision: selection.itemRevision,
|
||||||
|
providerId: selection.providerId,
|
||||||
|
rendition: "display",
|
||||||
|
});
|
||||||
|
const cached = await media.find(key);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const remote = selection.media?.display.url;
|
||||||
|
if (remote === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const stored = await media.store(key, remote);
|
||||||
|
cachedAssetCount = await media.count();
|
||||||
|
return stored ?? remote;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSelection(
|
||||||
|
selection: SceneSelection,
|
||||||
|
state: DisplayChannelStateView,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!stage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (unusable.has(selection.id)) {
|
||||||
|
// Already known broken here. Keep what is on screen rather than trying it again immediately.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const context = {
|
||||||
|
audienceKey: state.channel.key,
|
||||||
|
localNowMs: now(),
|
||||||
|
paused: state.playback.transport === "paused",
|
||||||
|
remainingMs: state.playback.remainingMs,
|
||||||
|
serverNowMs: state.serverNowMs,
|
||||||
|
};
|
||||||
|
const request = sceneRequestFor(selection, context, {
|
||||||
|
canMountContribution: (contributionId) =>
|
||||||
|
options.context.contributions
|
||||||
|
.list("signage.slide-renderer")
|
||||||
|
.some((candidate) => candidate.id === contributionId),
|
||||||
|
});
|
||||||
|
if (!request) {
|
||||||
|
// Not renderable here — a missing delivery URL, or a renderer the platform did not publish.
|
||||||
|
// That is a fact about this display, not a failure of playback.
|
||||||
|
unusable.add(selection.id);
|
||||||
|
recordError("SLIDE_UNRENDERABLE");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.kind === "native-image") {
|
||||||
|
const url = await localUrlFor(selection);
|
||||||
|
if (url !== undefined) {
|
||||||
|
Object.assign(request, { url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await stage.show(request);
|
||||||
|
if (outcome.kind === "failed") {
|
||||||
|
unusable.add(selection.id);
|
||||||
|
recordError(outcome.code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (outcome.kind === "superseded") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderedSelectionId = selection.id;
|
||||||
|
if (lastError !== undefined) {
|
||||||
|
lastError = undefined;
|
||||||
|
}
|
||||||
|
report();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apply(state: DisplayChannelStateView): Promise<void> {
|
||||||
|
if (stopped || !stage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The server is authoritative the instant it speaks. Any local cursor is abandoned rather than
|
||||||
|
// reconciled, and it was never reported anywhere to begin with.
|
||||||
|
online = true;
|
||||||
|
exitOffline();
|
||||||
|
lastState = state;
|
||||||
|
// The crossfade is an operator setting and arrives with the state, so the stage is told rather
|
||||||
|
// than assuming one.
|
||||||
|
stage.setCrossfade(state.crossfadeMs);
|
||||||
|
appliedRevision = state.playback.revision;
|
||||||
|
appliedSelectionRevision = state.playback.selectionRevision;
|
||||||
|
// Blackout covers the output; the timeline underneath keeps running and keeps being prepared, so
|
||||||
|
// ending it lands on whatever the server says is current rather than on a stale frame.
|
||||||
|
stage.setBlackout(state.playback.blackout);
|
||||||
|
|
||||||
|
const target = state.staticHold ?? state.current;
|
||||||
|
if (target === null) {
|
||||||
|
// Nothing renderable from the server. If something good is already on screen it stays; only a
|
||||||
|
// display with nothing at all shows the slate.
|
||||||
|
if (stage.currentKey() === undefined) {
|
||||||
|
stage.setSlate(true);
|
||||||
|
}
|
||||||
|
report();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stage.setSlate(false);
|
||||||
|
await renderSelection(target, state);
|
||||||
|
// Everything else the server sent is staged for later: a reconnect should have nothing left to
|
||||||
|
// download, and an outage should have something to continue from. The snapshot is written only
|
||||||
|
// once the frame is up, so what a display resumes from is what it actually showed — and the
|
||||||
|
// media prune runs after it, so nothing is discarded until its replacement is usable.
|
||||||
|
void prefetch(state);
|
||||||
|
await persist(state);
|
||||||
|
scheduleRefresh(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the last known good state.
|
||||||
|
*
|
||||||
|
* Written **after** the frame is on screen, so what a display resumes from is what it actually
|
||||||
|
* showed. Delivery URLs are stripped on the way in: one is a bearer capability with an expiry in
|
||||||
|
* it, and the bytes live in the media cache under a stable key instead.
|
||||||
|
*/
|
||||||
|
async function persist(state: DisplayChannelStateView): Promise<void> {
|
||||||
|
snapshot = snapshotFrom({
|
||||||
|
channel: state.channel,
|
||||||
|
crossfadeMs: state.crossfadeMs,
|
||||||
|
localNowMs: now(),
|
||||||
|
playback: {
|
||||||
|
blackout: state.playback.blackout,
|
||||||
|
currentSelectionId: state.playback.currentSelectionId,
|
||||||
|
currentSlotId: state.playback.currentSlotId,
|
||||||
|
deadlineAtMs: state.playback.deadlineAtMs,
|
||||||
|
pendingTimelineId: state.playback.pendingTimelineId,
|
||||||
|
remainingMs: state.playback.remainingMs,
|
||||||
|
revision: state.playback.revision,
|
||||||
|
startedAtMs: state.playback.startedAtMs,
|
||||||
|
transport: state.playback.transport,
|
||||||
|
},
|
||||||
|
selections: state.selections,
|
||||||
|
serverNowMs: state.serverNowMs,
|
||||||
|
staticHold: state.staticHold,
|
||||||
|
timeline: state.timeline,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await store.write(SNAPSHOT_STORAGE, snapshot);
|
||||||
|
} catch {
|
||||||
|
// Persistence is best effort and never blocks rendering. A display that cannot write still
|
||||||
|
// works — online.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Continues from the last authoritative anchor, with no network.
|
||||||
|
*
|
||||||
|
* Deterministic, arithmetic, and bounded: it replays selections the server already resolved and
|
||||||
|
* this display already cached, and it never invents a provider's answer. Its cursor is never
|
||||||
|
* reported anywhere — the moment the server speaks again, the server wins.
|
||||||
|
*/
|
||||||
|
async function runOffline(): Promise<void> {
|
||||||
|
if (stopped || online || !stage || !snapshot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
offlineActive = true;
|
||||||
|
// The crossfade is part of the persisted state too, so a screen that resumes offline fades the
|
||||||
|
// way its operator configured rather than the way this file happened to default.
|
||||||
|
stage.setCrossfade(snapshot.crossfadeMs);
|
||||||
|
const atMs = serverNowFrom(snapshot, now());
|
||||||
|
const evaluation = resolveOffline({ atMs, snapshot, unusableSelectionIds: unusable });
|
||||||
|
const resolution = evaluation.resolution;
|
||||||
|
|
||||||
|
if (resolution.kind === "empty") {
|
||||||
|
// The cached timeline genuinely has nothing eligible. Black is correct, and only when nothing
|
||||||
|
// good is already up.
|
||||||
|
if (stage.currentKey() === undefined) {
|
||||||
|
stage.setSlate(true);
|
||||||
|
}
|
||||||
|
} else if (resolution.kind === "static" || resolution.kind === "selection") {
|
||||||
|
await renderOfflineSelection(resolution.selection, atMs);
|
||||||
|
} else if (resolution.kind === "paused" && resolution.selection) {
|
||||||
|
await renderOfflineSelection(resolution.selection, atMs);
|
||||||
|
}
|
||||||
|
// `hold` is deliberate: keep the last good frame and say nothing.
|
||||||
|
|
||||||
|
report();
|
||||||
|
scheduleOffline(evaluation.nextBoundaryMs, atMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderOfflineSelection(selection: SceneSelection, atMs: number): Promise<void> {
|
||||||
|
if (!stage || !snapshot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const context = {
|
||||||
|
audienceKey: snapshot.channel.key,
|
||||||
|
localNowMs: now(),
|
||||||
|
paused: snapshot.playback.transport === "paused",
|
||||||
|
remainingMs: snapshot.playback.remainingMs,
|
||||||
|
serverNowMs: atMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
let request: ReturnType<typeof sceneRequestFor>;
|
||||||
|
if (selection.renderer.kind === "native-image") {
|
||||||
|
// Offline, only what is genuinely cached can be painted: a persisted selection carries no
|
||||||
|
// delivery URL, deliberately, so the local copy is the only source there is.
|
||||||
|
const cached = await media.find(
|
||||||
|
signageMediaKey({
|
||||||
|
itemId: selection.itemId,
|
||||||
|
itemRevision: selection.itemRevision,
|
||||||
|
providerId: selection.providerId,
|
||||||
|
rendition: "display",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
request =
|
||||||
|
cached === undefined ? undefined : nativeSceneRequestFor(selection, context, cached);
|
||||||
|
} else {
|
||||||
|
request = sceneRequestFor(selection, context, {
|
||||||
|
canMountContribution: (contributionId) =>
|
||||||
|
options.context.contributions
|
||||||
|
.list("signage.slide-renderer")
|
||||||
|
.some((candidate) => candidate.id === contributionId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
// Nothing local can draw this. The slot is skipped and the last good frame stays.
|
||||||
|
unusable.add(selection.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const outcome = await stage.show(request);
|
||||||
|
if (outcome.kind === "failed") {
|
||||||
|
unusable.add(selection.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (outcome.kind !== "superseded") {
|
||||||
|
renderedSelectionId = selection.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleOffline(boundaryMs: number | null, atMs: number): void {
|
||||||
|
if (offlineHandle !== undefined) {
|
||||||
|
clearTimer(offlineHandle);
|
||||||
|
offlineHandle = undefined;
|
||||||
|
}
|
||||||
|
if (stopped || online || boundaryMs === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
offlineHandle = setTimer(
|
||||||
|
() => {
|
||||||
|
offlineHandle = undefined;
|
||||||
|
void runOffline();
|
||||||
|
},
|
||||||
|
Math.max(250, boundaryMs - atMs),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitOffline(): void {
|
||||||
|
offlineActive = false;
|
||||||
|
if (offlineHandle !== undefined) {
|
||||||
|
clearTimer(offlineHandle);
|
||||||
|
offlineHandle = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prefetch(state: DisplayChannelStateView): Promise<void> {
|
||||||
|
for (const selection of state.selections) {
|
||||||
|
if (selection.renderer.kind !== "native-image") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await localUrlFor(selection);
|
||||||
|
} catch {
|
||||||
|
// A slide that cannot be pre-cached is simply not pre-cached. It will be tried again when it
|
||||||
|
// is actually needed, and the frame on screen is unaffected.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Superseded bytes are removed **only now**, after everything this state needs is present: a
|
||||||
|
// prune that ran first could leave a screen with less than it had.
|
||||||
|
await media.prune(
|
||||||
|
state.selections
|
||||||
|
.filter((selection) => selection.renderer.kind === "native-image")
|
||||||
|
.map((selection) =>
|
||||||
|
signageMediaKey({
|
||||||
|
itemId: selection.itemId,
|
||||||
|
itemRevision: selection.itemRevision,
|
||||||
|
providerId: selection.providerId,
|
||||||
|
rendition: "display",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
cachedAssetCount = await media.count();
|
||||||
|
report();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRefresh(state: DisplayChannelStateView): void {
|
||||||
|
if (refreshHandle !== undefined) {
|
||||||
|
clearTimer(refreshHandle);
|
||||||
|
refreshHandle = undefined;
|
||||||
|
}
|
||||||
|
if (stopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Just after the current slot's deadline, so a missed realtime message costs one late
|
||||||
|
// transition rather than a stuck screen; and never longer than the safety-net interval.
|
||||||
|
const untilDeadline =
|
||||||
|
state.playback.deadlineAtMs === null
|
||||||
|
? REFRESH_INTERVAL_MS
|
||||||
|
: Math.max(250, state.playback.deadlineAtMs - state.serverNowMs + 250);
|
||||||
|
refreshHandle = setTimer(
|
||||||
|
() => {
|
||||||
|
refreshHandle = undefined;
|
||||||
|
void refresh();
|
||||||
|
},
|
||||||
|
Math.min(REFRESH_INTERVAL_MS, untilDeadline),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
if (stopped || !session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await options.context.api.request(
|
||||||
|
`channels/${session.channelId}/display-state`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
online = false;
|
||||||
|
await runOffline();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await apply((await response.json()) as DisplayChannelStateView);
|
||||||
|
} catch {
|
||||||
|
// The server is unreachable. The frame stays and local continuation takes over; nothing about
|
||||||
|
// a network failure belongs on a lobby wall.
|
||||||
|
online = false;
|
||||||
|
await runOffline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function heartbeat(): Promise<void> {
|
||||||
|
if (stopped || !session || !telemetry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await options.context.api.request(`viewers/${session.viewerId}/heartbeat`, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
cachedAssetCount,
|
||||||
|
error:
|
||||||
|
lastError === undefined ? null : { code: lastError.code, detail: lastError.detail },
|
||||||
|
offlineReady: snapshot !== undefined && snapshot.selections.length > 0,
|
||||||
|
reportedContributionId:
|
||||||
|
lastState?.current?.renderer.kind === "contribution"
|
||||||
|
? lastState.current.renderer.contributionId
|
||||||
|
: null,
|
||||||
|
reportedPlaybackRevision: appliedRevision >= 0 ? appliedRevision : null,
|
||||||
|
reportedProviderId: lastState?.current?.providerId ?? null,
|
||||||
|
reportedSelectionId: renderedSelectionId ?? null,
|
||||||
|
reportedTimelineId: lastState?.playback.activeTimelineId ?? null,
|
||||||
|
}),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Telemetry is best effort and never affects what is on screen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
apply,
|
||||||
|
diagnostics,
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
stopped = false;
|
||||||
|
stage = createSceneStage({
|
||||||
|
crossfadeMs: 1000,
|
||||||
|
...(options.decodeImage ? { decodeImage: options.decodeImage } : {}),
|
||||||
|
mountContribution: async ({ container, contributionId, input }) => {
|
||||||
|
// The platform loads, verifies, and mounts the renderer. Signage hands it identity and its
|
||||||
|
// own opaque input, and receives a handle — never a URL, and never the module's code.
|
||||||
|
const mounted = await options.context.contributions.mount({
|
||||||
|
container,
|
||||||
|
contributionId,
|
||||||
|
input: input as never,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
unmount: () => mounted.unmount(),
|
||||||
|
update: (next: unknown) => mounted.update(next as never),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
root: options.root,
|
||||||
|
setTimer,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A frame from the last known good state goes up before the first request returns, so a
|
||||||
|
// reload during a service does not stare at black while a fetch completes.
|
||||||
|
try {
|
||||||
|
const restored = await store.read<OfflineSnapshot>(SNAPSHOT_STORAGE);
|
||||||
|
if (restored?.version === 1) {
|
||||||
|
snapshot = restored;
|
||||||
|
await runOffline();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Nothing persisted, or nothing readable. The display simply starts from the server.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (telemetry) {
|
||||||
|
try {
|
||||||
|
const response = await options.context.api.request("viewers/session", {
|
||||||
|
body: JSON.stringify({ viewerKey: await viewerKey() }),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
session = (await response.json()) as DisplaySessionView;
|
||||||
|
online = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
online = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
subscription = options.context.realtime.subscribe("display-state", {
|
||||||
|
onSnapshot: (state) => onRealtime(state),
|
||||||
|
onStatus: (status) => {
|
||||||
|
online = status === "connected";
|
||||||
|
report();
|
||||||
|
},
|
||||||
|
onUpdate: (state) => onRealtime(state),
|
||||||
|
});
|
||||||
|
heartbeatHandle = setInterval(
|
||||||
|
() => void heartbeat(),
|
||||||
|
session.heartbeatIntervalMs,
|
||||||
|
) as unknown;
|
||||||
|
await refresh();
|
||||||
|
void heartbeat();
|
||||||
|
}
|
||||||
|
report();
|
||||||
|
},
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
stopped = true;
|
||||||
|
subscription?.close();
|
||||||
|
subscription = undefined;
|
||||||
|
if (heartbeatHandle !== undefined) {
|
||||||
|
clearInterval(heartbeatHandle as never);
|
||||||
|
heartbeatHandle = undefined;
|
||||||
|
}
|
||||||
|
if (refreshHandle !== undefined) {
|
||||||
|
clearTimer(refreshHandle);
|
||||||
|
refreshHandle = undefined;
|
||||||
|
}
|
||||||
|
exitOffline();
|
||||||
|
await stage?.destroy();
|
||||||
|
stage = undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A realtime frame carries compact authoritative state and revisions, never content. A revision
|
||||||
|
* this display has not applied means "fetch the state you are authorized for", which is what
|
||||||
|
* closes the GET-then-subscribe race without a second transport (ADR 0005).
|
||||||
|
*/
|
||||||
|
function onRealtime(payload: unknown): void {
|
||||||
|
if (stopped || !session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const groups = (payload as { groups?: readonly Record<string, unknown>[] } | null)?.groups;
|
||||||
|
if (!Array.isArray(groups)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mine = groups.find((group) => group.groupId === session?.groupId);
|
||||||
|
if (!mine) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
online = true;
|
||||||
|
const revision = typeof mine.revision === "number" ? mine.revision : -1;
|
||||||
|
const selectionRevision =
|
||||||
|
typeof mine.selectionRevision === "number" ? mine.selectionRevision : null;
|
||||||
|
if (revision === appliedRevision && selectionRevision === appliedSelectionRevision) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { defineModuleUi, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { type ReactElement, useEffect, useRef } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { SignageSlate } from "../browser/slate.js";
|
||||||
|
import { createDisplayController } from "./controller.js";
|
||||||
|
import displayStyles from "./styles.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signage's full-viewport display.
|
||||||
|
*
|
||||||
|
* React renders the fixed furniture — the stage's root, the blackout cover, and the slate — and the
|
||||||
|
* controller owns everything that moves. That split is deliberate: the two scene layers are created
|
||||||
|
* once and never rebuilt, and a React tree that re-created them on every state change would be the
|
||||||
|
* `v1` renderer again.
|
||||||
|
*
|
||||||
|
* The artifact performs no permission check of its own. The host resolved an authorization-bearing
|
||||||
|
* descriptor before these bytes were ever fetched, and a television has nobody standing at it to act
|
||||||
|
* on a denial — telling a lobby wall "you may not see this" would only report something about the
|
||||||
|
* installation's authorization to whoever walks past.
|
||||||
|
*/
|
||||||
|
function SignageDisplay({ context }: { readonly context: ModuleUiHostContext }): ReactElement {
|
||||||
|
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = stageRef.current;
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controller = createDisplayController({ context, root });
|
||||||
|
void controller.start();
|
||||||
|
return () => {
|
||||||
|
void controller.stop();
|
||||||
|
};
|
||||||
|
}, [context]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="signage-display" data-testid="signage-display">
|
||||||
|
<div className="signage-stage" data-testid="signage-stage" ref={stageRef}>
|
||||||
|
{/* Shown only when the stage says there is nothing renderable *and* no good frame. */}
|
||||||
|
<div className="signage-slate-host">
|
||||||
|
<SignageSlate />
|
||||||
|
</div>
|
||||||
|
{/* A deliberate blackout: pure black over everything, while the hidden timeline runs on. */}
|
||||||
|
<div aria-hidden="true" className="signage-blackout" data-testid="signage-blackout" />
|
||||||
|
{/* Continuing from this screen's own last authoritative state. Small and in a corner: a
|
||||||
|
congregation is looking at this wall, and a network is not their problem. */}
|
||||||
|
<p className="signage-offline-note" data-testid="signage-offline-note">
|
||||||
|
Offline — continuing from the last update
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The compiled full-viewport display artifact. It uses the same role-neutral browser ABI as the
|
||||||
|
* management UI; its separate manifest entrypoint — not a mode flag — is what tells a host which
|
||||||
|
* artifact it is mounting.
|
||||||
|
*/
|
||||||
|
export default defineModuleUi({
|
||||||
|
id: "signage",
|
||||||
|
version: "0.1.0",
|
||||||
|
styles: displayStyles,
|
||||||
|
mount(container, context) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(<SignageDisplay context={context} />);
|
||||||
|
return () => root.unmount();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
/*
|
||||||
|
* The Signage display artifact's own stylesheet, isolated from the management UI's exactly as it is
|
||||||
|
* from the Dashboard's: two artifacts of one module share no styles unless they share a source file
|
||||||
|
* (ADR 0040).
|
||||||
|
*
|
||||||
|
* `:host { display: block }` overrides the host's transparent default deliberately — a full-viewport
|
||||||
|
* display needs a real box, and the host installs its base rules before this stylesheet precisely so
|
||||||
|
* an artifact can override them by ordinary cascade order.
|
||||||
|
*
|
||||||
|
* Almost every value here is a literal rather than a token, and that is the one deliberate exception
|
||||||
|
* in this module. A television is not a themed surface: its ground is black because anything else
|
||||||
|
* would show as a frame around a church's own artwork, and the slate must stay unmistakable whatever
|
||||||
|
* a deployment's theme becomes. The Dashboard's theme never recolours a provider's output or an
|
||||||
|
* uploaded image.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The box model for the tree Signage owns. An isolated root inherits nothing from the page around
|
||||||
|
* it, so a full-viewport layout that assumes `border-box` has to say so.
|
||||||
|
*/
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
--signage-slate-bar-one: #6f6f6f;
|
||||||
|
--signage-slate-bar-two: #6f6f2f;
|
||||||
|
--signage-slate-bar-three: #2f6f6f;
|
||||||
|
--signage-slate-bar-four: #2f6f2f;
|
||||||
|
--signage-slate-bar-five: #6f2f6f;
|
||||||
|
--signage-slate-bar-six: #6f2f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-display {
|
||||||
|
background: #000000;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: var(--ekklesia-typography-family-sans);
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-stage {
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------------------------
|
||||||
|
* The two permanent scene layers.
|
||||||
|
*
|
||||||
|
* Created once by the stage and never rebuilt. Only their contents, stacking, and opacity change —
|
||||||
|
* and an *outgoing* layer is never faded, which is what keeps the black root from showing through
|
||||||
|
* the middle of a transition.
|
||||||
|
* ------------------------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.signage-layer {
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
transition-duration: 0ms;
|
||||||
|
transition-property: opacity;
|
||||||
|
transition-timing-function: linear;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-layer__background {
|
||||||
|
background: #000000;
|
||||||
|
inset: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-layer__background-image {
|
||||||
|
display: none;
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
position: absolute;
|
||||||
|
transform: scale(1.08);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-layer__darken {
|
||||||
|
background: #000000;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-layer__foreground {
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-layer__renderer {
|
||||||
|
inset: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------------------------
|
||||||
|
* Blackout and the slate, both states of the stage root.
|
||||||
|
* ------------------------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.signage-blackout {
|
||||||
|
background: #000000;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
transition: opacity 200ms linear;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-blackout="true"] > .signage-blackout {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-slate-host {
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slate="true"] > .signage-slate-host {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-slate {
|
||||||
|
align-items: stretch;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-slate__bars {
|
||||||
|
display: grid;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-slate__bar {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-slate__bar--one {
|
||||||
|
background: var(--signage-slate-bar-one);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-slate__bar--two {
|
||||||
|
background: var(--signage-slate-bar-two);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-slate__bar--three {
|
||||||
|
background: var(--signage-slate-bar-three);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-slate__bar--four {
|
||||||
|
background: var(--signage-slate-bar-four);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-slate__bar--five {
|
||||||
|
background: var(--signage-slate-bar-five);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-slate__bar--six {
|
||||||
|
background: var(--signage-slate-bar-six);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-slate__message {
|
||||||
|
background: #000000;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 4vh;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin: 0;
|
||||||
|
padding: 3vh 0;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------------------------
|
||||||
|
* The offline indicator.
|
||||||
|
*
|
||||||
|
* Deliberately small, deliberately in a corner, and deliberately not an error. A screen continuing
|
||||||
|
* from its own last authoritative state is working; a full-page failure message on a lobby wall
|
||||||
|
* would be the actual failure.
|
||||||
|
* ------------------------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.signage-offline-note {
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
border-radius: 0.6vh;
|
||||||
|
bottom: 2vh;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
font-size: 1.6vh;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0;
|
||||||
|
padding: 0.8vh 1.4vh;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 2vh;
|
||||||
|
transition: opacity 400ms linear;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-offline="true"] > .signage-offline-note {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
Vendored
+17
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* What `@ekklesia/module-ui-build` turns a local stylesheet import into: that stylesheet's compiled
|
||||||
|
* CSS **text**, as a string (ADR 0040).
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import styles from "./styles.css";
|
||||||
|
* export default defineModuleUi({ id, version, styles, mount });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* There is no sidecar file and no URL — the text is compiled into the declared JavaScript artifact,
|
||||||
|
* and the host installs it into the artifact's isolated root at mount. Copy this file into your own
|
||||||
|
* module and include it in your UI TypeScript project.
|
||||||
|
*/
|
||||||
|
declare module "*.css" {
|
||||||
|
const stylesheet: string;
|
||||||
|
export default stylesheet;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { defineModuleSchema } from "@ekklesia/module-sdk";
|
||||||
|
import {
|
||||||
|
channelGroups,
|
||||||
|
channels,
|
||||||
|
imageAssets,
|
||||||
|
imageRenditions,
|
||||||
|
playbackStates,
|
||||||
|
providerSources,
|
||||||
|
selections,
|
||||||
|
selectionVariants,
|
||||||
|
timelineSlots,
|
||||||
|
timelines,
|
||||||
|
viewers,
|
||||||
|
} from "./tables.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
channelGroups,
|
||||||
|
channels,
|
||||||
|
imageAssets,
|
||||||
|
imageRenditions,
|
||||||
|
playbackStates,
|
||||||
|
providerSources,
|
||||||
|
selections,
|
||||||
|
selectionVariants,
|
||||||
|
timelineSlots,
|
||||||
|
timelines,
|
||||||
|
viewers,
|
||||||
|
} from "./tables.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signage's compiled schema entrypoint: the same table objects `tables.ts` declares, wrapped with the
|
||||||
|
* ownership marker Core validates during discovery. Side-effect free by contract — importing this
|
||||||
|
* connects to nothing and reads nothing.
|
||||||
|
*/
|
||||||
|
export default defineModuleSchema({
|
||||||
|
moduleId: "signage",
|
||||||
|
tables: {
|
||||||
|
channelGroups,
|
||||||
|
channels,
|
||||||
|
imageAssets,
|
||||||
|
imageRenditions,
|
||||||
|
playbackStates,
|
||||||
|
providerSources,
|
||||||
|
selections,
|
||||||
|
selectionVariants,
|
||||||
|
timelines,
|
||||||
|
timelineSlots,
|
||||||
|
viewers,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
bigint,
|
||||||
|
boolean,
|
||||||
|
check,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
pgTableCreator,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
|
uuid,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signage's own PostgreSQL tables.
|
||||||
|
*
|
||||||
|
* Kept to **pure Drizzle declarations** so the stable Drizzle Kit generator can load this file
|
||||||
|
* without executing the ESM Module SDK wrapper; `./index.ts` wraps these same objects with the
|
||||||
|
* ownership marker Core validates. The required `signage_` namespace is applied once here, and Core
|
||||||
|
* validates it independently of this helper.
|
||||||
|
*
|
||||||
|
* Two rules hold everywhere below, and both are architecture rather than style:
|
||||||
|
*
|
||||||
|
* - **No foreign key leaves this module.** A provider's id, a source id, an item id, and a Core
|
||||||
|
* device id are all stored as opaque text. Signage cannot join to another module's data and must
|
||||||
|
* never be able to (ADR 0007 of the platform, ADR 0001 here).
|
||||||
|
* - **No signed delivery URL is ever stored.** Objects are addressed by their logical key, and a
|
||||||
|
* read URL is requested fresh when something is about to be delivered — a stored URL is a
|
||||||
|
* credential with an expiry date in it.
|
||||||
|
*/
|
||||||
|
const moduleTable = pgTableCreator((localName) => `signage_${localName}`);
|
||||||
|
|
||||||
|
/** Timestamps are absolute instants. The installation's IANA zone is an operator setting used to
|
||||||
|
* write and read them for a person, never to interpret one at run time. */
|
||||||
|
const instant = (name: string) => timestamp(name, { mode: "date", withTimezone: true });
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Native images.
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One native image, identified by the SHA-256 of the bytes an operator uploaded (ADR 0007).
|
||||||
|
*
|
||||||
|
* `content_sha256` is unique, so uploading the same file twice returns the same asset instead of
|
||||||
|
* storing a second copy, and re-running a failed ingest is idempotent.
|
||||||
|
*/
|
||||||
|
export const imageAssets = moduleTable(
|
||||||
|
"image_assets",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey(),
|
||||||
|
contentSha256: text("content_sha256").notNull(),
|
||||||
|
displayName: text("display_name").notNull(),
|
||||||
|
originalFilename: text("original_filename").notNull(),
|
||||||
|
/** The archived original, byte for byte. Signage is not entitled to rewrite a church's file. */
|
||||||
|
originalObjectKey: text("original_object_key").notNull(),
|
||||||
|
originalContentType: text("original_content_type").notNull(),
|
||||||
|
originalByteSize: bigint("original_byte_size", { mode: "number" }).notNull(),
|
||||||
|
/** How the original should appear once its own orientation is applied. */
|
||||||
|
width: integer("width").notNull(),
|
||||||
|
height: integer("height").notNull(),
|
||||||
|
/** What is actually encoded in the file, kept for diagnostics without implying the archived
|
||||||
|
* bytes were rewritten. */
|
||||||
|
encodedWidth: integer("encoded_width").notNull(),
|
||||||
|
encodedHeight: integer("encoded_height").notNull(),
|
||||||
|
orientation: integer("orientation"),
|
||||||
|
hasAlpha: boolean("has_alpha").notNull().default(false),
|
||||||
|
/** `pending` while ingest is in flight, `ready` once every rendition is committed, `failed` when
|
||||||
|
* ingest gave up. Only `ready` is ever selectable by playback. */
|
||||||
|
status: text("status").notNull().default("pending"),
|
||||||
|
/** A stable sanitized code, never a caught exception, a path, or a provider message. */
|
||||||
|
failureCode: text("failure_code"),
|
||||||
|
/** Null means permanent. Local age never invents an expiration. */
|
||||||
|
expiresAt: instant("expires_at"),
|
||||||
|
createdAt: instant("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("signage_image_assets_content_sha256_key").on(table.contentSha256),
|
||||||
|
index("signage_image_assets_status_idx").on(table.status),
|
||||||
|
check(
|
||||||
|
"signage_image_assets_status_check",
|
||||||
|
sql`${table.status} in ('pending', 'ready', 'failed')`,
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"signage_image_assets_dimensions_check",
|
||||||
|
sql`${table.width} > 0 and ${table.height} > 0 and ${table.encodedWidth} > 0 and ${table.encodedHeight} > 0`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A generated rendition of one asset. Immutable: different bytes are a different asset, so a
|
||||||
|
* rendition is never rewritten in place.
|
||||||
|
*/
|
||||||
|
export const imageRenditions = moduleTable(
|
||||||
|
"image_renditions",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey(),
|
||||||
|
assetId: uuid("asset_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => imageAssets.id, { onDelete: "cascade" }),
|
||||||
|
kind: text("kind").notNull(),
|
||||||
|
objectKey: text("object_key").notNull(),
|
||||||
|
contentType: text("content_type").notNull(),
|
||||||
|
contentSha256: text("content_sha256").notNull(),
|
||||||
|
byteSize: bigint("byte_size", { mode: "number" }).notNull(),
|
||||||
|
width: integer("width").notNull(),
|
||||||
|
height: integer("height").notNull(),
|
||||||
|
createdAt: instant("created_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("signage_image_renditions_asset_kind_key").on(table.assetId, table.kind),
|
||||||
|
check("signage_image_renditions_kind_check", sql`${table.kind} in ('display', 'thumbnail')`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Timelines, sources, and slots.
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A saved, ordered, repeating pattern. It holds no cursor and no clock: running one is what a
|
||||||
|
* channel group does, and the same timeline may run on two groups without interfering (ADR 0003). */
|
||||||
|
export const timelines = moduleTable(
|
||||||
|
"timelines",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
/** Timeline-level eligibility, half-open `[eligibleFrom, eligibleUntil)`. */
|
||||||
|
eligibleFrom: instant("eligible_from"),
|
||||||
|
eligibleUntil: instant("eligible_until"),
|
||||||
|
/** Increments only on a semantic change, so a client can compare and refetch. */
|
||||||
|
revision: integer("revision").notNull().default(1),
|
||||||
|
createdAt: instant("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("signage_timelines_name_idx").on(table.name),
|
||||||
|
check(
|
||||||
|
"signage_timelines_eligibility_check",
|
||||||
|
sql`${table.eligibleFrom} is null or ${table.eligibleUntil} is null or ${table.eligibleFrom} < ${table.eligibleUntil}`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One **selection scope**: a provider's source as used by a timeline (ADR 0003).
|
||||||
|
*
|
||||||
|
* Every slot referencing this row passes the same `selection_scope` to `resolveNext`, so five
|
||||||
|
* Announcement slots walk one rotation rather than five. Two rows for the same provider source are
|
||||||
|
* two deliberately independent rotations.
|
||||||
|
*
|
||||||
|
* `provider_id` and `source_id` are opaque text. There is no foreign key here and there never can be:
|
||||||
|
* the provider is another module.
|
||||||
|
*/
|
||||||
|
export const providerSources = moduleTable(
|
||||||
|
"provider_sources",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey(),
|
||||||
|
timelineId: uuid("timeline_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => timelines.id, { onDelete: "cascade" }),
|
||||||
|
providerId: text("provider_id").notNull(),
|
||||||
|
sourceId: text("source_id").notNull(),
|
||||||
|
/** What an operator sees. Captured from provider discovery when the source was added, so the
|
||||||
|
* editor still reads sensibly when that provider is temporarily not running. */
|
||||||
|
label: text("label").notNull(),
|
||||||
|
/** Stable, opaque, and deliberately not derived from the timeline or slot id — either would
|
||||||
|
* silently reset a provider's rotation when an operator renamed or reordered something. */
|
||||||
|
selectionScope: text("selection_scope").notNull(),
|
||||||
|
createdAt: instant("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("signage_provider_sources_selection_scope_key").on(table.selectionScope),
|
||||||
|
index("signage_provider_sources_timeline_idx").on(table.timelineId),
|
||||||
|
index("signage_provider_sources_provider_idx").on(table.providerId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One position in a timeline's pattern.
|
||||||
|
*
|
||||||
|
* `position` is unique per timeline and kept contiguous from zero inside the transaction that
|
||||||
|
* changes it — a gap would make "the next slot" ambiguous exactly when playback needs it least. The
|
||||||
|
* slot's `id` is stable across every reorder, which is what lets history and a display's cursor
|
||||||
|
* survive an operator dragging a row.
|
||||||
|
*/
|
||||||
|
export const timelineSlots = moduleTable(
|
||||||
|
"timeline_slots",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey(),
|
||||||
|
timelineId: uuid("timeline_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => timelines.id, { onDelete: "cascade" }),
|
||||||
|
position: integer("position").notNull(),
|
||||||
|
/** `provider-source` asks for the next item; `pinned-item` shows exactly one. A native image is
|
||||||
|
* a pinned item of Signage's own built-in image provider (ADR 0001). */
|
||||||
|
kind: text("kind").notNull(),
|
||||||
|
sourceId: uuid("source_id").references(() => providerSources.id, { onDelete: "cascade" }),
|
||||||
|
/** Set for a pinned item only. Opaque: another module's identity for its own content. */
|
||||||
|
providerId: text("provider_id"),
|
||||||
|
itemId: text("item_id"),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
durationOverrideMs: integer("duration_override_ms"),
|
||||||
|
/** Per-slot eligibility, half-open `[eligibleFrom, eligibleUntil)`. */
|
||||||
|
eligibleFrom: instant("eligible_from"),
|
||||||
|
eligibleUntil: instant("eligible_until"),
|
||||||
|
/** Native-image presentation overrides. Null means "take the installation default". These apply
|
||||||
|
* to native images only; an external provider owns its rendering completely. */
|
||||||
|
backgroundMode: text("background_mode"),
|
||||||
|
backgroundColor: text("background_color"),
|
||||||
|
backgroundDarkened: boolean("background_darkened"),
|
||||||
|
blurPixels: integer("blur_pixels"),
|
||||||
|
foregroundShadow: boolean("foreground_shadow"),
|
||||||
|
createdAt: instant("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("signage_timeline_slots_position_key").on(table.timelineId, table.position),
|
||||||
|
index("signage_timeline_slots_source_idx").on(table.sourceId),
|
||||||
|
check("signage_timeline_slots_position_check", sql`${table.position} >= 0`),
|
||||||
|
check(
|
||||||
|
"signage_timeline_slots_kind_check",
|
||||||
|
sql`${table.kind} in ('provider-source', 'pinned-item')`,
|
||||||
|
),
|
||||||
|
// Exactly one of the two shapes is populated, so a slot can never be both or neither.
|
||||||
|
check(
|
||||||
|
"signage_timeline_slots_reference_check",
|
||||||
|
sql`(${table.kind} = 'provider-source' and ${table.sourceId} is not null and ${table.providerId} is null and ${table.itemId} is null)
|
||||||
|
or (${table.kind} = 'pinned-item' and ${table.sourceId} is null and ${table.providerId} is not null and ${table.itemId} is not null)`,
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"signage_timeline_slots_duration_check",
|
||||||
|
sql`${table.durationOverrideMs} is null or ${table.durationOverrideMs} > 0`,
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"signage_timeline_slots_eligibility_check",
|
||||||
|
sql`${table.eligibleFrom} is null or ${table.eligibleUntil} is null or ${table.eligibleFrom} < ${table.eligibleUntil}`,
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"signage_timeline_slots_background_check",
|
||||||
|
sql`${table.backgroundMode} is null or ${table.backgroundMode} in ('blurred', 'solid', 'black')`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Channel groups, channels, and authoritative playback.
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** One synchronized group: one authoritative timeline clock, one active timeline, one cursor
|
||||||
|
* (ADR 0004). A genuinely independent clock is another group. */
|
||||||
|
export const channelGroups = moduleTable(
|
||||||
|
"channel_groups",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
/** Increments when the group's channel membership changes, so a client knows to refetch. */
|
||||||
|
revision: integer("revision").notNull().default(1),
|
||||||
|
createdAt: instant("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [index("signage_channel_groups_name_idx").on(table.name)],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** An audience within a group. Channels differ in exactly one way: which variant of a resolved
|
||||||
|
* selection they render. Exactly one channel per group is the default. */
|
||||||
|
export const channels = moduleTable(
|
||||||
|
"channels",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey(),
|
||||||
|
groupId: uuid("group_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => channelGroups.id, { onDelete: "cascade" }),
|
||||||
|
/** The audience key handed to a provider. `default` is the base channel's. */
|
||||||
|
key: text("key").notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
isDefault: boolean("is_default").notNull().default(false),
|
||||||
|
createdAt: instant("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("signage_channels_group_key_key").on(table.groupId, table.key),
|
||||||
|
// Exactly one default per group, enforced by the database rather than by convention.
|
||||||
|
uniqueIndex("signage_channels_group_default_key")
|
||||||
|
.on(table.groupId)
|
||||||
|
.where(sql`${table.isDefault}`),
|
||||||
|
check("signage_channels_key_check", sql`${table.key} ~ '^[a-z][a-z0-9]*(-[a-z0-9]+)*$'`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per channel group: the authoritative clock (ADR 0005).
|
||||||
|
*
|
||||||
|
* `slot_started_at` and `slot_deadline_at` are **absolute instants**, never a remaining count. That
|
||||||
|
* is what lets two viewers who connected minutes apart agree, and what lets a restart resume the
|
||||||
|
* item it was already showing instead of choosing a new one.
|
||||||
|
*/
|
||||||
|
export const playbackStates = moduleTable(
|
||||||
|
"playback_states",
|
||||||
|
{
|
||||||
|
groupId: uuid("group_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => channelGroups.id, { onDelete: "cascade" }),
|
||||||
|
activeTimelineId: uuid("active_timeline_id").references(() => timelines.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
/** Queued activation, applied at the next safe slot boundary. */
|
||||||
|
pendingTimelineId: uuid("pending_timeline_id").references(() => timelines.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
pendingRequestedAt: instant("pending_requested_at"),
|
||||||
|
/** Which resolved selection is on screen. Nulled rather than cascaded when history is pruned. */
|
||||||
|
currentSelectionId: uuid("current_selection_id"),
|
||||||
|
/** Where in the pattern the cursor is. Kept beside the selection so an empty or skipped slot
|
||||||
|
* still advances deterministically. */
|
||||||
|
currentSlotId: uuid("current_slot_id"),
|
||||||
|
slotStartedAt: instant("slot_started_at"),
|
||||||
|
slotDeadlineAt: instant("slot_deadline_at"),
|
||||||
|
/** Preserved across pause and static hold, so releasing either continues the same slide. */
|
||||||
|
pausedRemainingMs: integer("paused_remaining_ms"),
|
||||||
|
paused: boolean("paused").notNull().default(false),
|
||||||
|
/** Covers the output with black while the hidden timeline keeps running. */
|
||||||
|
blackout: boolean("blackout").notNull().default(false),
|
||||||
|
/** A native image held on screen while the normal cursor and remainder stay preserved. */
|
||||||
|
staticAssetId: uuid("static_asset_id").references(() => imageAssets.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
/** An activation an operator asked for in advance. One per group: "activate this timeline at
|
||||||
|
* this instant" is a single intention, and a queue of them would be a scheduling product
|
||||||
|
* Signage does not have. */
|
||||||
|
scheduledTimelineId: uuid("scheduled_timeline_id").references(() => timelines.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
scheduledActivationAt: instant("scheduled_activation_at"),
|
||||||
|
/** Monotonic. Increments only on a semantic change; an exact no-op commits nothing. */
|
||||||
|
revision: integer("revision").notNull().default(1),
|
||||||
|
/** The next sequence number a resolved selection for this group will take. */
|
||||||
|
nextSequence: bigint("next_sequence", { mode: "number" }).notNull().default(1),
|
||||||
|
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
check(
|
||||||
|
"signage_playback_states_remaining_check",
|
||||||
|
sql`${table.pausedRemainingMs} is null or ${table.pausedRemainingMs} >= 0`,
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"signage_playback_states_window_check",
|
||||||
|
sql`${table.slotStartedAt} is null or ${table.slotDeadlineAt} is null or ${table.slotStartedAt} <= ${table.slotDeadlineAt}`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Resolved selections and bounded history.
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One logical selection, resolved once for the whole channel group (ADR 0004).
|
||||||
|
*
|
||||||
|
* Persisting this is what makes two viewers that joined at different times render the same slot,
|
||||||
|
* what lets `previous` walk actual playback rather than the slot order, what makes restart recovery
|
||||||
|
* show the same item, and what gives offline continuation something real to continue from.
|
||||||
|
*
|
||||||
|
* `slot_id` is nulled rather than cascaded when a slot is deleted: history is a record of what
|
||||||
|
* happened, and an operator editing a timeline must not erase it.
|
||||||
|
*/
|
||||||
|
export const selections = moduleTable(
|
||||||
|
"selections",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey(),
|
||||||
|
groupId: uuid("group_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => channelGroups.id, { onDelete: "cascade" }),
|
||||||
|
/** Monotonic per group. The ordering `previous` walks. */
|
||||||
|
sequence: bigint("sequence", { mode: "number" }).notNull(),
|
||||||
|
timelineId: uuid("timeline_id"),
|
||||||
|
slotId: uuid("slot_id").references(() => timelineSlots.id, { onDelete: "set null" }),
|
||||||
|
/** The selection scope this item came from, copied at resolution time. Kept even after the
|
||||||
|
* source row is gone, so history stays readable and a provider's rotation can be re-seeded from
|
||||||
|
* what actually played rather than from the beginning. Null for a pinned item. */
|
||||||
|
selectionScope: text("selection_scope"),
|
||||||
|
/** Opaque provider identity, and the provider's own identity for its content. */
|
||||||
|
providerId: text("provider_id").notNull(),
|
||||||
|
itemId: text("item_id").notNull(),
|
||||||
|
/** Changes whenever the payload changes and the item stays the same thing. */
|
||||||
|
itemRevision: text("item_revision").notNull(),
|
||||||
|
/** Which renderer draws this. Signage never imports it; the display asks the host to mount it. */
|
||||||
|
rendererContributionId: text("renderer_contribution_id"),
|
||||||
|
rendererContributionVersion: text("renderer_contribution_version"),
|
||||||
|
/** Opaque presentation values the provider itself supplied to its own renderer. */
|
||||||
|
presentation: jsonb("presentation"),
|
||||||
|
durationMs: integer("duration_ms").notNull(),
|
||||||
|
resolvedAt: instant("resolved_at").notNull().defaultNow(),
|
||||||
|
startedAt: instant("started_at"),
|
||||||
|
deadlineAt: instant("deadline_at"),
|
||||||
|
/** When this selection stops being valid, as supplied by the provider. */
|
||||||
|
availableUntil: instant("available_until"),
|
||||||
|
/** The provider said this item is explicitly gone. Removed at the next safe boundary. */
|
||||||
|
withdrawn: boolean("withdrawn").notNull().default(false),
|
||||||
|
/** A provider call failed temporarily and this is the last known good answer. */
|
||||||
|
stale: boolean("stale").notNull().default(false),
|
||||||
|
/** Increments when a refresh changes payloads without changing which item this is — which is
|
||||||
|
* what updates a mounted renderer in place without restarting its slot. */
|
||||||
|
revision: integer("revision").notNull().default(1),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("signage_selections_group_sequence_key").on(table.groupId, table.sequence),
|
||||||
|
index("signage_selections_group_resolved_idx").on(table.groupId, table.resolvedAt),
|
||||||
|
index("signage_selections_provider_item_idx").on(table.providerId, table.itemId),
|
||||||
|
check("signage_selections_duration_check", sql`${table.durationMs} > 0`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** One audience's payload for one selection. The default variant is always present; an
|
||||||
|
* audience-specific one exists only when the provider offered it. */
|
||||||
|
export const selectionVariants = moduleTable(
|
||||||
|
"selection_variants",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey(),
|
||||||
|
selectionId: uuid("selection_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => selections.id, { onDelete: "cascade" }),
|
||||||
|
/** The audience key this payload is for. The default variant uses the literal `default` key. */
|
||||||
|
audienceKey: text("audience_key").notNull(),
|
||||||
|
isDefault: boolean("is_default").notNull().default(false),
|
||||||
|
/** Opaque JSON. Signage validates that it *is* finite JSON and never what it means. */
|
||||||
|
payload: jsonb("payload").notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("signage_selection_variants_selection_audience_key").on(
|
||||||
|
table.selectionId,
|
||||||
|
table.audienceKey,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Viewers.
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One display Signage has seen.
|
||||||
|
*
|
||||||
|
* `viewer_key` is the persistent identity a display keeps in its own IndexedDB, so a reconnect is
|
||||||
|
* recognized as the same screen rather than a new one. `device_id` is the **safe** Core device
|
||||||
|
* identifier a paired display's principal carries — an opaque correlation value. Signage never sees,
|
||||||
|
* stores, or handles a device credential, and pairing remains entirely Core's.
|
||||||
|
*/
|
||||||
|
export const viewers = moduleTable(
|
||||||
|
"viewers",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey(),
|
||||||
|
viewerKey: text("viewer_key").notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
deviceId: text("device_id"),
|
||||||
|
channelId: uuid("channel_id").references(() => channels.id, { onDelete: "set null" }),
|
||||||
|
lastSeenAt: instant("last_seen_at"),
|
||||||
|
/** What the viewer last reported it was actually showing. Telemetry: a display never mutates
|
||||||
|
* authoritative playback, and a local cursor is never accepted as authority. */
|
||||||
|
reportedTimelineId: uuid("reported_timeline_id"),
|
||||||
|
reportedSelectionId: uuid("reported_selection_id"),
|
||||||
|
reportedProviderId: text("reported_provider_id"),
|
||||||
|
reportedContributionId: text("reported_contribution_id"),
|
||||||
|
reportedPlaybackRevision: integer("reported_playback_revision"),
|
||||||
|
/** Whether the viewer says it has enough cached to keep rendering through an outage. */
|
||||||
|
offlineReady: boolean("offline_ready").notNull().default(false),
|
||||||
|
cachedAssetCount: integer("cached_asset_count").notNull().default(0),
|
||||||
|
/** A stable sanitized code and a bounded operator-facing detail. Never an exception, a URL, a
|
||||||
|
* payload, or a stack. */
|
||||||
|
lastErrorCode: text("last_error_code"),
|
||||||
|
lastErrorDetail: text("last_error_detail"),
|
||||||
|
lastErrorAt: instant("last_error_at"),
|
||||||
|
createdAt: instant("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: instant("updated_at").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("signage_viewers_viewer_key_key").on(table.viewerKey),
|
||||||
|
index("signage_viewers_channel_idx").on(table.channelId),
|
||||||
|
index("signage_viewers_last_seen_idx").on(table.lastSeenAt),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import type { ModuleStorage } from "@ekklesia/module-sdk";
|
||||||
|
import {
|
||||||
|
resolveNativeScene,
|
||||||
|
SIGNAGE_NATIVE_PROVIDER_ID,
|
||||||
|
SIGNAGE_NATIVE_RENDERER,
|
||||||
|
type SignageNativePresentation,
|
||||||
|
} from "../../shared/native-images.js";
|
||||||
|
import type { SignageSettings } from "../../shared/settings.js";
|
||||||
|
import { createImageDeliveryUrls } from "../images/service.js";
|
||||||
|
import type { PlaybackSnapshot } from "../playback/service.js";
|
||||||
|
import type { SignageProviderRegistry } from "../providers/registry.js";
|
||||||
|
import type {
|
||||||
|
SignageStore,
|
||||||
|
StoredChannel,
|
||||||
|
StoredSelection,
|
||||||
|
StoredSlot,
|
||||||
|
StoredTimelineDetail,
|
||||||
|
} from "../store/types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything one channel's display needs to render, and to keep rendering when its network drops.
|
||||||
|
*
|
||||||
|
* Three groups of things travel together here, and each earns its place:
|
||||||
|
*
|
||||||
|
* - **What is on screen now**, resolved for this channel's audience, with fresh delivery URLs for a
|
||||||
|
* native image. This is the frame.
|
||||||
|
* - **The timeline's shape** — slot identities, eligibility windows, and estimated durations. This
|
||||||
|
* is what lets a disconnected display advance its own cursor with the same arithmetic the server
|
||||||
|
* reconciles with, rather than guessing (ADR 0006).
|
||||||
|
* - **Recent resolved selections**, again for this audience. This is what a disconnected display
|
||||||
|
* *renders* when its cursor lands on a slot it has already seen. It never resolves new provider
|
||||||
|
* content offline, because inventing a provider's answer is worse than keeping the last frame.
|
||||||
|
*
|
||||||
|
* A delivery URL is minted here and never stored: it is a bearer capability with an expiry in it,
|
||||||
|
* and a display refreshes state long before one lapses.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DisplayMedia {
|
||||||
|
readonly url: string;
|
||||||
|
readonly expiresAtMs: number;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisplaySelection {
|
||||||
|
readonly id: string;
|
||||||
|
readonly revision: number;
|
||||||
|
readonly slotId: string | null;
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly itemId: string;
|
||||||
|
readonly itemRevision: string;
|
||||||
|
readonly startedAtMs: number | null;
|
||||||
|
readonly deadlineAtMs: number | null;
|
||||||
|
readonly durationMs: number;
|
||||||
|
readonly availableUntilMs: number | null;
|
||||||
|
readonly withdrawn: boolean;
|
||||||
|
readonly stale: boolean;
|
||||||
|
/** `native-image` is drawn by Signage's own display code; `contribution` is mounted through the
|
||||||
|
* platform's contribution ABI by id. A display never receives a URL for either. */
|
||||||
|
readonly renderer:
|
||||||
|
| { readonly kind: "native-image" }
|
||||||
|
| {
|
||||||
|
readonly kind: "contribution";
|
||||||
|
readonly contributionId: string;
|
||||||
|
readonly contributionVersion: string;
|
||||||
|
}
|
||||||
|
| { readonly kind: "unavailable" };
|
||||||
|
/** The provider's own payload for this channel's audience, or its default. */
|
||||||
|
readonly payload: unknown;
|
||||||
|
readonly presentation: unknown;
|
||||||
|
/** Native images only: fresh delivery URLs, and the already-resolved scene. */
|
||||||
|
readonly media?: { readonly display: DisplayMedia; readonly thumbnail: DisplayMedia };
|
||||||
|
readonly scene?: ReturnType<typeof resolveNativeScene>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisplayTimelineSlot {
|
||||||
|
readonly id: string;
|
||||||
|
readonly position: number;
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly eligibleFromMs: number | null;
|
||||||
|
readonly eligibleUntilMs: number | null;
|
||||||
|
readonly estimatedDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisplayTimeline {
|
||||||
|
readonly id: string;
|
||||||
|
readonly revision: number;
|
||||||
|
readonly eligibleFromMs: number | null;
|
||||||
|
readonly eligibleUntilMs: number | null;
|
||||||
|
readonly slots: readonly DisplayTimelineSlot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisplayChannelState {
|
||||||
|
readonly serverNowMs: number;
|
||||||
|
readonly channel: {
|
||||||
|
readonly id: string;
|
||||||
|
readonly key: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly groupId: string;
|
||||||
|
};
|
||||||
|
readonly crossfadeMs: number;
|
||||||
|
readonly playback: PlaybackSnapshot;
|
||||||
|
readonly timeline: DisplayTimeline | null;
|
||||||
|
readonly current: DisplaySelection | null;
|
||||||
|
/** The held image, already resolved as a scene. Separate from `current` because a static hold sits
|
||||||
|
* *over* a preserved cursor rather than replacing it. */
|
||||||
|
readonly staticHold: DisplaySelection | null;
|
||||||
|
/** Bounded, newest first. Material for offline continuation, never a substitute for authority. */
|
||||||
|
readonly selections: readonly DisplaySelection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many resolved selections travel to a display. Enough to cover a pattern and then some, small
|
||||||
|
* enough that the response stays a response. */
|
||||||
|
export const SIGNAGE_DISPLAY_SELECTION_LIMIT = 24;
|
||||||
|
|
||||||
|
function slotPresentationFor(
|
||||||
|
slot: StoredSlot | undefined,
|
||||||
|
settings: SignageSettings,
|
||||||
|
): SignageNativePresentation {
|
||||||
|
const defaults = settings.nativePresentation;
|
||||||
|
return {
|
||||||
|
backgroundColor: slot?.presentation.backgroundColor ?? defaults.backgroundColor,
|
||||||
|
backgroundDarkened: slot?.presentation.backgroundDarkened ?? defaults.backgroundDarkened,
|
||||||
|
backgroundMode: slot?.presentation.backgroundMode ?? defaults.backgroundMode,
|
||||||
|
blurPixels: slot?.presentation.blurPixels ?? defaults.blurPixels,
|
||||||
|
foregroundShadow: slot?.presentation.foregroundShadow ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadFor(selection: StoredSelection, audienceKey: string): unknown {
|
||||||
|
const exact = selection.variants.find((variant) => variant.audienceKey === audienceKey);
|
||||||
|
if (exact) {
|
||||||
|
return exact.payload;
|
||||||
|
}
|
||||||
|
// The fallback *is* the contract: a provider with nothing audience-specific returns one variant,
|
||||||
|
// and every channel renders it (ADR 0002).
|
||||||
|
return selection.variants.find((variant) => variant.isDefault)?.payload ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NativePayloadShape {
|
||||||
|
readonly imageId?: unknown;
|
||||||
|
readonly width?: unknown;
|
||||||
|
readonly height?: unknown;
|
||||||
|
readonly renditionKeys?: { readonly display?: unknown; readonly thumbnail?: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildDisplayStateInput {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly storage: ModuleStorage;
|
||||||
|
readonly registry: SignageProviderRegistry;
|
||||||
|
readonly settings: SignageSettings;
|
||||||
|
readonly channel: StoredChannel;
|
||||||
|
readonly playback: PlaybackSnapshot;
|
||||||
|
readonly atMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildDisplayChannelState(
|
||||||
|
input: BuildDisplayStateInput,
|
||||||
|
): Promise<DisplayChannelState> {
|
||||||
|
const group = await input.store.findChannelGroup(input.channel.groupId);
|
||||||
|
const channelName = group?.channels.find((c) => c.id === input.channel.id)?.name ?? "Display";
|
||||||
|
const timelineDetail =
|
||||||
|
input.playback.activeTimelineId === null
|
||||||
|
? undefined
|
||||||
|
: await input.store.findTimeline(input.playback.activeTimelineId);
|
||||||
|
|
||||||
|
const history = await input.store.listSelections(
|
||||||
|
input.channel.groupId,
|
||||||
|
SIGNAGE_DISPLAY_SELECTION_LIMIT,
|
||||||
|
);
|
||||||
|
const rendered = await Promise.all(
|
||||||
|
history.map((selection) =>
|
||||||
|
renderSelection(input, selection, timelineDetail, input.channel.key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const current =
|
||||||
|
rendered.find((selection) => selection.id === input.playback.currentSelectionId) ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
channel: {
|
||||||
|
groupId: input.channel.groupId,
|
||||||
|
id: input.channel.id,
|
||||||
|
key: input.channel.key,
|
||||||
|
name: channelName,
|
||||||
|
},
|
||||||
|
crossfadeMs: input.settings.crossfadeMs,
|
||||||
|
current,
|
||||||
|
playback: input.playback,
|
||||||
|
selections: rendered,
|
||||||
|
serverNowMs: input.atMs,
|
||||||
|
staticHold:
|
||||||
|
input.playback.staticAssetId === null
|
||||||
|
? null
|
||||||
|
: await renderStaticHold(input, input.playback.staticAssetId),
|
||||||
|
timeline: timelineDetail ? describeTimeline(input, timelineDetail) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeTimeline(
|
||||||
|
input: BuildDisplayStateInput,
|
||||||
|
detail: StoredTimelineDetail,
|
||||||
|
): DisplayTimeline {
|
||||||
|
return {
|
||||||
|
eligibleFromMs: detail.timeline.eligibleFrom?.getTime() ?? null,
|
||||||
|
eligibleUntilMs: detail.timeline.eligibleUntil?.getTime() ?? null,
|
||||||
|
id: detail.timeline.id,
|
||||||
|
revision: detail.timeline.revision,
|
||||||
|
slots: detail.slots.map((slot) => {
|
||||||
|
const providerId =
|
||||||
|
slot.kind === "pinned-item"
|
||||||
|
? slot.providerId
|
||||||
|
: (detail.sources.find((source) => source.id === slot.sourceId)?.providerId ?? null);
|
||||||
|
return {
|
||||||
|
eligibleFromMs: slot.eligibleFrom?.getTime() ?? null,
|
||||||
|
eligibleUntilMs: slot.eligibleUntil?.getTime() ?? null,
|
||||||
|
enabled: slot.enabled,
|
||||||
|
// The same estimate the server uses to skip whole passes, so a disconnected display's
|
||||||
|
// arithmetic and the server's agree.
|
||||||
|
estimatedDurationMs:
|
||||||
|
slot.durationOverrideMs ??
|
||||||
|
(providerId === null ? undefined : input.registry.defaultDurationMs(providerId)) ??
|
||||||
|
input.settings.nativeImageDurationMs,
|
||||||
|
id: slot.id,
|
||||||
|
position: slot.position,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSelection(
|
||||||
|
input: BuildDisplayStateInput,
|
||||||
|
selection: StoredSelection,
|
||||||
|
timeline: StoredTimelineDetail | undefined,
|
||||||
|
audienceKey: string,
|
||||||
|
): Promise<DisplaySelection> {
|
||||||
|
const slot =
|
||||||
|
selection.slotId === null
|
||||||
|
? undefined
|
||||||
|
: timeline?.slots.find((candidate) => candidate.id === selection.slotId);
|
||||||
|
const payload = payloadFor(selection, audienceKey);
|
||||||
|
|
||||||
|
const base: DisplaySelection = {
|
||||||
|
availableUntilMs: selection.availableUntil?.getTime() ?? null,
|
||||||
|
deadlineAtMs: selection.deadlineAt?.getTime() ?? null,
|
||||||
|
durationMs: selection.durationMs,
|
||||||
|
id: selection.id,
|
||||||
|
itemId: selection.itemId,
|
||||||
|
itemRevision: selection.itemRevision,
|
||||||
|
payload,
|
||||||
|
presentation: selection.presentation,
|
||||||
|
providerId: selection.providerId,
|
||||||
|
renderer:
|
||||||
|
selection.providerId === SIGNAGE_NATIVE_PROVIDER_ID ||
|
||||||
|
selection.rendererContributionId === SIGNAGE_NATIVE_RENDERER
|
||||||
|
? { kind: "native-image" }
|
||||||
|
: selection.rendererContributionId === null ||
|
||||||
|
selection.rendererContributionVersion === null
|
||||||
|
? { kind: "unavailable" }
|
||||||
|
: {
|
||||||
|
contributionId: selection.rendererContributionId,
|
||||||
|
contributionVersion: selection.rendererContributionVersion,
|
||||||
|
kind: "contribution",
|
||||||
|
},
|
||||||
|
revision: selection.revision,
|
||||||
|
slotId: selection.slotId,
|
||||||
|
stale: selection.stale,
|
||||||
|
startedAtMs: selection.startedAt?.getTime() ?? null,
|
||||||
|
withdrawn: selection.withdrawn,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (base.renderer.kind !== "native-image") {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
const media = await nativeMediaFor(input, payload);
|
||||||
|
const shape = payload as NativePayloadShape | null;
|
||||||
|
const width = typeof shape?.width === "number" ? shape.width : 16;
|
||||||
|
const height = typeof shape?.height === "number" ? shape.height : 9;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...(media ? { media } : {}),
|
||||||
|
scene: resolveNativeScene({ height, width }, slotPresentationFor(slot, input.settings)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nativeMediaFor(
|
||||||
|
input: BuildDisplayStateInput,
|
||||||
|
payload: unknown,
|
||||||
|
): Promise<DisplaySelection["media"]> {
|
||||||
|
const shape = payload as NativePayloadShape | null;
|
||||||
|
const displayKey = shape?.renditionKeys?.display;
|
||||||
|
const thumbnailKey = shape?.renditionKeys?.thumbnail;
|
||||||
|
if (typeof displayKey !== "string" || typeof thumbnailKey !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const width = typeof shape?.width === "number" ? shape.width : 0;
|
||||||
|
const height = typeof shape?.height === "number" ? shape.height : 0;
|
||||||
|
try {
|
||||||
|
const urls = await createImageDeliveryUrls(input.storage, [
|
||||||
|
{ height, kind: "display", objectKey: displayKey, width },
|
||||||
|
{ height, kind: "thumbnail", objectKey: thumbnailKey, width },
|
||||||
|
]);
|
||||||
|
const display = urls.find((url) => url.kind === "display");
|
||||||
|
const thumbnail = urls.find((url) => url.kind === "thumbnail");
|
||||||
|
if (!display || !thumbnail) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
display: {
|
||||||
|
expiresAtMs: Date.parse(display.expiresAt),
|
||||||
|
height,
|
||||||
|
url: display.url,
|
||||||
|
width,
|
||||||
|
},
|
||||||
|
thumbnail: {
|
||||||
|
expiresAtMs: Date.parse(thumbnail.expiresAt),
|
||||||
|
height,
|
||||||
|
url: thumbnail.url,
|
||||||
|
width,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Storage being briefly unreachable costs this frame's URLs, not the whole state: a display
|
||||||
|
// that already has the bytes cached keeps rendering, and the next refresh carries fresh ones.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The held image, shaped exactly like a selection so a display's renderer path is the same one. */
|
||||||
|
async function renderStaticHold(
|
||||||
|
input: BuildDisplayStateInput,
|
||||||
|
imageId: string,
|
||||||
|
): Promise<DisplaySelection | null> {
|
||||||
|
const image = await input.store.findImage(imageId);
|
||||||
|
if (image?.status !== "ready") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const display = image.renditions.find((rendition) => rendition.kind === "display");
|
||||||
|
const thumbnail = image.renditions.find((rendition) => rendition.kind === "thumbnail");
|
||||||
|
const payload = {
|
||||||
|
displayName: image.displayName,
|
||||||
|
height: image.height,
|
||||||
|
imageId: image.id,
|
||||||
|
kind: "native-image",
|
||||||
|
renditionKeys: {
|
||||||
|
display: display?.objectKey ?? image.originalObjectKey,
|
||||||
|
thumbnail: thumbnail?.objectKey ?? image.originalObjectKey,
|
||||||
|
},
|
||||||
|
width: image.width,
|
||||||
|
};
|
||||||
|
const media = await nativeMediaFor(input, payload);
|
||||||
|
return {
|
||||||
|
availableUntilMs: image.expiresAt?.getTime() ?? null,
|
||||||
|
deadlineAtMs: null,
|
||||||
|
durationMs: 0,
|
||||||
|
id: `static:${image.id}`,
|
||||||
|
itemId: image.id,
|
||||||
|
itemRevision: image.contentSha256.slice(0, 32),
|
||||||
|
...(media ? { media } : {}),
|
||||||
|
payload,
|
||||||
|
presentation: null,
|
||||||
|
providerId: SIGNAGE_NATIVE_PROVIDER_ID,
|
||||||
|
renderer: { kind: "native-image" },
|
||||||
|
revision: 1,
|
||||||
|
scene: resolveNativeScene(image, slotPresentationFor(undefined, input.settings)),
|
||||||
|
slotId: null,
|
||||||
|
stale: false,
|
||||||
|
startedAtMs: null,
|
||||||
|
withdrawn: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Signage's one sanitized failure vocabulary.
|
||||||
|
*
|
||||||
|
* Every error that can reach a browser, a log, or an operator carries a **stable code and a fixed
|
||||||
|
* message**, and deliberately nothing else: no caught exception, no stack, no cause, no SQL, no
|
||||||
|
* object key, no storage endpoint, no provider text, no machine path, and no payload. This follows
|
||||||
|
* the same discipline as the platform's own `ModuleStorageError` and migration-runner errors.
|
||||||
|
*
|
||||||
|
* A `detail` is allowed only when it is a value **Signage itself supplied or the caller itself
|
||||||
|
* sent** — an asset id the caller named, a limit Signage declared. It is never something the host,
|
||||||
|
* the database, or another module handed back.
|
||||||
|
*/
|
||||||
|
export const SIGNAGE_ERROR_CODES = [
|
||||||
|
/** The request body, query, or path parameter is not something this route accepts. */
|
||||||
|
"SIGNAGE_INVALID_REQUEST",
|
||||||
|
/** The named resource does not exist, or is not one this caller may address. */
|
||||||
|
"SIGNAGE_NOT_FOUND",
|
||||||
|
/** The request is well formed but conflicts with current state. */
|
||||||
|
"SIGNAGE_CONFLICT",
|
||||||
|
/** The asset is referenced by a timeline slot, a pinned item, or a live static hold. */
|
||||||
|
"SIGNAGE_ASSET_IN_USE",
|
||||||
|
/** The uploaded bytes are not a still PNG, JPEG, or WebP. */
|
||||||
|
"SIGNAGE_IMAGE_UNSUPPORTED_FORMAT",
|
||||||
|
/** The uploaded bytes could not be decoded as an image at all. */
|
||||||
|
"SIGNAGE_IMAGE_MALFORMED",
|
||||||
|
/** Animation is not supported; a signage slide is a still frame. */
|
||||||
|
"SIGNAGE_IMAGE_ANIMATED",
|
||||||
|
/** Multipage input is not supported. */
|
||||||
|
"SIGNAGE_IMAGE_MULTIPAGE",
|
||||||
|
/** The upload exceeded Signage's per-file byte limit. */
|
||||||
|
"SIGNAGE_IMAGE_TOO_LARGE",
|
||||||
|
/** The image decodes to more pixels than Signage will process. */
|
||||||
|
"SIGNAGE_IMAGE_PIXEL_LIMIT",
|
||||||
|
/** Object storage could not be reached, or refused the operation. */
|
||||||
|
"SIGNAGE_STORAGE_UNAVAILABLE",
|
||||||
|
/** Signage could not complete the operation, and has said everything it safely can. */
|
||||||
|
"SIGNAGE_OPERATION_FAILED",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SignageErrorCode = (typeof SIGNAGE_ERROR_CODES)[number];
|
||||||
|
|
||||||
|
const messages: Readonly<Record<SignageErrorCode, string>> = Object.freeze({
|
||||||
|
SIGNAGE_ASSET_IN_USE: "This image is still referenced and cannot be deleted.",
|
||||||
|
SIGNAGE_CONFLICT: "The request conflicts with the current state.",
|
||||||
|
SIGNAGE_IMAGE_ANIMATED: "Animated images are not supported.",
|
||||||
|
SIGNAGE_IMAGE_MALFORMED: "The uploaded file is not a decodable image.",
|
||||||
|
SIGNAGE_IMAGE_MULTIPAGE: "Multipage images are not supported.",
|
||||||
|
SIGNAGE_IMAGE_PIXEL_LIMIT: "The image exceeds the decoded-pixel limit.",
|
||||||
|
SIGNAGE_IMAGE_TOO_LARGE: "The image exceeds the maximum upload size.",
|
||||||
|
SIGNAGE_IMAGE_UNSUPPORTED_FORMAT: "Only still PNG, JPEG, and WebP images are supported.",
|
||||||
|
SIGNAGE_INVALID_REQUEST: "The request is not valid.",
|
||||||
|
SIGNAGE_NOT_FOUND: "The requested resource was not found.",
|
||||||
|
SIGNAGE_OPERATION_FAILED: "The operation could not be completed.",
|
||||||
|
SIGNAGE_STORAGE_UNAVAILABLE: "Object storage is not available.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const statuses: Readonly<Record<SignageErrorCode, number>> = Object.freeze({
|
||||||
|
SIGNAGE_ASSET_IN_USE: 409,
|
||||||
|
SIGNAGE_CONFLICT: 409,
|
||||||
|
SIGNAGE_IMAGE_ANIMATED: 415,
|
||||||
|
SIGNAGE_IMAGE_MALFORMED: 422,
|
||||||
|
SIGNAGE_IMAGE_MULTIPAGE: 415,
|
||||||
|
SIGNAGE_IMAGE_PIXEL_LIMIT: 413,
|
||||||
|
SIGNAGE_IMAGE_TOO_LARGE: 413,
|
||||||
|
SIGNAGE_IMAGE_UNSUPPORTED_FORMAT: 415,
|
||||||
|
SIGNAGE_INVALID_REQUEST: 400,
|
||||||
|
SIGNAGE_NOT_FOUND: 404,
|
||||||
|
SIGNAGE_OPERATION_FAILED: 500,
|
||||||
|
SIGNAGE_STORAGE_UNAVAILABLE: 503,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** What a caller may safely be told alongside a code: values the caller sent, or limits Signage
|
||||||
|
* declared. Never a host value, a driver message, or another module's text. */
|
||||||
|
export type SignageErrorDetail = Readonly<Record<string, number | string | null>>;
|
||||||
|
|
||||||
|
export class SignageError extends Error {
|
||||||
|
readonly code: SignageErrorCode;
|
||||||
|
readonly status: number;
|
||||||
|
readonly detail?: SignageErrorDetail;
|
||||||
|
|
||||||
|
constructor(code: SignageErrorCode, detail?: SignageErrorDetail) {
|
||||||
|
super(messages[code]);
|
||||||
|
this.name = "SignageError";
|
||||||
|
this.code = code;
|
||||||
|
this.status = statuses[code];
|
||||||
|
if (detail !== undefined) {
|
||||||
|
this.detail = Object.freeze({ ...detail });
|
||||||
|
}
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signageErrorResponse(error: SignageError): Response {
|
||||||
|
return Response.json(
|
||||||
|
{ code: error.code, message: error.message, ...(error.detail ? { detail: error.detail } : {}) },
|
||||||
|
{ status: error.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one boundary every Signage route handler runs inside.
|
||||||
|
*
|
||||||
|
* A `SignageError` becomes its own sanitized response. **Anything else becomes
|
||||||
|
* `SIGNAGE_OPERATION_FAILED` with nothing of the caught value in it** — a database error, a storage
|
||||||
|
* failure, a bug, and a provider's own exception all look identical from outside, which is the
|
||||||
|
* point. The platform would sanitize an uncaught throw too; catching it here is how Signage keeps a
|
||||||
|
* useful code instead of a generic one.
|
||||||
|
*/
|
||||||
|
export async function signageBoundary(run: () => Promise<Response>): Promise<Response> {
|
||||||
|
try {
|
||||||
|
return await run();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SignageError) {
|
||||||
|
return signageErrorResponse(error);
|
||||||
|
}
|
||||||
|
return signageErrorResponse(new SignageError("SIGNAGE_OPERATION_FAILED"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type { ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
||||||
|
import { SignageError } from "./errors.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request-shaping helpers, and — more importantly — parameter validation.
|
||||||
|
*
|
||||||
|
* `context.params` holds the router's **decoded** path values. Decoding is not validation, and the
|
||||||
|
* platform deliberately supplies no generic parameter grammar: these are Signage's domain types, so
|
||||||
|
* validating them is Signage's job, before any database, storage, or identifier use.
|
||||||
|
*/
|
||||||
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
||||||
|
|
||||||
|
export function requireUuidParam(context: ModuleRouteHandlerContext, name: string): string {
|
||||||
|
const value = context.params[name];
|
||||||
|
if (value === undefined || !UUID.test(value)) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { parameter: name });
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUuid(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && UUID.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads a JSON request body a route declared a buffered allowance for. A body that is absent,
|
||||||
|
* oversized for its own declared shape, or not a JSON object is refused with Signage's own code
|
||||||
|
* rather than a parser's message. */
|
||||||
|
export async function readJsonObject(request: Request): Promise<Record<string, unknown>> {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = await request.json();
|
||||||
|
} catch {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" });
|
||||||
|
}
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" });
|
||||||
|
}
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalString(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
field: string,
|
||||||
|
options: { readonly maxLength: number },
|
||||||
|
): string | undefined {
|
||||||
|
const value = body[field];
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== "string" || value.trim().length === 0 || value.length > options.maxLength) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requiredString(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
field: string,
|
||||||
|
options: { readonly maxLength: number },
|
||||||
|
): string {
|
||||||
|
const value = optionalString(body, field, options);
|
||||||
|
if (value === undefined) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An absolute instant, or `null` to clear one. Signage never accepts an ambiguous local timestamp:
|
||||||
|
* a value must parse as an absolute instant or be refused. */
|
||||||
|
export function optionalInstant(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
field: string,
|
||||||
|
): Date | null | undefined {
|
||||||
|
const value = body[field];
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||||
|
}
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime()) || !/[Zz]|[+-]\d{2}:?\d{2}$/u.test(value)) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalInteger(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
field: string,
|
||||||
|
options: { readonly minimum: number; readonly maximum: number },
|
||||||
|
): number | null | undefined {
|
||||||
|
const value = body[field];
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof value !== "number" ||
|
||||||
|
!Number.isSafeInteger(value) ||
|
||||||
|
value < options.minimum ||
|
||||||
|
value > options.maximum
|
||||||
|
) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalBoolean(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
field: string,
|
||||||
|
): boolean | null | undefined {
|
||||||
|
const value = body[field];
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return value as null | undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== "boolean") {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* The bounds Signage's native image ingest enforces, in one place.
|
||||||
|
*
|
||||||
|
* These are Signage's own product limits, deliberately inside the platform's. The platform's
|
||||||
|
* absolute request-body ceiling is 32 MiB; Signage declares 25 MiB and refuses anything larger
|
||||||
|
* itself rather than relying on the platform's refusal. Two limits that agree are two chances to
|
||||||
|
* catch a mistake, and a module that only had the platform's would silently change its own product
|
||||||
|
* behaviour if the platform's ever moved.
|
||||||
|
*
|
||||||
|
* The upload route carries **raw image bytes**, not a multipart envelope, so the declared allowance
|
||||||
|
* is exactly the image limit. One request is one file; nothing in Signage can express an aggregate
|
||||||
|
* upload, and the filename and display name travel in the query string.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The largest image file an operator may upload. */
|
||||||
|
export const SIGNAGE_IMAGE_MAX_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The decoded-pixel budget.
|
||||||
|
*
|
||||||
|
* File size does not bound decoding: a few hundred kilobytes of PNG can decode to gigabytes of
|
||||||
|
* pixels, which is the actual shape of a decompression bomb. 40 million pixels is roughly a 7700 ×
|
||||||
|
* 5200 photograph — far beyond any signage slide, and far below anything that threatens the process.
|
||||||
|
*/
|
||||||
|
export const SIGNAGE_IMAGE_MAX_PIXELS = 40_000_000;
|
||||||
|
|
||||||
|
/** How many files the dashboard offers to queue at once. One request each, always. */
|
||||||
|
export const SIGNAGE_UPLOAD_BATCH_MAX_FILES = 20;
|
||||||
|
|
||||||
|
/** How many of those requests the dashboard runs concurrently. Bounded so a batch cannot pin the
|
||||||
|
* API's memory with several simultaneous full-size images, and so the dashboard stays responsive. */
|
||||||
|
export const SIGNAGE_UPLOAD_CONCURRENCY = 3;
|
||||||
|
|
||||||
|
export const SIGNAGE_IMAGE_DISPLAY_RENDITION = Object.freeze({
|
||||||
|
kind: "display" as const,
|
||||||
|
maxWidth: 1920,
|
||||||
|
maxHeight: 1080,
|
||||||
|
quality: 82,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SIGNAGE_IMAGE_THUMBNAIL_RENDITION = Object.freeze({
|
||||||
|
kind: "thumbnail" as const,
|
||||||
|
maxWidth: 480,
|
||||||
|
maxHeight: 270,
|
||||||
|
quality: 72,
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import sharp, { type Metadata, type OutputInfo } from "sharp";
|
||||||
|
import { SignageError } from "../errors.js";
|
||||||
|
import {
|
||||||
|
SIGNAGE_IMAGE_DISPLAY_RENDITION,
|
||||||
|
SIGNAGE_IMAGE_MAX_PIXELS,
|
||||||
|
SIGNAGE_IMAGE_THUMBNAIL_RENDITION,
|
||||||
|
} from "./limits.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signage's native image pipeline.
|
||||||
|
*
|
||||||
|
* Three properties matter more than the code:
|
||||||
|
*
|
||||||
|
* 1. **Format comes from the bytes.** A filename and a declared content type are attacker-controlled
|
||||||
|
* and routinely wrong even when they are not; the decoder decides what this is.
|
||||||
|
* 2. **The original is never rewritten.** It is a church's file, archived exactly as uploaded. Only
|
||||||
|
* the two renditions Signage generates are transformed.
|
||||||
|
* 3. **Nothing about presentation is baked in.** Background, blur, darkening, and shadow are
|
||||||
|
* resolved at render time from a slot's own settings, so changing them is a database update
|
||||||
|
* rather than a re-encode (ADR 0007).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SUPPORTED_FORMATS: ReadonlySet<string> = new Set(["jpeg", "png", "webp"]);
|
||||||
|
|
||||||
|
const CONTENT_TYPES: Readonly<Record<string, string>> = Object.freeze({
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
png: "image/png",
|
||||||
|
webp: "image/webp",
|
||||||
|
});
|
||||||
|
|
||||||
|
const FILE_EXTENSIONS: Readonly<Record<string, string>> = Object.freeze({
|
||||||
|
jpeg: "jpg",
|
||||||
|
png: "png",
|
||||||
|
webp: "webp",
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface SignageImageRendition {
|
||||||
|
readonly kind: "display" | "thumbnail";
|
||||||
|
readonly bytes: Uint8Array;
|
||||||
|
readonly contentType: string;
|
||||||
|
readonly contentSha256: string;
|
||||||
|
readonly byteSize: number;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignageProcessedImage {
|
||||||
|
readonly contentSha256: string;
|
||||||
|
readonly byteSize: number;
|
||||||
|
readonly format: string;
|
||||||
|
readonly contentType: string;
|
||||||
|
readonly fileExtension: string;
|
||||||
|
/** How the original should appear once its own orientation is applied. */
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
/** What is actually encoded in the archived bytes. */
|
||||||
|
readonly encodedWidth: number;
|
||||||
|
readonly encodedHeight: number;
|
||||||
|
readonly orientation: number | null;
|
||||||
|
readonly hasAlpha: boolean;
|
||||||
|
readonly renditions: readonly SignageImageRendition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sha256Hex(bytes: Uint8Array): string {
|
||||||
|
return createHash("sha256").update(bytes).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sharp reports a pixel-limit breach and a decode failure through the same exception type. The
|
||||||
|
* message is inspected here and then **discarded** — nothing from the decoder reaches a caller. */
|
||||||
|
function decodeFailure(error: unknown): SignageError {
|
||||||
|
const message = error instanceof Error ? error.message : "";
|
||||||
|
if (/pixel limit/iu.test(message)) {
|
||||||
|
return new SignageError("SIGNAGE_IMAGE_PIXEL_LIMIT", { maxPixels: SIGNAGE_IMAGE_MAX_PIXELS });
|
||||||
|
}
|
||||||
|
return new SignageError("SIGNAGE_IMAGE_MALFORMED");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readMetadata(bytes: Uint8Array): Promise<Metadata> {
|
||||||
|
try {
|
||||||
|
return await sharp(bytes, {
|
||||||
|
animated: true,
|
||||||
|
failOn: "error",
|
||||||
|
limitInputPixels: SIGNAGE_IMAGE_MAX_PIXELS,
|
||||||
|
}).metadata();
|
||||||
|
} catch (error) {
|
||||||
|
throw decodeFailure(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSupported(metadata: Metadata): void {
|
||||||
|
const pages = metadata.pages ?? 1;
|
||||||
|
if (pages > 1) {
|
||||||
|
const animated =
|
||||||
|
metadata.loop !== undefined || (Array.isArray(metadata.delay) && metadata.delay.length > 0);
|
||||||
|
throw new SignageError(animated ? "SIGNAGE_IMAGE_ANIMATED" : "SIGNAGE_IMAGE_MULTIPAGE", {
|
||||||
|
pages,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (metadata.format === undefined || !SUPPORTED_FORMATS.has(metadata.format)) {
|
||||||
|
// The detected format is echoed because the caller supplied the bytes it came from. Nothing
|
||||||
|
// about the host, the decoder, or the process is in this response.
|
||||||
|
throw new SignageError("SIGNAGE_IMAGE_UNSUPPORTED_FORMAT", {
|
||||||
|
detectedFormat: metadata.format ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(metadata.width) ||
|
||||||
|
(metadata.width ?? 0) < 1 ||
|
||||||
|
!Number.isSafeInteger(metadata.height) ||
|
||||||
|
(metadata.height ?? 0) < 1
|
||||||
|
) {
|
||||||
|
throw new SignageError("SIGNAGE_IMAGE_MALFORMED");
|
||||||
|
}
|
||||||
|
if ((metadata.width ?? 0) * (metadata.height ?? 0) > SIGNAGE_IMAGE_MAX_PIXELS) {
|
||||||
|
throw new SignageError("SIGNAGE_IMAGE_PIXEL_LIMIT", { maxPixels: SIGNAGE_IMAGE_MAX_PIXELS });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderRendition(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
target: {
|
||||||
|
readonly kind: "display" | "thumbnail";
|
||||||
|
maxWidth: number;
|
||||||
|
maxHeight: number;
|
||||||
|
quality: number;
|
||||||
|
},
|
||||||
|
): Promise<SignageImageRendition> {
|
||||||
|
let output: { data: Buffer; info: OutputInfo };
|
||||||
|
try {
|
||||||
|
output = await sharp(bytes, { failOn: "error", limitInputPixels: SIGNAGE_IMAGE_MAX_PIXELS })
|
||||||
|
// Honour the original's own orientation, so a phone photograph is not shown on its side.
|
||||||
|
.autoOrient()
|
||||||
|
// Transparency becomes black rather than white: a signage display's ground is black, and a
|
||||||
|
// white flash behind a logo is the artefact this avoids.
|
||||||
|
.flatten({ background: { b: 0, g: 0, r: 0 } })
|
||||||
|
// `inside` never crops, and `withoutEnlargement` never upscales — a small image stays small
|
||||||
|
// rather than being blown up into softness.
|
||||||
|
.resize({
|
||||||
|
fit: "inside",
|
||||||
|
height: target.maxHeight,
|
||||||
|
width: target.maxWidth,
|
||||||
|
withoutEnlargement: true,
|
||||||
|
})
|
||||||
|
.toColourspace("srgb")
|
||||||
|
// No `withMetadata()`: EXIF, GPS coordinates, and camera serial numbers are stripped from
|
||||||
|
// anything Signage publishes. The original keeps them; the rendition a browser fetches
|
||||||
|
// does not.
|
||||||
|
.webp({ effort: 4, quality: target.quality })
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
} catch (error) {
|
||||||
|
throw decodeFailure(error);
|
||||||
|
}
|
||||||
|
const data = new Uint8Array(output.data);
|
||||||
|
return {
|
||||||
|
byteSize: data.byteLength,
|
||||||
|
bytes: data,
|
||||||
|
contentSha256: sha256Hex(data),
|
||||||
|
contentType: CONTENT_TYPES.webp as string,
|
||||||
|
height: output.info.height,
|
||||||
|
kind: target.kind,
|
||||||
|
width: output.info.width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the uploaded bytes and produces both renditions, or throws one of Signage's own
|
||||||
|
* sanitized codes. Deterministic: the same input always yields the same output bytes, which is what
|
||||||
|
* makes a retry idempotent.
|
||||||
|
*/
|
||||||
|
export async function processSignageImage(bytes: Uint8Array): Promise<SignageProcessedImage> {
|
||||||
|
const metadata = await readMetadata(bytes);
|
||||||
|
assertSupported(metadata);
|
||||||
|
|
||||||
|
const format = metadata.format as string;
|
||||||
|
const oriented = metadata.autoOrient ?? {
|
||||||
|
height: metadata.height as number,
|
||||||
|
width: metadata.width as number,
|
||||||
|
};
|
||||||
|
const [display, thumbnail] = await Promise.all([
|
||||||
|
renderRendition(bytes, SIGNAGE_IMAGE_DISPLAY_RENDITION),
|
||||||
|
renderRendition(bytes, SIGNAGE_IMAGE_THUMBNAIL_RENDITION),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
byteSize: bytes.byteLength,
|
||||||
|
contentSha256: sha256Hex(bytes),
|
||||||
|
contentType: CONTENT_TYPES[format] as string,
|
||||||
|
encodedHeight: metadata.height as number,
|
||||||
|
encodedWidth: metadata.width as number,
|
||||||
|
fileExtension: FILE_EXTENSIONS[format] as string,
|
||||||
|
format,
|
||||||
|
hasAlpha: Boolean(metadata.hasAlpha),
|
||||||
|
height: oriented.height,
|
||||||
|
orientation: metadata.orientation ?? null,
|
||||||
|
renditions: [display, thumbnail],
|
||||||
|
width: oriented.width,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import {
|
||||||
|
type ModuleStorage,
|
||||||
|
type ModuleStorageBucket,
|
||||||
|
ModuleStorageError,
|
||||||
|
} from "@ekklesia/module-sdk";
|
||||||
|
import { SignageError } from "../errors.js";
|
||||||
|
import { type SignageStore, SignageStoreConflict, type StoredImage } from "../store/types.js";
|
||||||
|
import { SIGNAGE_IMAGE_MAX_BYTES } from "./limits.js";
|
||||||
|
import { processSignageImage, type SignageProcessedImage } from "./processing.js";
|
||||||
|
|
||||||
|
/** The one logical bucket Signage declares. It names no service, endpoint, region, or credential —
|
||||||
|
* which is what lets the same code run under Ekklesia and under a future standalone host. */
|
||||||
|
export const SIGNAGE_IMAGE_BUCKET = "native-images";
|
||||||
|
|
||||||
|
export function nativeImageBucket(storage: ModuleStorage): ModuleStorageBucket {
|
||||||
|
return storage.bucket(SIGNAGE_IMAGE_BUCKET);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object keys are scoped to the asset they belong to.
|
||||||
|
*
|
||||||
|
* Until the asset row commits these are **temporary**: nothing references them, nothing can select
|
||||||
|
* them, and a failed ingest deletes every one it wrote. Scoping them to the asset rather than to a
|
||||||
|
* throwaway upload id is what makes a successful upload cost exactly one write of each object
|
||||||
|
* instead of two — the commit is the database row, not a second copy of 25 MiB.
|
||||||
|
*/
|
||||||
|
export function originalObjectKey(assetId: string, extension: string): string {
|
||||||
|
return `assets/${assetId}/original.${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renditionObjectKey(assetId: string, kind: string): string {
|
||||||
|
return `assets/${assetId}/${kind}.webp`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every storage failure becomes one of Signage's own codes.
|
||||||
|
*
|
||||||
|
* `ModuleStorageError` already carries no endpoint, credential, or provider body; collapsing it
|
||||||
|
* further keeps even *which* storage operation failed out of Signage's responses, because that is
|
||||||
|
* not a caller's business.
|
||||||
|
*/
|
||||||
|
function storageFailure(error: unknown): SignageError {
|
||||||
|
if (error instanceof ModuleStorageError && error.code === "STORAGE_OBJECT_NOT_FOUND") {
|
||||||
|
return new SignageError("SIGNAGE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
return new SignageError("SIGNAGE_STORAGE_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IngestNativeImageInput {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly storage: ModuleStorage;
|
||||||
|
readonly bytes: Uint8Array;
|
||||||
|
readonly filename: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly expiresAt: Date | null;
|
||||||
|
/** Supplied so a test can pin identity. Production passes nothing and gets a fresh UUID. */
|
||||||
|
readonly newId?: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IngestNativeImageResult {
|
||||||
|
readonly image: StoredImage;
|
||||||
|
/** True when identical bytes were already stored, so nothing new was written anywhere. */
|
||||||
|
readonly deduplicated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ingests one native image.
|
||||||
|
*
|
||||||
|
* The order **is** the contract (ADR 0007):
|
||||||
|
*
|
||||||
|
* 1. The caller has already read the whole request body under Signage's own bound.
|
||||||
|
* 2. Decode and validate. An invalid image has cost zero storage writes and zero rows.
|
||||||
|
* 3. Deduplicate by content hash. Identical bytes return the existing asset — again, zero writes.
|
||||||
|
* 4. Write the original and both renditions to asset-scoped keys. Temporary: nothing refers to them.
|
||||||
|
* 5. Commit the asset and its renditions in one transaction. That row is what makes the asset exist,
|
||||||
|
* so a partial write can never be promoted into something playback could select.
|
||||||
|
* 6. On any failure after step 4, delete every object this attempt wrote.
|
||||||
|
*/
|
||||||
|
export async function ingestNativeImage(
|
||||||
|
input: IngestNativeImageInput,
|
||||||
|
): Promise<IngestNativeImageResult> {
|
||||||
|
if (input.bytes.byteLength > SIGNAGE_IMAGE_MAX_BYTES) {
|
||||||
|
throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes: SIGNAGE_IMAGE_MAX_BYTES });
|
||||||
|
}
|
||||||
|
|
||||||
|
const processed: SignageProcessedImage = await processSignageImage(input.bytes);
|
||||||
|
|
||||||
|
const existing = await input.store.findReadyImageByContentHash(processed.contentSha256);
|
||||||
|
if (existing) {
|
||||||
|
return { deduplicated: true, image: existing };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = nativeImageBucket(input.storage);
|
||||||
|
const assetId = (input.newId ?? randomUUID)();
|
||||||
|
const original = originalObjectKey(assetId, processed.fileExtension);
|
||||||
|
const written: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await bucket.put(original, input.bytes, { contentType: processed.contentType });
|
||||||
|
written.push(original);
|
||||||
|
|
||||||
|
const renditions = [];
|
||||||
|
for (const rendition of processed.renditions) {
|
||||||
|
const key = renditionObjectKey(assetId, rendition.kind);
|
||||||
|
await bucket.put(key, rendition.bytes, { contentType: rendition.contentType });
|
||||||
|
written.push(key);
|
||||||
|
renditions.push({
|
||||||
|
byteSize: rendition.byteSize,
|
||||||
|
contentSha256: rendition.contentSha256,
|
||||||
|
contentType: rendition.contentType,
|
||||||
|
height: rendition.height,
|
||||||
|
kind: rendition.kind,
|
||||||
|
objectKey: key,
|
||||||
|
width: rendition.width,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await input.store.insertImage({
|
||||||
|
contentSha256: processed.contentSha256,
|
||||||
|
displayName: input.displayName,
|
||||||
|
encodedHeight: processed.encodedHeight,
|
||||||
|
encodedWidth: processed.encodedWidth,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
hasAlpha: processed.hasAlpha,
|
||||||
|
height: processed.height,
|
||||||
|
id: assetId,
|
||||||
|
orientation: processed.orientation,
|
||||||
|
originalByteSize: processed.byteSize,
|
||||||
|
originalContentType: processed.contentType,
|
||||||
|
originalFilename: input.filename,
|
||||||
|
originalObjectKey: original,
|
||||||
|
renditions,
|
||||||
|
status: "ready",
|
||||||
|
width: processed.width,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Nothing this attempt wrote is referenced by anything, so removing all of it is always safe.
|
||||||
|
await Promise.all(written.map((key) => bucket.delete(key).catch(() => undefined)));
|
||||||
|
|
||||||
|
if (error instanceof SignageStoreConflict) {
|
||||||
|
// Another request committed the identical bytes while this one was writing. For the caller
|
||||||
|
// that is a success, not a failure: the image they uploaded is stored.
|
||||||
|
const raced = await input.store.findReadyImageByContentHash(processed.contentSha256);
|
||||||
|
if (raced) {
|
||||||
|
return { deduplicated: true, image: raced };
|
||||||
|
}
|
||||||
|
throw new SignageError("SIGNAGE_CONFLICT");
|
||||||
|
}
|
||||||
|
if (error instanceof SignageError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (error instanceof ModuleStorageError) {
|
||||||
|
throw storageFailure(error);
|
||||||
|
}
|
||||||
|
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await input.store.findImage(assetId);
|
||||||
|
if (!image) {
|
||||||
|
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||||
|
}
|
||||||
|
return { deduplicated: false, image };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignageImageDelivery {
|
||||||
|
readonly kind: string;
|
||||||
|
readonly url: string;
|
||||||
|
readonly expiresAt: string;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fresh, short-lived delivery URLs for one image's renditions.
|
||||||
|
*
|
||||||
|
* Requested when something is about to be delivered, and then forgotten. Signage persists the
|
||||||
|
* logical key and never the URL: a URL is a bearer capability with an expiry in it, and storing one
|
||||||
|
* in a table would be storing a credential that stops working.
|
||||||
|
*/
|
||||||
|
export async function createImageDeliveryUrls(
|
||||||
|
storage: ModuleStorage,
|
||||||
|
renditions: readonly {
|
||||||
|
readonly kind: string;
|
||||||
|
readonly objectKey: string;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
}[],
|
||||||
|
): Promise<readonly SignageImageDelivery[]> {
|
||||||
|
const bucket = nativeImageBucket(storage);
|
||||||
|
try {
|
||||||
|
return await Promise.all(
|
||||||
|
renditions.map(async (rendition) => {
|
||||||
|
const url = await bucket.createReadUrl(rendition.objectKey);
|
||||||
|
return {
|
||||||
|
expiresAt: url.expiresAt.toISOString(),
|
||||||
|
height: rendition.height,
|
||||||
|
kind: rendition.kind,
|
||||||
|
url: url.url,
|
||||||
|
width: rendition.width,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw storageFailure(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNativeImage(input: {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly assetId: string;
|
||||||
|
readonly displayName?: string | undefined;
|
||||||
|
readonly expiresAt?: Date | null | undefined;
|
||||||
|
}): Promise<StoredImage> {
|
||||||
|
const existing = await input.store.findImage(input.assetId);
|
||||||
|
if (!existing) {
|
||||||
|
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes: { displayName?: string; expiresAt?: Date | null } = {};
|
||||||
|
if (input.displayName !== undefined && input.displayName !== existing.displayName) {
|
||||||
|
changes.displayName = input.displayName;
|
||||||
|
}
|
||||||
|
if (input.expiresAt !== undefined) {
|
||||||
|
const current = existing.expiresAt?.getTime() ?? null;
|
||||||
|
const next = input.expiresAt?.getTime() ?? null;
|
||||||
|
if (current !== next) {
|
||||||
|
changes.expiresAt = input.expiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An exact no-op writes nothing at all.
|
||||||
|
if (Object.keys(changes).length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
await input.store.updateImage(input.assetId, changes);
|
||||||
|
const updated = await input.store.findImage(input.assetId);
|
||||||
|
if (!updated) {
|
||||||
|
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an image, and refuses while something still refers to it.
|
||||||
|
*
|
||||||
|
* A slot pinned to a deleted image would resolve to nothing at the moment its turn came, mid-service.
|
||||||
|
* Refusing with a count of what refers to it is something an operator can act on; a silent cascade
|
||||||
|
* is not.
|
||||||
|
*/
|
||||||
|
export async function deleteNativeImage(input: {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly storage: ModuleStorage;
|
||||||
|
readonly assetId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const existing = await input.store.findImage(input.assetId);
|
||||||
|
if (!existing) {
|
||||||
|
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
const references = await input.store.countImageReferences(input.assetId);
|
||||||
|
if (references.slots > 0 || references.staticHolds > 0) {
|
||||||
|
throw new SignageError("SIGNAGE_ASSET_IN_USE", {
|
||||||
|
heldByChannelGroups: references.staticHolds,
|
||||||
|
referencedBySlots: references.slots,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The row goes first: an asset nothing can select is the state that matters, and an object left
|
||||||
|
// behind by a storage outage is inert rather than dangerous.
|
||||||
|
await input.store.deleteImage(input.assetId);
|
||||||
|
const bucket = nativeImageBucket(input.storage);
|
||||||
|
const keys = [
|
||||||
|
existing.originalObjectKey,
|
||||||
|
...existing.renditions.map((rendition) => rendition.objectKey),
|
||||||
|
];
|
||||||
|
await Promise.all(keys.map((key) => bucket.delete(key).catch(() => undefined)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { SignageError } from "../errors.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a streamed request body under a hard byte bound.
|
||||||
|
*
|
||||||
|
* The upload route runs in the platform's `streamed` body mode, which means the platform counts
|
||||||
|
* bytes **as they are consumed** and never materializes the body itself. Signage counts them too,
|
||||||
|
* and stops at its own smaller limit — so an oversized upload is refused by Signage's own code with
|
||||||
|
* Signage's own message, rather than only by the platform's later `413`.
|
||||||
|
*
|
||||||
|
* The bytes are accumulated because an image cannot be validated, hashed, or resized from a partial
|
||||||
|
* stream: a decoder needs the whole file, and the decoded-pixel budget can only be checked once the
|
||||||
|
* header is present. The bound is what makes that safe — the peak is one image, never a request of
|
||||||
|
* unknown size, and never more than {@link maxBytes}.
|
||||||
|
*
|
||||||
|
* The source is cancelled as soon as the bound is passed, so a client sending more stops being read
|
||||||
|
* rather than being read and discarded.
|
||||||
|
*/
|
||||||
|
export async function readBoundedBody(request: Request, maxBytes: number): Promise<Uint8Array> {
|
||||||
|
const declared = request.headers.get("content-length");
|
||||||
|
if (declared !== null) {
|
||||||
|
const length = Number(declared);
|
||||||
|
// A declared length over the limit is refused before a byte is read. A missing or lying one
|
||||||
|
// bounds nothing, which is exactly why the running total below is the real check.
|
||||||
|
if (Number.isFinite(length) && length > maxBytes) {
|
||||||
|
throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = request.body;
|
||||||
|
if (!body) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
total += value.byteLength;
|
||||||
|
if (total > maxBytes) {
|
||||||
|
await reader.cancel().catch(() => undefined);
|
||||||
|
throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes });
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SignageError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// The platform errors the stream when *its* limit is passed, and cancels the source. Nothing
|
||||||
|
// about that error reaches a caller: it becomes Signage's own bounded code.
|
||||||
|
throw new SignageError("SIGNAGE_IMAGE_TOO_LARGE", { maxBytes });
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "body" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
bytes.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createSignageModuleRuntime } from "./runtime.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signage's compiled server entrypoint.
|
||||||
|
*
|
||||||
|
* `id` and `version` are build-artifact identity markers and must match `ekklesia.module.json`
|
||||||
|
* exactly; Core verifies both before this module is allowed to register anything (ADR 0006). The
|
||||||
|
* runtime itself is built by a factory (`./runtime.ts`) so a test can construct a fresh one rather
|
||||||
|
* than exercising a shared singleton's leftovers.
|
||||||
|
*/
|
||||||
|
export default createSignageModuleRuntime().definition;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { PlaybackSnapshot, SignagePlaybackService } from "./service.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `signage.playback` — sanitized playback state and commands for another module.
|
||||||
|
*
|
||||||
|
* The intended consumer is something like an Ekklesia Companion: a module that gives a volunteer at
|
||||||
|
* the back of a room a way to hold a slide or black out a screen. What it gets is exactly the
|
||||||
|
* operations an operator has in the dashboard, over the same authoritative service, with the same
|
||||||
|
* serialization.
|
||||||
|
*
|
||||||
|
* What it deliberately is **not**: a transport. There is no topic, no broker, no webhook, no
|
||||||
|
* integration vocabulary of any kind in this contract — a consumer that wanted to bridge these
|
||||||
|
* commands onto something else would do that in its own module, where that decision belongs.
|
||||||
|
*
|
||||||
|
* What it deliberately does not carry: a Core credential, a session, a device identity, a provider
|
||||||
|
* payload, or any private host internal. State here is identity, timing, and revisions.
|
||||||
|
*/
|
||||||
|
export const SIGNAGE_PLAYBACK_CAPABILITY_VERSION = "1.0.0";
|
||||||
|
|
||||||
|
/** One channel group's state, exactly as a display sees it. */
|
||||||
|
export type SignagePlaybackState = PlaybackSnapshot;
|
||||||
|
|
||||||
|
export interface SignagePlaybackCapability {
|
||||||
|
readonly contractVersion: string;
|
||||||
|
/** Every channel group and its current authoritative state. */
|
||||||
|
listGroups(): Promise<readonly SignagePlaybackState[]>;
|
||||||
|
readGroup(groupId: string): Promise<SignagePlaybackState>;
|
||||||
|
/** Immediately, or queued for the next safe slot boundary. */
|
||||||
|
activate(
|
||||||
|
groupId: string,
|
||||||
|
timelineId: string,
|
||||||
|
when: "now" | "next-boundary",
|
||||||
|
): Promise<SignagePlaybackState>;
|
||||||
|
pause(groupId: string): Promise<SignagePlaybackState>;
|
||||||
|
resume(groupId: string): Promise<SignagePlaybackState>;
|
||||||
|
next(groupId: string): Promise<SignagePlaybackState>;
|
||||||
|
previous(groupId: string): Promise<SignagePlaybackState>;
|
||||||
|
setBlackout(groupId: string, on: boolean): Promise<SignagePlaybackState>;
|
||||||
|
/** `null` clears the hold and continues the slide underneath from its preserved remainder. */
|
||||||
|
setStaticHold(groupId: string, imageId: string | null): Promise<SignagePlaybackState>;
|
||||||
|
/** Re-resolves provider availability and the current selection without disturbing the frame. */
|
||||||
|
reload(groupId: string): Promise<SignagePlaybackState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlaybackCapability(
|
||||||
|
playback: () => SignagePlaybackService,
|
||||||
|
listGroupIds: () => Promise<readonly string[]>,
|
||||||
|
): SignagePlaybackCapability {
|
||||||
|
const capability: SignagePlaybackCapability = {
|
||||||
|
activate: (groupId: string, timelineId: string, when: "now" | "next-boundary") =>
|
||||||
|
playback().activate(groupId, timelineId, when),
|
||||||
|
contractVersion: SIGNAGE_PLAYBACK_CAPABILITY_VERSION,
|
||||||
|
async listGroups(): Promise<readonly SignagePlaybackState[]> {
|
||||||
|
const ids = await listGroupIds();
|
||||||
|
return Promise.all(ids.map((groupId) => playback().snapshot(groupId)));
|
||||||
|
},
|
||||||
|
next: (groupId: string) => playback().next(groupId),
|
||||||
|
pause: (groupId: string) => playback().pause(groupId),
|
||||||
|
previous: (groupId: string) => playback().previous(groupId),
|
||||||
|
readGroup: (groupId: string) => playback().snapshot(groupId),
|
||||||
|
reload: (groupId: string) => playback().reload(groupId),
|
||||||
|
resume: (groupId: string) => playback().resume(groupId),
|
||||||
|
setBlackout: (groupId: string, on: boolean) => playback().setBlackout(groupId, on),
|
||||||
|
setStaticHold: (groupId: string, imageId: string | null) =>
|
||||||
|
playback().setStaticHold(groupId, imageId),
|
||||||
|
};
|
||||||
|
return Object.freeze(capability);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* The `signage.content-providers` contract, version 1 (ADR 0002).
|
||||||
|
*
|
||||||
|
* This is the seam every future content module reaches Signage through: Announcements, Scripture,
|
||||||
|
* Trivia, and anything else a church installs. It is the one contract in this repository with
|
||||||
|
* implementations Signage's authors will never see, so it is written to be implementable from the
|
||||||
|
* documentation alone.
|
||||||
|
*
|
||||||
|
* ## A provider does not import this file
|
||||||
|
*
|
||||||
|
* Modules are independently installed peers, and `pnpm boundaries` refuses a module that imports
|
||||||
|
* another module. A provider therefore resolves the capability, receives an `unknown`
|
||||||
|
* implementation, and casts it to **its own local copy** of these declarations — which is what keeps
|
||||||
|
* the two modules independently versioned and separately releasable. `docs/provider-contract.md`
|
||||||
|
* publishes the shapes for exactly that purpose.
|
||||||
|
*
|
||||||
|
* ## Signage validates what it receives, always
|
||||||
|
*
|
||||||
|
* Everything crossing this boundary is validated before it is believed: registrations, resolutions,
|
||||||
|
* payloads, and durations. A provider is trusted code in the same process, so this is not a sandbox
|
||||||
|
* — it is the difference between one module's bug staying one module's bug and a malformed value
|
||||||
|
* reaching a television.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The contract version Signage implements, and the only one it accepts a registration for. */
|
||||||
|
export const SIGNAGE_PROVIDER_CONTRACT_VERSION = "1.0.0";
|
||||||
|
|
||||||
|
/** Major-version compatibility: a provider written against `1.x` works, `2.x` does not. Stated as a
|
||||||
|
* predicate rather than a range string so no semantic-version parser has to cross this boundary. */
|
||||||
|
export function isSupportedProviderContractVersion(version: string): boolean {
|
||||||
|
return /^1\.\d+\.\d+$/u.test(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignageJsonValue =
|
||||||
|
| boolean
|
||||||
|
| number
|
||||||
|
| string
|
||||||
|
| null
|
||||||
|
| readonly SignageJsonValue[]
|
||||||
|
| { readonly [key: string]: SignageJsonValue };
|
||||||
|
|
||||||
|
/** The audience key of the base channel every group has. A provider that offers nothing
|
||||||
|
* audience-specific returns exactly one variant, under this key. */
|
||||||
|
export const SIGNAGE_DEFAULT_AUDIENCE_KEY = "default";
|
||||||
|
|
||||||
|
/** One of a provider's own sources, as an operator picks it in the timeline editor. */
|
||||||
|
export interface SignageProviderSourceDescriptor {
|
||||||
|
/** The provider's own identity for this source. Opaque to Signage. */
|
||||||
|
readonly id: string;
|
||||||
|
/** What an operator sees. Signage stores a copy so the editor still reads sensibly when the
|
||||||
|
* provider is temporarily not running. */
|
||||||
|
readonly label: string;
|
||||||
|
readonly description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One specific item, so an operator can pin it into a slot. */
|
||||||
|
export interface SignageProviderItemDescriptor {
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly description?: string;
|
||||||
|
/** When this item stops being available, as an absolute ISO 8601 instant. */
|
||||||
|
readonly availableUntil?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One audience's rendering of one logical selection. */
|
||||||
|
export interface SignageProviderVariant {
|
||||||
|
/** `null` — or {@link SIGNAGE_DEFAULT_AUDIENCE_KEY} — is the default every channel falls back to. */
|
||||||
|
readonly audienceKey: string | null;
|
||||||
|
/** Structured, finite JSON. Signage validates that it *is* JSON and never what it means. */
|
||||||
|
readonly payload: SignageJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a provider hands back for one slot occurrence: **one logical item**, never one per channel.
|
||||||
|
*
|
||||||
|
* Resolving once and deriving variants is what keeps every channel in a group on the same slide with
|
||||||
|
* the same deadline, and what makes an audience a rendering rather than a second program (ADR 0004).
|
||||||
|
*/
|
||||||
|
export interface SignageProviderResolution {
|
||||||
|
/** The provider's own stable identity for this content. */
|
||||||
|
readonly itemId: string;
|
||||||
|
/** Changes whenever the payload changes and the item stays the same thing. */
|
||||||
|
readonly itemRevision: string;
|
||||||
|
/** Overrides this provider's default duration for this one item. */
|
||||||
|
readonly durationMs?: number;
|
||||||
|
/** When this selection stops being valid, as an absolute ISO 8601 instant. */
|
||||||
|
readonly availableUntil?: string | null;
|
||||||
|
/** This item is explicitly gone. Signage removes it at the next safe reconciliation boundary
|
||||||
|
* rather than immediately, so a screen does not cut mid-slide. */
|
||||||
|
readonly withdrawn?: boolean;
|
||||||
|
/** Presentation values the provider supplies to its **own** renderer. Opaque to Signage, which
|
||||||
|
* never interprets, merges, or overrides them. */
|
||||||
|
readonly presentation?: SignageJsonValue;
|
||||||
|
/** At least the default variant. */
|
||||||
|
readonly variants: readonly SignageProviderVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What Signage tells a provider when it asks for the next item. Deliberately nothing more: a
|
||||||
|
* provider is already running in this process, and what it needs from Signage is *when*. */
|
||||||
|
export interface SignageProviderResolveRequest {
|
||||||
|
/** Stable and opaque. Every slot referencing one source instance passes the same value, which is
|
||||||
|
* what makes five Announcement slots one rotation rather than five (ADR 0003). */
|
||||||
|
readonly selectionScopeId: string;
|
||||||
|
/** Which of the provider's own sources is being asked. */
|
||||||
|
readonly sourceId: string;
|
||||||
|
readonly timelineId: string;
|
||||||
|
readonly channelGroupId: string;
|
||||||
|
/** Every audience channel in the group, in a stable order. */
|
||||||
|
readonly audienceKeys: readonly string[];
|
||||||
|
/** The instant being resolved for, as an absolute ISO 8601 instant. */
|
||||||
|
readonly at: string;
|
||||||
|
/** The installation's IANA zone, for a provider whose availability is calendar-shaped. */
|
||||||
|
readonly timezone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What Signage tells a provider when it wants one already-known item resolved again — a pinned
|
||||||
|
* slot's item, or a live refresh of what is currently on screen. */
|
||||||
|
export interface SignageProviderRefreshRequest
|
||||||
|
extends Omit<SignageProviderResolveRequest, "sourceId" | "selectionScopeId"> {
|
||||||
|
readonly itemId: string;
|
||||||
|
/** Present when the item came from a source; absent for a pinned item. */
|
||||||
|
readonly sourceId?: string;
|
||||||
|
readonly selectionScopeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignageProviderDiscoveryRequest {
|
||||||
|
readonly at: string;
|
||||||
|
readonly timezone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The renderer a provider's content is drawn by: a browser contribution the platform publishes
|
||||||
|
* against Signage's `signage.slide-renderer` capability (ADR 0041). Signage never imports it; its
|
||||||
|
* display asks the host to mount it by id. */
|
||||||
|
export interface SignageProviderRenderer {
|
||||||
|
readonly contributionId: string;
|
||||||
|
readonly contributionVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a provider module passes to `register`.
|
||||||
|
*
|
||||||
|
* Every function may reject or throw. Signage bounds each call with a deadline, catches everything,
|
||||||
|
* and converts the outcome into one of three answers — nothing available, explicitly withdrawn, or
|
||||||
|
* temporarily failed — which is the distinction the whole failure model rests on (ADR 0002).
|
||||||
|
*/
|
||||||
|
export interface SignageProviderRegistration {
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly providerVersion: string;
|
||||||
|
/** Which version of this contract the provider was written against. */
|
||||||
|
readonly contractVersion: string;
|
||||||
|
readonly renderer: SignageProviderRenderer;
|
||||||
|
/** How long this provider's content is shown when no slot overrides it. */
|
||||||
|
readonly defaultDurationMs: number;
|
||||||
|
listSources(
|
||||||
|
request: SignageProviderDiscoveryRequest,
|
||||||
|
):
|
||||||
|
| Promise<readonly SignageProviderSourceDescriptor[]>
|
||||||
|
| readonly SignageProviderSourceDescriptor[];
|
||||||
|
/** Optional. Without it, an operator cannot pin one of this provider's items. */
|
||||||
|
listItems?(
|
||||||
|
request: SignageProviderDiscoveryRequest & { readonly sourceId?: string },
|
||||||
|
): Promise<readonly SignageProviderItemDescriptor[]> | readonly SignageProviderItemDescriptor[];
|
||||||
|
/** **The only call that advances anything.** `null` means nothing is available for this source
|
||||||
|
* right now, and Signage skips the slot immediately with no error and no slate. */
|
||||||
|
resolveNext(
|
||||||
|
request: SignageProviderResolveRequest,
|
||||||
|
): Promise<SignageProviderResolution | null> | SignageProviderResolution | null;
|
||||||
|
/** Re-resolves one already-known item without advancing. */
|
||||||
|
refresh(
|
||||||
|
request: SignageProviderRefreshRequest,
|
||||||
|
): Promise<SignageProviderResolution | null> | SignageProviderResolution | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a provider may tell Signage has changed. A **notification**, never a command: Signage
|
||||||
|
* decides what to refresh and when, and nothing here can skip a slot or move a deadline. */
|
||||||
|
export interface SignageProviderInvalidation {
|
||||||
|
/** Limits the notification to one of the provider's sources. */
|
||||||
|
readonly sourceId?: string;
|
||||||
|
/** Limits it to one item — a cancelled event, a changed countdown. */
|
||||||
|
readonly itemId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What `register` returns. Held by the provider module and released in its own `stop` hook. */
|
||||||
|
export interface SignageProviderHandle {
|
||||||
|
/** Idempotent. Unregistering an already-unregistered provider is a no-op, which is what makes a
|
||||||
|
* rollback after a partially completed `start` safe. */
|
||||||
|
unregister(): void;
|
||||||
|
/** Notifies Signage that something it already resolved may have changed. Never throws for a
|
||||||
|
* provider that has been unregistered — a late notification from a module being torn down is
|
||||||
|
* ordinary, not an error. */
|
||||||
|
invalidate(change?: SignageProviderInvalidation): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The capability implementation Signage provides as `signage.content-providers`. */
|
||||||
|
export interface SignageContentProviderCapability {
|
||||||
|
readonly contractVersion: string;
|
||||||
|
/** Takes `unknown` deliberately: the caller is another module with its own copy of these types,
|
||||||
|
* and Signage validates the value rather than trusting a structural type it cannot enforce across
|
||||||
|
* a module boundary. */
|
||||||
|
register(registration: unknown): SignageProviderHandle;
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { SIGNAGE_NATIVE_PROVIDER_ID, SIGNAGE_NATIVE_RENDERER } from "../../shared/native-images.js";
|
||||||
|
import type { SignageStore, StoredImage } from "../store/types.js";
|
||||||
|
import type {
|
||||||
|
SignageProviderDiscoveryRequest,
|
||||||
|
SignageProviderItemDescriptor,
|
||||||
|
SignageProviderRefreshRequest,
|
||||||
|
SignageProviderRegistration,
|
||||||
|
SignageProviderResolution,
|
||||||
|
SignageProviderResolveRequest,
|
||||||
|
SignageProviderSourceDescriptor,
|
||||||
|
} from "./contract.js";
|
||||||
|
import { SIGNAGE_PROVIDER_CONTRACT_VERSION } from "./contract.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signage's own content provider.
|
||||||
|
*
|
||||||
|
* It implements the identical registration shape an external provider implements, registers through
|
||||||
|
* the identical capability, and resolves through the identical code path (ADR 0001). That is the
|
||||||
|
* whole point: there is **one** selection pipeline, so history, `previous`, restart recovery,
|
||||||
|
* audience variants, and offline continuation are written once rather than twice and drifting.
|
||||||
|
*
|
||||||
|
* Two things are special about it, and only two:
|
||||||
|
*
|
||||||
|
* - Its renderer is `signage.native-image`, which is not a browser contribution at all. A display
|
||||||
|
* that sees that identity paints an image element itself instead of asking the host to mount
|
||||||
|
* anything, because the content is Signage's own.
|
||||||
|
* - Its payload shape is Signage's own, so the display-state route may read it — to mint fresh
|
||||||
|
* delivery URLs from the stored object keys. Every *other* provider's payload stays opaque.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The one source Signage's own provider offers: every ready, currently-available image, in the
|
||||||
|
* order they were added, cycling forever. A pinned slot does not use this at all. */
|
||||||
|
export const SIGNAGE_NATIVE_LIBRARY_SOURCE = "library";
|
||||||
|
|
||||||
|
/** What a native-image selection carries. The renditions are **object keys**, not URLs: a URL is a
|
||||||
|
* bearer capability with an expiry, minted when something is about to be delivered and never
|
||||||
|
* stored (ADR 0007). */
|
||||||
|
// A `type` rather than an `interface` on purpose: TypeScript grants a type alias an implicit index
|
||||||
|
// signature, so this shape is assignable to `SignageJsonValue` without a cast — which is the
|
||||||
|
// compiler proving that everything Signage's own provider emits really is finite JSON.
|
||||||
|
export type SignageNativeImagePayload = {
|
||||||
|
readonly kind: "native-image";
|
||||||
|
readonly imageId: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
readonly renditionKeys: { readonly display: string; readonly thumbnail: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
function isAvailable(image: StoredImage, at: Date): boolean {
|
||||||
|
if (image.status !== "ready") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Half-open: available up to but not including the expiration instant.
|
||||||
|
return image.expiresAt === null || at < image.expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadFor(image: StoredImage): SignageNativeImagePayload {
|
||||||
|
const display = image.renditions.find((rendition) => rendition.kind === "display");
|
||||||
|
const thumbnail = image.renditions.find((rendition) => rendition.kind === "thumbnail");
|
||||||
|
return {
|
||||||
|
displayName: image.displayName,
|
||||||
|
height: image.height,
|
||||||
|
imageId: image.id,
|
||||||
|
kind: "native-image",
|
||||||
|
renditionKeys: {
|
||||||
|
display: display?.objectKey ?? image.originalObjectKey,
|
||||||
|
thumbnail: thumbnail?.objectKey ?? image.originalObjectKey,
|
||||||
|
},
|
||||||
|
width: image.width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolutionFor(image: StoredImage): SignageProviderResolution {
|
||||||
|
return {
|
||||||
|
availableUntil: image.expiresAt?.toISOString() ?? null,
|
||||||
|
// The image's own content hash is its revision: different bytes are a different asset, so a
|
||||||
|
// rendition can never change underneath a revision that stayed the same.
|
||||||
|
itemId: image.id,
|
||||||
|
itemRevision: image.contentSha256.slice(0, 32),
|
||||||
|
// One variant, under the default key. Native images have no audience-specific rendering: a
|
||||||
|
// church's slide is the same slide in the room and online.
|
||||||
|
variants: [{ audienceKey: null, payload: payloadFor(image) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NativeImageProviderOptions {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly defaultDurationMs: number;
|
||||||
|
readonly now?: () => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the registration for Signage's built-in image provider.
|
||||||
|
*
|
||||||
|
* The library source's rotation cursor is held **in memory, per selection scope**. That is a
|
||||||
|
* deliberate simplification with a stated cost: a restart resumes the library rotation from the
|
||||||
|
* image that last played in that scope when Signage can tell it — which it can, because it persists
|
||||||
|
* every resolved selection — and from the beginning when it cannot. The current slide itself is
|
||||||
|
* unaffected either way, because that is persisted authoritative state rather than a cursor.
|
||||||
|
*/
|
||||||
|
export interface NativeImageProvider {
|
||||||
|
readonly registration: SignageProviderRegistration;
|
||||||
|
/** Re-seeds a scope's rotation from what actually played last, so a restart continues the library
|
||||||
|
* where it left off rather than at the beginning. */
|
||||||
|
seedCursor(selectionScopeId: string, itemId: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNativeImageProvider(
|
||||||
|
options: NativeImageProviderOptions,
|
||||||
|
): NativeImageProvider {
|
||||||
|
const now = options.now ?? (() => new Date());
|
||||||
|
const cursors = new Map<string, string>();
|
||||||
|
|
||||||
|
async function available(at: Date): Promise<readonly StoredImage[]> {
|
||||||
|
const images = await options.store.listImages();
|
||||||
|
return (
|
||||||
|
images
|
||||||
|
.filter((image) => isAvailable(image, at))
|
||||||
|
// Oldest first, so a rotation an operator watched yesterday runs in the same order today.
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration: SignageProviderRegistration = {
|
||||||
|
contractVersion: SIGNAGE_PROVIDER_CONTRACT_VERSION,
|
||||||
|
defaultDurationMs: options.defaultDurationMs,
|
||||||
|
|
||||||
|
async listItems(
|
||||||
|
_request: SignageProviderDiscoveryRequest,
|
||||||
|
): Promise<readonly SignageProviderItemDescriptor[]> {
|
||||||
|
const at = now();
|
||||||
|
const images = await available(at);
|
||||||
|
return images.map((image) => ({
|
||||||
|
availableUntil: image.expiresAt?.toISOString() ?? null,
|
||||||
|
description: `${image.width}×${image.height}`,
|
||||||
|
id: image.id,
|
||||||
|
label: image.displayName,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
listSources(
|
||||||
|
_request: SignageProviderDiscoveryRequest,
|
||||||
|
): readonly SignageProviderSourceDescriptor[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Every Signage image that is ready and not expired, in the order it was added.",
|
||||||
|
id: SIGNAGE_NATIVE_LIBRARY_SOURCE,
|
||||||
|
label: "All Signage images",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
providerId: SIGNAGE_NATIVE_PROVIDER_ID,
|
||||||
|
providerVersion: "1.0.0",
|
||||||
|
|
||||||
|
async refresh(
|
||||||
|
request: SignageProviderRefreshRequest,
|
||||||
|
): Promise<SignageProviderResolution | null> {
|
||||||
|
const image = await options.store.findImage(request.itemId);
|
||||||
|
if (!image) {
|
||||||
|
// The operator deleted it. That is a withdrawal, not a failure: the item is genuinely gone,
|
||||||
|
// and the slot should stop showing it at the next safe boundary rather than keeping a stale
|
||||||
|
// frame forever.
|
||||||
|
return {
|
||||||
|
itemId: request.itemId,
|
||||||
|
itemRevision: "deleted",
|
||||||
|
variants: [{ audienceKey: null, payload: { kind: "withdrawn" } }],
|
||||||
|
withdrawn: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!isAvailable(image, new Date(request.at))) {
|
||||||
|
// Expired or not yet ready: nothing to show right now, which is a skip rather than an error.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolutionFor(image);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderer: { contributionId: SIGNAGE_NATIVE_RENDERER, contributionVersion: "1.0.0" },
|
||||||
|
|
||||||
|
async resolveNext(
|
||||||
|
request: SignageProviderResolveRequest,
|
||||||
|
): Promise<SignageProviderResolution | null> {
|
||||||
|
if (request.sourceId !== SIGNAGE_NATIVE_LIBRARY_SOURCE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const images = await available(new Date(request.at));
|
||||||
|
if (images.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const previous = cursors.get(request.selectionScopeId);
|
||||||
|
const index =
|
||||||
|
previous === undefined ? -1 : images.findIndex((image) => image.id === previous);
|
||||||
|
// Round robin. An image added or removed since the last turn simply shifts the ring; nothing
|
||||||
|
// about the cursor is an index into a list that may have changed underneath it.
|
||||||
|
const next = images[(index + 1) % images.length] as StoredImage;
|
||||||
|
cursors.set(request.selectionScopeId, next.id);
|
||||||
|
return resolutionFor(next);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
registration,
|
||||||
|
seedCursor(selectionScopeId: string, itemId: string): void {
|
||||||
|
cursors.set(selectionScopeId, itemId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import type {
|
||||||
|
SignageContentProviderCapability,
|
||||||
|
SignageJsonValue,
|
||||||
|
SignageProviderDiscoveryRequest,
|
||||||
|
SignageProviderHandle,
|
||||||
|
SignageProviderInvalidation,
|
||||||
|
SignageProviderItemDescriptor,
|
||||||
|
SignageProviderRefreshRequest,
|
||||||
|
SignageProviderRegistration,
|
||||||
|
SignageProviderResolveRequest,
|
||||||
|
SignageProviderSourceDescriptor,
|
||||||
|
} from "./contract.js";
|
||||||
|
import { SIGNAGE_PROVIDER_CONTRACT_VERSION } from "./contract.js";
|
||||||
|
import {
|
||||||
|
type ParsedProviderResolution,
|
||||||
|
parseItemDescriptors,
|
||||||
|
parseProviderRegistration,
|
||||||
|
parseProviderResolution,
|
||||||
|
parseSourceDescriptors,
|
||||||
|
SignageProviderError,
|
||||||
|
type SignageProviderErrorCode,
|
||||||
|
} from "./validation.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live registry of content providers.
|
||||||
|
*
|
||||||
|
* **In memory only, deliberately.** A registration is a running fact, not a stored one: a provider
|
||||||
|
* that is uninstalled or disabled leaves no row behind claiming it exists, and Signage never
|
||||||
|
* enumerates provider *modules* — only whatever registered itself while running.
|
||||||
|
*
|
||||||
|
* Every call out to a provider goes through {@link callProvider}: a deadline, a `catch`, and
|
||||||
|
* validation of what came back. A provider cannot crash the Signage runtime, hang playback, corrupt
|
||||||
|
* a selection, or put its own words on a screen or in a log.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** What management sees about one provider. Everything here is either the provider's own declared
|
||||||
|
* identity or a count Signage itself kept — never a provider message or a caught exception. */
|
||||||
|
export interface SignageProviderHealth {
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly providerVersion: string;
|
||||||
|
readonly contractVersion: string;
|
||||||
|
readonly renderer: { readonly contributionId: string; readonly contributionVersion: string };
|
||||||
|
readonly defaultDurationMs: number;
|
||||||
|
readonly registeredAt: string;
|
||||||
|
readonly consecutiveFailures: number;
|
||||||
|
readonly lastFailureCode: SignageProviderErrorCode | null;
|
||||||
|
readonly lastFailureAt: string | null;
|
||||||
|
readonly lastSuccessAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What Signage does when a provider says something it already resolved may have changed. The
|
||||||
|
* registry only *reports* the notification; deciding what to refresh, and when, belongs to
|
||||||
|
* playback, which is the only thing that knows what is on screen. */
|
||||||
|
export type SignageInvalidationListener = (notice: {
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly sourceId?: string;
|
||||||
|
readonly itemId?: string;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
export interface SignageProviderRegistryOptions {
|
||||||
|
/** How long one provider call may take. Signage's own operator setting. */
|
||||||
|
readonly timeoutMs: number;
|
||||||
|
readonly now?: () => Date;
|
||||||
|
readonly onInvalidate?: SignageInvalidationListener;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisteredProvider {
|
||||||
|
readonly registration: SignageProviderRegistration;
|
||||||
|
readonly registeredAt: Date;
|
||||||
|
consecutiveFailures: number;
|
||||||
|
lastFailureCode: SignageProviderErrorCode | null;
|
||||||
|
lastFailureAt: Date | null;
|
||||||
|
lastSuccessAt: Date | null;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignageProviderRegistry {
|
||||||
|
/** The capability implementation handed to `capabilities.provide`. */
|
||||||
|
readonly capability: SignageContentProviderCapability;
|
||||||
|
/** Every currently registered provider, in a stable order. */
|
||||||
|
health(): readonly SignageProviderHealth[];
|
||||||
|
has(providerId: string): boolean;
|
||||||
|
rendererFor(providerId: string): SignageProviderHealth["renderer"] | undefined;
|
||||||
|
defaultDurationMs(providerId: string): number | undefined;
|
||||||
|
listSources(
|
||||||
|
providerId: string,
|
||||||
|
request: SignageProviderDiscoveryRequest,
|
||||||
|
): Promise<readonly SignageProviderSourceDescriptor[]>;
|
||||||
|
listItems(
|
||||||
|
providerId: string,
|
||||||
|
request: SignageProviderDiscoveryRequest & { readonly sourceId?: string },
|
||||||
|
): Promise<readonly SignageProviderItemDescriptor[]>;
|
||||||
|
resolveNext(
|
||||||
|
providerId: string,
|
||||||
|
request: SignageProviderResolveRequest,
|
||||||
|
): Promise<ProviderCallOutcome>;
|
||||||
|
refresh(providerId: string, request: SignageProviderRefreshRequest): Promise<ProviderCallOutcome>;
|
||||||
|
/** Releases every registration. Used when Signage itself stops. */
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three answers a resolution call can produce, kept distinct all the way up (ADR 0002).
|
||||||
|
*
|
||||||
|
* Collapsing them would be this design's biggest mistake: an empty pool would show an error, and a
|
||||||
|
* provider restart would clear the screen.
|
||||||
|
*/
|
||||||
|
export type ProviderCallOutcome =
|
||||||
|
/** The provider answered. `resolution` is `null` when nothing is available right now. */
|
||||||
|
| { readonly kind: "resolved"; readonly resolution: ParsedProviderResolution | null }
|
||||||
|
/** The provider is not registered at all — uninstalled, disabled, or still starting. */
|
||||||
|
| { readonly kind: "unregistered" }
|
||||||
|
/** The provider threw, rejected, timed out, or returned something unusable. Temporary. */
|
||||||
|
| { readonly kind: "failed"; readonly code: SignageProviderErrorCode };
|
||||||
|
|
||||||
|
export function createProviderRegistry(
|
||||||
|
options: SignageProviderRegistryOptions,
|
||||||
|
): SignageProviderRegistry {
|
||||||
|
const now = options.now ?? (() => new Date());
|
||||||
|
const providers = new Map<string, RegisteredProvider>();
|
||||||
|
|
||||||
|
function recordSuccess(entry: RegisteredProvider): void {
|
||||||
|
entry.consecutiveFailures = 0;
|
||||||
|
entry.lastFailureCode = null;
|
||||||
|
entry.lastSuccessAt = now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFailure(entry: RegisteredProvider, code: SignageProviderErrorCode): void {
|
||||||
|
entry.consecutiveFailures += 1;
|
||||||
|
entry.lastFailureCode = code;
|
||||||
|
entry.lastFailureAt = now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One bounded, sanitized call into a provider.
|
||||||
|
*
|
||||||
|
* The deadline is what stops a provider that hangs from stopping a channel group: playback is
|
||||||
|
* serialized per group, so a call that never settles would freeze the wall. The timer never
|
||||||
|
* rejects the provider's own promise — a late answer is simply ignored, because cancelling
|
||||||
|
* another module's work is not something this contract can express.
|
||||||
|
*/
|
||||||
|
async function callProvider<Result>(
|
||||||
|
entry: RegisteredProvider,
|
||||||
|
invoke: () => Promise<Result> | Result,
|
||||||
|
): Promise<{ ok: true; value: Result } | { ok: false; code: SignageProviderErrorCode }> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
const value = await Promise.race([
|
||||||
|
Promise.resolve().then(invoke),
|
||||||
|
new Promise<never>((_resolve, reject) => {
|
||||||
|
timer = setTimeout(
|
||||||
|
() => reject(new SignageProviderError("SIGNAGE_PROVIDER_UNAVAILABLE")),
|
||||||
|
options.timeoutMs,
|
||||||
|
);
|
||||||
|
timer.unref?.();
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
recordSuccess(entry);
|
||||||
|
return { ok: true, value };
|
||||||
|
} catch (error) {
|
||||||
|
// Whatever the provider threw is discarded here. Its message, stack, and cause never reach a
|
||||||
|
// log, a response, or a screen.
|
||||||
|
const code =
|
||||||
|
error instanceof SignageProviderError && error.code === "SIGNAGE_PROVIDER_RESULT_INVALID"
|
||||||
|
? "SIGNAGE_PROVIDER_RESULT_INVALID"
|
||||||
|
: "SIGNAGE_PROVIDER_UNAVAILABLE";
|
||||||
|
recordFailure(entry, code);
|
||||||
|
return { code, ok: false };
|
||||||
|
} finally {
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function active(providerId: string): RegisteredProvider | undefined {
|
||||||
|
const entry = providers.get(providerId);
|
||||||
|
return entry?.active === true ? entry : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capability: SignageContentProviderCapability = Object.freeze({
|
||||||
|
contractVersion: SIGNAGE_PROVIDER_CONTRACT_VERSION,
|
||||||
|
|
||||||
|
register(value: unknown): SignageProviderHandle {
|
||||||
|
const registration = parseProviderRegistration(value);
|
||||||
|
const existing = providers.get(registration.providerId);
|
||||||
|
if (existing?.active === true) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_DUPLICATE", registration.providerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: RegisteredProvider = {
|
||||||
|
active: true,
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
lastFailureAt: null,
|
||||||
|
lastFailureCode: null,
|
||||||
|
lastSuccessAt: null,
|
||||||
|
registeredAt: now(),
|
||||||
|
registration,
|
||||||
|
};
|
||||||
|
providers.set(registration.providerId, entry);
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
invalidate(change?: SignageProviderInvalidation): void {
|
||||||
|
// A late notification from a module being torn down is ordinary, not an error.
|
||||||
|
if (!entry.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.onInvalidate?.({
|
||||||
|
providerId: registration.providerId,
|
||||||
|
...(typeof change?.sourceId === "string" ? { sourceId: change.sourceId } : {}),
|
||||||
|
...(typeof change?.itemId === "string" ? { itemId: change.itemId } : {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
unregister(): void {
|
||||||
|
// Idempotent, which is what makes a rollback after a partially completed `start` safe:
|
||||||
|
// the hook that failed runs its own cleanup, and reverse-order shutdown runs it again.
|
||||||
|
if (!entry.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.active = false;
|
||||||
|
providers.delete(registration.providerId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
capability,
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
for (const entry of providers.values()) {
|
||||||
|
entry.active = false;
|
||||||
|
}
|
||||||
|
providers.clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
defaultDurationMs(providerId: string): number | undefined {
|
||||||
|
return active(providerId)?.registration.defaultDurationMs;
|
||||||
|
},
|
||||||
|
|
||||||
|
has(providerId: string): boolean {
|
||||||
|
return active(providerId) !== undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
health(): readonly SignageProviderHealth[] {
|
||||||
|
return [...providers.values()]
|
||||||
|
.filter((entry) => entry.active)
|
||||||
|
.map((entry) => ({
|
||||||
|
consecutiveFailures: entry.consecutiveFailures,
|
||||||
|
contractVersion: entry.registration.contractVersion,
|
||||||
|
defaultDurationMs: entry.registration.defaultDurationMs,
|
||||||
|
lastFailureAt: entry.lastFailureAt?.toISOString() ?? null,
|
||||||
|
lastFailureCode: entry.lastFailureCode,
|
||||||
|
lastSuccessAt: entry.lastSuccessAt?.toISOString() ?? null,
|
||||||
|
providerId: entry.registration.providerId,
|
||||||
|
providerVersion: entry.registration.providerVersion,
|
||||||
|
registeredAt: entry.registeredAt.toISOString(),
|
||||||
|
renderer: {
|
||||||
|
contributionId: entry.registration.renderer.contributionId,
|
||||||
|
contributionVersion: entry.registration.renderer.contributionVersion,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.providerId.localeCompare(right.providerId));
|
||||||
|
},
|
||||||
|
|
||||||
|
async listItems(
|
||||||
|
providerId: string,
|
||||||
|
request: SignageProviderDiscoveryRequest & { readonly sourceId?: string },
|
||||||
|
): Promise<readonly SignageProviderItemDescriptor[]> {
|
||||||
|
const entry = active(providerId);
|
||||||
|
if (!entry?.registration.listItems) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const listItems = entry.registration.listItems.bind(entry.registration);
|
||||||
|
const result = await callProvider(entry, () => listItems(request));
|
||||||
|
if (!result.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return parseItemDescriptors(result.value);
|
||||||
|
} catch {
|
||||||
|
recordFailure(entry, "SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async listSources(
|
||||||
|
providerId: string,
|
||||||
|
request: SignageProviderDiscoveryRequest,
|
||||||
|
): Promise<readonly SignageProviderSourceDescriptor[]> {
|
||||||
|
const entry = active(providerId);
|
||||||
|
if (!entry) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const listSources = entry.registration.listSources.bind(entry.registration);
|
||||||
|
const result = await callProvider(entry, () => listSources(request));
|
||||||
|
if (!result.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return parseSourceDescriptors(result.value);
|
||||||
|
} catch {
|
||||||
|
recordFailure(entry, "SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh(
|
||||||
|
providerId: string,
|
||||||
|
request: SignageProviderRefreshRequest,
|
||||||
|
): Promise<ProviderCallOutcome> {
|
||||||
|
const entry = active(providerId);
|
||||||
|
if (!entry) {
|
||||||
|
return { kind: "unregistered" };
|
||||||
|
}
|
||||||
|
const refresh = entry.registration.refresh.bind(entry.registration);
|
||||||
|
const result = await callProvider(entry, () => refresh(request));
|
||||||
|
if (!result.ok) {
|
||||||
|
return { code: result.code, kind: "failed" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { kind: "resolved", resolution: parseProviderResolution(result.value) };
|
||||||
|
} catch {
|
||||||
|
recordFailure(entry, "SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
return { code: "SIGNAGE_PROVIDER_RESULT_INVALID", kind: "failed" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
rendererFor(providerId: string): SignageProviderHealth["renderer"] | undefined {
|
||||||
|
const entry = active(providerId);
|
||||||
|
return entry
|
||||||
|
? {
|
||||||
|
contributionId: entry.registration.renderer.contributionId,
|
||||||
|
contributionVersion: entry.registration.renderer.contributionVersion,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
async resolveNext(
|
||||||
|
providerId: string,
|
||||||
|
request: SignageProviderResolveRequest,
|
||||||
|
): Promise<ProviderCallOutcome> {
|
||||||
|
const entry = active(providerId);
|
||||||
|
if (!entry) {
|
||||||
|
return { kind: "unregistered" };
|
||||||
|
}
|
||||||
|
const resolveNext = entry.registration.resolveNext.bind(entry.registration);
|
||||||
|
const result = await callProvider(entry, () => resolveNext(request));
|
||||||
|
if (!result.ok) {
|
||||||
|
return { code: result.code, kind: "failed" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { kind: "resolved", resolution: parseProviderResolution(result.value) };
|
||||||
|
} catch {
|
||||||
|
recordFailure(entry, "SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
return { code: "SIGNAGE_PROVIDER_RESULT_INVALID", kind: "failed" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { SignageJsonValue };
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import type {
|
||||||
|
SignageJsonValue,
|
||||||
|
SignageProviderItemDescriptor,
|
||||||
|
SignageProviderRegistration,
|
||||||
|
SignageProviderSourceDescriptor,
|
||||||
|
SignageProviderVariant,
|
||||||
|
} from "./contract.js";
|
||||||
|
import { isSupportedProviderContractVersion, SIGNAGE_DEFAULT_AUDIENCE_KEY } from "./contract.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything crossing the provider boundary is validated here, before it is believed.
|
||||||
|
*
|
||||||
|
* A provider is trusted code in the same process, so this is not a sandbox and never claims to be.
|
||||||
|
* It is the difference between one module's bug staying one module's bug and a malformed value
|
||||||
|
* reaching a television — a `NaN` duration that makes a slide last forever, a cyclic payload that
|
||||||
|
* cannot be stored, an unbounded string that fills a realtime frame.
|
||||||
|
*
|
||||||
|
* Every rejection is a stable code carrying **nothing of the rejected value**.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SIGNAGE_PROVIDER_ERROR_CODES = [
|
||||||
|
/** The registration is not the shape this contract accepts. */
|
||||||
|
"SIGNAGE_PROVIDER_REGISTRATION_INVALID",
|
||||||
|
/** The provider was written against a contract version Signage does not implement. */
|
||||||
|
"SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED",
|
||||||
|
/** A provider with this id is already registered. */
|
||||||
|
"SIGNAGE_PROVIDER_DUPLICATE",
|
||||||
|
/** The provider returned something this contract does not accept. */
|
||||||
|
"SIGNAGE_PROVIDER_RESULT_INVALID",
|
||||||
|
/** The provider threw, rejected, or exceeded its deadline. Temporary by definition. */
|
||||||
|
"SIGNAGE_PROVIDER_UNAVAILABLE",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SignageProviderErrorCode = (typeof SIGNAGE_PROVIDER_ERROR_CODES)[number];
|
||||||
|
|
||||||
|
const providerErrorMessages: Readonly<Record<SignageProviderErrorCode, string>> = Object.freeze({
|
||||||
|
SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED:
|
||||||
|
"This content provider was written against an unsupported contract version.",
|
||||||
|
SIGNAGE_PROVIDER_DUPLICATE: "A content provider with this identifier is already registered.",
|
||||||
|
SIGNAGE_PROVIDER_REGISTRATION_INVALID: "The content provider registration is not valid.",
|
||||||
|
SIGNAGE_PROVIDER_RESULT_INVALID: "The content provider returned an unusable result.",
|
||||||
|
SIGNAGE_PROVIDER_UNAVAILABLE: "The content provider could not be reached.",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Carries a code and, at most, a value the **provider itself supplied as its own identity** — never
|
||||||
|
* its payload, its exception, or anything of the host's. */
|
||||||
|
export class SignageProviderError extends Error {
|
||||||
|
readonly code: SignageProviderErrorCode;
|
||||||
|
readonly providerId?: string;
|
||||||
|
|
||||||
|
constructor(code: SignageProviderErrorCode, providerId?: string) {
|
||||||
|
super(providerErrorMessages[code]);
|
||||||
|
this.name = "SignageProviderError";
|
||||||
|
this.code = code;
|
||||||
|
if (providerId !== undefined) {
|
||||||
|
this.providerId = providerId;
|
||||||
|
}
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signage's own bounds on what a provider may hand back. They exist so one provider cannot make a
|
||||||
|
* realtime frame, a database row, or a slide duration unusable for everything else. */
|
||||||
|
export const SIGNAGE_PROVIDER_LIMITS = Object.freeze({
|
||||||
|
/** A slide shorter than a second is a flicker; one longer than an hour is a static hold. */
|
||||||
|
maxDurationMs: 3_600_000,
|
||||||
|
/** One selection's payloads, serialized. Well inside the platform's 64 KiB realtime ceiling even
|
||||||
|
* before Signage decides to deliver large state over HTTP instead. */
|
||||||
|
maxPayloadBytes: 32 * 1024,
|
||||||
|
/** Discovery is a picker, not a catalogue. */
|
||||||
|
maxDiscoveryResults: 500,
|
||||||
|
maxIdentifierLength: 200,
|
||||||
|
maxLabelLength: 200,
|
||||||
|
maxVariants: 16,
|
||||||
|
minDurationMs: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const IDENTIFIER = /^[\w][\w.:-]*$/u;
|
||||||
|
const SEMANTIC_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?$/u;
|
||||||
|
|
||||||
|
function isBoundedString(value: unknown, maxLength: number): value is string {
|
||||||
|
return typeof value === "string" && value.length > 0 && value.length <= maxLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIdentifier(value: unknown): value is string {
|
||||||
|
return (
|
||||||
|
isBoundedString(value, SIGNAGE_PROVIDER_LIMITS.maxIdentifierLength) && IDENTIFIER.test(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFunction(value: unknown): boolean {
|
||||||
|
return typeof value === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a value is genuinely JSON-serializable, checked structurally rather than by attempting
|
||||||
|
* `JSON.stringify` and hoping.
|
||||||
|
*
|
||||||
|
* `undefined`, a function, a `Symbol`, a `Date`, a class instance, a cycle, `NaN`, and `Infinity`
|
||||||
|
* all either vanish or throw on the way to storage or a browser, and the failure would surface far
|
||||||
|
* from the provider that caused it. The walk is bounded, so a cycle is caught rather than followed.
|
||||||
|
*/
|
||||||
|
export function isFiniteJson(value: unknown, seen = new Set<object>()): value is SignageJsonValue {
|
||||||
|
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isFinite(value);
|
||||||
|
}
|
||||||
|
if (typeof value !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
const isArray = Array.isArray(value);
|
||||||
|
if (!isArray && Object.getPrototypeOf(value) !== Object.prototype) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const entries = isArray ? value : Object.values(value);
|
||||||
|
const valid = entries.every((entry) => isFiniteJson(entry, seen));
|
||||||
|
seen.delete(value);
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonByteLength(value: SignageJsonValue): number {
|
||||||
|
return new TextEncoder().encode(JSON.stringify(value) ?? "").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInstantOrNull(value: unknown): value is string | null | undefined {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Absolute instants only. An ambiguous local timestamp is refused rather than guessed at.
|
||||||
|
return !Number.isNaN(Date.parse(value)) && /[Zz]|[+-]\d{2}:?\d{2}$/u.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a registration and returns it in its canonical form.
|
||||||
|
*
|
||||||
|
* Throws {@link SignageProviderError} — never a `TypeError`, never anything carrying the offending
|
||||||
|
* value — because the caller is another module's `start` hook, and what it does with a rejection is
|
||||||
|
* its own business.
|
||||||
|
*/
|
||||||
|
export function parseProviderRegistration(value: unknown): SignageProviderRegistration {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID");
|
||||||
|
}
|
||||||
|
const providerId = value.providerId;
|
||||||
|
if (!isIdentifier(providerId)) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID");
|
||||||
|
}
|
||||||
|
if (typeof value.contractVersion !== "string") {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId);
|
||||||
|
}
|
||||||
|
if (!isSupportedProviderContractVersion(value.contractVersion)) {
|
||||||
|
// Reported separately from a malformed registration, because an operator can act on it: the
|
||||||
|
// provider module needs an update, and nothing about it is broken.
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED", providerId);
|
||||||
|
}
|
||||||
|
if (typeof value.providerVersion !== "string" || !SEMANTIC_VERSION.test(value.providerVersion)) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId);
|
||||||
|
}
|
||||||
|
const renderer = value.renderer;
|
||||||
|
if (
|
||||||
|
!isRecord(renderer) ||
|
||||||
|
!isIdentifier(renderer.contributionId) ||
|
||||||
|
typeof renderer.contributionVersion !== "string" ||
|
||||||
|
!SEMANTIC_VERSION.test(renderer.contributionVersion)
|
||||||
|
) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId);
|
||||||
|
}
|
||||||
|
const defaultDurationMs = value.defaultDurationMs;
|
||||||
|
if (
|
||||||
|
typeof defaultDurationMs !== "number" ||
|
||||||
|
!Number.isSafeInteger(defaultDurationMs) ||
|
||||||
|
defaultDurationMs < SIGNAGE_PROVIDER_LIMITS.minDurationMs ||
|
||||||
|
defaultDurationMs > SIGNAGE_PROVIDER_LIMITS.maxDurationMs
|
||||||
|
) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isFunction(value.listSources) ||
|
||||||
|
!isFunction(value.resolveNext) ||
|
||||||
|
!isFunction(value.refresh) ||
|
||||||
|
(value.listItems !== undefined && !isFunction(value.listItems))
|
||||||
|
) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_REGISTRATION_INVALID", providerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value as unknown as SignageProviderRegistration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSourceDescriptors(value: unknown): readonly SignageProviderSourceDescriptor[] {
|
||||||
|
if (!Array.isArray(value) || value.length > SIGNAGE_PROVIDER_LIMITS.maxDiscoveryResults) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
return value.map((entry) => {
|
||||||
|
if (
|
||||||
|
!isRecord(entry) ||
|
||||||
|
!isIdentifier(entry.id) ||
|
||||||
|
!isBoundedString(entry.label, SIGNAGE_PROVIDER_LIMITS.maxLabelLength) ||
|
||||||
|
(entry.description !== undefined &&
|
||||||
|
!isBoundedString(entry.description, SIGNAGE_PROVIDER_LIMITS.maxLabelLength))
|
||||||
|
) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
label: entry.label,
|
||||||
|
...(entry.description === undefined ? {} : { description: entry.description as string }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseItemDescriptors(value: unknown): readonly SignageProviderItemDescriptor[] {
|
||||||
|
if (!Array.isArray(value) || value.length > SIGNAGE_PROVIDER_LIMITS.maxDiscoveryResults) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
return value.map((entry) => {
|
||||||
|
if (
|
||||||
|
!isRecord(entry) ||
|
||||||
|
!isIdentifier(entry.id) ||
|
||||||
|
!isBoundedString(entry.label, SIGNAGE_PROVIDER_LIMITS.maxLabelLength) ||
|
||||||
|
!isInstantOrNull(entry.availableUntil) ||
|
||||||
|
(entry.description !== undefined &&
|
||||||
|
!isBoundedString(entry.description, SIGNAGE_PROVIDER_LIMITS.maxLabelLength))
|
||||||
|
) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
label: entry.label,
|
||||||
|
...(entry.description === undefined ? {} : { description: entry.description as string }),
|
||||||
|
...(entry.availableUntil === undefined
|
||||||
|
? {}
|
||||||
|
: { availableUntil: entry.availableUntil as string | null }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The canonical form of a resolution: variants keyed and deduplicated, the default guaranteed
|
||||||
|
* present, and every bound already applied. */
|
||||||
|
export interface ParsedProviderResolution {
|
||||||
|
readonly itemId: string;
|
||||||
|
readonly itemRevision: string;
|
||||||
|
readonly durationMs: number | undefined;
|
||||||
|
readonly availableUntil: Date | null;
|
||||||
|
readonly withdrawn: boolean;
|
||||||
|
readonly presentation: SignageJsonValue | undefined;
|
||||||
|
/** Always contains {@link SIGNAGE_DEFAULT_AUDIENCE_KEY}. */
|
||||||
|
readonly variants: ReadonlyMap<string, SignageJsonValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates one resolution.
|
||||||
|
*
|
||||||
|
* `null` passes straight through: "nothing available right now" is an answer, not a failure, and
|
||||||
|
* conflating the two is how an empty Trivia pool becomes an error on a lobby wall.
|
||||||
|
*/
|
||||||
|
export function parseProviderResolution(value: unknown): ParsedProviderResolution | null {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
if (!isIdentifier(value.itemId) || !isBoundedString(value.itemRevision, 128)) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
value.durationMs !== undefined &&
|
||||||
|
(typeof value.durationMs !== "number" ||
|
||||||
|
!Number.isSafeInteger(value.durationMs) ||
|
||||||
|
value.durationMs < SIGNAGE_PROVIDER_LIMITS.minDurationMs ||
|
||||||
|
value.durationMs > SIGNAGE_PROVIDER_LIMITS.maxDurationMs)
|
||||||
|
) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
if (!isInstantOrNull(value.availableUntil)) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
if (value.withdrawn !== undefined && typeof value.withdrawn !== "boolean") {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
if (value.presentation !== undefined && !isFiniteJson(value.presentation)) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawVariants = value.variants;
|
||||||
|
if (
|
||||||
|
!Array.isArray(rawVariants) ||
|
||||||
|
rawVariants.length === 0 ||
|
||||||
|
rawVariants.length > SIGNAGE_PROVIDER_LIMITS.maxVariants
|
||||||
|
) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants = new Map<string, SignageJsonValue>();
|
||||||
|
let payloadBytes = 0;
|
||||||
|
for (const entry of rawVariants as readonly unknown[]) {
|
||||||
|
if (!isRecord(entry)) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
const key =
|
||||||
|
entry.audienceKey === null || entry.audienceKey === undefined
|
||||||
|
? SIGNAGE_DEFAULT_AUDIENCE_KEY
|
||||||
|
: entry.audienceKey;
|
||||||
|
if (!isBoundedString(key, 64)) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
if (variants.has(key)) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
if (!isFiniteJson(entry.payload)) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
payloadBytes += jsonByteLength(entry.payload);
|
||||||
|
if (payloadBytes > SIGNAGE_PROVIDER_LIMITS.maxPayloadBytes) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
variants.set(key, entry.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every channel must have something to render, and a provider with no audience-specific content
|
||||||
|
// returns exactly one variant. Requiring the default is what makes the fallback rule real.
|
||||||
|
if (!variants.has(SIGNAGE_DEFAULT_AUDIENCE_KEY)) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
const withdrawn = value.withdrawn === true;
|
||||||
|
const availableUntil =
|
||||||
|
typeof value.availableUntil === "string" ? new Date(value.availableUntil) : null;
|
||||||
|
return {
|
||||||
|
availableUntil,
|
||||||
|
durationMs: typeof value.durationMs === "number" ? value.durationMs : undefined,
|
||||||
|
itemId: value.itemId,
|
||||||
|
itemRevision: value.itemRevision,
|
||||||
|
presentation:
|
||||||
|
value.presentation === undefined ? undefined : (value.presentation as SignageJsonValue),
|
||||||
|
variants,
|
||||||
|
withdrawn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseProviderVariants(
|
||||||
|
variants: readonly SignageProviderVariant[],
|
||||||
|
): ReadonlyMap<string, SignageJsonValue> {
|
||||||
|
const parsed = parseProviderResolution({
|
||||||
|
itemId: "x",
|
||||||
|
itemRevision: "1",
|
||||||
|
variants,
|
||||||
|
});
|
||||||
|
if (!parsed) {
|
||||||
|
throw new SignageProviderError("SIGNAGE_PROVIDER_RESULT_INVALID");
|
||||||
|
}
|
||||||
|
return parsed.variants;
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
||||||
|
import { SignageError, signageBoundary } from "../errors.js";
|
||||||
|
import { optionalInstant, optionalString, readJsonObject, requireUuidParam } from "../http.js";
|
||||||
|
import { SIGNAGE_IMAGE_MAX_BYTES } from "../images/limits.js";
|
||||||
|
import {
|
||||||
|
createImageDeliveryUrls,
|
||||||
|
deleteNativeImage,
|
||||||
|
ingestNativeImage,
|
||||||
|
updateNativeImage,
|
||||||
|
} from "../images/service.js";
|
||||||
|
import { readBoundedBody } from "../images/streaming.js";
|
||||||
|
import { createSignageStore } from "../store/drizzle.js";
|
||||||
|
import type { StoredImage } from "../store/types.js";
|
||||||
|
|
||||||
|
const FILENAME_MAX_LENGTH = 200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A filename is a label an operator sees, never a path and never an object key.
|
||||||
|
*
|
||||||
|
* Path separators and control characters are refused rather than escaped, because a display name has
|
||||||
|
* no business containing either — and the value is echoed back to a browser, where a control
|
||||||
|
* character is a rendering hazard and a separator invites something to read it as a location.
|
||||||
|
*/
|
||||||
|
function isSafeLabel(value: string): boolean {
|
||||||
|
if (value.length === 0 || value.length > FILENAME_MAX_LENGTH) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const character of value) {
|
||||||
|
const codePoint = character.codePointAt(0) ?? 0;
|
||||||
|
if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (character === "/" || character === "\\") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryValue(request: Request, name: string): string | undefined {
|
||||||
|
const value = new URL(request.url).searchParams.get(name);
|
||||||
|
return value === null ? undefined : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireLabel(request: Request, field: string, fallback?: string): string {
|
||||||
|
const value = queryValue(request, field)?.trim();
|
||||||
|
if (value === undefined || value.length === 0) {
|
||||||
|
if (fallback !== undefined) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||||
|
}
|
||||||
|
if (!isSafeLabel(value)) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExpiresAt(request: Request): Date | null {
|
||||||
|
const raw = queryValue(request, "expiresAt");
|
||||||
|
if (raw === undefined || raw === "") {
|
||||||
|
// Null is permanent. Local age never invents an expiration for a church's own slide.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = new Date(raw);
|
||||||
|
if (Number.isNaN(parsed.getTime()) || !/[Zz]|[+-]\d{2}:?\d{2}$/u.test(raw)) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "expiresAt" });
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a browser is given: the record plus fresh, short-lived delivery URLs. The logical object key
|
||||||
|
* never leaves the server — a browser has no use for it, and it is the one durable thing Signage
|
||||||
|
* stores about where bytes live. */
|
||||||
|
async function describe(
|
||||||
|
context: ModuleRouteHandlerContext,
|
||||||
|
image: StoredImage,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const delivery = await createImageDeliveryUrls(context.storage, image.renditions);
|
||||||
|
return {
|
||||||
|
byteSize: image.originalByteSize,
|
||||||
|
contentSha256: image.contentSha256,
|
||||||
|
contentType: image.originalContentType,
|
||||||
|
createdAt: image.createdAt.toISOString(),
|
||||||
|
displayName: image.displayName,
|
||||||
|
expiresAt: image.expiresAt?.toISOString() ?? null,
|
||||||
|
hasAlpha: image.hasAlpha,
|
||||||
|
height: image.height,
|
||||||
|
id: image.id,
|
||||||
|
originalFilename: image.originalFilename,
|
||||||
|
renditions: delivery,
|
||||||
|
status: image.status,
|
||||||
|
width: image.width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerImageRoutes(http: ModuleHttpRouteRegistrar): void {
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/images",
|
||||||
|
permission: "signage.view",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const images = await createSignageStore(context.services.database).listImages();
|
||||||
|
return Response.json({
|
||||||
|
images: await Promise.all(images.map((image) => describe(context, image))),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "POST",
|
||||||
|
path: "/images",
|
||||||
|
permission: "signage.assets.manage",
|
||||||
|
// One image, streamed. The platform counts bytes as the handler consumes them and never
|
||||||
|
// materializes the body; Signage counts them too and refuses at its own smaller limit first.
|
||||||
|
// The filename and display name travel in the query string, so the body is exactly the image.
|
||||||
|
maxBodyBytes: SIGNAGE_IMAGE_MAX_BYTES,
|
||||||
|
bodyMode: "streamed",
|
||||||
|
handler(request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const filename = requireLabel(request, "filename");
|
||||||
|
const displayName = requireLabel(request, "displayName", filename);
|
||||||
|
const expiresAt = readExpiresAt(request);
|
||||||
|
|
||||||
|
// The whole body is read, and only then validated and committed. A `413` can arrive after a
|
||||||
|
// streamed handler has started, which is exactly why nothing is written until this returns.
|
||||||
|
const bytes = await readBoundedBody(request, SIGNAGE_IMAGE_MAX_BYTES);
|
||||||
|
|
||||||
|
const result = await ingestNativeImage({
|
||||||
|
bytes,
|
||||||
|
displayName,
|
||||||
|
expiresAt,
|
||||||
|
filename,
|
||||||
|
storage: context.storage,
|
||||||
|
store: createSignageStore(context.services.database),
|
||||||
|
});
|
||||||
|
return Response.json(
|
||||||
|
{ deduplicated: result.deduplicated, image: await describe(context, result.image) },
|
||||||
|
{ status: result.deduplicated ? 200 : 201 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/images/:imageId",
|
||||||
|
permission: "signage.view",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const imageId = requireUuidParam(context, "imageId");
|
||||||
|
const image = await createSignageStore(context.services.database).findImage(imageId);
|
||||||
|
if (!image) {
|
||||||
|
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
return Response.json({ image: await describe(context, image) });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/images/:imageId",
|
||||||
|
permission: "signage.assets.manage",
|
||||||
|
// A rename and an expiration date. Nothing about this route needs a large body.
|
||||||
|
maxBodyBytes: 4096,
|
||||||
|
handler(request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const imageId = requireUuidParam(context, "imageId");
|
||||||
|
const body = await readJsonObject(request);
|
||||||
|
const displayName = optionalString(body, "displayName", {
|
||||||
|
maxLength: FILENAME_MAX_LENGTH,
|
||||||
|
});
|
||||||
|
if (displayName !== undefined && !isSafeLabel(displayName)) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "displayName" });
|
||||||
|
}
|
||||||
|
const image = await updateNativeImage({
|
||||||
|
assetId: imageId,
|
||||||
|
displayName,
|
||||||
|
expiresAt: optionalInstant(body, "expiresAt"),
|
||||||
|
store: createSignageStore(context.services.database),
|
||||||
|
});
|
||||||
|
return Response.json({ image: await describe(context, image) });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "DELETE",
|
||||||
|
path: "/images/:imageId",
|
||||||
|
permission: "signage.assets.manage",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const imageId = requireUuidParam(context, "imageId");
|
||||||
|
await deleteNativeImage({
|
||||||
|
assetId: imageId,
|
||||||
|
storage: context.storage,
|
||||||
|
store: createSignageStore(context.services.database),
|
||||||
|
});
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
||||||
|
import { SignageError, signageBoundary } from "../errors.js";
|
||||||
|
import {
|
||||||
|
isUuid,
|
||||||
|
optionalInstant,
|
||||||
|
readJsonObject,
|
||||||
|
requiredString,
|
||||||
|
requireUuidParam,
|
||||||
|
} from "../http.js";
|
||||||
|
import type { SignagePlaybackService } from "../playback/service.js";
|
||||||
|
import { createSignageStore } from "../store/drizzle.js";
|
||||||
|
import { SignageStoreConflict, type StoredChannelGroupDetail } from "../store/types.js";
|
||||||
|
|
||||||
|
/** An audience key is what a provider receives, so it uses the same flat lowercase grammar the rest
|
||||||
|
* of the platform uses for a local identifier. */
|
||||||
|
const AUDIENCE_KEY = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||||
|
|
||||||
|
function describeGroup(detail: StoredChannelGroupDetail): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
channels: detail.channels.map((channel) => ({
|
||||||
|
id: channel.id,
|
||||||
|
isDefault: channel.isDefault,
|
||||||
|
key: channel.key,
|
||||||
|
name: channel.name,
|
||||||
|
})),
|
||||||
|
id: detail.group.id,
|
||||||
|
name: detail.group.name,
|
||||||
|
revision: detail.group.revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPlaybackRoutes(
|
||||||
|
http: ModuleHttpRouteRegistrar,
|
||||||
|
playback: () => SignagePlaybackService,
|
||||||
|
): void {
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/channel-groups",
|
||||||
|
permission: "signage.view",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const groups = await createSignageStore(context.services.database).listChannelGroups();
|
||||||
|
return Response.json({
|
||||||
|
groups: await Promise.all(
|
||||||
|
groups.map(async (group) => ({
|
||||||
|
...describeGroup(group),
|
||||||
|
playback: await playback().snapshot(group.group.id),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "POST",
|
||||||
|
path: "/channel-groups",
|
||||||
|
permission: "signage.displays.manage",
|
||||||
|
maxBodyBytes: 4096,
|
||||||
|
handler(request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const body = await readJsonObject(request);
|
||||||
|
const store = createSignageStore(context.services.database);
|
||||||
|
const groupId = randomUUID();
|
||||||
|
// A group is created with its default channel and its playback row together: a group
|
||||||
|
// without a default channel has nowhere for a viewer to land.
|
||||||
|
await store.createChannelGroup({
|
||||||
|
channelId: randomUUID(),
|
||||||
|
channelName: "Default",
|
||||||
|
groupId,
|
||||||
|
name: requiredString(body, "name", { maxLength: 120 }),
|
||||||
|
});
|
||||||
|
const created = await store.findChannelGroup(groupId);
|
||||||
|
if (!created) {
|
||||||
|
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||||
|
}
|
||||||
|
return Response.json(describeGroup(created), { status: 201 });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "DELETE",
|
||||||
|
path: "/channel-groups/:groupId",
|
||||||
|
permission: "signage.displays.manage",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const groupId = requireUuidParam(context, "groupId");
|
||||||
|
const store = createSignageStore(context.services.database);
|
||||||
|
const groups = await store.listChannelGroups();
|
||||||
|
if (groups.length <= 1) {
|
||||||
|
// An installation always has somewhere for a viewer to land.
|
||||||
|
throw new SignageError("SIGNAGE_CONFLICT", { reason: "last-group" });
|
||||||
|
}
|
||||||
|
await store.deleteChannelGroup(groupId);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "POST",
|
||||||
|
path: "/channel-groups/:groupId/channels",
|
||||||
|
permission: "signage.displays.manage",
|
||||||
|
maxBodyBytes: 4096,
|
||||||
|
handler(request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const groupId = requireUuidParam(context, "groupId");
|
||||||
|
const body = await readJsonObject(request);
|
||||||
|
const key = requiredString(body, "key", { maxLength: 64 });
|
||||||
|
if (!AUDIENCE_KEY.test(key) || key === "default") {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "key" });
|
||||||
|
}
|
||||||
|
const store = createSignageStore(context.services.database);
|
||||||
|
const group = await store.findChannelGroup(groupId);
|
||||||
|
if (!group) {
|
||||||
|
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
if (group.channels.some((channel) => channel.key === key)) {
|
||||||
|
throw new SignageError("SIGNAGE_CONFLICT", { field: "key" });
|
||||||
|
}
|
||||||
|
await store.addChannel({
|
||||||
|
groupId,
|
||||||
|
id: randomUUID(),
|
||||||
|
isDefault: false,
|
||||||
|
key,
|
||||||
|
name: requiredString(body, "name", { maxLength: 120 }),
|
||||||
|
});
|
||||||
|
const updated = await store.findChannelGroup(groupId);
|
||||||
|
if (!updated) {
|
||||||
|
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||||
|
}
|
||||||
|
return Response.json(describeGroup(updated), { status: 201 });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "DELETE",
|
||||||
|
path: "/channels/:channelId",
|
||||||
|
permission: "signage.displays.manage",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const channelId = requireUuidParam(context, "channelId");
|
||||||
|
try {
|
||||||
|
await createSignageStore(context.services.database).removeChannel(channelId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SignageStoreConflict) {
|
||||||
|
// A group's default channel is the one a viewer lands on. It cannot be removed.
|
||||||
|
throw new SignageError("SIGNAGE_CONFLICT", { reason: "default-channel" });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/playback/:groupId",
|
||||||
|
permission: "signage.view",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const groupId = requireUuidParam(context, "groupId");
|
||||||
|
const store = createSignageStore(context.services.database);
|
||||||
|
const snapshot = await playback().snapshot(groupId);
|
||||||
|
const selection =
|
||||||
|
snapshot.currentSelectionId === null
|
||||||
|
? undefined
|
||||||
|
: await store.findSelection(snapshot.currentSelectionId);
|
||||||
|
const history = await store.listSelections(groupId, 20);
|
||||||
|
return Response.json({
|
||||||
|
current:
|
||||||
|
selection === undefined
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
deadlineAtMs: selection.deadlineAt?.getTime() ?? null,
|
||||||
|
durationMs: selection.durationMs,
|
||||||
|
id: selection.id,
|
||||||
|
itemId: selection.itemId,
|
||||||
|
itemRevision: selection.itemRevision,
|
||||||
|
providerId: selection.providerId,
|
||||||
|
rendererContributionId: selection.rendererContributionId,
|
||||||
|
revision: selection.revision,
|
||||||
|
stale: selection.stale,
|
||||||
|
startedAtMs: selection.startedAt?.getTime() ?? null,
|
||||||
|
withdrawn: selection.withdrawn,
|
||||||
|
},
|
||||||
|
// Identity and timing only. A payload belongs to a display, which asks for the channel
|
||||||
|
// state it is authorized for.
|
||||||
|
history: history.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
itemId: entry.itemId,
|
||||||
|
providerId: entry.providerId,
|
||||||
|
sequence: entry.sequence,
|
||||||
|
startedAtMs: entry.startedAt?.getTime() ?? null,
|
||||||
|
})),
|
||||||
|
snapshot,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function command(
|
||||||
|
path: string,
|
||||||
|
run: (
|
||||||
|
service: SignagePlaybackService,
|
||||||
|
groupId: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
context: ModuleRouteHandlerContext,
|
||||||
|
) => Promise<unknown>,
|
||||||
|
options: { readonly maxBodyBytes: number },
|
||||||
|
): void {
|
||||||
|
http.route({
|
||||||
|
method: "POST",
|
||||||
|
path,
|
||||||
|
permission: "signage.playback.control",
|
||||||
|
maxBodyBytes: options.maxBodyBytes,
|
||||||
|
handler(request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const groupId = requireUuidParam(context, "groupId");
|
||||||
|
const body = options.maxBodyBytes === 0 ? {} : await readJsonObject(request);
|
||||||
|
return Response.json(await run(playback(), groupId, body, context));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
command(
|
||||||
|
"/playback/:groupId/activate",
|
||||||
|
async (service, groupId, body) => {
|
||||||
|
const timelineId = body.timelineId;
|
||||||
|
if (!isUuid(timelineId)) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "timelineId" });
|
||||||
|
}
|
||||||
|
const at = optionalInstant(body, "at");
|
||||||
|
if (at) {
|
||||||
|
return service.activate(groupId, timelineId, { at });
|
||||||
|
}
|
||||||
|
const when = body.when === "next-boundary" ? "next-boundary" : "now";
|
||||||
|
return service.activate(groupId, timelineId, when);
|
||||||
|
},
|
||||||
|
{ maxBodyBytes: 2048 },
|
||||||
|
);
|
||||||
|
|
||||||
|
command("/playback/:groupId/pause", (service, groupId) => service.pause(groupId), {
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
});
|
||||||
|
command("/playback/:groupId/resume", (service, groupId) => service.resume(groupId), {
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
});
|
||||||
|
command("/playback/:groupId/next", (service, groupId) => service.next(groupId), {
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
});
|
||||||
|
command("/playback/:groupId/previous", (service, groupId) => service.previous(groupId), {
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
});
|
||||||
|
command("/playback/:groupId/reload", (service, groupId) => service.reload(groupId), {
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
});
|
||||||
|
command(
|
||||||
|
"/playback/:groupId/blackout",
|
||||||
|
(service, groupId, body) => {
|
||||||
|
if (typeof body.on !== "boolean") {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "on" });
|
||||||
|
}
|
||||||
|
return service.setBlackout(groupId, body.on);
|
||||||
|
},
|
||||||
|
{ maxBodyBytes: 1024 },
|
||||||
|
);
|
||||||
|
command(
|
||||||
|
"/playback/:groupId/static",
|
||||||
|
(service, groupId, body) => {
|
||||||
|
const assetId = body.assetId;
|
||||||
|
if (assetId === null) {
|
||||||
|
return service.setStaticHold(groupId, null);
|
||||||
|
}
|
||||||
|
if (!isUuid(assetId)) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "assetId" });
|
||||||
|
}
|
||||||
|
return service.setStaticHold(groupId, assetId);
|
||||||
|
},
|
||||||
|
{ maxBodyBytes: 1024 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
||||||
|
import { SignageError, signageBoundary } from "../errors.js";
|
||||||
|
import type { SignageProviderRegistry } from "../providers/registry.js";
|
||||||
|
import { readSignageSettings } from "../settings.js";
|
||||||
|
|
||||||
|
/** A provider id is another module's opaque identifier, and arrives here as a decoded path value —
|
||||||
|
* which is untrusted input whatever the declared path grammar was. Validated against Signage's own
|
||||||
|
* bound before any lookup. */
|
||||||
|
const PROVIDER_ID = /^[\w][\w.:-]{0,199}$/u;
|
||||||
|
|
||||||
|
function requireProviderId(context: ModuleRouteHandlerContext): string {
|
||||||
|
const value = context.params.providerId;
|
||||||
|
if (value === undefined || !PROVIDER_ID.test(value)) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { parameter: "providerId" });
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider health and discovery.
|
||||||
|
*
|
||||||
|
* Everything these routes return is either a provider's own declared identity or a count Signage
|
||||||
|
* itself kept. A provider's exception, message, stack, or payload never appears — which is what
|
||||||
|
* makes "an incompatible provider is clearly reported in management" true without also making a
|
||||||
|
* provider's internals an operator-visible surface.
|
||||||
|
*/
|
||||||
|
export function registerProviderRoutes(
|
||||||
|
http: ModuleHttpRouteRegistrar,
|
||||||
|
registry: () => SignageProviderRegistry,
|
||||||
|
): void {
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/providers",
|
||||||
|
permission: "signage.view",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler() {
|
||||||
|
return signageBoundary(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
Response.json({
|
||||||
|
// Only what is actually registered right now. A registration is a running fact, not a
|
||||||
|
// stored one, so a provider that was uninstalled leaves nothing behind claiming it
|
||||||
|
// exists.
|
||||||
|
providers: registry().health(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/providers/:providerId/sources",
|
||||||
|
permission: "signage.view",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const providerId = requireProviderId(context);
|
||||||
|
const settings = readSignageSettings(context.config);
|
||||||
|
// A provider that is not registered, is failing, or answers with something unusable yields
|
||||||
|
// an empty list rather than an error: the editor should say "no sources right now", not
|
||||||
|
// break, and provider health already reports why.
|
||||||
|
const sources = await registry().listSources(providerId, {
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
timezone: settings.timezone,
|
||||||
|
});
|
||||||
|
return Response.json({ providerId, sources });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/providers/:providerId/items",
|
||||||
|
permission: "signage.view",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const providerId = requireProviderId(context);
|
||||||
|
const settings = readSignageSettings(context.config);
|
||||||
|
const sourceId = new URL(request.url).searchParams.get("sourceId");
|
||||||
|
const items = await registry().listItems(providerId, {
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
timezone: settings.timezone,
|
||||||
|
...(sourceId === null ? {} : { sourceId }),
|
||||||
|
});
|
||||||
|
return Response.json({ items, providerId });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
||||||
|
import { SignageError, signageBoundary } from "../errors.js";
|
||||||
|
import {
|
||||||
|
isUuid,
|
||||||
|
optionalBoolean,
|
||||||
|
optionalInstant,
|
||||||
|
optionalInteger,
|
||||||
|
optionalString,
|
||||||
|
readJsonObject,
|
||||||
|
requiredString,
|
||||||
|
requireUuidParam,
|
||||||
|
} from "../http.js";
|
||||||
|
import { createSignageStore } from "../store/drizzle.js";
|
||||||
|
import type { SignageStore, StoredSlotPresentation, StoredTimelineDetail } from "../store/types.js";
|
||||||
|
import {
|
||||||
|
addProviderSource,
|
||||||
|
addSlot,
|
||||||
|
createTimeline,
|
||||||
|
deleteTimeline,
|
||||||
|
duplicateTimeline,
|
||||||
|
removeProviderSource,
|
||||||
|
removeSlot,
|
||||||
|
reorderSlots,
|
||||||
|
requireTimeline,
|
||||||
|
SIGNAGE_TIMELINE_LIMITS,
|
||||||
|
updateSlot,
|
||||||
|
updateTimeline,
|
||||||
|
} from "../timelines/service.js";
|
||||||
|
|
||||||
|
const PROVIDER_ID = /^[\w][\w.:-]{0,199}$/u;
|
||||||
|
|
||||||
|
function store(context: ModuleRouteHandlerContext): SignageStore {
|
||||||
|
return createSignageStore(context.services.database);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The wire shape of a timeline. Instants are ISO 8601 strings, which is what a browser should
|
||||||
|
* receive — never an invented epoch or a locale-formatted representation. */
|
||||||
|
function describe(detail: StoredTimelineDetail): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
slots: detail.slots.map((slot) => ({
|
||||||
|
durationOverrideMs: slot.durationOverrideMs,
|
||||||
|
eligibleFrom: slot.eligibleFrom?.toISOString() ?? null,
|
||||||
|
eligibleUntil: slot.eligibleUntil?.toISOString() ?? null,
|
||||||
|
enabled: slot.enabled,
|
||||||
|
id: slot.id,
|
||||||
|
itemId: slot.itemId,
|
||||||
|
kind: slot.kind,
|
||||||
|
position: slot.position,
|
||||||
|
presentation: slot.presentation,
|
||||||
|
providerId: slot.providerId,
|
||||||
|
sourceId: slot.sourceId,
|
||||||
|
})),
|
||||||
|
sources: detail.sources.map((source) => ({
|
||||||
|
id: source.id,
|
||||||
|
label: source.label,
|
||||||
|
providerId: source.providerId,
|
||||||
|
sourceId: source.sourceId,
|
||||||
|
// The selection scope is deliberately **not** exposed. It is an internal stable identity a
|
||||||
|
// provider receives; a dashboard that showed it would invite someone to depend on its shape.
|
||||||
|
})),
|
||||||
|
timeline: {
|
||||||
|
description: detail.timeline.description,
|
||||||
|
eligibleFrom: detail.timeline.eligibleFrom?.toISOString() ?? null,
|
||||||
|
eligibleUntil: detail.timeline.eligibleUntil?.toISOString() ?? null,
|
||||||
|
id: detail.timeline.id,
|
||||||
|
name: detail.timeline.name,
|
||||||
|
revision: detail.timeline.revision,
|
||||||
|
updatedAt: detail.timeline.updatedAt.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPresentation(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Partial<StoredSlotPresentation> | undefined {
|
||||||
|
const raw = body.presentation;
|
||||||
|
if (raw === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "presentation" });
|
||||||
|
}
|
||||||
|
const value = raw as Record<string, unknown>;
|
||||||
|
const mode = value.backgroundMode;
|
||||||
|
if (
|
||||||
|
mode !== undefined &&
|
||||||
|
mode !== null &&
|
||||||
|
mode !== "blurred" &&
|
||||||
|
mode !== "solid" &&
|
||||||
|
mode !== "black"
|
||||||
|
) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "backgroundMode" });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...(mode === undefined
|
||||||
|
? {}
|
||||||
|
: { backgroundMode: mode as StoredSlotPresentation["backgroundMode"] }),
|
||||||
|
...(value.backgroundColor === undefined
|
||||||
|
? {}
|
||||||
|
: { backgroundColor: optionalString(value, "backgroundColor", { maxLength: 7 }) ?? null }),
|
||||||
|
...(value.backgroundDarkened === undefined
|
||||||
|
? {}
|
||||||
|
: { backgroundDarkened: optionalBoolean(value, "backgroundDarkened") ?? null }),
|
||||||
|
...(value.blurPixels === undefined
|
||||||
|
? {}
|
||||||
|
: { blurPixels: optionalInteger(value, "blurPixels", { maximum: 120, minimum: 0 }) ?? null }),
|
||||||
|
...(value.foregroundShadow === undefined
|
||||||
|
? {}
|
||||||
|
: { foregroundShadow: optionalBoolean(value, "foregroundShadow") ?? null }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireOpaqueId(body: Record<string, unknown>, field: string): string {
|
||||||
|
const value = body[field];
|
||||||
|
if (typeof value !== "string" || !PROVIDER_ID.test(value)) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field });
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerTimelineRoutes(http: ModuleHttpRouteRegistrar): void {
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/timelines",
|
||||||
|
permission: "signage.view",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const timelines = await store(context).listTimelines();
|
||||||
|
return Response.json({
|
||||||
|
timelines: timelines.map((timeline) => ({
|
||||||
|
description: timeline.description,
|
||||||
|
eligibleFrom: timeline.eligibleFrom?.toISOString() ?? null,
|
||||||
|
eligibleUntil: timeline.eligibleUntil?.toISOString() ?? null,
|
||||||
|
id: timeline.id,
|
||||||
|
name: timeline.name,
|
||||||
|
revision: timeline.revision,
|
||||||
|
updatedAt: timeline.updatedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "POST",
|
||||||
|
path: "/timelines",
|
||||||
|
permission: "signage.timelines.manage",
|
||||||
|
maxBodyBytes: 8192,
|
||||||
|
handler(request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const body = await readJsonObject(request);
|
||||||
|
const detail = await createTimeline({
|
||||||
|
description: optionalString(body, "description", { maxLength: 500 }) ?? null,
|
||||||
|
eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null,
|
||||||
|
eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null,
|
||||||
|
name: requiredString(body, "name", {
|
||||||
|
maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength,
|
||||||
|
}),
|
||||||
|
store: store(context),
|
||||||
|
});
|
||||||
|
return Response.json(describe(detail), { status: 201 });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/timelines/:timelineId",
|
||||||
|
permission: "signage.view",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const timelineId = requireUuidParam(context, "timelineId");
|
||||||
|
return Response.json(describe(await requireTimeline(store(context), timelineId)));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/timelines/:timelineId",
|
||||||
|
permission: "signage.timelines.manage",
|
||||||
|
maxBodyBytes: 8192,
|
||||||
|
handler(request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const timelineId = requireUuidParam(context, "timelineId");
|
||||||
|
const body = await readJsonObject(request);
|
||||||
|
const detail = await updateTimeline({
|
||||||
|
changes: {
|
||||||
|
...(body.name === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
name: requiredString(body, "name", {
|
||||||
|
maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
...(body.description === undefined
|
||||||
|
? {}
|
||||||
|
: { description: optionalString(body, "description", { maxLength: 500 }) ?? null }),
|
||||||
|
...(body.eligibleFrom === undefined
|
||||||
|
? {}
|
||||||
|
: { eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null }),
|
||||||
|
...(body.eligibleUntil === undefined
|
||||||
|
? {}
|
||||||
|
: { eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null }),
|
||||||
|
},
|
||||||
|
store: store(context),
|
||||||
|
timelineId,
|
||||||
|
});
|
||||||
|
return Response.json(describe(detail));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "DELETE",
|
||||||
|
path: "/timelines/:timelineId",
|
||||||
|
permission: "signage.timelines.manage",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const timelineId = requireUuidParam(context, "timelineId");
|
||||||
|
await deleteTimeline({ store: store(context), timelineId });
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "POST",
|
||||||
|
path: "/timelines/:timelineId/duplicate",
|
||||||
|
permission: "signage.timelines.manage",
|
||||||
|
maxBodyBytes: 4096,
|
||||||
|
handler(request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const timelineId = requireUuidParam(context, "timelineId");
|
||||||
|
const body = await readJsonObject(request);
|
||||||
|
const detail = await duplicateTimeline({
|
||||||
|
name: requiredString(body, "name", { maxLength: SIGNAGE_TIMELINE_LIMITS.maxNameLength }),
|
||||||
|
store: store(context),
|
||||||
|
timelineId,
|
||||||
|
});
|
||||||
|
return Response.json(describe(detail), { status: 201 });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "POST",
|
||||||
|
path: "/timelines/:timelineId/sources",
|
||||||
|
permission: "signage.timelines.manage",
|
||||||
|
maxBodyBytes: 4096,
|
||||||
|
handler(request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const timelineId = requireUuidParam(context, "timelineId");
|
||||||
|
const body = await readJsonObject(request);
|
||||||
|
const detail = await addProviderSource({
|
||||||
|
label: requiredString(body, "label", { maxLength: 200 }),
|
||||||
|
providerId: requireOpaqueId(body, "providerId"),
|
||||||
|
sourceId: requireOpaqueId(body, "sourceId"),
|
||||||
|
store: store(context),
|
||||||
|
timelineId,
|
||||||
|
});
|
||||||
|
return Response.json(describe(detail), { status: 201 });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "DELETE",
|
||||||
|
path: "/timelines/:timelineId/sources/:sourceId",
|
||||||
|
permission: "signage.timelines.manage",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const timelineId = requireUuidParam(context, "timelineId");
|
||||||
|
const sourceId = requireUuidParam(context, "sourceId");
|
||||||
|
return Response.json(
|
||||||
|
describe(await removeProviderSource({ sourceId, store: store(context), timelineId })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "POST",
|
||||||
|
path: "/timelines/:timelineId/slots",
|
||||||
|
permission: "signage.timelines.manage",
|
||||||
|
maxBodyBytes: 8192,
|
||||||
|
handler(request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const timelineId = requireUuidParam(context, "timelineId");
|
||||||
|
const body = await readJsonObject(request);
|
||||||
|
const sourceId = body.sourceId;
|
||||||
|
const detail = await addSlot({
|
||||||
|
durationOverrideMs: optionalInteger(body, "durationOverrideMs", {
|
||||||
|
maximum: SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs,
|
||||||
|
minimum: SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs,
|
||||||
|
}),
|
||||||
|
eligibleFrom: optionalInstant(body, "eligibleFrom"),
|
||||||
|
eligibleUntil: optionalInstant(body, "eligibleUntil"),
|
||||||
|
enabled: optionalBoolean(body, "enabled") ?? true,
|
||||||
|
...(sourceId === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
source: {
|
||||||
|
sourceId: isUuid(sourceId)
|
||||||
|
? sourceId
|
||||||
|
: (() => {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "sourceId" });
|
||||||
|
})(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(body.itemId === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
pinned: {
|
||||||
|
itemId: requireOpaqueId(body, "itemId"),
|
||||||
|
providerId: requireOpaqueId(body, "providerId"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
position: optionalInteger(body, "position", { maximum: 10_000, minimum: 0 }) ?? undefined,
|
||||||
|
presentation: readPresentation(body),
|
||||||
|
store: store(context),
|
||||||
|
timelineId,
|
||||||
|
});
|
||||||
|
return Response.json(describe(detail), { status: 201 });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/timelines/:timelineId/slots/:slotId",
|
||||||
|
permission: "signage.timelines.manage",
|
||||||
|
maxBodyBytes: 8192,
|
||||||
|
handler(request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const timelineId = requireUuidParam(context, "timelineId");
|
||||||
|
const slotId = requireUuidParam(context, "slotId");
|
||||||
|
const body = await readJsonObject(request);
|
||||||
|
const presentation = readPresentation(body);
|
||||||
|
const detail = await updateSlot({
|
||||||
|
changes: {
|
||||||
|
...(body.enabled === undefined
|
||||||
|
? {}
|
||||||
|
: { enabled: optionalBoolean(body, "enabled") ?? true }),
|
||||||
|
...(body.durationOverrideMs === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
durationOverrideMs: optionalInteger(body, "durationOverrideMs", {
|
||||||
|
maximum: SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs,
|
||||||
|
minimum: SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs,
|
||||||
|
}) as number | null,
|
||||||
|
}),
|
||||||
|
...(body.eligibleFrom === undefined
|
||||||
|
? {}
|
||||||
|
: { eligibleFrom: optionalInstant(body, "eligibleFrom") ?? null }),
|
||||||
|
...(body.eligibleUntil === undefined
|
||||||
|
? {}
|
||||||
|
: { eligibleUntil: optionalInstant(body, "eligibleUntil") ?? null }),
|
||||||
|
...(body.itemId === undefined ? {} : { itemId: requireOpaqueId(body, "itemId") }),
|
||||||
|
...(presentation === undefined ? {} : { presentation }),
|
||||||
|
},
|
||||||
|
slotId,
|
||||||
|
store: store(context),
|
||||||
|
timelineId,
|
||||||
|
});
|
||||||
|
return Response.json(describe(detail));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "DELETE",
|
||||||
|
path: "/timelines/:timelineId/slots/:slotId",
|
||||||
|
permission: "signage.timelines.manage",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const timelineId = requireUuidParam(context, "timelineId");
|
||||||
|
const slotId = requireUuidParam(context, "slotId");
|
||||||
|
return Response.json(
|
||||||
|
describe(await removeSlot({ slotId, store: store(context), timelineId })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "PUT",
|
||||||
|
path: "/timelines/:timelineId/slot-order",
|
||||||
|
permission: "signage.timelines.manage",
|
||||||
|
maxBodyBytes: 16_384,
|
||||||
|
handler(request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const timelineId = requireUuidParam(context, "timelineId");
|
||||||
|
const body = await readJsonObject(request);
|
||||||
|
const ids = body.slotIds;
|
||||||
|
if (
|
||||||
|
!Array.isArray(ids) ||
|
||||||
|
ids.length > SIGNAGE_TIMELINE_LIMITS.maxSlotsPerTimeline ||
|
||||||
|
!ids.every((id) => isUuid(id))
|
||||||
|
) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "slotIds" });
|
||||||
|
}
|
||||||
|
return Response.json(
|
||||||
|
describe(
|
||||||
|
await reorderSlots({
|
||||||
|
orderedSlotIds: ids as string[],
|
||||||
|
store: store(context),
|
||||||
|
timelineId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import type { ModuleHttpRouteRegistrar, ModuleRouteHandlerContext } from "@ekklesia/module-sdk";
|
||||||
|
import { buildDisplayChannelState } from "../display/state.js";
|
||||||
|
import { SignageError, signageBoundary } from "../errors.js";
|
||||||
|
import {
|
||||||
|
optionalBoolean,
|
||||||
|
optionalInteger,
|
||||||
|
optionalString,
|
||||||
|
readJsonObject,
|
||||||
|
requireUuidParam,
|
||||||
|
} from "../http.js";
|
||||||
|
import type { SignagePlaybackService } from "../playback/service.js";
|
||||||
|
import type { SignageProviderRegistry } from "../providers/registry.js";
|
||||||
|
import { readSignageSettings } from "../settings.js";
|
||||||
|
import { createSignageStore } from "../store/drizzle.js";
|
||||||
|
import type { SignageStore, StoredChannel } from "../store/types.js";
|
||||||
|
import {
|
||||||
|
describeViewerHealth,
|
||||||
|
openViewerSession,
|
||||||
|
recordViewerHeartbeat,
|
||||||
|
SIGNAGE_VIEWER_NAME_MAX,
|
||||||
|
updateViewerAssignment,
|
||||||
|
} from "../viewers/service.js";
|
||||||
|
|
||||||
|
const OPAQUE_ID = /^[\w][\w.:-]{0,199}$/u;
|
||||||
|
|
||||||
|
function store(context: ModuleRouteHandlerContext): SignageStore {
|
||||||
|
return createSignageStore(context.services.database);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The safe Core device identifier, when the caller is a paired display.
|
||||||
|
*
|
||||||
|
* A device principal is deliberately **not** an authenticated person (ADR 0036), and Signage treats
|
||||||
|
* it as exactly what it is: an opaque identifier to correlate its own roster with Core's. Pairing,
|
||||||
|
* revocation, and every credential remain entirely Core's — nothing in Signage reads, stores, or
|
||||||
|
* forwards one.
|
||||||
|
*/
|
||||||
|
function deviceIdOf(context: ModuleRouteHandlerContext): string | null {
|
||||||
|
return context.principal.kind === "device" ? context.principal.deviceId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireChannel(
|
||||||
|
signageStore: SignageStore,
|
||||||
|
channelId: string,
|
||||||
|
): Promise<StoredChannel> {
|
||||||
|
const groups = await signageStore.listChannelGroups();
|
||||||
|
for (const group of groups) {
|
||||||
|
const channel = group.channels.find((candidate) => candidate.id === channelId);
|
||||||
|
if (channel) {
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerViewerRoutes(
|
||||||
|
http: ModuleHttpRouteRegistrar,
|
||||||
|
playback: () => SignagePlaybackService,
|
||||||
|
registry: () => SignageProviderRegistry,
|
||||||
|
): void {
|
||||||
|
http.route({
|
||||||
|
method: "POST",
|
||||||
|
path: "/viewers/session",
|
||||||
|
permission: "signage.display.view",
|
||||||
|
maxBodyBytes: 2048,
|
||||||
|
handler(request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const body = await readJsonObject(request);
|
||||||
|
const viewerKey = body.viewerKey;
|
||||||
|
if (typeof viewerKey !== "string") {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "viewerKey" });
|
||||||
|
}
|
||||||
|
// A display registers with the identity it keeps in its own IndexedDB and lands on the
|
||||||
|
// default channel. It never chooses one: a public screen that could would let anyone who
|
||||||
|
// reaches the page decide what a wall shows (ADR 0004).
|
||||||
|
const session = await openViewerSession({
|
||||||
|
at: new Date(),
|
||||||
|
deviceId: deviceIdOf(context),
|
||||||
|
store: store(context),
|
||||||
|
viewerKey,
|
||||||
|
});
|
||||||
|
return Response.json({
|
||||||
|
channelId: session.channel.id,
|
||||||
|
channelKey: session.channel.key,
|
||||||
|
groupId: session.groupId,
|
||||||
|
heartbeatIntervalMs: 12_000,
|
||||||
|
viewerId: session.viewer.id,
|
||||||
|
viewerName: session.viewer.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "POST",
|
||||||
|
path: "/viewers/:viewerId/heartbeat",
|
||||||
|
permission: "signage.display.view",
|
||||||
|
maxBodyBytes: 4096,
|
||||||
|
handler(request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const viewerId = requireUuidParam(context, "viewerId");
|
||||||
|
const body = await readJsonObject(request);
|
||||||
|
const settings = readSignageSettings(context.config);
|
||||||
|
const rawError = body.error;
|
||||||
|
// Telemetry only. Nothing a display reports here changes authoritative playback, and a
|
||||||
|
// local cursor is never accepted as truth.
|
||||||
|
const result = await recordViewerHeartbeat({
|
||||||
|
at: new Date(),
|
||||||
|
heartbeat: {
|
||||||
|
cachedAssetCount:
|
||||||
|
optionalInteger(body, "cachedAssetCount", {
|
||||||
|
maximum: 100_000,
|
||||||
|
minimum: 0,
|
||||||
|
}) ?? undefined,
|
||||||
|
offlineReady: optionalBoolean(body, "offlineReady") ?? undefined,
|
||||||
|
reportedContributionId:
|
||||||
|
optionalString(body, "reportedContributionId", { maxLength: 200 }) ?? null,
|
||||||
|
reportedPlaybackRevision:
|
||||||
|
optionalInteger(body, "reportedPlaybackRevision", {
|
||||||
|
maximum: Number.MAX_SAFE_INTEGER,
|
||||||
|
minimum: 0,
|
||||||
|
}) ?? null,
|
||||||
|
reportedProviderId:
|
||||||
|
optionalString(body, "reportedProviderId", { maxLength: 200 }) ?? null,
|
||||||
|
reportedSelectionId:
|
||||||
|
optionalString(body, "reportedSelectionId", { maxLength: 64 }) ?? null,
|
||||||
|
reportedTimelineId:
|
||||||
|
optionalString(body, "reportedTimelineId", { maxLength: 64 }) ?? null,
|
||||||
|
...(rawError === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
error:
|
||||||
|
rawError === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
code: readErrorCode(rawError),
|
||||||
|
detail: readErrorDetail(rawError),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
minimumWriteIntervalMs: settings.viewerHeartbeatWriteMs,
|
||||||
|
store: store(context),
|
||||||
|
viewerId,
|
||||||
|
});
|
||||||
|
// The response says what a display needs to keep itself honest, and nothing about other
|
||||||
|
// displays or the installation.
|
||||||
|
return Response.json({ acknowledged: true, recorded: result.written });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/channels/:channelId/display-state",
|
||||||
|
permission: "signage.display.view",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const channelId = requireUuidParam(context, "channelId");
|
||||||
|
const signageStore = store(context);
|
||||||
|
const channel = await requireChannel(signageStore, channelId);
|
||||||
|
const snapshot = await playback().snapshot(channel.groupId);
|
||||||
|
return Response.json(
|
||||||
|
await buildDisplayChannelState({
|
||||||
|
atMs: Date.now(),
|
||||||
|
channel,
|
||||||
|
playback: snapshot,
|
||||||
|
registry: registry(),
|
||||||
|
settings: readSignageSettings(context.config),
|
||||||
|
storage: context.storage,
|
||||||
|
store: signageStore,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/viewers",
|
||||||
|
permission: "signage.view",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const atMs = Date.now();
|
||||||
|
const viewers = await store(context).listViewers();
|
||||||
|
return Response.json({
|
||||||
|
viewers: viewers.map((viewer) => describeViewerHealth(viewer, atMs)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/viewers/:viewerId",
|
||||||
|
permission: "signage.displays.manage",
|
||||||
|
maxBodyBytes: 4096,
|
||||||
|
handler(request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const viewerId = requireUuidParam(context, "viewerId");
|
||||||
|
const body = await readJsonObject(request);
|
||||||
|
const channelId = body.channelId;
|
||||||
|
if (channelId !== undefined && typeof channelId !== "string") {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "channelId" });
|
||||||
|
}
|
||||||
|
const viewer = await updateViewerAssignment({
|
||||||
|
...(channelId === undefined ? {} : { channelId }),
|
||||||
|
name: optionalString(body, "name", { maxLength: SIGNAGE_VIEWER_NAME_MAX }),
|
||||||
|
store: store(context),
|
||||||
|
viewerId,
|
||||||
|
});
|
||||||
|
return Response.json({ viewer: describeViewerHealth(viewer, Date.now()) });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "DELETE",
|
||||||
|
path: "/viewers/:viewerId",
|
||||||
|
permission: "signage.displays.manage",
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
return signageBoundary(async () => {
|
||||||
|
const viewerId = requireUuidParam(context, "viewerId");
|
||||||
|
const signageStore = store(context);
|
||||||
|
if (!(await signageStore.findViewer(viewerId))) {
|
||||||
|
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
// Removing a screen from the roster is a record-keeping action. A display that checks in
|
||||||
|
// again is simply registered afresh, because its own identity is the one it keeps.
|
||||||
|
await signageStore.deleteViewer(viewerId);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readErrorCode(value: unknown): string {
|
||||||
|
if (typeof value !== "object" || value === null) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error" });
|
||||||
|
}
|
||||||
|
const code = (value as { code?: unknown }).code;
|
||||||
|
if (typeof code !== "string" || !OPAQUE_ID.test(code)) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.code" });
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readErrorDetail(value: unknown): string | null {
|
||||||
|
const detail = (value as { detail?: unknown }).detail;
|
||||||
|
if (detail === undefined || detail === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof detail !== "string") {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.detail" });
|
||||||
|
}
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import {
|
||||||
|
defineModuleRuntime,
|
||||||
|
type ModuleRuntimeContext,
|
||||||
|
type ModuleRuntimeDefinition,
|
||||||
|
} from "@ekklesia/module-sdk";
|
||||||
|
import { SIGNAGE_SLIDE_RENDERER_INPUT_VERSION } from "../shared/renderer-input.js";
|
||||||
|
import {
|
||||||
|
SIGNAGE_IMAGE_MAX_BYTES,
|
||||||
|
SIGNAGE_IMAGE_MAX_PIXELS,
|
||||||
|
SIGNAGE_UPLOAD_BATCH_MAX_FILES,
|
||||||
|
SIGNAGE_UPLOAD_CONCURRENCY,
|
||||||
|
} from "./images/limits.js";
|
||||||
|
import { createPlaybackCapability } from "./playback/capability.js";
|
||||||
|
import {
|
||||||
|
createPlaybackService,
|
||||||
|
type PlaybackSnapshot,
|
||||||
|
type SignagePlaybackService,
|
||||||
|
} from "./playback/service.js";
|
||||||
|
import type { SignageProviderHandle } from "./providers/contract.js";
|
||||||
|
import {
|
||||||
|
createNativeImageProvider,
|
||||||
|
type NativeImageProvider,
|
||||||
|
} from "./providers/native-provider.js";
|
||||||
|
import { createProviderRegistry, type SignageProviderRegistry } from "./providers/registry.js";
|
||||||
|
import { registerImageRoutes } from "./routes/images.js";
|
||||||
|
import { registerPlaybackRoutes } from "./routes/playback.js";
|
||||||
|
import { registerProviderRoutes } from "./routes/providers.js";
|
||||||
|
import { registerTimelineRoutes } from "./routes/timelines.js";
|
||||||
|
import { registerViewerRoutes } from "./routes/viewers.js";
|
||||||
|
import { readSignageSettings } from "./settings.js";
|
||||||
|
import { createSignageStore } from "./store/drizzle.js";
|
||||||
|
import { createDisplaysCapability } from "./viewers/capability.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signage's server runtime, as a factory rather than a module-level singleton.
|
||||||
|
*
|
||||||
|
* A factory because the runtime owns real state — the live provider registry, the built-in image
|
||||||
|
* provider's registration handle, the playback service and its timers — and a test that exercised
|
||||||
|
* registration twice against one shared instance would be testing leftovers. `index.ts` calls this
|
||||||
|
* once; a test calls it per case.
|
||||||
|
*
|
||||||
|
* Registration stays declarative and I/O-free: nothing here connects to PostgreSQL, contacts object
|
||||||
|
* storage, reads the environment, opens a socket, or applies a migration. Live services reach
|
||||||
|
* Signage only through the contexts the platform hands `start`, `stop`, and route handlers.
|
||||||
|
*/
|
||||||
|
export interface SignageRuntimeOptions {
|
||||||
|
readonly now?: () => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignageModuleRuntime {
|
||||||
|
readonly definition: ModuleRuntimeDefinition;
|
||||||
|
/** The live registry, exposed for tests and for the routes that report provider health. */
|
||||||
|
readonly registry: SignageProviderRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Realtime carries compact authoritative state and revisions, never content (ADR 0005). Large
|
||||||
|
* state — a timeline, a history, a renderer payload — is fetched over authorized HTTP keyed by
|
||||||
|
* revision, which closes the GET-then-subscribe race without a second transport. */
|
||||||
|
export const SIGNAGE_DISPLAY_CHANNEL = "display-state";
|
||||||
|
export const SIGNAGE_MANAGEMENT_CHANNEL = "management-state";
|
||||||
|
|
||||||
|
export function createSignageModuleRuntime(
|
||||||
|
options: SignageRuntimeOptions = {},
|
||||||
|
): SignageModuleRuntime {
|
||||||
|
const now = options.now ?? (() => new Date());
|
||||||
|
let registry: SignageProviderRegistry | undefined;
|
||||||
|
let nativeProvider: NativeImageProvider | undefined;
|
||||||
|
let nativeHandle: SignageProviderHandle | undefined;
|
||||||
|
let playback: SignagePlaybackService | undefined;
|
||||||
|
let store: ReturnType<typeof createSignageStore> | undefined;
|
||||||
|
/** The latest snapshot per group, so a publication never has to read the database from inside the
|
||||||
|
* very lock that produced it. */
|
||||||
|
const snapshots = new Map<string, PlaybackSnapshot>();
|
||||||
|
|
||||||
|
function requireRegistry(): SignageProviderRegistry {
|
||||||
|
if (!registry) {
|
||||||
|
throw new Error("Signage's provider registry is only available after registration.");
|
||||||
|
}
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePlayback(): SignagePlaybackService {
|
||||||
|
if (!playback) {
|
||||||
|
throw new Error("Signage's playback service is only available after start.");
|
||||||
|
}
|
||||||
|
return playback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireStore(): ReturnType<typeof createSignageStore> {
|
||||||
|
if (!store) {
|
||||||
|
throw new Error("Signage's database facade is only available after start.");
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish(context: ModuleRuntimeContext): void {
|
||||||
|
const groups = [...snapshots.values()].sort((left, right) =>
|
||||||
|
left.groupId.localeCompare(right.groupId),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
context.realtime.publish(SIGNAGE_DISPLAY_CHANNEL, {
|
||||||
|
groups,
|
||||||
|
serverNowMs: now().getTime(),
|
||||||
|
});
|
||||||
|
context.realtime.publish(SIGNAGE_MANAGEMENT_CHANNEL, {
|
||||||
|
// Revisions only. The dashboard refetches what changed over authorized HTTP.
|
||||||
|
groups: groups.map((snapshot) => ({
|
||||||
|
groupId: snapshot.groupId,
|
||||||
|
revision: snapshot.revision,
|
||||||
|
selectionRevision: snapshot.selectionRevision,
|
||||||
|
transport: snapshot.transport,
|
||||||
|
})),
|
||||||
|
providers: requireRegistry()
|
||||||
|
.health()
|
||||||
|
.map((provider) => ({
|
||||||
|
consecutiveFailures: provider.consecutiveFailures,
|
||||||
|
lastFailureCode: provider.lastFailureCode,
|
||||||
|
providerId: provider.providerId,
|
||||||
|
})),
|
||||||
|
serverNowMs: now().getTime(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Realtime is best effort and never blocks or changes committed playback state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = defineModuleRuntime({
|
||||||
|
id: "signage",
|
||||||
|
version: "0.1.0",
|
||||||
|
|
||||||
|
register({ capabilities, config, http, realtime }) {
|
||||||
|
const settings = readSignageSettings(config);
|
||||||
|
|
||||||
|
registry = createProviderRegistry({
|
||||||
|
now,
|
||||||
|
onInvalidate: (notice) => {
|
||||||
|
// A notification, never a command: playback decides what to refresh and when, because it
|
||||||
|
// is the only thing that knows what is on screen.
|
||||||
|
void playback?.invalidate(notice).catch(() => undefined);
|
||||||
|
},
|
||||||
|
timeoutMs: settings.providerTimeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The extension seam every future content module reaches Signage through (ADR 0002).
|
||||||
|
capabilities.provide("signage.content-providers", registry.capability);
|
||||||
|
|
||||||
|
// The browser half of the same seam. Nothing registers against it server-side: a contribution
|
||||||
|
// is declared in a *contributing* module's manifest and published by the platform, and this
|
||||||
|
// declaration is what makes `capabilityRange` mean something. Resolving it tells a module
|
||||||
|
// which version of Signage's renderer input contract this installation speaks.
|
||||||
|
capabilities.provide(
|
||||||
|
"signage.slide-renderer",
|
||||||
|
Object.freeze({ inputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sanitized playback state and commands for a future consumer such as an Ekklesia Companion.
|
||||||
|
// Exactly what an operator has in the dashboard, over the same serialized service — and
|
||||||
|
// deliberately no transport, topic, broker, or integration vocabulary of any kind.
|
||||||
|
capabilities.provide(
|
||||||
|
"signage.playback",
|
||||||
|
createPlaybackCapability(requirePlayback, async () => {
|
||||||
|
const groups = await requireStore().listChannelGroups();
|
||||||
|
return groups.map((group) => group.group.id);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sanitized viewer and channel health, and assignment, for another module. Names, online
|
||||||
|
// state, and what a screen last reported — never a credential, a pairing token, or a host
|
||||||
|
// internal.
|
||||||
|
capabilities.provide("signage.displays", createDisplaysCapability(requireStore, now));
|
||||||
|
|
||||||
|
realtime.channel({ channel: SIGNAGE_DISPLAY_CHANNEL, permission: "signage.display.view" });
|
||||||
|
realtime.channel({ channel: SIGNAGE_MANAGEMENT_CHANNEL, permission: "signage.view" });
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
method: "GET",
|
||||||
|
path: "/settings",
|
||||||
|
permission: "signage.view",
|
||||||
|
// A GET route accepts no request body at all.
|
||||||
|
maxBodyBytes: 0,
|
||||||
|
handler(_request, context) {
|
||||||
|
const current = readSignageSettings(context.config);
|
||||||
|
// Operator settings and Signage's own declared limits. Signage declares no `secret`
|
||||||
|
// configuration key, and this route would still never serialize one: what an operator may
|
||||||
|
// see of a module's configuration is the module's deliberate decision, value by value.
|
||||||
|
//
|
||||||
|
// The limits travel with the settings so the dashboard never hardcodes a number the
|
||||||
|
// server enforces — a UI believing a stale limit would offer an upload the server refuses.
|
||||||
|
return Response.json({
|
||||||
|
crossfadeMs: current.crossfadeMs,
|
||||||
|
moduleVersion: "0.1.0",
|
||||||
|
nativeImageDurationMs: current.nativeImageDurationMs,
|
||||||
|
nativePresentation: current.nativePresentation,
|
||||||
|
rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
|
||||||
|
timezone: current.timezone,
|
||||||
|
uploads: {
|
||||||
|
batchMaxFiles: SIGNAGE_UPLOAD_BATCH_MAX_FILES,
|
||||||
|
concurrency: SIGNAGE_UPLOAD_CONCURRENCY,
|
||||||
|
maxBytes: SIGNAGE_IMAGE_MAX_BYTES,
|
||||||
|
maxPixels: SIGNAGE_IMAGE_MAX_PIXELS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registerImageRoutes(http);
|
||||||
|
registerProviderRoutes(http, requireRegistry);
|
||||||
|
registerTimelineRoutes(http);
|
||||||
|
registerPlaybackRoutes(http, requirePlayback);
|
||||||
|
registerViewerRoutes(http, requirePlayback, requireRegistry);
|
||||||
|
},
|
||||||
|
|
||||||
|
async start(context: ModuleRuntimeContext) {
|
||||||
|
const settings = readSignageSettings(context.config);
|
||||||
|
store = createSignageStore(context.services.database);
|
||||||
|
|
||||||
|
// Signage's own content provider registers through the identical capability an external
|
||||||
|
// provider uses. There is one selection pipeline, not a native special case (ADR 0001).
|
||||||
|
nativeProvider = createNativeImageProvider({
|
||||||
|
defaultDurationMs: settings.nativeImageDurationMs,
|
||||||
|
now,
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
nativeHandle = requireRegistry().capability.register(nativeProvider.registration);
|
||||||
|
|
||||||
|
playback = createPlaybackService({
|
||||||
|
now: () => now().getTime(),
|
||||||
|
onSnapshot: (snapshot) => {
|
||||||
|
snapshots.set(snapshot.groupId, snapshot);
|
||||||
|
publish(context);
|
||||||
|
},
|
||||||
|
registry: requireRegistry(),
|
||||||
|
settings,
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
|
||||||
|
// One default channel group with one default channel, and nothing else. `in-person` and
|
||||||
|
// `online` are an administrator's decision, not an install-time one (ADR 0004).
|
||||||
|
await playback.ensureDefaultGroup();
|
||||||
|
await playback.start();
|
||||||
|
|
||||||
|
// Every declared channel must receive an initial publication before `start` completes, so a
|
||||||
|
// subscriber that connects immediately is given state rather than silence.
|
||||||
|
publish(context);
|
||||||
|
},
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
// Idempotent, and safe after a `start` that failed partway: `stop` runs before rollback, and
|
||||||
|
// may run again during reverse-order shutdown.
|
||||||
|
playback?.stop();
|
||||||
|
playback = undefined;
|
||||||
|
store = undefined;
|
||||||
|
snapshots.clear();
|
||||||
|
nativeHandle?.unregister();
|
||||||
|
nativeHandle = undefined;
|
||||||
|
nativeProvider = undefined;
|
||||||
|
registry?.clear();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
definition,
|
||||||
|
get registry(): SignageProviderRegistry {
|
||||||
|
return requireRegistry();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { ModuleConfiguration } from "@ekklesia/module-sdk";
|
||||||
|
import {
|
||||||
|
isSignageBackgroundMode,
|
||||||
|
SIGNAGE_DEFAULT_SETTINGS,
|
||||||
|
SIGNAGE_HEX_COLOR,
|
||||||
|
type SignageSettings,
|
||||||
|
} from "../shared/settings.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads Signage's own declared configuration through the one platform contract that offers it.
|
||||||
|
*
|
||||||
|
* Signage never reads `process.env`, never queries a platform table for a setting, and has no way to
|
||||||
|
* see another module's configuration — which is what keeps it runnable under a future standalone
|
||||||
|
* host that stores configuration somewhere else entirely.
|
||||||
|
*
|
||||||
|
* The manifest declares a default for every key, so in a real deployment each accessor is guaranteed
|
||||||
|
* a value before any Signage code runs. The fallbacks exist because the accessors are typed
|
||||||
|
* `T | undefined` for keys a manifest does not declare, and because a module-layer test may
|
||||||
|
* construct a `ModuleConfiguration` by hand.
|
||||||
|
*/
|
||||||
|
export function readSignageSettings(config: ModuleConfiguration): SignageSettings {
|
||||||
|
const defaults = SIGNAGE_DEFAULT_SETTINGS;
|
||||||
|
const backgroundColor = config.getString("defaultBackgroundColor");
|
||||||
|
const backgroundMode = config.getEnum("defaultBackgroundMode");
|
||||||
|
const durationSeconds = config.getInteger("nativeImageDurationSeconds");
|
||||||
|
const heartbeatSeconds = config.getInteger("viewerHeartbeatWriteSeconds");
|
||||||
|
return Object.freeze({
|
||||||
|
crossfadeMs: config.getInteger("crossfadeMilliseconds") ?? defaults.crossfadeMs,
|
||||||
|
nativeImageDurationMs:
|
||||||
|
durationSeconds === undefined ? defaults.nativeImageDurationMs : durationSeconds * 1000,
|
||||||
|
nativePresentation: Object.freeze({
|
||||||
|
backgroundColor:
|
||||||
|
backgroundColor !== undefined && SIGNAGE_HEX_COLOR.test(backgroundColor)
|
||||||
|
? backgroundColor.toLowerCase()
|
||||||
|
: defaults.nativePresentation.backgroundColor,
|
||||||
|
backgroundDarkened:
|
||||||
|
config.getBoolean("defaultBackgroundDarkened") ??
|
||||||
|
defaults.nativePresentation.backgroundDarkened,
|
||||||
|
backgroundMode: isSignageBackgroundMode(backgroundMode)
|
||||||
|
? backgroundMode
|
||||||
|
: defaults.nativePresentation.backgroundMode,
|
||||||
|
blurPixels: config.getInteger("defaultBlurPixels") ?? defaults.nativePresentation.blurPixels,
|
||||||
|
}),
|
||||||
|
playbackHistoryLimit:
|
||||||
|
config.getInteger("playbackHistoryLimit") ?? defaults.playbackHistoryLimit,
|
||||||
|
providerTimeoutMs:
|
||||||
|
config.getInteger("providerTimeoutMilliseconds") ?? defaults.providerTimeoutMs,
|
||||||
|
timezone: config.getString("timezone") ?? defaults.timezone,
|
||||||
|
viewerHeartbeatWriteMs:
|
||||||
|
heartbeatSeconds === undefined ? defaults.viewerHeartbeatWriteMs : heartbeatSeconds * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { ModuleDatabase } from "@ekklesia/module-sdk";
|
||||||
|
import { and, asc, desc, eq, inArray, lt, sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
channelGroups,
|
||||||
|
channels,
|
||||||
|
playbackStates,
|
||||||
|
selections,
|
||||||
|
selectionVariants,
|
||||||
|
} from "../../schema/tables.js";
|
||||||
|
import {
|
||||||
|
type NewStoredSelection,
|
||||||
|
type PlaybackChanges,
|
||||||
|
type SelectionChanges,
|
||||||
|
type SignagePlaybackStore,
|
||||||
|
SignageStoreConflict,
|
||||||
|
type StoredChannel,
|
||||||
|
type StoredChannelGroupDetail,
|
||||||
|
type StoredPlaybackState,
|
||||||
|
type StoredSelection,
|
||||||
|
type StoredSelectionVariant,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The playback half of Signage's production store.
|
||||||
|
*
|
||||||
|
* Two things here are transactional because they cannot be safe otherwise: creating a group together
|
||||||
|
* with its playback row and its default channel, and taking the next selection sequence. A group
|
||||||
|
* without a default channel has nowhere for a viewer to land, and two selections that took the same
|
||||||
|
* sequence would make history ambiguous exactly where `previous` reads it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type SelectionRow = typeof selections.$inferSelect;
|
||||||
|
|
||||||
|
function toPlayback(row: typeof playbackStates.$inferSelect): StoredPlaybackState {
|
||||||
|
return {
|
||||||
|
activeTimelineId: row.activeTimelineId,
|
||||||
|
blackout: row.blackout,
|
||||||
|
currentSelectionId: row.currentSelectionId,
|
||||||
|
currentSlotId: row.currentSlotId,
|
||||||
|
groupId: row.groupId,
|
||||||
|
nextSequence: row.nextSequence,
|
||||||
|
paused: row.paused,
|
||||||
|
pausedRemainingMs: row.pausedRemainingMs,
|
||||||
|
pendingRequestedAt: row.pendingRequestedAt,
|
||||||
|
pendingTimelineId: row.pendingTimelineId,
|
||||||
|
revision: row.revision,
|
||||||
|
scheduledActivationAt: row.scheduledActivationAt,
|
||||||
|
scheduledTimelineId: row.scheduledTimelineId,
|
||||||
|
slotDeadlineAt: row.slotDeadlineAt,
|
||||||
|
slotStartedAt: row.slotStartedAt,
|
||||||
|
staticAssetId: row.staticAssetId,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toChannel(row: typeof channels.$inferSelect): StoredChannel {
|
||||||
|
return {
|
||||||
|
groupId: row.groupId,
|
||||||
|
id: row.id,
|
||||||
|
isDefault: row.isDefault,
|
||||||
|
key: row.key,
|
||||||
|
name: row.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSelection(
|
||||||
|
row: SelectionRow,
|
||||||
|
variants: readonly StoredSelectionVariant[],
|
||||||
|
): StoredSelection {
|
||||||
|
return {
|
||||||
|
availableUntil: row.availableUntil,
|
||||||
|
deadlineAt: row.deadlineAt,
|
||||||
|
durationMs: row.durationMs,
|
||||||
|
groupId: row.groupId,
|
||||||
|
id: row.id,
|
||||||
|
itemId: row.itemId,
|
||||||
|
itemRevision: row.itemRevision,
|
||||||
|
presentation: row.presentation,
|
||||||
|
providerId: row.providerId,
|
||||||
|
rendererContributionId: row.rendererContributionId,
|
||||||
|
rendererContributionVersion: row.rendererContributionVersion,
|
||||||
|
resolvedAt: row.resolvedAt,
|
||||||
|
revision: row.revision,
|
||||||
|
selectionScope: row.selectionScope,
|
||||||
|
sequence: row.sequence,
|
||||||
|
slotId: row.slotId,
|
||||||
|
stale: row.stale,
|
||||||
|
startedAt: row.startedAt,
|
||||||
|
timelineId: row.timelineId,
|
||||||
|
variants,
|
||||||
|
withdrawn: row.withdrawn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlaybackStore(database: ModuleDatabase): SignagePlaybackStore {
|
||||||
|
async function variantsFor(
|
||||||
|
selectionIds: readonly string[],
|
||||||
|
): Promise<Map<string, StoredSelectionVariant[]>> {
|
||||||
|
const grouped = new Map<string, StoredSelectionVariant[]>();
|
||||||
|
if (selectionIds.length === 0) {
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
const rows = await database
|
||||||
|
.select()
|
||||||
|
.from(selectionVariants)
|
||||||
|
.where(inArray(selectionVariants.selectionId, [...selectionIds]));
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = grouped.get(row.selectionId) ?? [];
|
||||||
|
list.push({ audienceKey: row.audienceKey, isDefault: row.isDefault, payload: row.payload });
|
||||||
|
grouped.set(row.selectionId, list);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateSelections(rows: readonly SelectionRow[]): Promise<StoredSelection[]> {
|
||||||
|
const variants = await variantsFor(rows.map((row) => row.id));
|
||||||
|
return rows.map((row) => toSelection(row, variants.get(row.id) ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function groupDetail(
|
||||||
|
groupRow: typeof channelGroups.$inferSelect,
|
||||||
|
): Promise<StoredChannelGroupDetail | undefined> {
|
||||||
|
const [channelRows, playbackRows] = await Promise.all([
|
||||||
|
database
|
||||||
|
.select()
|
||||||
|
.from(channels)
|
||||||
|
.where(eq(channels.groupId, groupRow.id))
|
||||||
|
.orderBy(desc(channels.isDefault), asc(channels.key)),
|
||||||
|
database.select().from(playbackStates).where(eq(playbackStates.groupId, groupRow.id)),
|
||||||
|
]);
|
||||||
|
const playback = playbackRows[0];
|
||||||
|
if (!playback) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
channels: channelRows.map(toChannel),
|
||||||
|
group: { id: groupRow.id, name: groupRow.name, revision: groupRow.revision },
|
||||||
|
playback: toPlayback(playback),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async addChannel(channel: StoredChannel): Promise<void> {
|
||||||
|
await database.transaction(async (transaction) => {
|
||||||
|
await transaction.insert(channels).values({
|
||||||
|
groupId: channel.groupId,
|
||||||
|
id: channel.id,
|
||||||
|
isDefault: channel.isDefault,
|
||||||
|
key: channel.key,
|
||||||
|
name: channel.name,
|
||||||
|
});
|
||||||
|
await transaction
|
||||||
|
.update(channelGroups)
|
||||||
|
.set({ revision: sql`${channelGroups.revision} + 1`, updatedAt: new Date() })
|
||||||
|
.where(eq(channelGroups.id, channel.groupId));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async createChannelGroup(input: {
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly channelId: string;
|
||||||
|
readonly channelName: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await database.transaction(async (transaction) => {
|
||||||
|
await transaction.insert(channelGroups).values({ id: input.groupId, name: input.name });
|
||||||
|
await transaction.insert(channels).values({
|
||||||
|
groupId: input.groupId,
|
||||||
|
id: input.channelId,
|
||||||
|
isDefault: true,
|
||||||
|
key: "default",
|
||||||
|
name: input.channelName,
|
||||||
|
});
|
||||||
|
await transaction.insert(playbackStates).values({ groupId: input.groupId });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteChannelGroup(groupId: string): Promise<void> {
|
||||||
|
await database.delete(channelGroups).where(eq(channelGroups.id, groupId));
|
||||||
|
},
|
||||||
|
|
||||||
|
async findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined> {
|
||||||
|
const rows = await database.select().from(channelGroups).where(eq(channelGroups.id, groupId));
|
||||||
|
const row = rows[0];
|
||||||
|
return row ? groupDetail(row) : undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
async findLatestSelectionForScope(
|
||||||
|
groupId: string,
|
||||||
|
selectionScope: string,
|
||||||
|
): Promise<StoredSelection | undefined> {
|
||||||
|
const rows = await database
|
||||||
|
.select()
|
||||||
|
.from(selections)
|
||||||
|
.where(and(eq(selections.groupId, groupId), eq(selections.selectionScope, selectionScope)))
|
||||||
|
.orderBy(desc(selections.sequence))
|
||||||
|
.limit(1);
|
||||||
|
return (await hydrateSelections(rows))[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
async findSelection(selectionId: string): Promise<StoredSelection | undefined> {
|
||||||
|
const rows = await database.select().from(selections).where(eq(selections.id, selectionId));
|
||||||
|
return (await hydrateSelections(rows))[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
async insertSelection(selection: NewStoredSelection): Promise<void> {
|
||||||
|
await database.transaction(async (transaction) => {
|
||||||
|
await transaction.insert(selections).values({
|
||||||
|
availableUntil: selection.availableUntil,
|
||||||
|
deadlineAt: selection.deadlineAt,
|
||||||
|
durationMs: selection.durationMs,
|
||||||
|
groupId: selection.groupId,
|
||||||
|
id: selection.id,
|
||||||
|
itemId: selection.itemId,
|
||||||
|
itemRevision: selection.itemRevision,
|
||||||
|
presentation: selection.presentation,
|
||||||
|
providerId: selection.providerId,
|
||||||
|
rendererContributionId: selection.rendererContributionId,
|
||||||
|
rendererContributionVersion: selection.rendererContributionVersion,
|
||||||
|
selectionScope: selection.selectionScope,
|
||||||
|
sequence: selection.sequence,
|
||||||
|
slotId: selection.slotId,
|
||||||
|
stale: selection.stale,
|
||||||
|
startedAt: selection.startedAt,
|
||||||
|
timelineId: selection.timelineId,
|
||||||
|
withdrawn: selection.withdrawn,
|
||||||
|
});
|
||||||
|
if (selection.variants.length > 0) {
|
||||||
|
await transaction.insert(selectionVariants).values(
|
||||||
|
selection.variants.map((variant) => ({
|
||||||
|
audienceKey: variant.audienceKey,
|
||||||
|
id: randomUUID(),
|
||||||
|
isDefault: variant.isDefault,
|
||||||
|
payload: variant.payload,
|
||||||
|
selectionId: selection.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async listChannelGroups(): Promise<readonly StoredChannelGroupDetail[]> {
|
||||||
|
const rows = await database.select().from(channelGroups).orderBy(asc(channelGroups.name));
|
||||||
|
const details = await Promise.all(rows.map((row) => groupDetail(row)));
|
||||||
|
return details.filter((detail): detail is StoredChannelGroupDetail => detail !== undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listSelections(groupId: string, limit: number): Promise<readonly StoredSelection[]> {
|
||||||
|
const rows = await database
|
||||||
|
.select()
|
||||||
|
.from(selections)
|
||||||
|
.where(eq(selections.groupId, groupId))
|
||||||
|
.orderBy(desc(selections.sequence))
|
||||||
|
.limit(limit);
|
||||||
|
return hydrateSelections(rows);
|
||||||
|
},
|
||||||
|
|
||||||
|
async pruneSelections(groupId: string, limit: number): Promise<void> {
|
||||||
|
const keep = await database
|
||||||
|
.select({ sequence: selections.sequence })
|
||||||
|
.from(selections)
|
||||||
|
.where(eq(selections.groupId, groupId))
|
||||||
|
.orderBy(desc(selections.sequence))
|
||||||
|
.limit(limit);
|
||||||
|
const oldest = keep.at(-1)?.sequence;
|
||||||
|
if (oldest === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await database
|
||||||
|
.delete(selections)
|
||||||
|
.where(and(eq(selections.groupId, groupId), lt(selections.sequence, oldest)));
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeChannel(channelId: string): Promise<void> {
|
||||||
|
await database.transaction(async (transaction) => {
|
||||||
|
const rows = await transaction.select().from(channels).where(eq(channels.id, channelId));
|
||||||
|
const channel = rows[0];
|
||||||
|
if (!channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (channel.isDefault) {
|
||||||
|
// Something must remain for a viewer to land on.
|
||||||
|
throw new SignageStoreConflict("channel.default");
|
||||||
|
}
|
||||||
|
await transaction.delete(channels).where(eq(channels.id, channelId));
|
||||||
|
await transaction
|
||||||
|
.update(channelGroups)
|
||||||
|
.set({ revision: sql`${channelGroups.revision} + 1`, updatedAt: new Date() })
|
||||||
|
.where(eq(channelGroups.id, channel.groupId));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async takeSelectionSequence(groupId: string): Promise<number> {
|
||||||
|
const rows = await database
|
||||||
|
.update(playbackStates)
|
||||||
|
.set({ nextSequence: sql`${playbackStates.nextSequence} + 1` })
|
||||||
|
.where(eq(playbackStates.groupId, groupId))
|
||||||
|
.returning({ nextSequence: playbackStates.nextSequence });
|
||||||
|
const next = rows[0]?.nextSequence;
|
||||||
|
if (next === undefined) {
|
||||||
|
throw new SignageStoreConflict("playback.sequence");
|
||||||
|
}
|
||||||
|
// `returning` gives the incremented value, so the sequence this call owns is one below it.
|
||||||
|
return next - 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updatePlayback(
|
||||||
|
groupId: string,
|
||||||
|
changes: PlaybackChanges,
|
||||||
|
options?: { readonly bumpRevision?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const values: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
|
for (const [key, value] of Object.entries(changes)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
values[key] = value;
|
||||||
|
} else if (Object.hasOwn(changes, key)) {
|
||||||
|
values[key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options?.bumpRevision !== false) {
|
||||||
|
values.revision = sql`${playbackStates.revision} + 1`;
|
||||||
|
}
|
||||||
|
await database.update(playbackStates).set(values).where(eq(playbackStates.groupId, groupId));
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSelection(selectionId: string, changes: SelectionChanges): Promise<void> {
|
||||||
|
await database.transaction(async (transaction) => {
|
||||||
|
const values: Record<string, unknown> = {};
|
||||||
|
for (const key of [
|
||||||
|
"availableUntil",
|
||||||
|
"deadlineAt",
|
||||||
|
"durationMs",
|
||||||
|
"itemRevision",
|
||||||
|
"presentation",
|
||||||
|
"stale",
|
||||||
|
"startedAt",
|
||||||
|
"withdrawn",
|
||||||
|
] as const) {
|
||||||
|
if (changes[key] !== undefined) {
|
||||||
|
values[key] = changes[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changes.bumpRevision === true) {
|
||||||
|
values.revision = sql`${selections.revision} + 1`;
|
||||||
|
}
|
||||||
|
if (Object.keys(values).length > 0) {
|
||||||
|
await transaction.update(selections).set(values).where(eq(selections.id, selectionId));
|
||||||
|
}
|
||||||
|
if (changes.variants) {
|
||||||
|
// Replaced wholesale: a partial update would leave one audience rendering a payload from
|
||||||
|
// a revision that no longer exists.
|
||||||
|
await transaction
|
||||||
|
.delete(selectionVariants)
|
||||||
|
.where(eq(selectionVariants.selectionId, selectionId));
|
||||||
|
if (changes.variants.length > 0) {
|
||||||
|
await transaction.insert(selectionVariants).values(
|
||||||
|
changes.variants.map((variant) => ({
|
||||||
|
audienceKey: variant.audienceKey,
|
||||||
|
id: randomUUID(),
|
||||||
|
isDefault: variant.isDefault,
|
||||||
|
payload: variant.payload,
|
||||||
|
selectionId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import type { ModuleDatabase } from "@ekklesia/module-sdk";
|
||||||
|
import { asc, eq, or, sql } from "drizzle-orm";
|
||||||
|
import { playbackStates, providerSources, timelineSlots, timelines } from "../../schema/tables.js";
|
||||||
|
import {
|
||||||
|
type NewStoredProviderSource,
|
||||||
|
type NewStoredSlot,
|
||||||
|
type NewStoredTimeline,
|
||||||
|
SignageStoreConflict,
|
||||||
|
type SignageTimelineStore,
|
||||||
|
type SlotChanges,
|
||||||
|
type StoredProviderSource,
|
||||||
|
type StoredSlot,
|
||||||
|
type StoredTimeline,
|
||||||
|
type StoredTimelineDetail,
|
||||||
|
type StoredTimelineUsage,
|
||||||
|
type TimelineChanges,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timeline half of Signage's production store.
|
||||||
|
*
|
||||||
|
* Two invariants live here rather than in the services above, because they are only safe if they are
|
||||||
|
* transactional:
|
||||||
|
*
|
||||||
|
* - **Slot positions stay unique and contiguous from zero.** Every operation that can change the
|
||||||
|
* order rewrites the whole order inside one transaction, so a gap can never be observed — not even
|
||||||
|
* briefly, by a reconciliation running at the same moment.
|
||||||
|
* - **A timeline's revision increments exactly once per semantic change**, in the same transaction
|
||||||
|
* as the change, so a client that compares revisions can never see a changed timeline at an
|
||||||
|
* unchanged revision.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type TimelineRow = typeof timelines.$inferSelect;
|
||||||
|
type SlotRow = typeof timelineSlots.$inferSelect;
|
||||||
|
type SourceRow = typeof providerSources.$inferSelect;
|
||||||
|
|
||||||
|
function toTimeline(row: TimelineRow): StoredTimeline {
|
||||||
|
return {
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
description: row.description,
|
||||||
|
eligibleFrom: row.eligibleFrom,
|
||||||
|
eligibleUntil: row.eligibleUntil,
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
revision: row.revision,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSource(row: SourceRow): StoredProviderSource {
|
||||||
|
return {
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
id: row.id,
|
||||||
|
label: row.label,
|
||||||
|
providerId: row.providerId,
|
||||||
|
selectionScope: row.selectionScope,
|
||||||
|
sourceId: row.sourceId,
|
||||||
|
timelineId: row.timelineId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSlot(row: SlotRow): StoredSlot {
|
||||||
|
return {
|
||||||
|
durationOverrideMs: row.durationOverrideMs,
|
||||||
|
eligibleFrom: row.eligibleFrom,
|
||||||
|
eligibleUntil: row.eligibleUntil,
|
||||||
|
enabled: row.enabled,
|
||||||
|
id: row.id,
|
||||||
|
itemId: row.itemId,
|
||||||
|
kind: row.kind === "pinned-item" ? "pinned-item" : "provider-source",
|
||||||
|
position: row.position,
|
||||||
|
presentation: {
|
||||||
|
backgroundColor: row.backgroundColor,
|
||||||
|
backgroundDarkened: row.backgroundDarkened,
|
||||||
|
backgroundMode:
|
||||||
|
row.backgroundMode === "blurred" ||
|
||||||
|
row.backgroundMode === "solid" ||
|
||||||
|
row.backgroundMode === "black"
|
||||||
|
? row.backgroundMode
|
||||||
|
: null,
|
||||||
|
blurPixels: row.blurPixels,
|
||||||
|
foregroundShadow: row.foregroundShadow,
|
||||||
|
},
|
||||||
|
providerId: row.providerId,
|
||||||
|
sourceId: row.sourceId,
|
||||||
|
timelineId: row.timelineId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotValues(slot: NewStoredSlot, position: number) {
|
||||||
|
return {
|
||||||
|
backgroundColor: slot.presentation.backgroundColor,
|
||||||
|
backgroundDarkened: slot.presentation.backgroundDarkened,
|
||||||
|
backgroundMode: slot.presentation.backgroundMode,
|
||||||
|
blurPixels: slot.presentation.blurPixels,
|
||||||
|
durationOverrideMs: slot.durationOverrideMs,
|
||||||
|
eligibleFrom: slot.eligibleFrom,
|
||||||
|
eligibleUntil: slot.eligibleUntil,
|
||||||
|
enabled: slot.enabled,
|
||||||
|
foregroundShadow: slot.presentation.foregroundShadow,
|
||||||
|
id: slot.id,
|
||||||
|
itemId: slot.itemId,
|
||||||
|
kind: slot.kind,
|
||||||
|
position,
|
||||||
|
providerId: slot.providerId,
|
||||||
|
sourceId: slot.sourceId,
|
||||||
|
timelineId: slot.timelineId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTimelineStore(database: ModuleDatabase): SignageTimelineStore {
|
||||||
|
/**
|
||||||
|
* Rewrites `position` for one timeline from an explicit order.
|
||||||
|
*
|
||||||
|
* Positions are written in two passes, through a temporary negative range, because the unique
|
||||||
|
* `(timeline_id, position)` index is checked per statement: moving a slot from 3 to 1 while
|
||||||
|
* another still holds 1 would collide. Negative positions are outside every real value, so the
|
||||||
|
* intermediate state cannot be mistaken for an order.
|
||||||
|
*/
|
||||||
|
async function renumber(
|
||||||
|
transaction: ModuleDatabase,
|
||||||
|
timelineId: string,
|
||||||
|
orderedSlotIds: readonly string[],
|
||||||
|
): Promise<void> {
|
||||||
|
for (const [index, slotId] of orderedSlotIds.entries()) {
|
||||||
|
await transaction
|
||||||
|
.update(timelineSlots)
|
||||||
|
.set({ position: -1 - index })
|
||||||
|
.where(eq(timelineSlots.id, slotId));
|
||||||
|
}
|
||||||
|
for (const [index, slotId] of orderedSlotIds.entries()) {
|
||||||
|
await transaction
|
||||||
|
.update(timelineSlots)
|
||||||
|
.set({ position: index, updatedAt: new Date() })
|
||||||
|
.where(eq(timelineSlots.id, slotId));
|
||||||
|
}
|
||||||
|
await bumpRevision(transaction, timelineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bumpRevision(transaction: ModuleDatabase, timelineId: string): Promise<void> {
|
||||||
|
await transaction
|
||||||
|
.update(timelines)
|
||||||
|
.set({ revision: sql`${timelines.revision} + 1`, updatedAt: new Date() })
|
||||||
|
.where(eq(timelines.id, timelineId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function orderedSlotIdsFor(
|
||||||
|
transaction: ModuleDatabase,
|
||||||
|
timelineId: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const rows = await transaction
|
||||||
|
.select({ id: timelineSlots.id, position: timelineSlots.position })
|
||||||
|
.from(timelineSlots)
|
||||||
|
.where(eq(timelineSlots.timelineId, timelineId))
|
||||||
|
.orderBy(asc(timelineSlots.position));
|
||||||
|
return rows.map((row) => row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function timelineIdForSlot(
|
||||||
|
transaction: ModuleDatabase,
|
||||||
|
slotId: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const rows = await transaction
|
||||||
|
.select({ timelineId: timelineSlots.timelineId })
|
||||||
|
.from(timelineSlots)
|
||||||
|
.where(eq(timelineSlots.id, slotId));
|
||||||
|
return rows[0]?.timelineId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async addSlot(slot: NewStoredSlot, position?: number): Promise<void> {
|
||||||
|
await database.transaction(async (transaction) => {
|
||||||
|
const existing = await orderedSlotIdsFor(transaction, slot.timelineId);
|
||||||
|
const index =
|
||||||
|
position === undefined
|
||||||
|
? existing.length
|
||||||
|
: Math.max(0, Math.min(position, existing.length));
|
||||||
|
await transaction.insert(timelineSlots).values(slotValues(slot, -1_000_000));
|
||||||
|
const next = [...existing.slice(0, index), slot.id, ...existing.slice(index)];
|
||||||
|
await renumber(transaction, slot.timelineId, next);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async addSource(source: NewStoredProviderSource): Promise<void> {
|
||||||
|
await database.insert(providerSources).values({
|
||||||
|
id: source.id,
|
||||||
|
label: source.label,
|
||||||
|
providerId: source.providerId,
|
||||||
|
selectionScope: source.selectionScope,
|
||||||
|
sourceId: source.sourceId,
|
||||||
|
timelineId: source.timelineId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTimeline(input: {
|
||||||
|
readonly timeline: NewStoredTimeline;
|
||||||
|
readonly sources: readonly NewStoredProviderSource[];
|
||||||
|
readonly slots: readonly NewStoredSlot[];
|
||||||
|
}): Promise<void> {
|
||||||
|
await database.transaction(async (transaction) => {
|
||||||
|
await transaction.insert(timelines).values({
|
||||||
|
description: input.timeline.description,
|
||||||
|
eligibleFrom: input.timeline.eligibleFrom,
|
||||||
|
eligibleUntil: input.timeline.eligibleUntil,
|
||||||
|
id: input.timeline.id,
|
||||||
|
name: input.timeline.name,
|
||||||
|
});
|
||||||
|
if (input.sources.length > 0) {
|
||||||
|
await transaction.insert(providerSources).values(
|
||||||
|
input.sources.map((source) => ({
|
||||||
|
id: source.id,
|
||||||
|
label: source.label,
|
||||||
|
providerId: source.providerId,
|
||||||
|
selectionScope: source.selectionScope,
|
||||||
|
sourceId: source.sourceId,
|
||||||
|
timelineId: source.timelineId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.slots.length > 0) {
|
||||||
|
await transaction
|
||||||
|
.insert(timelineSlots)
|
||||||
|
.values(input.slots.map((slot, index) => slotValues(slot, index)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteTimeline(timelineId: string): Promise<void> {
|
||||||
|
// Sources and slots cascade with the timeline; resolved selections do not, because history is
|
||||||
|
// a record of what a screen actually showed.
|
||||||
|
await database.delete(timelines).where(eq(timelines.id, timelineId));
|
||||||
|
},
|
||||||
|
|
||||||
|
async findTimeline(timelineId: string): Promise<StoredTimelineDetail | undefined> {
|
||||||
|
const timelineRows = await database
|
||||||
|
.select()
|
||||||
|
.from(timelines)
|
||||||
|
.where(eq(timelines.id, timelineId));
|
||||||
|
const row = timelineRows[0];
|
||||||
|
if (!row) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const [sourceRows, slotRows] = await Promise.all([
|
||||||
|
database
|
||||||
|
.select()
|
||||||
|
.from(providerSources)
|
||||||
|
.where(eq(providerSources.timelineId, timelineId))
|
||||||
|
.orderBy(asc(providerSources.createdAt)),
|
||||||
|
database
|
||||||
|
.select()
|
||||||
|
.from(timelineSlots)
|
||||||
|
.where(eq(timelineSlots.timelineId, timelineId))
|
||||||
|
.orderBy(asc(timelineSlots.position)),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
slots: slotRows.map(toSlot),
|
||||||
|
sources: sourceRows.map(toSource),
|
||||||
|
timeline: toTimeline(row),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async listTimelines(): Promise<readonly StoredTimeline[]> {
|
||||||
|
const rows = await database.select().from(timelines).orderBy(asc(timelines.name));
|
||||||
|
return rows.map(toTimeline);
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeSlot(slotId: string): Promise<void> {
|
||||||
|
await database.transaction(async (transaction) => {
|
||||||
|
const timelineId = await timelineIdForSlot(transaction, slotId);
|
||||||
|
if (timelineId === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await transaction.delete(timelineSlots).where(eq(timelineSlots.id, slotId));
|
||||||
|
await renumber(transaction, timelineId, await orderedSlotIdsFor(transaction, timelineId));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeSource(sourceId: string): Promise<void> {
|
||||||
|
await database.transaction(async (transaction) => {
|
||||||
|
const rows = await transaction
|
||||||
|
.select({ timelineId: providerSources.timelineId })
|
||||||
|
.from(providerSources)
|
||||||
|
.where(eq(providerSources.id, sourceId));
|
||||||
|
const timelineId = rows[0]?.timelineId;
|
||||||
|
if (timelineId === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Slots referencing the source cascade with it; the remainder is renumbered so the pattern
|
||||||
|
// an operator reads never has a hole in it.
|
||||||
|
await transaction.delete(providerSources).where(eq(providerSources.id, sourceId));
|
||||||
|
await renumber(transaction, timelineId, await orderedSlotIdsFor(transaction, timelineId));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async reorderSlots(timelineId: string, orderedSlotIds: readonly string[]): Promise<void> {
|
||||||
|
await database.transaction(async (transaction) => {
|
||||||
|
const current = await orderedSlotIdsFor(transaction, timelineId);
|
||||||
|
const given = new Set(orderedSlotIds);
|
||||||
|
// Exhaustive and exact: anything else could lose a slot, duplicate one, or leave a gap.
|
||||||
|
if (given.size !== orderedSlotIds.length || given.size !== current.length) {
|
||||||
|
throw new SignageStoreConflict("timeline.slotOrder");
|
||||||
|
}
|
||||||
|
for (const slotId of current) {
|
||||||
|
if (!given.has(slotId)) {
|
||||||
|
throw new SignageStoreConflict("timeline.slotOrder");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await renumber(transaction, timelineId, orderedSlotIds);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async timelineUsage(timelineId: string): Promise<StoredTimelineUsage> {
|
||||||
|
const rows = await database
|
||||||
|
.select({
|
||||||
|
activeTimelineId: playbackStates.activeTimelineId,
|
||||||
|
pendingTimelineId: playbackStates.pendingTimelineId,
|
||||||
|
scheduledTimelineId: playbackStates.scheduledTimelineId,
|
||||||
|
})
|
||||||
|
.from(playbackStates)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
eq(playbackStates.activeTimelineId, timelineId),
|
||||||
|
eq(playbackStates.pendingTimelineId, timelineId),
|
||||||
|
eq(playbackStates.scheduledTimelineId, timelineId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
activeGroups: rows.filter((row) => row.activeTimelineId === timelineId).length,
|
||||||
|
pendingGroups: rows.filter((row) => row.pendingTimelineId === timelineId).length,
|
||||||
|
scheduledGroups: rows.filter((row) => row.scheduledTimelineId === timelineId).length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSlot(slotId: string, changes: SlotChanges): Promise<void> {
|
||||||
|
await database.transaction(async (transaction) => {
|
||||||
|
const timelineId = await timelineIdForSlot(transaction, slotId);
|
||||||
|
if (timelineId === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const values: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
|
if (changes.enabled !== undefined) {
|
||||||
|
values.enabled = changes.enabled;
|
||||||
|
}
|
||||||
|
if (changes.durationOverrideMs !== undefined) {
|
||||||
|
values.durationOverrideMs = changes.durationOverrideMs;
|
||||||
|
}
|
||||||
|
if (changes.eligibleFrom !== undefined) {
|
||||||
|
values.eligibleFrom = changes.eligibleFrom;
|
||||||
|
}
|
||||||
|
if (changes.eligibleUntil !== undefined) {
|
||||||
|
values.eligibleUntil = changes.eligibleUntil;
|
||||||
|
}
|
||||||
|
if (changes.itemId !== undefined) {
|
||||||
|
values.itemId = changes.itemId;
|
||||||
|
}
|
||||||
|
const presentation = changes.presentation;
|
||||||
|
if (presentation) {
|
||||||
|
if (presentation.backgroundMode !== undefined) {
|
||||||
|
values.backgroundMode = presentation.backgroundMode;
|
||||||
|
}
|
||||||
|
if (presentation.backgroundColor !== undefined) {
|
||||||
|
values.backgroundColor = presentation.backgroundColor;
|
||||||
|
}
|
||||||
|
if (presentation.backgroundDarkened !== undefined) {
|
||||||
|
values.backgroundDarkened = presentation.backgroundDarkened;
|
||||||
|
}
|
||||||
|
if (presentation.blurPixels !== undefined) {
|
||||||
|
values.blurPixels = presentation.blurPixels;
|
||||||
|
}
|
||||||
|
if (presentation.foregroundShadow !== undefined) {
|
||||||
|
values.foregroundShadow = presentation.foregroundShadow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await transaction.update(timelineSlots).set(values).where(eq(timelineSlots.id, slotId));
|
||||||
|
await bumpRevision(transaction, timelineId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateTimeline(timelineId: string, changes: TimelineChanges): Promise<void> {
|
||||||
|
const values: Record<string, unknown> = {
|
||||||
|
revision: sql`${timelines.revision} + 1`,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (changes.name !== undefined) {
|
||||||
|
values.name = changes.name;
|
||||||
|
}
|
||||||
|
if (changes.description !== undefined) {
|
||||||
|
values.description = changes.description;
|
||||||
|
}
|
||||||
|
if (changes.eligibleFrom !== undefined) {
|
||||||
|
values.eligibleFrom = changes.eligibleFrom;
|
||||||
|
}
|
||||||
|
if (changes.eligibleUntil !== undefined) {
|
||||||
|
values.eligibleUntil = changes.eligibleUntil;
|
||||||
|
}
|
||||||
|
await database.update(timelines).set(values).where(eq(timelines.id, timelineId));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { ModuleDatabase } from "@ekklesia/module-sdk";
|
||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
import { viewers } from "../../schema/tables.js";
|
||||||
|
import type { NewStoredViewer, SignageViewerStore, StoredViewer, ViewerChanges } from "./types.js";
|
||||||
|
|
||||||
|
/** The viewer half of Signage's production store. Deliberately dull: heartbeat coalescing, health
|
||||||
|
* derivation, and every other decision live above it. */
|
||||||
|
|
||||||
|
function toViewer(row: typeof viewers.$inferSelect): StoredViewer {
|
||||||
|
return {
|
||||||
|
cachedAssetCount: row.cachedAssetCount,
|
||||||
|
channelId: row.channelId,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
deviceId: row.deviceId,
|
||||||
|
id: row.id,
|
||||||
|
lastError:
|
||||||
|
row.lastErrorCode === null || row.lastErrorAt === null
|
||||||
|
? null
|
||||||
|
: { at: row.lastErrorAt, code: row.lastErrorCode, detail: row.lastErrorDetail },
|
||||||
|
lastSeenAt: row.lastSeenAt,
|
||||||
|
name: row.name,
|
||||||
|
offlineReady: row.offlineReady,
|
||||||
|
reportedContributionId: row.reportedContributionId,
|
||||||
|
reportedPlaybackRevision: row.reportedPlaybackRevision,
|
||||||
|
reportedProviderId: row.reportedProviderId,
|
||||||
|
reportedSelectionId: row.reportedSelectionId,
|
||||||
|
reportedTimelineId: row.reportedTimelineId,
|
||||||
|
viewerKey: row.viewerKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createViewerStore(database: ModuleDatabase): SignageViewerStore {
|
||||||
|
return {
|
||||||
|
async deleteViewer(viewerId: string): Promise<void> {
|
||||||
|
await database.delete(viewers).where(eq(viewers.id, viewerId));
|
||||||
|
},
|
||||||
|
|
||||||
|
async findViewer(viewerId: string): Promise<StoredViewer | undefined> {
|
||||||
|
const rows = await database.select().from(viewers).where(eq(viewers.id, viewerId));
|
||||||
|
const row = rows[0];
|
||||||
|
return row ? toViewer(row) : undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
async findViewerByKey(viewerKey: string): Promise<StoredViewer | undefined> {
|
||||||
|
const rows = await database.select().from(viewers).where(eq(viewers.viewerKey, viewerKey));
|
||||||
|
const row = rows[0];
|
||||||
|
return row ? toViewer(row) : undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
async insertViewer(viewer: NewStoredViewer): Promise<void> {
|
||||||
|
await database.insert(viewers).values({
|
||||||
|
cachedAssetCount: viewer.cachedAssetCount,
|
||||||
|
channelId: viewer.channelId,
|
||||||
|
deviceId: viewer.deviceId,
|
||||||
|
id: viewer.id,
|
||||||
|
name: viewer.name,
|
||||||
|
offlineReady: viewer.offlineReady,
|
||||||
|
reportedContributionId: viewer.reportedContributionId,
|
||||||
|
reportedPlaybackRevision: viewer.reportedPlaybackRevision,
|
||||||
|
reportedProviderId: viewer.reportedProviderId,
|
||||||
|
reportedSelectionId: viewer.reportedSelectionId,
|
||||||
|
reportedTimelineId: viewer.reportedTimelineId,
|
||||||
|
viewerKey: viewer.viewerKey,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async listViewers(): Promise<readonly StoredViewer[]> {
|
||||||
|
const rows = await database.select().from(viewers).orderBy(asc(viewers.name));
|
||||||
|
return rows.map(toViewer);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateViewer(viewerId: string, changes: ViewerChanges): Promise<void> {
|
||||||
|
const values: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
|
for (const key of [
|
||||||
|
"cachedAssetCount",
|
||||||
|
"channelId",
|
||||||
|
"deviceId",
|
||||||
|
"lastSeenAt",
|
||||||
|
"name",
|
||||||
|
"offlineReady",
|
||||||
|
"reportedContributionId",
|
||||||
|
"reportedPlaybackRevision",
|
||||||
|
"reportedProviderId",
|
||||||
|
"reportedSelectionId",
|
||||||
|
"reportedTimelineId",
|
||||||
|
] as const) {
|
||||||
|
if (changes[key] !== undefined) {
|
||||||
|
values[key] = changes[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changes.lastError !== undefined) {
|
||||||
|
values.lastErrorAt = changes.lastError?.at ?? null;
|
||||||
|
values.lastErrorCode = changes.lastError?.code ?? null;
|
||||||
|
values.lastErrorDetail = changes.lastError?.detail ?? null;
|
||||||
|
}
|
||||||
|
await database.update(viewers).set(values).where(eq(viewers.id, viewerId));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { ModuleDatabase } from "@ekklesia/module-sdk";
|
||||||
|
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
imageAssets,
|
||||||
|
imageRenditions,
|
||||||
|
playbackStates,
|
||||||
|
timelineSlots,
|
||||||
|
} from "../../schema/tables.js";
|
||||||
|
import { SIGNAGE_NATIVE_PROVIDER_ID } from "../../shared/native-images.js";
|
||||||
|
import { createPlaybackStore } from "./drizzle-playback.js";
|
||||||
|
import { createTimelineStore } from "./drizzle-timelines.js";
|
||||||
|
import { createViewerStore } from "./drizzle-viewers.js";
|
||||||
|
import {
|
||||||
|
type NewStoredImage,
|
||||||
|
type SignageImageStore,
|
||||||
|
type SignageStore,
|
||||||
|
SignageStoreConflict,
|
||||||
|
type StoredImage,
|
||||||
|
type StoredImageReferences,
|
||||||
|
type StoredImageRendition,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The production implementation of Signage's data port, over the platform's `ModuleDatabase`.
|
||||||
|
*
|
||||||
|
* This file is deliberately dull: it maps rows and nothing else. Every decision — what to write,
|
||||||
|
* when a revision changes, what a no-op is — lives in the services above it, so that behaviour can
|
||||||
|
* be proven without PostgreSQL and this mapping can be proven against a live one.
|
||||||
|
*
|
||||||
|
* The facade is the only way Signage reaches PostgreSQL. Nothing here constructs a client, reads a
|
||||||
|
* connection string, or touches another module's table.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assetColumns = {
|
||||||
|
contentSha256: imageAssets.contentSha256,
|
||||||
|
createdAt: imageAssets.createdAt,
|
||||||
|
displayName: imageAssets.displayName,
|
||||||
|
encodedHeight: imageAssets.encodedHeight,
|
||||||
|
encodedWidth: imageAssets.encodedWidth,
|
||||||
|
expiresAt: imageAssets.expiresAt,
|
||||||
|
hasAlpha: imageAssets.hasAlpha,
|
||||||
|
height: imageAssets.height,
|
||||||
|
id: imageAssets.id,
|
||||||
|
orientation: imageAssets.orientation,
|
||||||
|
originalByteSize: imageAssets.originalByteSize,
|
||||||
|
originalContentType: imageAssets.originalContentType,
|
||||||
|
originalFilename: imageAssets.originalFilename,
|
||||||
|
originalObjectKey: imageAssets.originalObjectKey,
|
||||||
|
status: imageAssets.status,
|
||||||
|
width: imageAssets.width,
|
||||||
|
};
|
||||||
|
|
||||||
|
type AssetRow = {
|
||||||
|
[Key in keyof typeof assetColumns]: (typeof imageAssets.$inferSelect)[Key];
|
||||||
|
};
|
||||||
|
|
||||||
|
const renditionColumns = {
|
||||||
|
assetId: imageRenditions.assetId,
|
||||||
|
byteSize: imageRenditions.byteSize,
|
||||||
|
contentSha256: imageRenditions.contentSha256,
|
||||||
|
contentType: imageRenditions.contentType,
|
||||||
|
height: imageRenditions.height,
|
||||||
|
kind: imageRenditions.kind,
|
||||||
|
objectKey: imageRenditions.objectKey,
|
||||||
|
width: imageRenditions.width,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** PostgreSQL reports a uniqueness violation as SQLSTATE `23505`. The code is all that is read; the
|
||||||
|
* driver's message, detail, and constraint text are never surfaced. */
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === "object" && error !== null && (error as { code?: unknown }).code === "23505"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRendition(row: {
|
||||||
|
byteSize: number;
|
||||||
|
contentSha256: string;
|
||||||
|
contentType: string;
|
||||||
|
height: number;
|
||||||
|
kind: string;
|
||||||
|
objectKey: string;
|
||||||
|
width: number;
|
||||||
|
}): StoredImageRendition {
|
||||||
|
return {
|
||||||
|
byteSize: row.byteSize,
|
||||||
|
contentSha256: row.contentSha256,
|
||||||
|
contentType: row.contentType,
|
||||||
|
height: row.height,
|
||||||
|
kind: row.kind === "thumbnail" ? "thumbnail" : "display",
|
||||||
|
objectKey: row.objectKey,
|
||||||
|
width: row.width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toImage(row: AssetRow, renditions: readonly StoredImageRendition[]): StoredImage {
|
||||||
|
return {
|
||||||
|
contentSha256: row.contentSha256,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
displayName: row.displayName,
|
||||||
|
encodedHeight: row.encodedHeight,
|
||||||
|
encodedWidth: row.encodedWidth,
|
||||||
|
expiresAt: row.expiresAt,
|
||||||
|
hasAlpha: row.hasAlpha,
|
||||||
|
height: row.height,
|
||||||
|
id: row.id,
|
||||||
|
orientation: row.orientation,
|
||||||
|
originalByteSize: row.originalByteSize,
|
||||||
|
originalContentType: row.originalContentType,
|
||||||
|
originalFilename: row.originalFilename,
|
||||||
|
originalObjectKey: row.originalObjectKey,
|
||||||
|
renditions,
|
||||||
|
status: row.status === "ready" ? "ready" : row.status === "failed" ? "failed" : "pending",
|
||||||
|
width: row.width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createImageStore(database: ModuleDatabase): SignageImageStore {
|
||||||
|
async function renditionsFor(
|
||||||
|
assetIds: readonly string[],
|
||||||
|
): Promise<Map<string, StoredImageRendition[]>> {
|
||||||
|
const grouped = new Map<string, StoredImageRendition[]>();
|
||||||
|
if (assetIds.length === 0) {
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
const rows = await database
|
||||||
|
.select(renditionColumns)
|
||||||
|
.from(imageRenditions)
|
||||||
|
.where(inArray(imageRenditions.assetId, [...assetIds]));
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = grouped.get(row.assetId) ?? [];
|
||||||
|
list.push(toRendition(row));
|
||||||
|
grouped.set(row.assetId, list);
|
||||||
|
}
|
||||||
|
for (const list of grouped.values()) {
|
||||||
|
list.sort((left, right) => left.kind.localeCompare(right.kind));
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrate(rows: readonly AssetRow[]): Promise<StoredImage[]> {
|
||||||
|
const renditions = await renditionsFor(rows.map((row) => row.id));
|
||||||
|
return rows.map((row) => toImage(row, renditions.get(row.id) ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async countImageReferences(imageId: string): Promise<StoredImageReferences> {
|
||||||
|
const [slots, holds] = await Promise.all([
|
||||||
|
database
|
||||||
|
.select({ id: timelineSlots.id })
|
||||||
|
.from(timelineSlots)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(timelineSlots.providerId, SIGNAGE_NATIVE_PROVIDER_ID),
|
||||||
|
eq(timelineSlots.itemId, imageId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
database
|
||||||
|
.select({ groupId: playbackStates.groupId })
|
||||||
|
.from(playbackStates)
|
||||||
|
.where(eq(playbackStates.staticAssetId, imageId)),
|
||||||
|
]);
|
||||||
|
return { slots: slots.length, staticHolds: holds.length };
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteImage(imageId: string): Promise<void> {
|
||||||
|
// Renditions cascade with the asset, so one delete is the whole removal.
|
||||||
|
await database.delete(imageAssets).where(eq(imageAssets.id, imageId));
|
||||||
|
},
|
||||||
|
|
||||||
|
async findImage(imageId: string): Promise<StoredImage | undefined> {
|
||||||
|
const rows = (await database
|
||||||
|
.select(assetColumns)
|
||||||
|
.from(imageAssets)
|
||||||
|
.where(eq(imageAssets.id, imageId))) as AssetRow[];
|
||||||
|
return (await hydrate(rows))[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
async findReadyImageByContentHash(contentSha256: string): Promise<StoredImage | undefined> {
|
||||||
|
const rows = (await database
|
||||||
|
.select(assetColumns)
|
||||||
|
.from(imageAssets)
|
||||||
|
.where(
|
||||||
|
and(eq(imageAssets.contentSha256, contentSha256), eq(imageAssets.status, "ready")),
|
||||||
|
)) as AssetRow[];
|
||||||
|
return (await hydrate(rows))[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
async insertImage(image: NewStoredImage): Promise<void> {
|
||||||
|
try {
|
||||||
|
await database.transaction(async (transaction) => {
|
||||||
|
await transaction.insert(imageAssets).values({
|
||||||
|
contentSha256: image.contentSha256,
|
||||||
|
displayName: image.displayName,
|
||||||
|
encodedHeight: image.encodedHeight,
|
||||||
|
encodedWidth: image.encodedWidth,
|
||||||
|
expiresAt: image.expiresAt,
|
||||||
|
hasAlpha: image.hasAlpha,
|
||||||
|
height: image.height,
|
||||||
|
id: image.id,
|
||||||
|
orientation: image.orientation,
|
||||||
|
originalByteSize: image.originalByteSize,
|
||||||
|
originalContentType: image.originalContentType,
|
||||||
|
originalFilename: image.originalFilename,
|
||||||
|
originalObjectKey: image.originalObjectKey,
|
||||||
|
status: image.status,
|
||||||
|
width: image.width,
|
||||||
|
});
|
||||||
|
await transaction.insert(imageRenditions).values(
|
||||||
|
image.renditions.map((rendition) => ({
|
||||||
|
assetId: image.id,
|
||||||
|
byteSize: rendition.byteSize,
|
||||||
|
contentSha256: rendition.contentSha256,
|
||||||
|
contentType: rendition.contentType,
|
||||||
|
height: rendition.height,
|
||||||
|
id: randomUUID(),
|
||||||
|
kind: rendition.kind,
|
||||||
|
objectKey: rendition.objectKey,
|
||||||
|
width: rendition.width,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new SignageStoreConflict("image.contentSha256");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async listImages(): Promise<readonly StoredImage[]> {
|
||||||
|
const rows = (await database
|
||||||
|
.select(assetColumns)
|
||||||
|
.from(imageAssets)
|
||||||
|
.orderBy(desc(imageAssets.createdAt))) as AssetRow[];
|
||||||
|
return hydrate(rows);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateImage(
|
||||||
|
imageId: string,
|
||||||
|
changes: { readonly displayName?: string; readonly expiresAt?: Date | null },
|
||||||
|
): Promise<void> {
|
||||||
|
const values: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
|
if (changes.displayName !== undefined) {
|
||||||
|
values.displayName = changes.displayName;
|
||||||
|
}
|
||||||
|
if (changes.expiresAt !== undefined) {
|
||||||
|
values.expiresAt = changes.expiresAt;
|
||||||
|
}
|
||||||
|
await database.update(imageAssets).set(values).where(eq(imageAssets.id, imageId));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signage's complete production store: the image half above, plus the timeline half beside it.
|
||||||
|
*
|
||||||
|
* Composed rather than written as one object because the two halves share nothing but the database
|
||||||
|
* handle, and keeping them separate is what lets each be read on its own.
|
||||||
|
*/
|
||||||
|
export function createSignageStore(database: ModuleDatabase): SignageStore {
|
||||||
|
return {
|
||||||
|
...createImageStore(database),
|
||||||
|
...createTimelineStore(database),
|
||||||
|
...createPlaybackStore(database),
|
||||||
|
...createViewerStore(database),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
/**
|
||||||
|
* Signage's narrow data port.
|
||||||
|
*
|
||||||
|
* Every piece of Signage's behaviour — ingest, timeline editing, provider resolution, authoritative
|
||||||
|
* playback, viewer health — is written against this interface rather than against Drizzle directly.
|
||||||
|
* There are two implementations: the production one over the platform's `ModuleDatabase`
|
||||||
|
* (`./drizzle.ts`), and an in-memory one used by tests.
|
||||||
|
*
|
||||||
|
* This is the same discipline Core's own migration runner follows, and it exists for the same
|
||||||
|
* reason: the behaviour worth proving is *what Signage decides*, and proving that against a real
|
||||||
|
* PostgreSQL for every case would make the interesting tests slow, flaky, and rare. The Drizzle
|
||||||
|
* implementation is exercised separately, against a live disposable database, where what is being
|
||||||
|
* proven is the mapping rather than the decision.
|
||||||
|
*
|
||||||
|
* The port is deliberately **narrow and domain-shaped**. It exposes the operations Signage performs,
|
||||||
|
* not a query language: there is no way to express an arbitrary join, and no way for a caller to
|
||||||
|
* reach a table this file does not name.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface StoredImageRendition {
|
||||||
|
readonly kind: "display" | "thumbnail";
|
||||||
|
readonly objectKey: string;
|
||||||
|
readonly contentType: string;
|
||||||
|
readonly contentSha256: string;
|
||||||
|
readonly byteSize: number;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredImage {
|
||||||
|
readonly id: string;
|
||||||
|
readonly contentSha256: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly originalFilename: string;
|
||||||
|
readonly originalObjectKey: string;
|
||||||
|
readonly originalContentType: string;
|
||||||
|
readonly originalByteSize: number;
|
||||||
|
/** How the original should appear once its own orientation is applied. */
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
readonly encodedWidth: number;
|
||||||
|
readonly encodedHeight: number;
|
||||||
|
readonly orientation: number | null;
|
||||||
|
readonly hasAlpha: boolean;
|
||||||
|
readonly status: "pending" | "ready" | "failed";
|
||||||
|
/** Null is permanent. */
|
||||||
|
readonly expiresAt: Date | null;
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly renditions: readonly StoredImageRendition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewStoredImage extends Omit<StoredImage, "createdAt" | "status"> {
|
||||||
|
readonly status: "ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredImageReferences {
|
||||||
|
/** Timeline slots pinned to this image. */
|
||||||
|
readonly slots: number;
|
||||||
|
/** Channel groups currently holding this image as a static hold. */
|
||||||
|
readonly staticHolds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignageImageStore {
|
||||||
|
listImages(): Promise<readonly StoredImage[]>;
|
||||||
|
findImage(imageId: string): Promise<StoredImage | undefined>;
|
||||||
|
/** Content-addressed lookup, restricted to `ready` assets: an asset whose ingest failed must not
|
||||||
|
* make a retry of the same bytes a no-op. */
|
||||||
|
findReadyImageByContentHash(contentSha256: string): Promise<StoredImage | undefined>;
|
||||||
|
/**
|
||||||
|
* Inserts the asset and every rendition **atomically**. A partially inserted asset would be
|
||||||
|
* selectable by playback with renditions that do not exist.
|
||||||
|
*
|
||||||
|
* Rejects with {@link SignageStoreConflict} when an asset with the same content hash already
|
||||||
|
* exists, so a race between two identical uploads resolves to one asset rather than to a
|
||||||
|
* corrupted pair.
|
||||||
|
*/
|
||||||
|
insertImage(image: NewStoredImage): Promise<void>;
|
||||||
|
/** Applies only the fields present. An exact no-op is the caller's to detect, not the store's. */
|
||||||
|
updateImage(
|
||||||
|
imageId: string,
|
||||||
|
changes: { readonly displayName?: string; readonly expiresAt?: Date | null },
|
||||||
|
): Promise<void>;
|
||||||
|
deleteImage(imageId: string): Promise<void>;
|
||||||
|
countImageReferences(imageId: string): Promise<StoredImageReferences>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown by a store implementation when a uniqueness guarantee the database owns was violated.
|
||||||
|
* Carries no driver message, no SQL, and no constraint name — only which guarantee it was. */
|
||||||
|
export class SignageStoreConflict extends Error {
|
||||||
|
readonly guarantee: string;
|
||||||
|
|
||||||
|
constructor(guarantee: string) {
|
||||||
|
super("A Signage uniqueness guarantee was violated.");
|
||||||
|
this.name = "SignageStoreConflict";
|
||||||
|
this.guarantee = guarantee;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Timelines, provider sources, and slots.
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface StoredTimeline {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string | null;
|
||||||
|
/** Half-open `[eligibleFrom, eligibleUntil)`. */
|
||||||
|
readonly eligibleFrom: Date | null;
|
||||||
|
readonly eligibleUntil: Date | null;
|
||||||
|
/** Increments only on a semantic change, so a client can compare and refetch. */
|
||||||
|
readonly revision: number;
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredProviderSource {
|
||||||
|
readonly id: string;
|
||||||
|
readonly timelineId: string;
|
||||||
|
/** Another module's opaque identity. Never a foreign key. */
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly sourceId: string;
|
||||||
|
readonly label: string;
|
||||||
|
/** Stable and opaque. Every slot referencing this row passes it to `resolveNext`, which is what
|
||||||
|
* makes repeated slots one rotation rather than several (ADR 0003). */
|
||||||
|
readonly selectionScope: string;
|
||||||
|
readonly createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StoredSlotKind = "provider-source" | "pinned-item";
|
||||||
|
|
||||||
|
/** Native-image presentation overrides. Every field is `null` for "take the installation default",
|
||||||
|
* and the whole group is meaningless for a slot whose content comes from another module — an
|
||||||
|
* external provider owns its rendering completely. */
|
||||||
|
export interface StoredSlotPresentation {
|
||||||
|
readonly backgroundMode: "blurred" | "solid" | "black" | null;
|
||||||
|
readonly backgroundColor: string | null;
|
||||||
|
readonly backgroundDarkened: boolean | null;
|
||||||
|
readonly blurPixels: number | null;
|
||||||
|
readonly foregroundShadow: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredSlot {
|
||||||
|
readonly id: string;
|
||||||
|
readonly timelineId: string;
|
||||||
|
/** Unique within the timeline and contiguous from zero. */
|
||||||
|
readonly position: number;
|
||||||
|
readonly kind: StoredSlotKind;
|
||||||
|
/** Set for a `provider-source` slot. */
|
||||||
|
readonly sourceId: string | null;
|
||||||
|
/** Set for a `pinned-item` slot. Both opaque. */
|
||||||
|
readonly providerId: string | null;
|
||||||
|
readonly itemId: string | null;
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly durationOverrideMs: number | null;
|
||||||
|
readonly eligibleFrom: Date | null;
|
||||||
|
readonly eligibleUntil: Date | null;
|
||||||
|
readonly presentation: StoredSlotPresentation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A timeline and everything in it, in one read. Slots are ordered by position. */
|
||||||
|
export interface StoredTimelineDetail {
|
||||||
|
readonly timeline: StoredTimeline;
|
||||||
|
readonly sources: readonly StoredProviderSource[];
|
||||||
|
readonly slots: readonly StoredSlot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NewStoredTimeline = Omit<StoredTimeline, "createdAt" | "revision" | "updatedAt">;
|
||||||
|
export type NewStoredProviderSource = Omit<StoredProviderSource, "createdAt">;
|
||||||
|
/** Position is assigned by the store, which is what keeps positions contiguous without every caller
|
||||||
|
* having to know the current length. */
|
||||||
|
export type NewStoredSlot = Omit<StoredSlot, "position">;
|
||||||
|
|
||||||
|
export interface TimelineChanges {
|
||||||
|
readonly name?: string;
|
||||||
|
readonly description?: string | null;
|
||||||
|
readonly eligibleFrom?: Date | null;
|
||||||
|
readonly eligibleUntil?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlotChanges {
|
||||||
|
readonly enabled?: boolean;
|
||||||
|
readonly durationOverrideMs?: number | null;
|
||||||
|
readonly eligibleFrom?: Date | null;
|
||||||
|
readonly eligibleUntil?: Date | null;
|
||||||
|
readonly presentation?: Partial<StoredSlotPresentation>;
|
||||||
|
readonly itemId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a timeline is currently referenced by live playback. Reported so a delete can refuse with
|
||||||
|
* something an operator can act on rather than cascading silently. */
|
||||||
|
export interface StoredTimelineUsage {
|
||||||
|
readonly activeGroups: number;
|
||||||
|
readonly pendingGroups: number;
|
||||||
|
readonly scheduledGroups: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignageTimelineStore {
|
||||||
|
listTimelines(): Promise<readonly StoredTimeline[]>;
|
||||||
|
findTimeline(timelineId: string): Promise<StoredTimelineDetail | undefined>;
|
||||||
|
/**
|
||||||
|
* Creates a timeline together with every source and slot it starts with, **atomically**.
|
||||||
|
*
|
||||||
|
* One operation rather than three, because that is what makes duplication safe: a copy that
|
||||||
|
* committed its timeline and then failed on its slots would be a half-copy an operator has to
|
||||||
|
* find and clean up. Slot positions are assigned from the order given.
|
||||||
|
*/
|
||||||
|
createTimeline(input: {
|
||||||
|
readonly timeline: NewStoredTimeline;
|
||||||
|
readonly sources: readonly NewStoredProviderSource[];
|
||||||
|
readonly slots: readonly NewStoredSlot[];
|
||||||
|
}): Promise<void>;
|
||||||
|
/** Applies only the fields present, and bumps the revision. An exact no-op is the caller's to
|
||||||
|
* detect: the store does not guess what "changed" means. */
|
||||||
|
updateTimeline(timelineId: string, changes: TimelineChanges): Promise<void>;
|
||||||
|
deleteTimeline(timelineId: string): Promise<void>;
|
||||||
|
timelineUsage(timelineId: string): Promise<StoredTimelineUsage>;
|
||||||
|
|
||||||
|
addSource(source: NewStoredProviderSource): Promise<void>;
|
||||||
|
/** Removes the source and every slot that referenced it, then renumbers what remains. */
|
||||||
|
removeSource(sourceId: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Appends a slot at the end, or inserts it at `position` and shifts the rest. */
|
||||||
|
addSlot(slot: NewStoredSlot, position?: number): Promise<void>;
|
||||||
|
updateSlot(slotId: string, changes: SlotChanges): Promise<void>;
|
||||||
|
/** Removes the slot and renumbers the remainder so positions stay contiguous. */
|
||||||
|
removeSlot(slotId: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Rewrites the whole order.
|
||||||
|
*
|
||||||
|
* All-or-nothing and exhaustive: the given ids must be exactly the timeline's current slots, so a
|
||||||
|
* reorder can never lose a slot, duplicate one, or leave a gap. Rejects with
|
||||||
|
* {@link SignageStoreConflict} otherwise.
|
||||||
|
*/
|
||||||
|
reorderSlots(timelineId: string, orderedSlotIds: readonly string[]): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The complete port. It grows one section at a time as Signage's behaviour does. */
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Channel groups, channels, playback, and resolved selections.
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface StoredChannel {
|
||||||
|
readonly id: string;
|
||||||
|
readonly groupId: string;
|
||||||
|
/** The audience key handed to a provider. The base channel's is `default`. */
|
||||||
|
readonly key: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredChannelGroup {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly revision: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StoredTransport = "idle" | "playing" | "paused" | "static";
|
||||||
|
|
||||||
|
/** One channel group's authoritative clock. Every instant is absolute (ADR 0005). */
|
||||||
|
export interface StoredPlaybackState {
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly activeTimelineId: string | null;
|
||||||
|
readonly pendingTimelineId: string | null;
|
||||||
|
readonly pendingRequestedAt: Date | null;
|
||||||
|
readonly scheduledTimelineId: string | null;
|
||||||
|
readonly scheduledActivationAt: Date | null;
|
||||||
|
readonly currentSelectionId: string | null;
|
||||||
|
readonly currentSlotId: string | null;
|
||||||
|
readonly slotStartedAt: Date | null;
|
||||||
|
readonly slotDeadlineAt: Date | null;
|
||||||
|
readonly pausedRemainingMs: number | null;
|
||||||
|
readonly paused: boolean;
|
||||||
|
readonly blackout: boolean;
|
||||||
|
readonly staticAssetId: string | null;
|
||||||
|
readonly revision: number;
|
||||||
|
readonly nextSequence: number;
|
||||||
|
readonly updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredChannelGroupDetail {
|
||||||
|
readonly group: StoredChannelGroup;
|
||||||
|
readonly channels: readonly StoredChannel[];
|
||||||
|
readonly playback: StoredPlaybackState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlaybackChanges = Partial<
|
||||||
|
Omit<StoredPlaybackState, "groupId" | "revision" | "updatedAt">
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface StoredSelectionVariant {
|
||||||
|
readonly audienceKey: string;
|
||||||
|
readonly isDefault: boolean;
|
||||||
|
readonly payload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One logical selection, resolved once for a whole channel group. */
|
||||||
|
export interface StoredSelection {
|
||||||
|
readonly id: string;
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly sequence: number;
|
||||||
|
readonly timelineId: string | null;
|
||||||
|
readonly slotId: string | null;
|
||||||
|
readonly selectionScope: string | null;
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly itemId: string;
|
||||||
|
readonly itemRevision: string;
|
||||||
|
readonly rendererContributionId: string | null;
|
||||||
|
readonly rendererContributionVersion: string | null;
|
||||||
|
readonly presentation: unknown;
|
||||||
|
readonly durationMs: number;
|
||||||
|
readonly resolvedAt: Date;
|
||||||
|
readonly startedAt: Date | null;
|
||||||
|
readonly deadlineAt: Date | null;
|
||||||
|
readonly availableUntil: Date | null;
|
||||||
|
readonly withdrawn: boolean;
|
||||||
|
readonly stale: boolean;
|
||||||
|
/** Increments when a refresh changes payloads without changing which item this is — the change
|
||||||
|
* that updates a mounted renderer in place rather than restarting its slot. */
|
||||||
|
readonly revision: number;
|
||||||
|
readonly variants: readonly StoredSelectionVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NewStoredSelection = Omit<StoredSelection, "resolvedAt" | "revision">;
|
||||||
|
|
||||||
|
export interface SelectionChanges {
|
||||||
|
readonly startedAt?: Date | null;
|
||||||
|
readonly deadlineAt?: Date | null;
|
||||||
|
readonly availableUntil?: Date | null;
|
||||||
|
readonly withdrawn?: boolean;
|
||||||
|
readonly stale?: boolean;
|
||||||
|
readonly durationMs?: number;
|
||||||
|
readonly itemRevision?: string;
|
||||||
|
readonly presentation?: unknown;
|
||||||
|
/** Replaces the whole variant set. A partial update would leave an audience rendering a payload
|
||||||
|
* from a revision that no longer exists. */
|
||||||
|
readonly variants?: readonly StoredSelectionVariant[];
|
||||||
|
/** Increments the selection's revision, which is what tells a mounted renderer to update. */
|
||||||
|
readonly bumpRevision?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignagePlaybackStore {
|
||||||
|
listChannelGroups(): Promise<readonly StoredChannelGroupDetail[]>;
|
||||||
|
findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined>;
|
||||||
|
/** Creates a group, its playback row, and its one default channel, atomically. A group without a
|
||||||
|
* default channel would have nowhere for a viewer to land. */
|
||||||
|
createChannelGroup(input: {
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly channelId: string;
|
||||||
|
readonly channelName: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
deleteChannelGroup(groupId: string): Promise<void>;
|
||||||
|
addChannel(channel: StoredChannel): Promise<void>;
|
||||||
|
/** Refuses to remove a group's default channel: something must remain for a viewer to join. */
|
||||||
|
removeChannel(channelId: string): Promise<void>;
|
||||||
|
/** Applies only the fields present, and bumps the group's playback revision. */
|
||||||
|
updatePlayback(
|
||||||
|
groupId: string,
|
||||||
|
changes: PlaybackChanges,
|
||||||
|
options?: { readonly bumpRevision?: boolean },
|
||||||
|
): Promise<void>;
|
||||||
|
/** Takes the next sequence number for this group and increments it, atomically. */
|
||||||
|
takeSelectionSequence(groupId: string): Promise<number>;
|
||||||
|
|
||||||
|
insertSelection(selection: NewStoredSelection): Promise<void>;
|
||||||
|
findSelection(selectionId: string): Promise<StoredSelection | undefined>;
|
||||||
|
updateSelection(selectionId: string, changes: SelectionChanges): Promise<void>;
|
||||||
|
/** Most recent first, bounded. This is the history `previous` walks. */
|
||||||
|
listSelections(groupId: string, limit: number): Promise<readonly StoredSelection[]>;
|
||||||
|
/** The most recent selection for one selection scope, so a provider's rotation can be re-seeded
|
||||||
|
* from what actually played rather than from the beginning. */
|
||||||
|
findLatestSelectionForScope(
|
||||||
|
groupId: string,
|
||||||
|
selectionScope: string,
|
||||||
|
): Promise<StoredSelection | undefined>;
|
||||||
|
/** Keeps the newest `limit` selections for a group and removes the rest. */
|
||||||
|
pruneSelections(groupId: string, limit: number): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The complete port. It grows one section at a time as Signage's behaviour does. */
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
// Viewers.
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A sanitized report from a display. Never an exception, a stack, a URL, or a payload. */
|
||||||
|
export interface StoredViewerError {
|
||||||
|
readonly code: string;
|
||||||
|
readonly detail: string | null;
|
||||||
|
readonly at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredViewer {
|
||||||
|
readonly id: string;
|
||||||
|
/** The persistent identity a display keeps in its own IndexedDB, so a reconnect is recognized as
|
||||||
|
* the same screen rather than a new one. */
|
||||||
|
readonly viewerKey: string;
|
||||||
|
readonly name: string;
|
||||||
|
/** The **safe** Core device identifier a paired display's principal carries — an opaque
|
||||||
|
* correlation value. Signage never sees, stores, or handles a device credential. */
|
||||||
|
readonly deviceId: string | null;
|
||||||
|
readonly channelId: string | null;
|
||||||
|
readonly lastSeenAt: Date | null;
|
||||||
|
readonly reportedTimelineId: string | null;
|
||||||
|
readonly reportedSelectionId: string | null;
|
||||||
|
readonly reportedProviderId: string | null;
|
||||||
|
readonly reportedContributionId: string | null;
|
||||||
|
readonly reportedPlaybackRevision: number | null;
|
||||||
|
readonly offlineReady: boolean;
|
||||||
|
readonly cachedAssetCount: number;
|
||||||
|
readonly lastError: StoredViewerError | null;
|
||||||
|
readonly createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NewStoredViewer = Omit<StoredViewer, "createdAt" | "lastSeenAt" | "lastError">;
|
||||||
|
|
||||||
|
export interface ViewerChanges {
|
||||||
|
readonly name?: string;
|
||||||
|
readonly channelId?: string | null;
|
||||||
|
readonly deviceId?: string | null;
|
||||||
|
readonly lastSeenAt?: Date;
|
||||||
|
readonly reportedTimelineId?: string | null;
|
||||||
|
readonly reportedSelectionId?: string | null;
|
||||||
|
readonly reportedProviderId?: string | null;
|
||||||
|
readonly reportedContributionId?: string | null;
|
||||||
|
readonly reportedPlaybackRevision?: number | null;
|
||||||
|
readonly offlineReady?: boolean;
|
||||||
|
readonly cachedAssetCount?: number;
|
||||||
|
readonly lastError?: StoredViewerError | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignageViewerStore {
|
||||||
|
listViewers(): Promise<readonly StoredViewer[]>;
|
||||||
|
findViewer(viewerId: string): Promise<StoredViewer | undefined>;
|
||||||
|
findViewerByKey(viewerKey: string): Promise<StoredViewer | undefined>;
|
||||||
|
insertViewer(viewer: NewStoredViewer): Promise<void>;
|
||||||
|
updateViewer(viewerId: string, changes: ViewerChanges): Promise<void>;
|
||||||
|
deleteViewer(viewerId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The complete port. It grows one section at a time as Signage's behaviour does. */
|
||||||
|
export type SignageStore = SignageImageStore &
|
||||||
|
SignagePlaybackStore &
|
||||||
|
SignageTimelineStore &
|
||||||
|
SignageViewerStore;
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { SIGNAGE_HEX_COLOR } from "../../shared/settings.js";
|
||||||
|
import { SignageError } from "../errors.js";
|
||||||
|
import {
|
||||||
|
type NewStoredProviderSource,
|
||||||
|
type NewStoredSlot,
|
||||||
|
type SignageStore,
|
||||||
|
SignageStoreConflict,
|
||||||
|
type SlotChanges,
|
||||||
|
type StoredSlotPresentation,
|
||||||
|
type StoredTimelineDetail,
|
||||||
|
type TimelineChanges,
|
||||||
|
} from "../store/types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timeline, source, and slot management.
|
||||||
|
*
|
||||||
|
* The rules that matter are here rather than in the routes, because they are decisions rather than
|
||||||
|
* transport: what a duplicate copies and what it deliberately does not, when an edit is a no-op,
|
||||||
|
* and what a delete refuses.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Bounds an operator can actually hit. Generous for a real church, small enough that a timeline
|
||||||
|
* stays something a person can read. */
|
||||||
|
export const SIGNAGE_TIMELINE_LIMITS = Object.freeze({
|
||||||
|
maxNameLength: 120,
|
||||||
|
maxSlotDurationMs: 3_600_000,
|
||||||
|
maxSlotsPerTimeline: 200,
|
||||||
|
maxSourcesPerTimeline: 50,
|
||||||
|
minSlotDurationMs: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface TimelineIdentitySource {
|
||||||
|
/** Overridable so a test can pin identity. Production takes fresh UUIDs. */
|
||||||
|
readonly newId?: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextId(input: TimelineIdentitySource): string {
|
||||||
|
return (input.newId ?? randomUUID)();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireInterval(from: Date | null | undefined, until: Date | null | undefined): void {
|
||||||
|
if (from && until && from.getTime() >= until.getTime()) {
|
||||||
|
// Half-open `[from, until)`: an empty or inverted window is a mistake an operator wants to hear
|
||||||
|
// about now rather than discover as a slot that never appears.
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "eligibility" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePresentation(
|
||||||
|
input: Partial<StoredSlotPresentation> | undefined,
|
||||||
|
): StoredSlotPresentation {
|
||||||
|
const presentation: StoredSlotPresentation = {
|
||||||
|
backgroundColor: input?.backgroundColor ?? null,
|
||||||
|
backgroundDarkened: input?.backgroundDarkened ?? null,
|
||||||
|
backgroundMode: input?.backgroundMode ?? null,
|
||||||
|
blurPixels: input?.blurPixels ?? null,
|
||||||
|
foregroundShadow: input?.foregroundShadow ?? null,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
presentation.backgroundColor !== null &&
|
||||||
|
!SIGNAGE_HEX_COLOR.test(presentation.backgroundColor)
|
||||||
|
) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "backgroundColor" });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
presentation.blurPixels !== null &&
|
||||||
|
(!Number.isSafeInteger(presentation.blurPixels) ||
|
||||||
|
presentation.blurPixels < 0 ||
|
||||||
|
presentation.blurPixels > 120)
|
||||||
|
) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "blurPixels" });
|
||||||
|
}
|
||||||
|
return presentation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTimelineInput extends TimelineIdentitySource {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description?: string | null;
|
||||||
|
readonly eligibleFrom?: Date | null;
|
||||||
|
readonly eligibleUntil?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTimeline(input: CreateTimelineInput): Promise<StoredTimelineDetail> {
|
||||||
|
requireInterval(input.eligibleFrom, input.eligibleUntil);
|
||||||
|
const id = nextId(input);
|
||||||
|
await input.store.createTimeline({
|
||||||
|
slots: [],
|
||||||
|
sources: [],
|
||||||
|
timeline: {
|
||||||
|
description: input.description ?? null,
|
||||||
|
eligibleFrom: input.eligibleFrom ?? null,
|
||||||
|
eligibleUntil: input.eligibleUntil ?? null,
|
||||||
|
id,
|
||||||
|
name: input.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return requireTimeline(input.store, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireTimeline(
|
||||||
|
store: SignageStore,
|
||||||
|
timelineId: string,
|
||||||
|
): Promise<StoredTimelineDetail> {
|
||||||
|
const detail = await store.findTimeline(timelineId);
|
||||||
|
if (!detail) {
|
||||||
|
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicates a timeline, and gives every copied source a **new selection scope**.
|
||||||
|
*
|
||||||
|
* That is the one non-obvious decision here, and it is deliberate. A duplicate is a separate
|
||||||
|
* program: an operator copying "Sunday Morning" to make "Sunday Evening" expects the two to rotate
|
||||||
|
* independently, and a shared scope would make the evening loop continue wherever the morning one
|
||||||
|
* happened to be. Slot ids are new for the same reason — history belongs to the timeline that
|
||||||
|
* actually played.
|
||||||
|
*/
|
||||||
|
export async function duplicateTimeline(input: {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly timelineId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly newId?: () => string;
|
||||||
|
}): Promise<StoredTimelineDetail> {
|
||||||
|
const original = await requireTimeline(input.store, input.timelineId);
|
||||||
|
const newId = input.newId ?? randomUUID;
|
||||||
|
const timelineId = newId();
|
||||||
|
const sourceIds = new Map<string, string>();
|
||||||
|
|
||||||
|
const sources: NewStoredProviderSource[] = original.sources.map((source) => {
|
||||||
|
const id = newId();
|
||||||
|
sourceIds.set(source.id, id);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: source.label,
|
||||||
|
providerId: source.providerId,
|
||||||
|
// A new rotation, not a continuation of the original's.
|
||||||
|
selectionScope: newId(),
|
||||||
|
sourceId: source.sourceId,
|
||||||
|
timelineId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const slots: NewStoredSlot[] = original.slots.map((slot) => ({
|
||||||
|
durationOverrideMs: slot.durationOverrideMs,
|
||||||
|
eligibleFrom: slot.eligibleFrom,
|
||||||
|
eligibleUntil: slot.eligibleUntil,
|
||||||
|
enabled: slot.enabled,
|
||||||
|
id: newId(),
|
||||||
|
itemId: slot.itemId,
|
||||||
|
kind: slot.kind,
|
||||||
|
presentation: slot.presentation,
|
||||||
|
providerId: slot.providerId,
|
||||||
|
sourceId: slot.sourceId === null ? null : (sourceIds.get(slot.sourceId) ?? null),
|
||||||
|
timelineId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await input.store.createTimeline({
|
||||||
|
slots,
|
||||||
|
sources,
|
||||||
|
timeline: {
|
||||||
|
description: original.timeline.description,
|
||||||
|
eligibleFrom: original.timeline.eligibleFrom,
|
||||||
|
eligibleUntil: original.timeline.eligibleUntil,
|
||||||
|
id: timelineId,
|
||||||
|
name: input.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return requireTimeline(input.store, timelineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTimeline(input: {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly timelineId: string;
|
||||||
|
readonly changes: TimelineChanges;
|
||||||
|
}): Promise<StoredTimelineDetail> {
|
||||||
|
const current = await requireTimeline(input.store, input.timelineId);
|
||||||
|
const changes: TimelineChanges = {};
|
||||||
|
const timeline = current.timeline;
|
||||||
|
|
||||||
|
if (input.changes.name !== undefined && input.changes.name !== timeline.name) {
|
||||||
|
Object.assign(changes, { name: input.changes.name });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.changes.description !== undefined &&
|
||||||
|
input.changes.description !== timeline.description
|
||||||
|
) {
|
||||||
|
Object.assign(changes, { description: input.changes.description });
|
||||||
|
}
|
||||||
|
for (const field of ["eligibleFrom", "eligibleUntil"] as const) {
|
||||||
|
const next = input.changes[field];
|
||||||
|
if (next !== undefined && (next?.getTime() ?? null) !== (timeline[field]?.getTime() ?? null)) {
|
||||||
|
Object.assign(changes, { [field]: next });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An exact no-op writes nothing and — crucially — does not bump the revision, so a client polling
|
||||||
|
// on revision is not woken by an operator saving a form they did not change.
|
||||||
|
if (Object.keys(changes).length === 0) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
requireInterval(
|
||||||
|
changes.eligibleFrom === undefined ? timeline.eligibleFrom : changes.eligibleFrom,
|
||||||
|
changes.eligibleUntil === undefined ? timeline.eligibleUntil : changes.eligibleUntil,
|
||||||
|
);
|
||||||
|
await input.store.updateTimeline(input.timelineId, changes);
|
||||||
|
return requireTimeline(input.store, input.timelineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a timeline, and refuses while playback still points at it.
|
||||||
|
*
|
||||||
|
* A timeline deleted out from under a running channel group would blank a wall mid-service. Refusing
|
||||||
|
* with counts is something an operator can act on: activate something else first.
|
||||||
|
*/
|
||||||
|
export async function deleteTimeline(input: {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly timelineId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await requireTimeline(input.store, input.timelineId);
|
||||||
|
const usage = await input.store.timelineUsage(input.timelineId);
|
||||||
|
if (usage.activeGroups > 0 || usage.pendingGroups > 0 || usage.scheduledGroups > 0) {
|
||||||
|
throw new SignageError("SIGNAGE_CONFLICT", {
|
||||||
|
activeGroups: usage.activeGroups,
|
||||||
|
pendingGroups: usage.pendingGroups,
|
||||||
|
scheduledGroups: usage.scheduledGroups,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await input.store.deleteTimeline(input.timelineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addProviderSource(input: {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly timelineId: string;
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly sourceId: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly newId?: () => string;
|
||||||
|
}): Promise<StoredTimelineDetail> {
|
||||||
|
const detail = await requireTimeline(input.store, input.timelineId);
|
||||||
|
if (detail.sources.length >= SIGNAGE_TIMELINE_LIMITS.maxSourcesPerTimeline) {
|
||||||
|
throw new SignageError("SIGNAGE_CONFLICT", {
|
||||||
|
maxSources: SIGNAGE_TIMELINE_LIMITS.maxSourcesPerTimeline,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const newId = input.newId ?? randomUUID;
|
||||||
|
await input.store.addSource({
|
||||||
|
id: newId(),
|
||||||
|
label: input.label,
|
||||||
|
providerId: input.providerId,
|
||||||
|
// Generated here, stable forever, and deliberately not derived from the timeline or slot id:
|
||||||
|
// either would silently reset a provider's rotation when an operator renamed or reordered
|
||||||
|
// something (ADR 0003).
|
||||||
|
selectionScope: newId(),
|
||||||
|
sourceId: input.sourceId,
|
||||||
|
timelineId: input.timelineId,
|
||||||
|
});
|
||||||
|
return requireTimeline(input.store, input.timelineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeProviderSource(input: {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly timelineId: string;
|
||||||
|
readonly sourceId: string;
|
||||||
|
}): Promise<StoredTimelineDetail> {
|
||||||
|
const detail = await requireTimeline(input.store, input.timelineId);
|
||||||
|
if (!detail.sources.some((source) => source.id === input.sourceId)) {
|
||||||
|
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
await input.store.removeSource(input.sourceId);
|
||||||
|
return requireTimeline(input.store, input.timelineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddSlotInput extends TimelineIdentitySource {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly timelineId: string;
|
||||||
|
readonly position?: number | undefined;
|
||||||
|
readonly enabled?: boolean | undefined;
|
||||||
|
readonly durationOverrideMs?: number | null | undefined;
|
||||||
|
readonly eligibleFrom?: Date | null | undefined;
|
||||||
|
readonly eligibleUntil?: Date | null | undefined;
|
||||||
|
readonly presentation?: Partial<StoredSlotPresentation> | undefined;
|
||||||
|
/** Exactly one of these two shapes. */
|
||||||
|
readonly source?: { readonly sourceId: string } | undefined;
|
||||||
|
readonly pinned?: { readonly providerId: string; readonly itemId: string } | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addSlot(input: AddSlotInput): Promise<StoredTimelineDetail> {
|
||||||
|
const detail = await requireTimeline(input.store, input.timelineId);
|
||||||
|
if (detail.slots.length >= SIGNAGE_TIMELINE_LIMITS.maxSlotsPerTimeline) {
|
||||||
|
throw new SignageError("SIGNAGE_CONFLICT", {
|
||||||
|
maxSlots: SIGNAGE_TIMELINE_LIMITS.maxSlotsPerTimeline,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ((input.source === undefined) === (input.pinned === undefined)) {
|
||||||
|
// A slot is a provider source or a pinned item. Both or neither is not a slot.
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "kind" });
|
||||||
|
}
|
||||||
|
if (input.source && !detail.sources.some((source) => source.id === input.source?.sourceId)) {
|
||||||
|
// A slot may only reference a source configured on its own timeline.
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "sourceId" });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.durationOverrideMs !== undefined &&
|
||||||
|
input.durationOverrideMs !== null &&
|
||||||
|
(!Number.isSafeInteger(input.durationOverrideMs) ||
|
||||||
|
input.durationOverrideMs < SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs ||
|
||||||
|
input.durationOverrideMs > SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs)
|
||||||
|
) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "durationOverrideMs" });
|
||||||
|
}
|
||||||
|
requireInterval(input.eligibleFrom, input.eligibleUntil);
|
||||||
|
|
||||||
|
await input.store.addSlot(
|
||||||
|
{
|
||||||
|
durationOverrideMs: input.durationOverrideMs ?? null,
|
||||||
|
eligibleFrom: input.eligibleFrom ?? null,
|
||||||
|
eligibleUntil: input.eligibleUntil ?? null,
|
||||||
|
enabled: input.enabled ?? true,
|
||||||
|
id: nextId(input),
|
||||||
|
itemId: input.pinned?.itemId ?? null,
|
||||||
|
kind: input.source ? "provider-source" : "pinned-item",
|
||||||
|
presentation: normalizePresentation(input.presentation),
|
||||||
|
providerId: input.pinned?.providerId ?? null,
|
||||||
|
sourceId: input.source?.sourceId ?? null,
|
||||||
|
timelineId: input.timelineId,
|
||||||
|
},
|
||||||
|
input.position,
|
||||||
|
);
|
||||||
|
return requireTimeline(input.store, input.timelineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSlot(input: {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly timelineId: string;
|
||||||
|
readonly slotId: string;
|
||||||
|
readonly changes: SlotChanges;
|
||||||
|
}): Promise<StoredTimelineDetail> {
|
||||||
|
const detail = await requireTimeline(input.store, input.timelineId);
|
||||||
|
const slot = detail.slots.find((candidate) => candidate.id === input.slotId);
|
||||||
|
if (!slot) {
|
||||||
|
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
const changes: SlotChanges = {};
|
||||||
|
if (input.changes.enabled !== undefined && input.changes.enabled !== slot.enabled) {
|
||||||
|
Object.assign(changes, { enabled: input.changes.enabled });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.changes.durationOverrideMs !== undefined &&
|
||||||
|
input.changes.durationOverrideMs !== slot.durationOverrideMs
|
||||||
|
) {
|
||||||
|
const value = input.changes.durationOverrideMs;
|
||||||
|
if (
|
||||||
|
value !== null &&
|
||||||
|
(!Number.isSafeInteger(value) ||
|
||||||
|
value < SIGNAGE_TIMELINE_LIMITS.minSlotDurationMs ||
|
||||||
|
value > SIGNAGE_TIMELINE_LIMITS.maxSlotDurationMs)
|
||||||
|
) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "durationOverrideMs" });
|
||||||
|
}
|
||||||
|
Object.assign(changes, { durationOverrideMs: value });
|
||||||
|
}
|
||||||
|
for (const field of ["eligibleFrom", "eligibleUntil"] as const) {
|
||||||
|
const next = input.changes[field];
|
||||||
|
if (next !== undefined && (next?.getTime() ?? null) !== (slot[field]?.getTime() ?? null)) {
|
||||||
|
Object.assign(changes, { [field]: next });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.changes.itemId !== undefined && input.changes.itemId !== slot.itemId) {
|
||||||
|
if (slot.kind !== "pinned-item") {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "itemId" });
|
||||||
|
}
|
||||||
|
Object.assign(changes, { itemId: input.changes.itemId });
|
||||||
|
}
|
||||||
|
if (input.changes.presentation) {
|
||||||
|
const merged = normalizePresentation({ ...slot.presentation, ...input.changes.presentation });
|
||||||
|
const changed = (Object.keys(merged) as (keyof StoredSlotPresentation)[]).some(
|
||||||
|
(key) => merged[key] !== slot.presentation[key],
|
||||||
|
);
|
||||||
|
if (changed) {
|
||||||
|
Object.assign(changes, { presentation: merged });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(changes).length === 0) {
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
requireInterval(
|
||||||
|
changes.eligibleFrom === undefined ? slot.eligibleFrom : changes.eligibleFrom,
|
||||||
|
changes.eligibleUntil === undefined ? slot.eligibleUntil : changes.eligibleUntil,
|
||||||
|
);
|
||||||
|
await input.store.updateSlot(input.slotId, changes);
|
||||||
|
return requireTimeline(input.store, input.timelineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeSlot(input: {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly timelineId: string;
|
||||||
|
readonly slotId: string;
|
||||||
|
}): Promise<StoredTimelineDetail> {
|
||||||
|
const detail = await requireTimeline(input.store, input.timelineId);
|
||||||
|
if (!detail.slots.some((slot) => slot.id === input.slotId)) {
|
||||||
|
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
await input.store.removeSlot(input.slotId);
|
||||||
|
return requireTimeline(input.store, input.timelineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderSlots(input: {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly timelineId: string;
|
||||||
|
readonly orderedSlotIds: readonly string[];
|
||||||
|
}): Promise<StoredTimelineDetail> {
|
||||||
|
await requireTimeline(input.store, input.timelineId);
|
||||||
|
try {
|
||||||
|
await input.store.reorderSlots(input.timelineId, input.orderedSlotIds);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SignageStoreConflict) {
|
||||||
|
// The order was not exactly this timeline's current slots. Refusing is what makes a reorder
|
||||||
|
// unable to lose a slot, duplicate one, or leave a hole in the pattern.
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "slotIds" });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return requireTimeline(input.store, input.timelineId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { SignageError } from "../errors.js";
|
||||||
|
import type { SignageStore } from "../store/types.js";
|
||||||
|
import { describeViewerHealth, updateViewerAssignment, type ViewerHealth } from "./service.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `signage.displays` — sanitized viewer and channel health, and assignment, for another module.
|
||||||
|
*
|
||||||
|
* What a consuming module gets is exactly the roster an operator sees in the dashboard: names,
|
||||||
|
* online state, assignment, what each screen last reported, and its most recent safe error code.
|
||||||
|
*
|
||||||
|
* What it deliberately does not get: a device credential, a pairing token, a session, a provider
|
||||||
|
* payload, an object key, a delivery URL, or any private host internal. `deviceId` is the same
|
||||||
|
* opaque correlation value Core exposes on a device principal — it identifies a screen, and it
|
||||||
|
* unlocks nothing.
|
||||||
|
*/
|
||||||
|
export const SIGNAGE_DISPLAYS_CAPABILITY_VERSION = "1.0.0";
|
||||||
|
|
||||||
|
export interface SignageChannelSummary {
|
||||||
|
readonly id: string;
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly groupName: string;
|
||||||
|
readonly key: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignageDisplaysCapability {
|
||||||
|
readonly contractVersion: string;
|
||||||
|
listChannels(): Promise<readonly SignageChannelSummary[]>;
|
||||||
|
listViewers(): Promise<readonly ViewerHealth[]>;
|
||||||
|
/** Moves a screen to another channel. Refuses a channel that does not exist rather than leaving a
|
||||||
|
* display pointed at nothing. */
|
||||||
|
assignViewer(viewerId: string, channelId: string): Promise<ViewerHealth>;
|
||||||
|
renameViewer(viewerId: string, name: string): Promise<ViewerHealth>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDisplaysCapability(
|
||||||
|
store: () => SignageStore,
|
||||||
|
now: () => Date,
|
||||||
|
): SignageDisplaysCapability {
|
||||||
|
async function health(
|
||||||
|
viewerId: string,
|
||||||
|
name?: string,
|
||||||
|
channelId?: string,
|
||||||
|
): Promise<ViewerHealth> {
|
||||||
|
const viewer = await updateViewerAssignment({
|
||||||
|
...(channelId === undefined ? {} : { channelId }),
|
||||||
|
...(name === undefined ? {} : { name }),
|
||||||
|
store: store(),
|
||||||
|
viewerId,
|
||||||
|
});
|
||||||
|
return describeViewerHealth(viewer, now().getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
const capability: SignageDisplaysCapability = {
|
||||||
|
assignViewer(viewerId: string, channelId: string): Promise<ViewerHealth> {
|
||||||
|
if (typeof channelId !== "string" || channelId.length === 0) {
|
||||||
|
return Promise.reject(new SignageError("SIGNAGE_INVALID_REQUEST", { field: "channelId" }));
|
||||||
|
}
|
||||||
|
return health(viewerId, undefined, channelId);
|
||||||
|
},
|
||||||
|
contractVersion: SIGNAGE_DISPLAYS_CAPABILITY_VERSION,
|
||||||
|
async listChannels(): Promise<readonly SignageChannelSummary[]> {
|
||||||
|
const groups = await store().listChannelGroups();
|
||||||
|
return groups.flatMap((group) =>
|
||||||
|
group.channels.map((channel) => ({
|
||||||
|
groupId: group.group.id,
|
||||||
|
groupName: group.group.name,
|
||||||
|
id: channel.id,
|
||||||
|
isDefault: channel.isDefault,
|
||||||
|
key: channel.key,
|
||||||
|
name: channel.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async listViewers(): Promise<readonly ViewerHealth[]> {
|
||||||
|
const atMs = now().getTime();
|
||||||
|
const viewers = await store().listViewers();
|
||||||
|
return viewers.map((viewer) => describeViewerHealth(viewer, atMs));
|
||||||
|
},
|
||||||
|
renameViewer(viewerId: string, name: string): Promise<ViewerHealth> {
|
||||||
|
return health(viewerId, name, undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return Object.freeze(capability);
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { SignageError } from "../errors.js";
|
||||||
|
import type {
|
||||||
|
SignageStore,
|
||||||
|
StoredChannel,
|
||||||
|
StoredViewer,
|
||||||
|
StoredViewerError,
|
||||||
|
ViewerChanges,
|
||||||
|
} from "../store/types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Viewer registration, assignment, heartbeat, and health (ADR 0004).
|
||||||
|
*
|
||||||
|
* Two rules shape everything here.
|
||||||
|
*
|
||||||
|
* **A viewer never chooses its channel.** A display registers with the identity it keeps in its own
|
||||||
|
* IndexedDB and lands on the default channel of the default group. An administrator moves it from
|
||||||
|
* the dashboard. A public display that could select its own channel would let anyone who reaches the
|
||||||
|
* page decide what a screen shows.
|
||||||
|
*
|
||||||
|
* **A heartbeat is telemetry, never authority.** What a viewer reports about what it is showing is
|
||||||
|
* recorded so an operator can see it; it never changes playback, and a local cursor is never
|
||||||
|
* accepted as truth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The longest a viewer's own reported error detail may be. Bounded because it is operator-facing
|
||||||
|
* text a display supplied, and because an unbounded one is a way to fill a table. */
|
||||||
|
export const SIGNAGE_VIEWER_ERROR_DETAIL_MAX = 200;
|
||||||
|
export const SIGNAGE_VIEWER_NAME_MAX = 120;
|
||||||
|
/** A display is considered offline when nothing has been heard from it for this long. Comfortably
|
||||||
|
* more than a few missed heartbeats, so a slow network does not make a working screen look dead. */
|
||||||
|
export const SIGNAGE_VIEWER_OFFLINE_AFTER_MS = 90_000;
|
||||||
|
|
||||||
|
export interface ViewerSessionInput {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly viewerKey: string;
|
||||||
|
/** The safe Core device identifier, when the principal is a paired device. Never a credential. */
|
||||||
|
readonly deviceId: string | null;
|
||||||
|
readonly at: Date;
|
||||||
|
readonly newId?: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewerSession {
|
||||||
|
readonly viewer: StoredViewer;
|
||||||
|
readonly channel: StoredChannel;
|
||||||
|
readonly groupId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEWER_KEY = /^[\w-]{8,128}$/u;
|
||||||
|
|
||||||
|
export function isViewerKey(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && VIEWER_KEY.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a display, or recognizes one that has been here before.
|
||||||
|
*
|
||||||
|
* A returning viewer keeps its assignment, its name, and its history. Nothing about a reconnect
|
||||||
|
* looks like a new screen, which is what keeps a display roster from filling with ghosts every time
|
||||||
|
* a television reboots.
|
||||||
|
*/
|
||||||
|
export async function openViewerSession(input: ViewerSessionInput): Promise<ViewerSession> {
|
||||||
|
if (!isViewerKey(input.viewerKey)) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "viewerKey" });
|
||||||
|
}
|
||||||
|
const newId = input.newId ?? randomUUID;
|
||||||
|
const groups = await input.store.listChannelGroups();
|
||||||
|
const group = groups[0];
|
||||||
|
if (!group) {
|
||||||
|
throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-channel-group" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await input.store.findViewerByKey(input.viewerKey);
|
||||||
|
if (existing) {
|
||||||
|
const changes: ViewerChanges = { lastSeenAt: input.at };
|
||||||
|
if (input.deviceId !== null && input.deviceId !== existing.deviceId) {
|
||||||
|
// A screen that was paired since it last checked in. The identifier is opaque and is stored
|
||||||
|
// only so an operator can correlate the two rosters.
|
||||||
|
Object.assign(changes, { deviceId: input.deviceId });
|
||||||
|
}
|
||||||
|
if (existing.channelId === null) {
|
||||||
|
// Its channel was removed. It lands back on a default rather than rendering nothing.
|
||||||
|
Object.assign(changes, { channelId: defaultChannelOf(group.channels).id });
|
||||||
|
}
|
||||||
|
await input.store.updateViewer(existing.id, changes);
|
||||||
|
const refreshed = await input.store.findViewer(existing.id);
|
||||||
|
if (!refreshed) {
|
||||||
|
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||||
|
}
|
||||||
|
return resolveSession(input.store, refreshed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = defaultChannelOf(group.channels);
|
||||||
|
const viewer: StoredViewer = {
|
||||||
|
cachedAssetCount: 0,
|
||||||
|
channelId: channel.id,
|
||||||
|
createdAt: input.at,
|
||||||
|
deviceId: input.deviceId,
|
||||||
|
id: newId(),
|
||||||
|
lastError: null,
|
||||||
|
lastSeenAt: input.at,
|
||||||
|
name: "New display",
|
||||||
|
offlineReady: false,
|
||||||
|
reportedContributionId: null,
|
||||||
|
reportedPlaybackRevision: null,
|
||||||
|
reportedProviderId: null,
|
||||||
|
reportedSelectionId: null,
|
||||||
|
reportedTimelineId: null,
|
||||||
|
viewerKey: input.viewerKey,
|
||||||
|
};
|
||||||
|
await input.store.insertViewer(viewer);
|
||||||
|
await input.store.updateViewer(viewer.id, { lastSeenAt: input.at });
|
||||||
|
return { channel, groupId: group.group.id, viewer };
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultChannelOf(channels: readonly StoredChannel[]): StoredChannel {
|
||||||
|
const fallback = channels.find((channel) => channel.isDefault) ?? channels[0];
|
||||||
|
if (!fallback) {
|
||||||
|
throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-channel" });
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSession(store: SignageStore, viewer: StoredViewer): Promise<ViewerSession> {
|
||||||
|
const groups = await store.listChannelGroups();
|
||||||
|
for (const group of groups) {
|
||||||
|
const channel = group.channels.find((candidate) => candidate.id === viewer.channelId);
|
||||||
|
if (channel) {
|
||||||
|
return { channel, groupId: group.group.id, viewer };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const first = groups[0];
|
||||||
|
if (!first) {
|
||||||
|
throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-channel-group" });
|
||||||
|
}
|
||||||
|
return { channel: defaultChannelOf(first.channels), groupId: first.group.id, viewer };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewerHeartbeat {
|
||||||
|
readonly reportedTimelineId?: string | null | undefined;
|
||||||
|
readonly reportedSelectionId?: string | null | undefined;
|
||||||
|
readonly reportedProviderId?: string | null | undefined;
|
||||||
|
readonly reportedContributionId?: string | null | undefined;
|
||||||
|
readonly reportedPlaybackRevision?: number | null | undefined;
|
||||||
|
readonly offlineReady?: boolean | undefined;
|
||||||
|
readonly cachedAssetCount?: number | undefined;
|
||||||
|
readonly error?: { readonly code: string; readonly detail?: string | null } | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a heartbeat, and **coalesces** it.
|
||||||
|
*
|
||||||
|
* A steady display checks in every few seconds. Writing a row every time would turn a wall of
|
||||||
|
* televisions into a constant stream of PostgreSQL writes that says nothing new. So a write happens
|
||||||
|
* only when something an operator would notice changed, or when enough time has passed that "last
|
||||||
|
* seen" would otherwise go stale.
|
||||||
|
*
|
||||||
|
* Returns whether a write actually happened, so this is measurable rather than asserted.
|
||||||
|
*/
|
||||||
|
export async function recordViewerHeartbeat(input: {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly viewerId: string;
|
||||||
|
readonly heartbeat: ViewerHeartbeat;
|
||||||
|
readonly at: Date;
|
||||||
|
readonly minimumWriteIntervalMs: number;
|
||||||
|
}): Promise<{ readonly written: boolean; readonly viewer: StoredViewer }> {
|
||||||
|
const viewer = await input.store.findViewer(input.viewerId);
|
||||||
|
if (!viewer) {
|
||||||
|
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = normalizeViewerError(input.heartbeat.error, input.at);
|
||||||
|
const changed =
|
||||||
|
fieldChanged(viewer.reportedTimelineId, input.heartbeat.reportedTimelineId) ||
|
||||||
|
fieldChanged(viewer.reportedSelectionId, input.heartbeat.reportedSelectionId) ||
|
||||||
|
fieldChanged(viewer.reportedProviderId, input.heartbeat.reportedProviderId) ||
|
||||||
|
fieldChanged(viewer.reportedContributionId, input.heartbeat.reportedContributionId) ||
|
||||||
|
fieldChanged(viewer.reportedPlaybackRevision, input.heartbeat.reportedPlaybackRevision) ||
|
||||||
|
fieldChanged(viewer.offlineReady, input.heartbeat.offlineReady) ||
|
||||||
|
fieldChanged(viewer.cachedAssetCount, input.heartbeat.cachedAssetCount) ||
|
||||||
|
(viewer.lastError?.code ?? null) !== (error?.code ?? null);
|
||||||
|
|
||||||
|
const staleMs = input.at.getTime() - (viewer.lastSeenAt?.getTime() ?? 0);
|
||||||
|
if (!changed && staleMs < input.minimumWriteIntervalMs) {
|
||||||
|
// Nothing new, and "last seen" is recent enough to still be true. Say nothing to the database.
|
||||||
|
return { viewer, written: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes: ViewerChanges = { lastSeenAt: input.at };
|
||||||
|
for (const key of [
|
||||||
|
"cachedAssetCount",
|
||||||
|
"offlineReady",
|
||||||
|
"reportedContributionId",
|
||||||
|
"reportedPlaybackRevision",
|
||||||
|
"reportedProviderId",
|
||||||
|
"reportedSelectionId",
|
||||||
|
"reportedTimelineId",
|
||||||
|
] as const) {
|
||||||
|
const value = input.heartbeat[key];
|
||||||
|
if (value !== undefined) {
|
||||||
|
Object.assign(changes, { [key]: value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.heartbeat.error !== undefined) {
|
||||||
|
Object.assign(changes, { lastError: error });
|
||||||
|
}
|
||||||
|
await input.store.updateViewer(input.viewerId, changes);
|
||||||
|
const updated = await input.store.findViewer(input.viewerId);
|
||||||
|
if (!updated) {
|
||||||
|
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||||
|
}
|
||||||
|
return { viewer: updated, written: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldChanged<Value>(current: Value, next: Value | undefined): boolean {
|
||||||
|
return next !== undefined && next !== current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A display's own reported error, bounded and stripped. A code and a short detail — never a stack,
|
||||||
|
* a URL, a payload, or anything else a browser might have had to hand. */
|
||||||
|
function normalizeViewerError(error: ViewerHeartbeat["error"], at: Date): StoredViewerError | null {
|
||||||
|
if (error === undefined || error === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof error.code !== "string" || error.code.length === 0 || error.code.length > 64) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.code" });
|
||||||
|
}
|
||||||
|
const detail = error.detail ?? null;
|
||||||
|
if (
|
||||||
|
detail !== null &&
|
||||||
|
(typeof detail !== "string" || detail.length > SIGNAGE_VIEWER_ERROR_DETAIL_MAX)
|
||||||
|
) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "error.detail" });
|
||||||
|
}
|
||||||
|
return { at, code: error.code, detail };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewerHealth {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly online: boolean;
|
||||||
|
readonly channelId: string | null;
|
||||||
|
readonly deviceId: string | null;
|
||||||
|
readonly lastSeenAtMs: number | null;
|
||||||
|
readonly reportedTimelineId: string | null;
|
||||||
|
readonly reportedSelectionId: string | null;
|
||||||
|
readonly reportedProviderId: string | null;
|
||||||
|
readonly reportedContributionId: string | null;
|
||||||
|
readonly offlineReady: boolean;
|
||||||
|
readonly cachedAssetCount: number;
|
||||||
|
readonly lastError: {
|
||||||
|
readonly code: string;
|
||||||
|
readonly detail: string | null;
|
||||||
|
readonly atMs: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What management sees. Everything is either Signage's own record or a value the display itself
|
||||||
|
* reported through the bounded heartbeat contract — never a credential and never a host internal. */
|
||||||
|
export function describeViewerHealth(viewer: StoredViewer, atMs: number): ViewerHealth {
|
||||||
|
const lastSeenAtMs = viewer.lastSeenAt?.getTime() ?? null;
|
||||||
|
return {
|
||||||
|
cachedAssetCount: viewer.cachedAssetCount,
|
||||||
|
channelId: viewer.channelId,
|
||||||
|
deviceId: viewer.deviceId,
|
||||||
|
id: viewer.id,
|
||||||
|
lastError:
|
||||||
|
viewer.lastError === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
atMs: viewer.lastError.at.getTime(),
|
||||||
|
code: viewer.lastError.code,
|
||||||
|
detail: viewer.lastError.detail,
|
||||||
|
},
|
||||||
|
lastSeenAtMs,
|
||||||
|
name: viewer.name,
|
||||||
|
offlineReady: viewer.offlineReady,
|
||||||
|
online: lastSeenAtMs !== null && atMs - lastSeenAtMs < SIGNAGE_VIEWER_OFFLINE_AFTER_MS,
|
||||||
|
reportedContributionId: viewer.reportedContributionId,
|
||||||
|
reportedProviderId: viewer.reportedProviderId,
|
||||||
|
reportedSelectionId: viewer.reportedSelectionId,
|
||||||
|
reportedTimelineId: viewer.reportedTimelineId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assigns a viewer to a channel, or renames it. Refuses a channel that does not exist rather than
|
||||||
|
* silently leaving a screen unassigned. */
|
||||||
|
export async function updateViewerAssignment(input: {
|
||||||
|
readonly store: SignageStore;
|
||||||
|
readonly viewerId: string;
|
||||||
|
readonly name?: string | undefined;
|
||||||
|
readonly channelId?: string | undefined;
|
||||||
|
}): Promise<StoredViewer> {
|
||||||
|
const viewer = await input.store.findViewer(input.viewerId);
|
||||||
|
if (!viewer) {
|
||||||
|
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
const changes: ViewerChanges = {};
|
||||||
|
if (input.name !== undefined && input.name !== viewer.name) {
|
||||||
|
if (input.name.length === 0 || input.name.length > SIGNAGE_VIEWER_NAME_MAX) {
|
||||||
|
throw new SignageError("SIGNAGE_INVALID_REQUEST", { field: "name" });
|
||||||
|
}
|
||||||
|
Object.assign(changes, { name: input.name });
|
||||||
|
}
|
||||||
|
if (input.channelId !== undefined && input.channelId !== viewer.channelId) {
|
||||||
|
const groups = await input.store.listChannelGroups();
|
||||||
|
const exists = groups.some((group) =>
|
||||||
|
group.channels.some((channel) => channel.id === input.channelId),
|
||||||
|
);
|
||||||
|
if (!exists) {
|
||||||
|
throw new SignageError("SIGNAGE_NOT_FOUND");
|
||||||
|
}
|
||||||
|
Object.assign(changes, { channelId: input.channelId });
|
||||||
|
}
|
||||||
|
if (Object.keys(changes).length === 0) {
|
||||||
|
return viewer;
|
||||||
|
}
|
||||||
|
await input.store.updateViewer(input.viewerId, changes);
|
||||||
|
const updated = await input.store.findViewer(input.viewerId);
|
||||||
|
if (!updated) {
|
||||||
|
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Everything about a native Signage image that both halves of the module must agree on.
|
||||||
|
*
|
||||||
|
* Import-free on purpose: this file is compiled into the Node server project *and* into the browser
|
||||||
|
* artifacts, so the server's stored presentation and the display's painted presentation are resolved
|
||||||
|
* by the same function rather than by two implementations that drift.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The identity Signage's own built-in content provider registers under. It is an ordinary provider
|
||||||
|
* from every other part of the system's point of view (ADR 0001), which is why there is exactly one
|
||||||
|
* selection pipeline instead of a native special case. */
|
||||||
|
export const SIGNAGE_NATIVE_PROVIDER_ID = "signage.native-images";
|
||||||
|
|
||||||
|
/** The renderer identity a native image resolves to. Native images are drawn by Signage's own
|
||||||
|
* display code rather than by a contributed renderer, so this is deliberately not a contribution
|
||||||
|
* id — a display that sees it renders an image element instead of asking the host to mount
|
||||||
|
* anything. */
|
||||||
|
export const SIGNAGE_NATIVE_RENDERER = "signage.native-image";
|
||||||
|
|
||||||
|
export const SIGNAGE_ASPECT_16_9 = 16 / 9;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far from exactly 16:9 an image may be and still be treated as filling the frame.
|
||||||
|
*
|
||||||
|
* 0.02 accepts the ratios real export pipelines actually produce — 1920×1080 exactly, 1918×1080 from
|
||||||
|
* a cropped canvas, 1280×720 — and rejects anything a viewer would see letterboxed. It is a
|
||||||
|
* documented tolerance rather than an exact comparison because an exact one would put a 1919-pixel
|
||||||
|
* export on a blurred background for no reason a person could see.
|
||||||
|
*/
|
||||||
|
export const SIGNAGE_ASPECT_TOLERANCE = 0.02;
|
||||||
|
|
||||||
|
export function isSixteenByNine(width: number, height: number): boolean {
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Math.abs(width / height - SIGNAGE_ASPECT_16_9) <= SIGNAGE_ASPECT_TOLERANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignageImageFit = "fill" | "contain";
|
||||||
|
|
||||||
|
/** What a scene layer needs to paint one native image. Every value is already resolved: the layer
|
||||||
|
* makes no decisions of its own. */
|
||||||
|
export interface SignageNativeScene {
|
||||||
|
readonly fit: SignageImageFit;
|
||||||
|
readonly backgroundMode: "blurred" | "solid" | "black" | "none";
|
||||||
|
readonly backgroundColor: string;
|
||||||
|
readonly backgroundImage: boolean;
|
||||||
|
readonly blurPixels: number;
|
||||||
|
readonly darkenOpacity: number;
|
||||||
|
readonly shadow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The presentation values a slot may override, already merged with the installation defaults by
|
||||||
|
* the server. */
|
||||||
|
export interface SignageNativePresentation {
|
||||||
|
readonly backgroundMode: "blurred" | "solid" | "black";
|
||||||
|
readonly backgroundColor: string;
|
||||||
|
readonly backgroundDarkened: boolean;
|
||||||
|
readonly blurPixels: number;
|
||||||
|
readonly foregroundShadow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How much a darkened background is dimmed. One value rather than a setting: an operator asking for
|
||||||
|
* "darker" wants the text readable, not a dimmer they have to tune. */
|
||||||
|
export const SIGNAGE_DARKEN_OPACITY = 0.25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves one native image plus its slot's presentation into everything a scene layer needs.
|
||||||
|
*
|
||||||
|
* A true 16:9 image covers the whole frame on its own — no blurred duplicate, no pillarbox, and no
|
||||||
|
* floating-card shadow, all of which would be artefacts on an image that already fits. Anything else
|
||||||
|
* is shown **whole** and backed, because cropping a church's slide to fill a screen cuts words off it.
|
||||||
|
*/
|
||||||
|
export function resolveNativeScene(
|
||||||
|
image: { readonly width: number; readonly height: number },
|
||||||
|
presentation: SignageNativePresentation,
|
||||||
|
): SignageNativeScene {
|
||||||
|
if (isSixteenByNine(image.width, image.height)) {
|
||||||
|
return {
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
backgroundImage: false,
|
||||||
|
backgroundMode: "none",
|
||||||
|
blurPixels: 0,
|
||||||
|
darkenOpacity: 0,
|
||||||
|
fit: "fill",
|
||||||
|
shadow: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
backgroundColor: presentation.backgroundColor,
|
||||||
|
backgroundImage: presentation.backgroundMode === "blurred",
|
||||||
|
backgroundMode: presentation.backgroundMode,
|
||||||
|
blurPixels: presentation.backgroundMode === "blurred" ? presentation.blurPixels : 0,
|
||||||
|
darkenOpacity: presentation.backgroundDarkened ? SIGNAGE_DARKEN_OPACITY : 0,
|
||||||
|
fit: "contain",
|
||||||
|
shadow: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
/**
|
||||||
|
* The arithmetic of authoritative playback (ADR 0005).
|
||||||
|
*
|
||||||
|
* Import-free, and compiled into **both** halves of the module on purpose: the server reconciles a
|
||||||
|
* channel group's clock with these functions, and a disconnected display continues from its last
|
||||||
|
* authoritative anchor with the *same* functions. Two implementations of this would drift, and the
|
||||||
|
* drift would only ever be visible on a wall, hours into an outage, with nobody watching.
|
||||||
|
*
|
||||||
|
* Three properties are what this file exists to guarantee, and each is the one a naive
|
||||||
|
* implementation loses:
|
||||||
|
*
|
||||||
|
* 1. **Absolute instants, never a countdown.** A slot has a start and a deadline; a timer is only a
|
||||||
|
* hint about when to look again.
|
||||||
|
* 2. **Reconcile forward from the previous authoritative deadline**, never from "now". A callback
|
||||||
|
* that fired 40 ms late costs 40 ms once, not 40 ms on every slide for the rest of the day.
|
||||||
|
* 3. **Catch up by arithmetic.** A screen that was off for a week must not step through a week of
|
||||||
|
* slides — and must not consume a provider's rotation doing it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One slot, reduced to exactly what timing needs. */
|
||||||
|
export interface TimingSlot {
|
||||||
|
readonly id: string;
|
||||||
|
readonly position: number;
|
||||||
|
readonly enabled: boolean;
|
||||||
|
/** Half-open `[from, until)`, in absolute epoch milliseconds. */
|
||||||
|
readonly eligibleFromMs: number | null;
|
||||||
|
readonly eligibleUntilMs: number | null;
|
||||||
|
/**
|
||||||
|
* What this slot is *expected* to last, from its own override, its provider's registered default,
|
||||||
|
* or the native-image default.
|
||||||
|
*
|
||||||
|
* An estimate, deliberately: the real duration of a provider slot is only known once the provider
|
||||||
|
* has resolved, and resolving thousands of times to catch up is exactly what this file avoids. The
|
||||||
|
* estimate decides how far to skip; the resolution decides the deadline of the slot actually
|
||||||
|
* landed on.
|
||||||
|
*/
|
||||||
|
readonly estimatedDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimingTimeline {
|
||||||
|
readonly id: string;
|
||||||
|
readonly eligibleFromMs: number | null;
|
||||||
|
readonly eligibleUntilMs: number | null;
|
||||||
|
/** In position order. */
|
||||||
|
readonly slots: readonly TimingSlot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a channel group's clock currently is. */
|
||||||
|
export interface TimingCursor {
|
||||||
|
readonly slotId: string | null;
|
||||||
|
readonly startedAtMs: number | null;
|
||||||
|
readonly deadlineAtMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimingAdvance =
|
||||||
|
/** The current slot is still running. Nothing to do until its deadline. */
|
||||||
|
| { readonly kind: "hold" }
|
||||||
|
/** Move to this slot, starting exactly here. */
|
||||||
|
| {
|
||||||
|
readonly kind: "advance";
|
||||||
|
readonly slotId: string;
|
||||||
|
readonly startedAtMs: number;
|
||||||
|
readonly deadlineAtMs: number;
|
||||||
|
/** How many slot boundaries were crossed to get here. Zero for an ordinary transition; large
|
||||||
|
* after an outage, and reported so a caller can tell "the next slide" from "catching up". */
|
||||||
|
readonly skipped: number;
|
||||||
|
}
|
||||||
|
/** Nothing in this timeline is eligible right now. */
|
||||||
|
| { readonly kind: "empty" };
|
||||||
|
|
||||||
|
/** A safety limit on any walk. Reaching it means a bug, not a legitimate schedule, and stopping is
|
||||||
|
* better than spinning on a wall. */
|
||||||
|
export const TIMING_TRANSITION_LIMIT = 10_000;
|
||||||
|
|
||||||
|
export function isTimelineEligible(timeline: TimingTimeline, atMs: number): boolean {
|
||||||
|
if (timeline.eligibleFromMs !== null && atMs < timeline.eligibleFromMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return timeline.eligibleUntilMs === null || atMs < timeline.eligibleUntilMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Half-open `[from, until)`: eligible exactly at `from`, ineligible exactly at `until`. It is the
|
||||||
|
* only convention under which two adjacent windows neither overlap nor leave a gap. */
|
||||||
|
export function isSlotEligible(slot: TimingSlot, atMs: number): boolean {
|
||||||
|
if (!slot.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (slot.eligibleFromMs !== null && atMs < slot.eligibleFromMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return slot.eligibleUntilMs === null || atMs < slot.eligibleUntilMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eligibleSlots(timeline: TimingTimeline, atMs: number): readonly TimingSlot[] {
|
||||||
|
if (!isTimelineEligible(timeline, atMs)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return timeline.slots.filter((slot) => isSlotEligible(slot, atMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first instant in `(after, through]` at which any slot's eligibility can change.
|
||||||
|
*
|
||||||
|
* Between two such instants the eligible set is constant, which is exactly what makes skipping whole
|
||||||
|
* passes of the pattern safe. Without this, a catch-up would happily skip past a slot that was
|
||||||
|
* scheduled to appear during the outage.
|
||||||
|
*/
|
||||||
|
export function firstEligibilityBoundary(
|
||||||
|
timeline: TimingTimeline,
|
||||||
|
afterMs: number,
|
||||||
|
throughMs: number,
|
||||||
|
): number | null {
|
||||||
|
let best: number | null = null;
|
||||||
|
const candidates: (number | null)[] = [timeline.eligibleFromMs, timeline.eligibleUntilMs];
|
||||||
|
for (const slot of timeline.slots) {
|
||||||
|
candidates.push(slot.eligibleFromMs, slot.eligibleUntilMs);
|
||||||
|
}
|
||||||
|
for (const value of candidates) {
|
||||||
|
if (value === null || value <= afterMs || value > throughMs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (best === null || value < best) {
|
||||||
|
best = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A currently showing slot can be cut short by **its own** window, never by another slot's. */
|
||||||
|
function ownInvalidBoundary(
|
||||||
|
slot: TimingSlot | undefined,
|
||||||
|
startedAtMs: number,
|
||||||
|
throughMs: number,
|
||||||
|
): number | null {
|
||||||
|
if (!slot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = slot.eligibleUntilMs;
|
||||||
|
if (value === null || value <= startedAtMs || value > throughMs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextEligibleAfter(
|
||||||
|
timeline: TimingTimeline,
|
||||||
|
currentSlotId: string | null,
|
||||||
|
atMs: number,
|
||||||
|
): TimingSlot | null {
|
||||||
|
const eligible = eligibleSlots(timeline, atMs);
|
||||||
|
if (eligible.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (currentSlotId === null) {
|
||||||
|
return eligible[0] as TimingSlot;
|
||||||
|
}
|
||||||
|
const current = timeline.slots.find((slot) => slot.id === currentSlotId);
|
||||||
|
if (!current) {
|
||||||
|
return eligible[0] as TimingSlot;
|
||||||
|
}
|
||||||
|
// The pattern repeats, so "after" wraps. Position — not eligibility order — is what an operator
|
||||||
|
// arranged, so the successor is the next *position* that happens to be eligible.
|
||||||
|
const after = eligible.find((slot) => slot.position > current.position);
|
||||||
|
return after ?? (eligible[0] as TimingSlot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next instant a display or a channel group must look again.
|
||||||
|
*
|
||||||
|
* The current slot's deadline, or the next eligibility boundary when nothing is running. `null`
|
||||||
|
* means there is nothing scheduled to change — an empty timeline with no future windows — and a
|
||||||
|
* caller should wait to be told rather than poll.
|
||||||
|
*/
|
||||||
|
export function nextTimingBoundary(
|
||||||
|
timeline: TimingTimeline,
|
||||||
|
cursor: TimingCursor,
|
||||||
|
atMs: number,
|
||||||
|
): number | null {
|
||||||
|
const candidates: number[] = [];
|
||||||
|
if (cursor.deadlineAtMs !== null && cursor.deadlineAtMs > atMs) {
|
||||||
|
candidates.push(cursor.deadlineAtMs);
|
||||||
|
}
|
||||||
|
const boundaries: (number | null)[] = [timeline.eligibleFromMs, timeline.eligibleUntilMs];
|
||||||
|
for (const slot of timeline.slots) {
|
||||||
|
boundaries.push(slot.eligibleFromMs, slot.eligibleUntilMs);
|
||||||
|
}
|
||||||
|
for (const value of boundaries) {
|
||||||
|
if (value !== null && value > atMs) {
|
||||||
|
candidates.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates.length === 0 ? null : Math.min(...candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks a cursor forward to `atMs`.
|
||||||
|
*
|
||||||
|
* The heart of it is the boundary choice: when a slot's deadline has passed, the next slot starts at
|
||||||
|
* **that deadline**, not at `atMs`. That single decision is what keeps a loop from stretching by the
|
||||||
|
* accumulated lateness of every timer that ever fired.
|
||||||
|
*
|
||||||
|
* Long gaps are crossed with arithmetic. Within one stretch where the eligible set cannot change,
|
||||||
|
* whole passes of the pattern are skipped by division rather than by stepping — so a week offline
|
||||||
|
* costs one reconciliation and one provider call, not ten thousand of each.
|
||||||
|
*/
|
||||||
|
export function advanceCursor(
|
||||||
|
timeline: TimingTimeline,
|
||||||
|
cursor: TimingCursor,
|
||||||
|
atMs: number,
|
||||||
|
): TimingAdvance {
|
||||||
|
const current =
|
||||||
|
cursor.slotId === null ? undefined : timeline.slots.find((s) => s.id === cursor.slotId);
|
||||||
|
|
||||||
|
// Still running, and still allowed to be: nothing to decide.
|
||||||
|
if (
|
||||||
|
current &&
|
||||||
|
cursor.deadlineAtMs !== null &&
|
||||||
|
cursor.deadlineAtMs > atMs &&
|
||||||
|
isSlotEligible(current, atMs) &&
|
||||||
|
isTimelineEligible(timeline, atMs)
|
||||||
|
) {
|
||||||
|
return { kind: "hold" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the next slot legitimately begins.
|
||||||
|
let boundary = atMs;
|
||||||
|
if (cursor.deadlineAtMs !== null && cursor.deadlineAtMs <= atMs) {
|
||||||
|
boundary = cursor.deadlineAtMs;
|
||||||
|
}
|
||||||
|
const invalidAt = ownInvalidBoundary(current, cursor.startedAtMs ?? boundary, atMs);
|
||||||
|
if (invalidAt !== null) {
|
||||||
|
boundary = Math.min(boundary, invalidAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
let slotId = cursor.slotId;
|
||||||
|
let startedAtMs = boundary;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (let step = 0; step < TIMING_TRANSITION_LIMIT; step += 1) {
|
||||||
|
const next = nextEligibleAfter(timeline, slotId, startedAtMs);
|
||||||
|
if (!next) {
|
||||||
|
// Nothing eligible at this instant. If something becomes eligible later in the gap, jump
|
||||||
|
// straight to that instant rather than declaring the timeline empty for the whole outage.
|
||||||
|
const boundaryAhead = firstEligibilityBoundary(timeline, startedAtMs, atMs);
|
||||||
|
if (boundaryAhead === null) {
|
||||||
|
return { kind: "empty" };
|
||||||
|
}
|
||||||
|
startedAtMs = boundaryAhead;
|
||||||
|
slotId = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadlineAtMs = startedAtMs + next.estimatedDurationMs;
|
||||||
|
if (deadlineAtMs > atMs) {
|
||||||
|
return { deadlineAtMs, kind: "advance", skipped, slotId: next.id, startedAtMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The landed-on slot is already over. Skip whole passes of the pattern with a division, but only
|
||||||
|
// as far as the millisecond before the next instant the eligible set could change.
|
||||||
|
const changeAt = firstEligibilityBoundary(timeline, startedAtMs, atMs);
|
||||||
|
const through = changeAt === null ? atMs : changeAt - 1;
|
||||||
|
const stable = eligibleSlots(timeline, startedAtMs);
|
||||||
|
const passDuration = stable.reduce((total, slot) => total + slot.estimatedDurationMs, 0);
|
||||||
|
if (passDuration > 0 && deadlineAtMs <= through) {
|
||||||
|
const passes = Math.floor((through - startedAtMs) / passDuration);
|
||||||
|
if (passes > 0) {
|
||||||
|
startedAtMs += passes * passDuration;
|
||||||
|
skipped += passes * stable.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startedAtMs = deadlineAtMs;
|
||||||
|
slotId = next.id;
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreachable for any legitimate schedule. Holding is safer on a wall than spinning.
|
||||||
|
return { kind: "hold" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How much of a running slot is left. Never negative, and `null` when nothing is running. */
|
||||||
|
export function remainingMs(cursor: TimingCursor, atMs: number): number | null {
|
||||||
|
if (cursor.deadlineAtMs === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.max(0, cursor.deadlineAtMs - atMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a resumed slot's window sits.
|
||||||
|
*
|
||||||
|
* The deadline is `now + remainder`, and the start is reconstructed **backwards** from it so the
|
||||||
|
* whole-slot window stays consistent — a renderer computing progress from `start` and `deadline`
|
||||||
|
* gets the same answer it would have got had the slot never been paused. A remainder captured under
|
||||||
|
* a longer duration is clamped, so the reconstructed start can never land after the resume instant.
|
||||||
|
*/
|
||||||
|
export function resumeWindow(
|
||||||
|
atMs: number,
|
||||||
|
remainderMs: number,
|
||||||
|
fullDurationMs: number,
|
||||||
|
): { readonly startedAtMs: number; readonly deadlineAtMs: number } {
|
||||||
|
const remaining = Math.max(0, Math.min(remainderMs, fullDurationMs));
|
||||||
|
const deadlineAtMs = atMs + remaining;
|
||||||
|
return { deadlineAtMs, startedAtMs: deadlineAtMs - fullDurationMs };
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* The versioned input Signage hands a provider's browser renderer (ADR 0002, ADR 0006).
|
||||||
|
*
|
||||||
|
* This is the domain contract behind Signage's `signage.slide-renderer` capability. The platform
|
||||||
|
* carries it as opaque JSON and validates only that it *is* JSON (ADR 0041); what it means is
|
||||||
|
* entirely between Signage and the contributing module, and it is versioned by Signage's own
|
||||||
|
* capability version so a renderer written against v1 keeps working when v2 exists.
|
||||||
|
*
|
||||||
|
* Import-free, because it is compiled into both the Node server that builds it and the browser
|
||||||
|
* artifacts that consume it.
|
||||||
|
*
|
||||||
|
* ## What is deliberately absent
|
||||||
|
*
|
||||||
|
* No API client, no realtime subscription, no principal, no permissions, no configuration, no
|
||||||
|
* database, no storage, no credential, and no URL that is not already inside `payload`. Mounting a
|
||||||
|
* renderer grants its module nothing: everything it needs arrives here, because Signage is the party
|
||||||
|
* that was authorized to obtain it. A renderer that needed to reach the network would need a later
|
||||||
|
* contract version, and this one does not weaken to accommodate it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SIGNAGE_SLIDE_RENDERER_CONTRACT = "signage.slide-renderer";
|
||||||
|
|
||||||
|
/** Bumped when the shape below changes in a way a renderer can observe. */
|
||||||
|
export const SIGNAGE_SLIDE_RENDERER_INPUT_VERSION = "1.0.0";
|
||||||
|
|
||||||
|
/** Both clocks at the moment this input was built, so a renderer can compute the authoritative time
|
||||||
|
* without trusting the local one.
|
||||||
|
*
|
||||||
|
* A television's clock is routinely wrong — sometimes by hours. `serverNowMs` is what the server
|
||||||
|
* said the time was; `localNowMs` is what `Date.now()` said at the same instant on this device. A
|
||||||
|
* renderer that wants the authoritative now computes `serverNowMs + (Date.now() - localNowMs)`,
|
||||||
|
* which is correct regardless of how far off the device's clock is, and stays correct across a
|
||||||
|
* pause because both anchors move together. */
|
||||||
|
export interface SignageRendererClock {
|
||||||
|
readonly serverNowMs: number;
|
||||||
|
readonly localNowMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The authoritative slot window. A renderer may do anything it likes inside it and may not extend
|
||||||
|
* it: the deadline was set by Signage before the renderer mounted. */
|
||||||
|
export interface SignageRendererTiming {
|
||||||
|
/** Absolute epoch milliseconds, on the server's clock. */
|
||||||
|
readonly startedAtMs: number;
|
||||||
|
readonly deadlineAtMs: number;
|
||||||
|
readonly durationMs: number;
|
||||||
|
readonly paused: boolean;
|
||||||
|
/** How much of the slot is left when paused. `null` while running — the deadline is the answer. */
|
||||||
|
readonly remainingMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignageSlideRendererInput {
|
||||||
|
readonly contract: typeof SIGNAGE_SLIDE_RENDERER_CONTRACT;
|
||||||
|
readonly version: string;
|
||||||
|
/** Which provider's content this is. Opaque; a renderer already knows its own. */
|
||||||
|
readonly providerId: string;
|
||||||
|
/** The provider's own identity for this content, and the revision of it being rendered. */
|
||||||
|
readonly itemId: string;
|
||||||
|
readonly itemRevision: string;
|
||||||
|
/** The audience this channel renders. A renderer that has nothing audience-specific ignores it. */
|
||||||
|
readonly audienceKey: string;
|
||||||
|
/** The provider's own payload, exactly as it returned it. */
|
||||||
|
readonly payload: unknown;
|
||||||
|
/** Presentation values the provider supplied to its own renderer, or `null`. Signage never
|
||||||
|
* interprets, merges, or overrides them, and never restyles a provider's output. */
|
||||||
|
readonly presentation: unknown;
|
||||||
|
readonly timing: SignageRendererTiming;
|
||||||
|
readonly clock: SignageRendererClock;
|
||||||
|
/**
|
||||||
|
* Changes whenever anything in this input changed.
|
||||||
|
*
|
||||||
|
* A renderer that keeps internal state keyed on it can tell "the same slide, updated" from "a
|
||||||
|
* different slide" without comparing the whole input — which is exactly the distinction that lets
|
||||||
|
* a cancelled event update a mounted React tree instead of restarting its slot.
|
||||||
|
*/
|
||||||
|
readonly generation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the generation token. Deterministic, so two displays that received the same state compute
|
||||||
|
* the same token and neither remounts when the other does not. */
|
||||||
|
export function signageRendererGeneration(input: {
|
||||||
|
readonly selectionId: string;
|
||||||
|
readonly revision: number;
|
||||||
|
readonly audienceKey: string;
|
||||||
|
readonly paused: boolean;
|
||||||
|
}): string {
|
||||||
|
return `${input.selectionId}:${input.revision}:${input.audienceKey}:${input.paused ? "p" : "r"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether two inputs describe the same logical slide, so a consumer can update in place rather than
|
||||||
|
* remounting. Identity — not payload equality — is what decides. */
|
||||||
|
export function isSameSignageSlide(
|
||||||
|
left: SignageSlideRendererInput,
|
||||||
|
right: SignageSlideRendererInput,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
left.providerId === right.providerId &&
|
||||||
|
left.itemId === right.itemId &&
|
||||||
|
left.audienceKey === right.audienceKey
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Signage's own operator settings, as one plain, JSON-safe shape.
|
||||||
|
*
|
||||||
|
* Deliberately **import-free**. This file is compiled into both halves of the module: the Node
|
||||||
|
* server project and the browser project, whose TypeScript configuration declares `"types": []` so
|
||||||
|
* that `process.env` fails to compile. Importing `@ekklesia/module-sdk` here would pull the backend
|
||||||
|
* contracts barrel — and with it Drizzle's types and Node's globals — into every browser artifact,
|
||||||
|
* which is exactly the boundary ADR 0024 closed. Reading these values from the platform's
|
||||||
|
* configuration contract is therefore the server's job (`src/server/settings.ts`).
|
||||||
|
*
|
||||||
|
* Every value here is an **operator setting**: it survives restarts and is immutable for the life of
|
||||||
|
* the process (ADR 0029). Domain state — which timeline is active, which slot is on screen, whether
|
||||||
|
* playback is paused — lives in Signage's own tables instead, because those must change without one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** How a native image that is not 16:9 is backed. External providers own their own rendering
|
||||||
|
* completely and are never subject to these values. */
|
||||||
|
export const SIGNAGE_BACKGROUND_MODES = ["blurred", "solid", "black"] as const;
|
||||||
|
|
||||||
|
export type SignageBackgroundMode = (typeof SIGNAGE_BACKGROUND_MODES)[number];
|
||||||
|
|
||||||
|
export function isSignageBackgroundMode(value: unknown): value is SignageBackgroundMode {
|
||||||
|
return (
|
||||||
|
typeof value === "string" && (SIGNAGE_BACKGROUND_MODES as readonly string[]).includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The presentation a native image falls back to when a slot overrides nothing. */
|
||||||
|
export interface SignageNativePresentationDefaults {
|
||||||
|
readonly backgroundMode: SignageBackgroundMode;
|
||||||
|
readonly backgroundColor: string;
|
||||||
|
readonly backgroundDarkened: boolean;
|
||||||
|
readonly blurPixels: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignageSettings {
|
||||||
|
/** IANA zone every schedule in this installation is written and displayed in. Signage never
|
||||||
|
* interprets an ambiguous local timestamp: stored instants are absolute, and this exists so a
|
||||||
|
* *person* reading or writing one sees the zone the church actually keeps. */
|
||||||
|
readonly timezone: string;
|
||||||
|
readonly nativeImageDurationMs: number;
|
||||||
|
readonly crossfadeMs: number;
|
||||||
|
readonly nativePresentation: SignageNativePresentationDefaults;
|
||||||
|
/** How long one content-provider call may take before Signage treats it as a temporary failure. */
|
||||||
|
readonly providerTimeoutMs: number;
|
||||||
|
/** How many resolved selections per channel group are retained as playback history. */
|
||||||
|
readonly playbackHistoryLimit: number;
|
||||||
|
/** Shortest interval between two persisted heartbeat writes for one steady viewer. */
|
||||||
|
readonly viewerHeartbeatWriteMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SIGNAGE_HEX_COLOR = /^#[0-9a-f]{6}$/iu;
|
||||||
|
|
||||||
|
/** What Signage falls back to when a host supplies no configuration at all — the same values the
|
||||||
|
* manifest declares as defaults, so the two can never disagree about what "unset" means. */
|
||||||
|
export const SIGNAGE_DEFAULT_SETTINGS: SignageSettings = Object.freeze({
|
||||||
|
crossfadeMs: 1000,
|
||||||
|
nativeImageDurationMs: 7000,
|
||||||
|
nativePresentation: Object.freeze({
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
backgroundDarkened: false,
|
||||||
|
backgroundMode: "blurred",
|
||||||
|
blurPixels: 20,
|
||||||
|
}),
|
||||||
|
playbackHistoryLimit: 50,
|
||||||
|
providerTimeoutMs: 2000,
|
||||||
|
timezone: "America/Chicago",
|
||||||
|
viewerHeartbeatWriteMs: 30_000,
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { EkklesiaUiStyles, EmptyState, Tabs } from "@ekklesia/ui";
|
||||||
|
import { type ReactElement, useMemo, useState } from "react";
|
||||||
|
import { DesktopOnly } from "../browser/desktop-gate.js";
|
||||||
|
import { createSignageApi } from "./api.js";
|
||||||
|
import { useResource } from "./hooks.js";
|
||||||
|
import { DisplaysPanel } from "./panels/DisplaysPanel.js";
|
||||||
|
import { ImagesPanel } from "./panels/ImagesPanel.js";
|
||||||
|
import { PlaybackPanel } from "./panels/PlaybackPanel.js";
|
||||||
|
import { ProvidersPanel } from "./panels/ProvidersPanel.js";
|
||||||
|
import { renderLoadable } from "./panels/shared.js";
|
||||||
|
import { TimelinesPanel } from "./panels/TimelinesPanel.js";
|
||||||
|
import type { ChannelGroupView, SettingsResponse } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Signage management screen.
|
||||||
|
*
|
||||||
|
* Host-neutral by construction: every value comes from the host context, and nothing here hardcodes
|
||||||
|
* a URL, an origin, or a global. Presentation is entirely the platform's shared elements plus
|
||||||
|
* Signage's own stylesheet, so this looks like the rest of Ekklesia without copying a colour.
|
||||||
|
*
|
||||||
|
* Permission checks here decide what an operator is *offered*. The server decides what happens: a
|
||||||
|
* hidden button is a courtesy, and every route is enforced regardless of what this believes.
|
||||||
|
*/
|
||||||
|
export function SignageDashboard({
|
||||||
|
context,
|
||||||
|
}: {
|
||||||
|
readonly context: ModuleUiHostContext;
|
||||||
|
}): ReactElement {
|
||||||
|
const api = useMemo(() => createSignageApi(context.api), [context.api]);
|
||||||
|
// Read before the resources below, so a viewer who may not see Signage at all asks for nothing.
|
||||||
|
// A hidden screen is a usability choice; the server is what actually refuses, and making a
|
||||||
|
// request it will certainly refuse is noise in a log rather than security.
|
||||||
|
const mayView = context.permissions.has("signage.view");
|
||||||
|
const settings = useResource(
|
||||||
|
() => (mayView ? api.get<SettingsResponse>("settings") : Promise.resolve(undefined)),
|
||||||
|
[mayView],
|
||||||
|
);
|
||||||
|
const groups = useResource(
|
||||||
|
() =>
|
||||||
|
mayView
|
||||||
|
? api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups")
|
||||||
|
: Promise.resolve(undefined),
|
||||||
|
[mayView],
|
||||||
|
);
|
||||||
|
const [tab, setTab] = useState("playback");
|
||||||
|
const [groupId, setGroupId] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const mayManageAssets = context.permissions.has("signage.assets.manage");
|
||||||
|
const mayManageTimelines = context.permissions.has("signage.timelines.manage");
|
||||||
|
const mayControl = context.permissions.has("signage.playback.control");
|
||||||
|
const mayManageDisplays = context.permissions.has("signage.displays.manage");
|
||||||
|
|
||||||
|
if (!mayView) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
description="Ask an administrator for the signage.view permission."
|
||||||
|
title="No access to Signage"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedGroupId =
|
||||||
|
groupId ?? (groups.state.status === "ready" ? groups.state.value?.groups[0]?.id : undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EkklesiaUiStyles />
|
||||||
|
<DesktopOnly>
|
||||||
|
{renderLoadable(
|
||||||
|
settings.state,
|
||||||
|
(loaded) =>
|
||||||
|
loaded === undefined ? undefined : (
|
||||||
|
<Tabs
|
||||||
|
label="Signage sections"
|
||||||
|
onSelect={setTab}
|
||||||
|
selectedId={tab}
|
||||||
|
tabs={[
|
||||||
|
{
|
||||||
|
content: (
|
||||||
|
<PlaybackPanel
|
||||||
|
api={api}
|
||||||
|
canControl={mayControl}
|
||||||
|
context={context}
|
||||||
|
groupId={selectedGroupId}
|
||||||
|
onGroupChange={setGroupId}
|
||||||
|
settings={loaded}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
id: "playback",
|
||||||
|
label: "Playback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: (
|
||||||
|
<TimelinesPanel
|
||||||
|
api={api}
|
||||||
|
canManage={mayManageTimelines}
|
||||||
|
onActivate={
|
||||||
|
mayControl && selectedGroupId !== undefined
|
||||||
|
? (timelineId) => {
|
||||||
|
void api
|
||||||
|
.send("POST", `playback/${selectedGroupId}/activate`, {
|
||||||
|
timelineId,
|
||||||
|
when: "now",
|
||||||
|
})
|
||||||
|
.then(() => setTab("playback"))
|
||||||
|
.catch(() => setTab("playback"));
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
settings={loaded}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
id: "timelines",
|
||||||
|
label: "Timelines",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: (
|
||||||
|
<ImagesPanel api={api} canManage={mayManageAssets} settings={loaded} />
|
||||||
|
),
|
||||||
|
id: "images",
|
||||||
|
label: "Images",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: <DisplaysPanel api={api} canManage={mayManageDisplays} />,
|
||||||
|
id: "displays",
|
||||||
|
label: "Displays",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: <ProvidersPanel api={api} context={context} settings={loaded} />,
|
||||||
|
id: "providers",
|
||||||
|
label: "Providers",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
{ label: "Loading Signage…", onRetry: settings.reload },
|
||||||
|
)}
|
||||||
|
</DesktopOnly>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
import type { ModuleUiApiClient } from "@ekklesia/module-ui-sdk";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signage's dashboard talking to Signage's own backend.
|
||||||
|
*
|
||||||
|
* Every path here is **relative to this module's own API root**, and the host resolves it. Nothing
|
||||||
|
* in this file builds `/api/v1/modules/signage/...`: that prefix is Ekklesia's private deployment
|
||||||
|
* detail, and a UI that wrote it would be welded to Ekklesia and unhostable anywhere else.
|
||||||
|
*
|
||||||
|
* Failures become one small, typed shape. The server already sanitizes what it says; this keeps a
|
||||||
|
* network error and a refusal indistinguishable in the places where they should be — a screen that
|
||||||
|
* cannot load — and distinguishable where they matter, in a form that can show *why* a save was
|
||||||
|
* refused.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SignageApiFailure {
|
||||||
|
readonly code: string;
|
||||||
|
readonly message: string;
|
||||||
|
readonly status: number;
|
||||||
|
readonly detail?: Readonly<Record<string, number | string | null>> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SignageRequestError extends Error {
|
||||||
|
readonly failure: SignageApiFailure;
|
||||||
|
|
||||||
|
constructor(failure: SignageApiFailure) {
|
||||||
|
super(failure.message);
|
||||||
|
this.name = "SignageRequestError";
|
||||||
|
this.failure = failure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GENERIC: SignageApiFailure = {
|
||||||
|
code: "SIGNAGE_REQUEST_FAILED",
|
||||||
|
message: "Signage could not be reached.",
|
||||||
|
status: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readFailure(response: Response): Promise<SignageApiFailure> {
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
code?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
detail?: unknown;
|
||||||
|
};
|
||||||
|
if (typeof body.code === "string" && typeof body.message === "string") {
|
||||||
|
return {
|
||||||
|
code: body.code,
|
||||||
|
...(typeof body.detail === "object" && body.detail !== null
|
||||||
|
? { detail: body.detail as SignageApiFailure["detail"] }
|
||||||
|
: {}),
|
||||||
|
message: body.message,
|
||||||
|
status: response.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A response that is not Signage's own error shape tells the operator nothing useful, and
|
||||||
|
// guessing at it would put someone else's words on the screen.
|
||||||
|
}
|
||||||
|
return { ...GENERIC, status: response.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignageApi {
|
||||||
|
get<Result>(path: string): Promise<Result>;
|
||||||
|
send<Result>(
|
||||||
|
method: "POST" | "PATCH" | "PUT" | "DELETE",
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<Result>;
|
||||||
|
/** One image, one request. The queue that drives this lives in `browser/uploads.ts`. */
|
||||||
|
upload<Result>(path: string, bytes: Blob, contentType: string): Promise<Result>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSignageApi(client: ModuleUiApiClient): SignageApi {
|
||||||
|
async function request<Result>(path: string, init: RequestInit | undefined): Promise<Result> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await client.request(path, init);
|
||||||
|
} catch {
|
||||||
|
throw new SignageRequestError(GENERIC);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new SignageRequestError(await readFailure(response));
|
||||||
|
}
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as Result;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return (await response.json()) as Result;
|
||||||
|
} catch {
|
||||||
|
throw new SignageRequestError({ ...GENERIC, status: response.status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: (path) => request(path, undefined),
|
||||||
|
send: (method, path, body) =>
|
||||||
|
request(path, {
|
||||||
|
...(body === undefined
|
||||||
|
? {}
|
||||||
|
: { body: JSON.stringify(body), headers: { "content-type": "application/json" } }),
|
||||||
|
method,
|
||||||
|
}),
|
||||||
|
upload: (path, bytes, contentType) =>
|
||||||
|
request(path, { body: bytes, headers: { "content-type": contentType }, method: "POST" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { type SignageApiFailure, SignageRequestError } from "./api.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One loading state, used everywhere, so every panel in the dashboard fails the same way.
|
||||||
|
*
|
||||||
|
* The states are the ones an operator can actually act on: still loading, loaded, or failed with a
|
||||||
|
* reason Signage itself gave. There is no fourth state for "loaded but empty" — a panel decides what
|
||||||
|
* empty means for its own data, because "no timelines yet" and "no displays yet" are different
|
||||||
|
* sentences.
|
||||||
|
*/
|
||||||
|
export type Loadable<Value> =
|
||||||
|
| { readonly status: "loading" }
|
||||||
|
| { readonly status: "ready"; readonly value: Value }
|
||||||
|
| { readonly status: "failed"; readonly failure: SignageApiFailure };
|
||||||
|
|
||||||
|
export interface Resource<Value> {
|
||||||
|
readonly state: Loadable<Value>;
|
||||||
|
/** Re-reads without flashing the panel back to a spinner: the previous value stays on screen
|
||||||
|
* until the new one arrives, which is what makes a five-second refresh bearable to look at. */
|
||||||
|
reload(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResource<Value>(
|
||||||
|
load: () => Promise<Value>,
|
||||||
|
dependencies: readonly unknown[],
|
||||||
|
): Resource<Value> {
|
||||||
|
const [state, setState] = useState<Loadable<Value>>({ status: "loading" });
|
||||||
|
const generation = useRef(0);
|
||||||
|
const loadRef = useRef(load);
|
||||||
|
loadRef.current = load;
|
||||||
|
|
||||||
|
const run = useCallback(() => {
|
||||||
|
generation.current += 1;
|
||||||
|
const token = generation.current;
|
||||||
|
void loadRef
|
||||||
|
.current()
|
||||||
|
.then((value) => {
|
||||||
|
// A response from a request the panel has already moved on from must never overwrite a
|
||||||
|
// newer one.
|
||||||
|
if (token === generation.current) {
|
||||||
|
setState({ status: "ready", value });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (token !== generation.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState({
|
||||||
|
failure:
|
||||||
|
error instanceof SignageRequestError
|
||||||
|
? error.failure
|
||||||
|
: {
|
||||||
|
code: "SIGNAGE_REQUEST_FAILED",
|
||||||
|
message: "Signage could not be reached.",
|
||||||
|
status: 0,
|
||||||
|
},
|
||||||
|
status: "failed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The caller's dependency list is spread deliberately: a resource reloads when *its* inputs
|
||||||
|
// change, which is the whole reason this takes one.
|
||||||
|
useEffect(run, [run, ...dependencies]);
|
||||||
|
|
||||||
|
return { reload: run, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A mutation with a pending flag and a sanitized failure, so a button can disable itself and a form
|
||||||
|
* can say why a save was refused. */
|
||||||
|
export interface Action {
|
||||||
|
readonly pending: boolean;
|
||||||
|
readonly failure: SignageApiFailure | undefined;
|
||||||
|
run(work: () => Promise<unknown>): void;
|
||||||
|
clearFailure(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAction(onSettled?: () => void): Action {
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [failure, setFailure] = useState<SignageApiFailure | undefined>(undefined);
|
||||||
|
const settled = useRef(onSettled);
|
||||||
|
settled.current = onSettled;
|
||||||
|
|
||||||
|
const run = useCallback((work: () => Promise<unknown>) => {
|
||||||
|
setPending(true);
|
||||||
|
setFailure(undefined);
|
||||||
|
void work()
|
||||||
|
.then(() => {
|
||||||
|
setPending(false);
|
||||||
|
settled.current?.();
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
setPending(false);
|
||||||
|
setFailure(
|
||||||
|
error instanceof SignageRequestError
|
||||||
|
? error.failure
|
||||||
|
: {
|
||||||
|
code: "SIGNAGE_REQUEST_FAILED",
|
||||||
|
message: "Signage could not be reached.",
|
||||||
|
status: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { clearFailure: () => setFailure(undefined), failure, pending, run };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-renders on an interval, so a countdown or a "last seen" reads correctly without every panel
|
||||||
|
* inventing its own timer. Paused when the interval is zero. */
|
||||||
|
export function useTick(intervalMs: number): number {
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (intervalMs <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handle = setInterval(() => setTick((value) => value + 1), intervalMs);
|
||||||
|
return () => clearInterval(handle);
|
||||||
|
}, [intervalMs]);
|
||||||
|
return tick;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { EKKLESIA_UI_STYLESHEET } from "@ekklesia/ui";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { SignageDashboard } from "./SignageDashboard.js";
|
||||||
|
import ownStyles from "./styles.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signage's compiled management-UI artifact. `pnpm build` bundles this file and everything it
|
||||||
|
* imports into the single browser artifact `entrypoints.ui` declares.
|
||||||
|
*
|
||||||
|
* `styles` is this artifact's complete stylesheet — the shared elements' rules plus Signage's own —
|
||||||
|
* compiled in as text and installed by the host into this artifact's isolated root at mount
|
||||||
|
* (ADR 0040). The design tokens both stylesheets reference are inherited from the host's own root
|
||||||
|
* rather than bundled (ADR 0039).
|
||||||
|
*/
|
||||||
|
export default defineModuleUi({
|
||||||
|
id: "signage",
|
||||||
|
version: "0.1.0",
|
||||||
|
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
|
||||||
|
mount(container, context) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(<SignageDashboard context={context} />);
|
||||||
|
return () => root.unmount();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
EmptyState,
|
||||||
|
Field,
|
||||||
|
Panel,
|
||||||
|
Select,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextInput,
|
||||||
|
} from "@ekklesia/ui";
|
||||||
|
import { type ReactElement, useState } from "react";
|
||||||
|
import type { SignageApi } from "../api.js";
|
||||||
|
import { useAction, useResource, useTick } from "../hooks.js";
|
||||||
|
import type { ChannelGroupView, ViewerHealthView } from "../types.js";
|
||||||
|
import { FailureNote, formatSince, OnlineBadge, renderLoadable } from "./shared.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel groups, audience channels, and the display roster.
|
||||||
|
*
|
||||||
|
* A church starts with one group and one default channel. `in-person` and `online` are created here,
|
||||||
|
* when someone actually needs them — not shipped as rows nobody asked for and everybody has to
|
||||||
|
* understand and ignore (ADR 0004).
|
||||||
|
*/
|
||||||
|
export function DisplaysPanel({
|
||||||
|
api,
|
||||||
|
canManage,
|
||||||
|
}: {
|
||||||
|
readonly api: SignageApi;
|
||||||
|
readonly canManage: boolean;
|
||||||
|
}): ReactElement {
|
||||||
|
const groups = useResource(
|
||||||
|
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const viewers = useResource(
|
||||||
|
() => api.get<{ viewers: readonly ViewerHealthView[] }>("viewers"),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const edit = useAction(() => {
|
||||||
|
groups.reload();
|
||||||
|
viewers.reload();
|
||||||
|
});
|
||||||
|
const [groupName, setGroupName] = useState("");
|
||||||
|
const [channelDraft, setChannelDraft] = useState<{ groupId: string; key: string; name: string }>({
|
||||||
|
groupId: "",
|
||||||
|
key: "",
|
||||||
|
name: "",
|
||||||
|
});
|
||||||
|
// "Last seen" is a moving number: without a tick it would freeze at whatever it said when the
|
||||||
|
// roster loaded, and an operator would read a screen that went dark ten minutes ago as current.
|
||||||
|
useTick(5000);
|
||||||
|
const nowMs = Date.now();
|
||||||
|
|
||||||
|
const channels =
|
||||||
|
groups.state.status === "ready"
|
||||||
|
? groups.state.value.groups.flatMap((group) =>
|
||||||
|
group.channels.map((channel) => ({ ...channel, groupName: group.name })),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="signage-columns">
|
||||||
|
<Panel
|
||||||
|
actions={
|
||||||
|
canManage ? (
|
||||||
|
<div className="signage-inline-form">
|
||||||
|
<Field label="New channel group">
|
||||||
|
{(control) => (
|
||||||
|
<TextInput
|
||||||
|
{...control}
|
||||||
|
onChange={(event) => setGroupName(event.target.value)}
|
||||||
|
placeholder="Nursery hallway"
|
||||||
|
value={groupName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Button
|
||||||
|
disabled={edit.pending || groupName.trim().length === 0}
|
||||||
|
onClick={() =>
|
||||||
|
edit.run(async () => {
|
||||||
|
await api.send("POST", "channel-groups", { name: groupName.trim() });
|
||||||
|
setGroupName("");
|
||||||
|
})
|
||||||
|
}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
description="A group is one clock. An audience channel is one rendering of it."
|
||||||
|
title="Channel groups"
|
||||||
|
>
|
||||||
|
{renderLoadable(
|
||||||
|
groups.state,
|
||||||
|
(value) => (
|
||||||
|
<div className="signage-groups" data-testid="signage-groups">
|
||||||
|
{value.groups.map((group) => (
|
||||||
|
<div className="signage-group" key={group.id}>
|
||||||
|
<div className="signage-group__header">
|
||||||
|
<h4 className="signage-editor__subtitle">{group.name}</h4>
|
||||||
|
<Badge tone="info">{group.playback.transport}</Badge>
|
||||||
|
</div>
|
||||||
|
<ul className="ek-list">
|
||||||
|
{group.channels.map((channel) => (
|
||||||
|
<li className="ek-list__item signage-row" key={channel.id}>
|
||||||
|
<span className="signage-row__name">{channel.name}</span>
|
||||||
|
<Badge tone={channel.isDefault ? "success" : "neutral"}>
|
||||||
|
{channel.key}
|
||||||
|
</Badge>
|
||||||
|
{canManage && !channel.isDefault ? (
|
||||||
|
<Button
|
||||||
|
disabled={edit.pending}
|
||||||
|
onClick={() =>
|
||||||
|
edit.run(() => api.send("DELETE", `channels/${channel.id}`))
|
||||||
|
}
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
) : undefined}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{canManage ? (
|
||||||
|
<div className="signage-inline-form" data-testid="signage-add-channel">
|
||||||
|
<Field hint="Lowercase, e.g. online" label="Audience key">
|
||||||
|
{(control) => (
|
||||||
|
<TextInput
|
||||||
|
{...control}
|
||||||
|
onChange={(event) =>
|
||||||
|
setChannelDraft({
|
||||||
|
groupId: group.id,
|
||||||
|
key: event.target.value,
|
||||||
|
name: channelDraft.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
value={channelDraft.groupId === group.id ? channelDraft.key : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="Channel name">
|
||||||
|
{(control) => (
|
||||||
|
<TextInput
|
||||||
|
{...control}
|
||||||
|
onChange={(event) =>
|
||||||
|
setChannelDraft({
|
||||||
|
groupId: group.id,
|
||||||
|
key: channelDraft.key,
|
||||||
|
name: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
value={channelDraft.groupId === group.id ? channelDraft.name : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
edit.pending ||
|
||||||
|
channelDraft.groupId !== group.id ||
|
||||||
|
channelDraft.key.trim() === "" ||
|
||||||
|
channelDraft.name.trim() === ""
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
edit.run(async () => {
|
||||||
|
await api.send("POST", `channel-groups/${group.id}/channels`, {
|
||||||
|
key: channelDraft.key.trim(),
|
||||||
|
name: channelDraft.name.trim(),
|
||||||
|
});
|
||||||
|
setChannelDraft({ groupId: "", key: "", name: "" });
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add audience channel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ label: "Loading channel groups…", onRetry: groups.reload },
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel
|
||||||
|
description="Every screen Signage has seen, and what it last reported."
|
||||||
|
title="Displays"
|
||||||
|
>
|
||||||
|
{renderLoadable(
|
||||||
|
viewers.state,
|
||||||
|
(value) =>
|
||||||
|
value.viewers.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
description="Open a Signage display on a screen and it will register itself here."
|
||||||
|
title="No displays yet"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table label="Display health">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell header>Name</TableCell>
|
||||||
|
<TableCell header>State</TableCell>
|
||||||
|
<TableCell header>Channel</TableCell>
|
||||||
|
<TableCell header>Last seen</TableCell>
|
||||||
|
<TableCell header>Offline ready</TableCell>
|
||||||
|
<TableCell header>Most recent error</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{value.viewers.map((viewer) => (
|
||||||
|
<TableRow key={viewer.id}>
|
||||||
|
<TableCell>
|
||||||
|
{canManage ? (
|
||||||
|
<TextInput
|
||||||
|
aria-label={`Name for ${viewer.name}`}
|
||||||
|
defaultValue={viewer.name}
|
||||||
|
onBlur={(event) =>
|
||||||
|
event.target.value !== viewer.name &&
|
||||||
|
edit.run(() =>
|
||||||
|
api.send("PATCH", `viewers/${viewer.id}`, {
|
||||||
|
name: event.target.value,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
viewer.name
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<OnlineBadge online={viewer.online} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{canManage ? (
|
||||||
|
<Select
|
||||||
|
aria-label={`Channel for ${viewer.name}`}
|
||||||
|
onChange={(event) =>
|
||||||
|
edit.run(() =>
|
||||||
|
api.send("PATCH", `viewers/${viewer.id}`, {
|
||||||
|
channelId: event.target.value,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
value={viewer.channelId ?? ""}
|
||||||
|
>
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<option key={channel.id} value={channel.id}>
|
||||||
|
{`${channel.groupName} · ${channel.name}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
(channels.find((channel) => channel.id === viewer.channelId)?.name ?? "—")
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatSince(viewer.lastSeenAtMs, nowMs)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge tone={viewer.offlineReady ? "success" : "neutral"}>
|
||||||
|
{viewer.offlineReady ? `${viewer.cachedAssetCount} cached` : "Not ready"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{viewer.lastError === null ? (
|
||||||
|
"—"
|
||||||
|
) : (
|
||||||
|
<span title={viewer.lastError.detail ?? undefined}>
|
||||||
|
{viewer.lastError.code}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
),
|
||||||
|
{ label: "Loading displays…", onRetry: viewers.reload },
|
||||||
|
)}
|
||||||
|
<FailureNote failure={edit.failure} />
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { Badge, Button, EmptyState, Field, Panel, TextInput } from "@ekklesia/ui";
|
||||||
|
import { type ChangeEvent, type ReactElement, useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
createUploadQueue,
|
||||||
|
type UploadFile,
|
||||||
|
type UploadItem,
|
||||||
|
type UploadQueue,
|
||||||
|
} from "../../browser/uploads.js";
|
||||||
|
import type { SignageApi } from "../api.js";
|
||||||
|
import { useAction, useResource } from "../hooks.js";
|
||||||
|
import type { ImageRecord, SettingsResponse } from "../types.js";
|
||||||
|
import { FailureNote, formatInstant, renderLoadable } from "./shared.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The native image library and its upload queue.
|
||||||
|
*
|
||||||
|
* The upload behaviour is the part worth reading: an operator drops twenty slides and gets twenty
|
||||||
|
* individually bounded requests, a few at a time. The page stays responsive because only a few are
|
||||||
|
* in flight; one bad file fails alone; and the operator learns about a file that is too large or of
|
||||||
|
* the wrong type before a byte is sent, while the server still checks both again.
|
||||||
|
*/
|
||||||
|
export function ImagesPanel({
|
||||||
|
api,
|
||||||
|
settings,
|
||||||
|
canManage,
|
||||||
|
}: {
|
||||||
|
readonly api: SignageApi;
|
||||||
|
readonly settings: SettingsResponse;
|
||||||
|
readonly canManage: boolean;
|
||||||
|
}): ReactElement {
|
||||||
|
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []);
|
||||||
|
const [uploads, setUploads] = useState<readonly UploadItem[]>([]);
|
||||||
|
const queueRef = useRef<UploadQueue | undefined>(undefined);
|
||||||
|
const [renaming, setRenaming] = useState<{ id: string; value: string } | undefined>(undefined);
|
||||||
|
const rename = useAction(() => {
|
||||||
|
setRenaming(undefined);
|
||||||
|
images.reload();
|
||||||
|
});
|
||||||
|
const remove = useAction(() => images.reload());
|
||||||
|
|
||||||
|
const queue = useMemo(() => {
|
||||||
|
const created = createUploadQueue({
|
||||||
|
concurrency: settings.uploads.concurrency,
|
||||||
|
maxBytes: settings.uploads.maxBytes,
|
||||||
|
maxFiles: settings.uploads.batchMaxFiles,
|
||||||
|
onChange: (items) => setUploads(items),
|
||||||
|
upload: async (file) => {
|
||||||
|
// One image per request, with the label in the query string so the body is exactly the
|
||||||
|
// bytes. The server streams and counts them; nothing here buffers a second copy.
|
||||||
|
const query = new URLSearchParams({ displayName: file.name, filename: file.name });
|
||||||
|
await api.upload(`images?${query.toString()}`, file as unknown as Blob, file.type);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
queueRef.current = created;
|
||||||
|
return created;
|
||||||
|
}, [
|
||||||
|
api,
|
||||||
|
settings.uploads.batchMaxFiles,
|
||||||
|
settings.uploads.concurrency,
|
||||||
|
settings.uploads.maxBytes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onFiles = useCallback(
|
||||||
|
(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files ?? []) as unknown as UploadFile[];
|
||||||
|
if (files.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queue.enqueue(files);
|
||||||
|
void queue.drain().then(() => images.reload());
|
||||||
|
event.target.value = "";
|
||||||
|
},
|
||||||
|
[images, queue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const finished = uploads.filter(
|
||||||
|
(item) => item.status !== "queued" && item.status !== "uploading",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
actions={
|
||||||
|
canManage ? (
|
||||||
|
<label className="signage-upload-trigger">
|
||||||
|
<span className="ek-button ek-button--primary">Add images</span>
|
||||||
|
<input
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
className="signage-upload-input"
|
||||||
|
data-testid="signage-upload-input"
|
||||||
|
multiple
|
||||||
|
onChange={onFiles}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
description={`PNG, JPEG, and WebP. Up to ${Math.round(settings.uploads.maxBytes / (1024 * 1024))} MiB each, ${settings.uploads.batchMaxFiles} at a time.`}
|
||||||
|
title="Images"
|
||||||
|
>
|
||||||
|
{uploads.length === 0 ? undefined : (
|
||||||
|
<div className="signage-uploads" data-testid="signage-uploads">
|
||||||
|
<ul className="ek-list">
|
||||||
|
{uploads.map((item) => (
|
||||||
|
<li className="ek-list__item signage-row" key={item.id}>
|
||||||
|
<span className="signage-row__name">{item.name}</span>
|
||||||
|
<Badge
|
||||||
|
tone={
|
||||||
|
item.status === "done"
|
||||||
|
? "success"
|
||||||
|
: item.status === "failed" || item.status === "rejected"
|
||||||
|
? "danger"
|
||||||
|
: "info"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
{item.message === undefined ? undefined : (
|
||||||
|
<span className="signage-uploads__message">{item.message}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{finished.length === 0 ? undefined : (
|
||||||
|
<Button onClick={() => queue.clearFinished()}>Clear finished</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderLoadable(
|
||||||
|
images.state,
|
||||||
|
(value) =>
|
||||||
|
value.images.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
description="Add a PNG, JPEG, or WebP and it will be available to every timeline."
|
||||||
|
title="No images yet"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="signage-image-grid" data-testid="signage-image-grid">
|
||||||
|
{value.images.map((image) => {
|
||||||
|
const thumbnail = image.renditions.find(
|
||||||
|
(rendition) => rendition.kind === "thumbnail",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<li className="signage-image" key={image.id}>
|
||||||
|
{thumbnail === undefined ? (
|
||||||
|
<div className="signage-image__missing">No preview</div>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
alt={image.displayName}
|
||||||
|
className="signage-image__thumbnail"
|
||||||
|
height={thumbnail.height}
|
||||||
|
src={thumbnail.url}
|
||||||
|
width={thumbnail.width}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="signage-image__meta">
|
||||||
|
{renaming?.id === image.id ? (
|
||||||
|
<Field label="Name">
|
||||||
|
{(control) => (
|
||||||
|
<TextInput
|
||||||
|
{...control}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRenaming({ id: image.id, value: event.target.value })
|
||||||
|
}
|
||||||
|
value={renaming.value}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
<p className="signage-image__name">{image.displayName}</p>
|
||||||
|
)}
|
||||||
|
<p className="signage-image__detail">
|
||||||
|
{`${image.width}×${image.height} · ${formatInstant(image.createdAt, settings.timezone)}`}
|
||||||
|
</p>
|
||||||
|
{image.expiresAt === null ? undefined : (
|
||||||
|
<Badge className="signage-image__expiry" tone="warning">
|
||||||
|
{`Expires ${formatInstant(image.expiresAt, settings.timezone)}`}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<div className="signage-image__actions">
|
||||||
|
{renaming?.id === image.id ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
disabled={rename.pending}
|
||||||
|
onClick={() =>
|
||||||
|
rename.run(() =>
|
||||||
|
api.send("PATCH", `images/${image.id}`, {
|
||||||
|
displayName: renaming.value,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setRenaming(undefined)}>Cancel</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => setRenaming({ id: image.id, value: image.displayName })}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
disabled={remove.pending}
|
||||||
|
onClick={() => remove.run(() => api.send("DELETE", `images/${image.id}`))}
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
),
|
||||||
|
{ label: "Loading images…", onRetry: images.reload },
|
||||||
|
)}
|
||||||
|
<FailureNote failure={rename.failure ?? remove.failure} />
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { Badge, Button, EmptyState, Field, Panel, Select } from "@ekklesia/ui";
|
||||||
|
import { type ReactElement, useState } from "react";
|
||||||
|
import type { SignageApi } from "../api.js";
|
||||||
|
import { useAction, useResource, useTick } from "../hooks.js";
|
||||||
|
import type {
|
||||||
|
ChannelGroupView,
|
||||||
|
PlaybackDetailView,
|
||||||
|
SettingsResponse,
|
||||||
|
TimelineSummary,
|
||||||
|
} from "../types.js";
|
||||||
|
import { PreviewPanel } from "./PreviewPanel.js";
|
||||||
|
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live transport for one channel group.
|
||||||
|
*
|
||||||
|
* Every control here is the same operation the `signage.playback` capability exposes, over the same
|
||||||
|
* serialized service — a volunteer with a phone and an operator at this screen are pressing the same
|
||||||
|
* button, and neither can produce a state the other did not ask for.
|
||||||
|
*/
|
||||||
|
export function PlaybackPanel({
|
||||||
|
api,
|
||||||
|
context,
|
||||||
|
settings,
|
||||||
|
canControl,
|
||||||
|
groupId,
|
||||||
|
onGroupChange,
|
||||||
|
}: {
|
||||||
|
readonly api: SignageApi;
|
||||||
|
readonly context: ModuleUiHostContext;
|
||||||
|
readonly settings: SettingsResponse;
|
||||||
|
readonly canControl: boolean;
|
||||||
|
readonly groupId: string | undefined;
|
||||||
|
readonly onGroupChange: (groupId: string) => void;
|
||||||
|
}): ReactElement {
|
||||||
|
const groups = useResource(
|
||||||
|
() => api.get<{ groups: readonly ChannelGroupView[] }>("channel-groups"),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const timelines = useResource(
|
||||||
|
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const detail = useResource(
|
||||||
|
() =>
|
||||||
|
groupId === undefined
|
||||||
|
? Promise.resolve(undefined)
|
||||||
|
: api.get<PlaybackDetailView>(`playback/${groupId}`),
|
||||||
|
[groupId],
|
||||||
|
);
|
||||||
|
const command = useAction(() => {
|
||||||
|
detail.reload();
|
||||||
|
groups.reload();
|
||||||
|
});
|
||||||
|
const [activateId, setActivateId] = useState("");
|
||||||
|
const previewChannelId =
|
||||||
|
groups.state.status === "ready"
|
||||||
|
? (groups.state.value.groups
|
||||||
|
.find((group) => group.id === groupId)
|
||||||
|
?.channels.find((channel) => channel.isDefault)?.id ?? undefined)
|
||||||
|
: undefined;
|
||||||
|
// A one-second tick so the remaining time counts down rather than sitting still between refreshes.
|
||||||
|
useTick(1000);
|
||||||
|
|
||||||
|
function run(path: string, body?: unknown): void {
|
||||||
|
if (groupId === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
command.run(() => api.send("POST", `playback/${groupId}/${path}`, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="signage-columns">
|
||||||
|
<Panel
|
||||||
|
actions={renderLoadable(
|
||||||
|
groups.state,
|
||||||
|
(value) => (
|
||||||
|
<Field label="Channel group">
|
||||||
|
{(control) => (
|
||||||
|
<Select
|
||||||
|
{...control}
|
||||||
|
onChange={(event) => onGroupChange(event.target.value)}
|
||||||
|
value={groupId ?? ""}
|
||||||
|
>
|
||||||
|
{value.groups.map((group) => (
|
||||||
|
<option key={group.id} value={group.id}>
|
||||||
|
{group.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
),
|
||||||
|
{ label: "Loading groups…" },
|
||||||
|
)}
|
||||||
|
description="One clock for every channel in the group."
|
||||||
|
title="Playback"
|
||||||
|
>
|
||||||
|
{groupId === undefined ? (
|
||||||
|
<EmptyState title="No channel group selected" />
|
||||||
|
) : (
|
||||||
|
renderLoadable(
|
||||||
|
detail.state,
|
||||||
|
(value) =>
|
||||||
|
value === undefined ? (
|
||||||
|
<EmptyState title="No channel group selected" />
|
||||||
|
) : (
|
||||||
|
<div className="signage-playback" data-testid="signage-playback">
|
||||||
|
<div className="signage-playback__status">
|
||||||
|
<Badge
|
||||||
|
tone={
|
||||||
|
value.snapshot.transport === "playing"
|
||||||
|
? "success"
|
||||||
|
: value.snapshot.transport === "idle"
|
||||||
|
? "warning"
|
||||||
|
: "info"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value.snapshot.transport}
|
||||||
|
</Badge>
|
||||||
|
{value.snapshot.blackout ? <Badge tone="danger">Blackout</Badge> : undefined}
|
||||||
|
{value.current?.stale === true ? (
|
||||||
|
<Badge tone="warning">Stale</Badge>
|
||||||
|
) : undefined}
|
||||||
|
{value.current?.withdrawn === true ? (
|
||||||
|
<Badge tone="warning">Withdrawn</Badge>
|
||||||
|
) : undefined}
|
||||||
|
{value.snapshot.pendingTimelineId === null ? undefined : (
|
||||||
|
<Badge tone="info">Activation queued</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{value.current === null ? (
|
||||||
|
<EmptyState
|
||||||
|
description="Nothing is currently resolvable. A display shows its safe slate."
|
||||||
|
title="Nothing on screen"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<dl className="signage-playback__current" data-testid="signage-current">
|
||||||
|
<dt>Provider</dt>
|
||||||
|
<dd>{value.current.providerId}</dd>
|
||||||
|
<dt>Item</dt>
|
||||||
|
<dd>{value.current.itemId}</dd>
|
||||||
|
<dt>Renderer</dt>
|
||||||
|
<dd>{value.current.rendererContributionId ?? "—"}</dd>
|
||||||
|
<dt>Duration</dt>
|
||||||
|
<dd>{formatDuration(value.current.durationMs)}</dd>
|
||||||
|
<dt>Remaining</dt>
|
||||||
|
<dd data-testid="signage-remaining">
|
||||||
|
{value.snapshot.remainingMs === null
|
||||||
|
? "—"
|
||||||
|
: formatDuration(value.snapshot.remainingMs)}
|
||||||
|
</dd>
|
||||||
|
<dt>Started</dt>
|
||||||
|
<dd>{formatInstant(value.current.startedAtMs, settings.timezone)}</dd>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canControl ? (
|
||||||
|
<div className="signage-playback__controls" data-testid="signage-controls">
|
||||||
|
<Button disabled={command.pending} onClick={() => run("previous")}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
{value.snapshot.transport === "paused" ? (
|
||||||
|
<Button
|
||||||
|
disabled={command.pending}
|
||||||
|
onClick={() => run("resume")}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
Resume
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button disabled={command.pending} onClick={() => run("pause")}>
|
||||||
|
Pause
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button disabled={command.pending} onClick={() => run("next")}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={command.pending}
|
||||||
|
onClick={() => run("blackout", { on: !value.snapshot.blackout })}
|
||||||
|
variant={value.snapshot.blackout ? "primary" : "secondary"}
|
||||||
|
>
|
||||||
|
{value.snapshot.blackout ? "End blackout" : "Blackout"}
|
||||||
|
</Button>
|
||||||
|
{value.snapshot.staticAssetId === null ? undefined : (
|
||||||
|
<Button
|
||||||
|
disabled={command.pending}
|
||||||
|
onClick={() => run("static", { assetId: null })}
|
||||||
|
>
|
||||||
|
Release hold
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button disabled={command.pending} onClick={() => run("reload")}>
|
||||||
|
Reload content
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{canControl ? (
|
||||||
|
<div className="signage-playback__activate">
|
||||||
|
{renderLoadable(
|
||||||
|
timelines.state,
|
||||||
|
(loaded) => (
|
||||||
|
<>
|
||||||
|
<Field label="Activate timeline">
|
||||||
|
{(control) => (
|
||||||
|
<Select
|
||||||
|
{...control}
|
||||||
|
onChange={(event) => setActivateId(event.target.value)}
|
||||||
|
value={activateId}
|
||||||
|
>
|
||||||
|
<option value="">Choose a timeline…</option>
|
||||||
|
{loaded.timelines.map((timeline) => (
|
||||||
|
<option key={timeline.id} value={timeline.id}>
|
||||||
|
{timeline.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Button
|
||||||
|
disabled={command.pending || activateId === ""}
|
||||||
|
onClick={() =>
|
||||||
|
run("activate", { timelineId: activateId, when: "now" })
|
||||||
|
}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
Now
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={command.pending || activateId === ""}
|
||||||
|
onClick={() =>
|
||||||
|
run("activate", { timelineId: activateId, when: "next-boundary" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
At next slot
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
{ label: "Loading timelines…" },
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{value.history.length === 0 ? undefined : (
|
||||||
|
<div className="signage-playback__history">
|
||||||
|
<h4 className="signage-editor__subtitle">Recently played</h4>
|
||||||
|
<ul className="ek-list">
|
||||||
|
{value.history.slice(0, 8).map((entry) => (
|
||||||
|
<li className="ek-list__item signage-row" key={entry.id}>
|
||||||
|
<span className="signage-row__name">{entry.itemId}</span>
|
||||||
|
<span className="signage-playback__history-meta">
|
||||||
|
{`${entry.providerId} · ${formatInstant(entry.startedAtMs, settings.timezone)}`}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ label: "Loading playback…", onRetry: detail.reload },
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<FailureNote failure={command.failure} />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<PreviewPanel api={api} channelId={previewChannelId} context={context} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { EmptyState, Panel } from "@ekklesia/ui";
|
||||||
|
import { type ReactElement, useEffect, useRef } from "react";
|
||||||
|
import { SignageSlate } from "../../browser/slate.js";
|
||||||
|
import { createDisplayController, type DisplayChannelStateView } from "../../display/controller.js";
|
||||||
|
import type { SignageApi } from "../api.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live preview.
|
||||||
|
*
|
||||||
|
* It mounts **the display controller itself** — the same scene stage, the same native-image
|
||||||
|
* presentation resolver, the same transition, the same renderer host — against the same channel
|
||||||
|
* state a wall reads. A preview that reimplemented any of that would drift from what the room
|
||||||
|
* actually sees, which makes it worse than no preview at all.
|
||||||
|
*
|
||||||
|
* It differs from a display in exactly two ways, both of them `telemetry: false`: it registers no
|
||||||
|
* viewer, so it never appears in the display roster, and it sends no heartbeat, so it never reports
|
||||||
|
* health for a screen that does not exist. It drives its own refresh instead of subscribing.
|
||||||
|
*/
|
||||||
|
export function PreviewPanel({
|
||||||
|
api,
|
||||||
|
channelId,
|
||||||
|
context,
|
||||||
|
refreshMs = 2000,
|
||||||
|
}: {
|
||||||
|
readonly api: SignageApi;
|
||||||
|
readonly channelId: string | undefined;
|
||||||
|
readonly context: ModuleUiHostContext;
|
||||||
|
readonly refreshMs?: number;
|
||||||
|
}): ReactElement {
|
||||||
|
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = stageRef.current;
|
||||||
|
if (!root || channelId === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let stopped = false;
|
||||||
|
const controller = createDisplayController({ context, root, telemetry: false });
|
||||||
|
let handle: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
async function pump(): Promise<void> {
|
||||||
|
if (stopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const state = await api.get<DisplayChannelStateView>(`channels/${channelId}/display-state`);
|
||||||
|
if (!stopped) {
|
||||||
|
await controller.apply(state);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A preview that cannot reach the server simply keeps its last frame, exactly as a wall
|
||||||
|
// does. Nothing about a failed poll belongs in the operator's face.
|
||||||
|
}
|
||||||
|
if (!stopped) {
|
||||||
|
handle = setTimeout(() => void pump(), refreshMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void controller.start().then(() => pump());
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
if (handle !== undefined) {
|
||||||
|
clearTimeout(handle);
|
||||||
|
}
|
||||||
|
void controller.stop();
|
||||||
|
};
|
||||||
|
}, [api, channelId, context, refreshMs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
description="Exactly what a display on this channel is showing, rendered by the display's own code."
|
||||||
|
title="Live preview"
|
||||||
|
>
|
||||||
|
{channelId === undefined ? (
|
||||||
|
<EmptyState title="No channel to preview" />
|
||||||
|
) : (
|
||||||
|
<div className="signage-preview">
|
||||||
|
<div className="signage-preview__frame">
|
||||||
|
<div className="signage-stage" data-testid="signage-preview-stage" ref={stageRef}>
|
||||||
|
<div className="signage-slate-host">
|
||||||
|
<SignageSlate />
|
||||||
|
</div>
|
||||||
|
<div aria-hidden="true" className="signage-blackout" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
EmptyState,
|
||||||
|
Panel,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
} from "@ekklesia/ui";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import type { SignageApi } from "../api.js";
|
||||||
|
import { useResource } from "../hooks.js";
|
||||||
|
import type { ProviderHealth, SettingsResponse } from "../types.js";
|
||||||
|
import { formatInstant, renderLoadable } from "./shared.js";
|
||||||
|
|
||||||
|
const SLIDE_RENDERER_CAPABILITY = "signage.slide-renderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider health, and the one thing only a browser can tell an operator.
|
||||||
|
*
|
||||||
|
* A registration is a **running fact**: a provider that was uninstalled or disabled simply is not
|
||||||
|
* here, and there is no stored row claiming otherwise. Everything shown is either the provider's own
|
||||||
|
* declared identity or a count Signage kept — never a provider's exception, message, or payload.
|
||||||
|
*
|
||||||
|
* Renderer availability is checked here rather than on the server because the server genuinely
|
||||||
|
* cannot know it: whether the platform published a module's contribution is a fact the *host* holds,
|
||||||
|
* and `context.contributions` is where it is legible. A provider whose renderer was not published —
|
||||||
|
* because the module is missing, disabled, or outside Signage's compatible range — is reported as
|
||||||
|
* exactly that, which is the difference between "this is broken" and "this needs an update".
|
||||||
|
*/
|
||||||
|
export function ProvidersPanel({
|
||||||
|
api,
|
||||||
|
context,
|
||||||
|
settings,
|
||||||
|
}: {
|
||||||
|
readonly api: SignageApi;
|
||||||
|
readonly context: ModuleUiHostContext;
|
||||||
|
readonly settings: SettingsResponse;
|
||||||
|
}): ReactElement {
|
||||||
|
const providers = useResource(
|
||||||
|
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const published = new Set(
|
||||||
|
context.contributions.list(SLIDE_RENDERER_CAPABILITY).map((contribution) => contribution.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
description={`Renderer input contract ${settings.rendererInputVersion}. Only providers that are actually running appear here.`}
|
||||||
|
title="Content providers"
|
||||||
|
>
|
||||||
|
{renderLoadable(
|
||||||
|
providers.state,
|
||||||
|
(value) =>
|
||||||
|
value.providers.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
description="Signage works on its own with native images. Install a content module to add more."
|
||||||
|
title="No content providers registered"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table label="Provider health">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell header>Provider</TableCell>
|
||||||
|
<TableCell header>Contract</TableCell>
|
||||||
|
<TableCell header>Renderer</TableCell>
|
||||||
|
<TableCell header>Health</TableCell>
|
||||||
|
<TableCell header>Last success</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{value.providers.map((provider) => {
|
||||||
|
const rendererPublished = published.has(provider.renderer.contributionId);
|
||||||
|
const isNative = provider.renderer.contributionId === "signage.native-image";
|
||||||
|
return (
|
||||||
|
<TableRow key={provider.providerId}>
|
||||||
|
<TableCell>
|
||||||
|
<span className="signage-provider__id">{provider.providerId}</span>
|
||||||
|
<span className="signage-provider__version">{`v${provider.providerVersion}`}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{provider.contractVersion}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isNative ? (
|
||||||
|
<Badge tone="success">Signage renders this</Badge>
|
||||||
|
) : rendererPublished ? (
|
||||||
|
<Badge tone="success">{provider.renderer.contributionId}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge tone="warning">Renderer not published</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{provider.consecutiveFailures === 0 ? (
|
||||||
|
<Badge tone="success">Healthy</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge tone="danger">
|
||||||
|
{`${provider.consecutiveFailures} failures · ${provider.lastFailureCode ?? "unknown"}`}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatInstant(provider.lastSuccessAt, settings.timezone)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
),
|
||||||
|
{ label: "Loading providers…", onRetry: providers.reload },
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
EmptyState,
|
||||||
|
Field,
|
||||||
|
Panel,
|
||||||
|
Select,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextInput,
|
||||||
|
} from "@ekklesia/ui";
|
||||||
|
import { type ReactElement, useState } from "react";
|
||||||
|
import type { SignageApi } from "../api.js";
|
||||||
|
import { useAction, useResource } from "../hooks.js";
|
||||||
|
import type {
|
||||||
|
ImageRecord,
|
||||||
|
ProviderHealth,
|
||||||
|
ProviderItemDescriptor,
|
||||||
|
ProviderSourceDescriptor,
|
||||||
|
SettingsResponse,
|
||||||
|
TimelineDetail,
|
||||||
|
TimelineSummary,
|
||||||
|
} from "../types.js";
|
||||||
|
import { FailureNote, formatDuration, formatInstant, renderLoadable } from "./shared.js";
|
||||||
|
|
||||||
|
const NATIVE_PROVIDER_ID = "signage.native-images";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timeline editor.
|
||||||
|
*
|
||||||
|
* The pattern an operator arranges here is the product: an ordered list of slots, each either a
|
||||||
|
* provider source that asks for the next item or one pinned item. Repeating a source is how a loop
|
||||||
|
* gets three announcements in a row — the five occurrences share one rotation, and this screen makes
|
||||||
|
* that visible by naming the source on every row rather than hiding it.
|
||||||
|
*/
|
||||||
|
export function TimelinesPanel({
|
||||||
|
api,
|
||||||
|
settings,
|
||||||
|
canManage,
|
||||||
|
onActivate,
|
||||||
|
}: {
|
||||||
|
readonly api: SignageApi;
|
||||||
|
readonly settings: SettingsResponse;
|
||||||
|
readonly canManage: boolean;
|
||||||
|
readonly onActivate: ((timelineId: string) => void) | undefined;
|
||||||
|
}): ReactElement {
|
||||||
|
const timelines = useResource(
|
||||||
|
() => api.get<{ timelines: readonly TimelineSummary[] }>("timelines"),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const providers = useResource(
|
||||||
|
() => api.get<{ providers: readonly ProviderHealth[] }>("providers"),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | undefined>(undefined);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const create = useAction(() => {
|
||||||
|
setNewName("");
|
||||||
|
timelines.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="signage-columns">
|
||||||
|
<Panel
|
||||||
|
actions={
|
||||||
|
canManage ? (
|
||||||
|
<div className="signage-inline-form">
|
||||||
|
<Field label="New timeline">
|
||||||
|
{(control) => (
|
||||||
|
<TextInput
|
||||||
|
{...control}
|
||||||
|
onChange={(event) => setNewName(event.target.value)}
|
||||||
|
placeholder="Sunday morning"
|
||||||
|
value={newName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Button
|
||||||
|
disabled={create.pending || newName.trim().length === 0}
|
||||||
|
onClick={() =>
|
||||||
|
create.run(() => api.send("POST", "timelines", { name: newName.trim() }))
|
||||||
|
}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
description="Saved patterns an operator builds on demand. Signage ships none."
|
||||||
|
title="Timelines"
|
||||||
|
>
|
||||||
|
{renderLoadable(
|
||||||
|
timelines.state,
|
||||||
|
(value) =>
|
||||||
|
value.timelines.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
description="Create one and add slots to it. There are no presets, on purpose."
|
||||||
|
title="No timelines yet"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table label="Saved timelines">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell header>Name</TableCell>
|
||||||
|
<TableCell header>Eligible</TableCell>
|
||||||
|
<TableCell header>Revision</TableCell>
|
||||||
|
<TableCell header>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{value.timelines.map((timeline) => (
|
||||||
|
<TableRow key={timeline.id} selected={timeline.id === selectedId}>
|
||||||
|
<TableCell>
|
||||||
|
<Button onClick={() => setSelectedId(timeline.id)}>{timeline.name}</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{timeline.eligibleFrom === null && timeline.eligibleUntil === null
|
||||||
|
? "Always"
|
||||||
|
: `${formatInstant(timeline.eligibleFrom, settings.timezone)} → ${formatInstant(timeline.eligibleUntil, settings.timezone)}`}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{timeline.revision}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{onActivate === undefined ? undefined : (
|
||||||
|
<Button onClick={() => onActivate(timeline.id)} variant="primary">
|
||||||
|
Activate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
),
|
||||||
|
{ label: "Loading timelines…", onRetry: timelines.reload },
|
||||||
|
)}
|
||||||
|
<FailureNote failure={create.failure} />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{selectedId === undefined ? undefined : (
|
||||||
|
<TimelineEditor
|
||||||
|
api={api}
|
||||||
|
canManage={canManage}
|
||||||
|
key={selectedId}
|
||||||
|
onChanged={timelines.reload}
|
||||||
|
onClose={() => setSelectedId(undefined)}
|
||||||
|
providers={providers.state.status === "ready" ? providers.state.value.providers : []}
|
||||||
|
settings={settings}
|
||||||
|
timelineId={selectedId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineEditor({
|
||||||
|
api,
|
||||||
|
canManage,
|
||||||
|
onChanged,
|
||||||
|
onClose,
|
||||||
|
providers,
|
||||||
|
settings,
|
||||||
|
timelineId,
|
||||||
|
}: {
|
||||||
|
readonly api: SignageApi;
|
||||||
|
readonly canManage: boolean;
|
||||||
|
readonly onChanged: () => void;
|
||||||
|
readonly onClose: () => void;
|
||||||
|
readonly providers: readonly ProviderHealth[];
|
||||||
|
readonly settings: SettingsResponse;
|
||||||
|
readonly timelineId: string;
|
||||||
|
}): ReactElement {
|
||||||
|
const detail = useResource(
|
||||||
|
() => api.get<TimelineDetail>(`timelines/${timelineId}`),
|
||||||
|
[timelineId],
|
||||||
|
);
|
||||||
|
const images = useResource(() => api.get<{ images: readonly ImageRecord[] }>("images"), []);
|
||||||
|
const edit = useAction(() => {
|
||||||
|
detail.reload();
|
||||||
|
onChanged();
|
||||||
|
});
|
||||||
|
const [providerId, setProviderId] = useState("");
|
||||||
|
const [sources, setSources] = useState<readonly ProviderSourceDescriptor[]>([]);
|
||||||
|
const [items, setItems] = useState<readonly ProviderItemDescriptor[]>([]);
|
||||||
|
|
||||||
|
function loadProvider(nextProviderId: string): void {
|
||||||
|
setProviderId(nextProviderId);
|
||||||
|
setSources([]);
|
||||||
|
setItems([]);
|
||||||
|
if (nextProviderId === "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void api
|
||||||
|
.get<{ sources: readonly ProviderSourceDescriptor[] }>(
|
||||||
|
`providers/${encodeURIComponent(nextProviderId)}/sources`,
|
||||||
|
)
|
||||||
|
.then((response) => setSources(response.sources))
|
||||||
|
.catch(() => setSources([]));
|
||||||
|
void api
|
||||||
|
.get<{ items: readonly ProviderItemDescriptor[] }>(
|
||||||
|
`providers/${encodeURIComponent(nextProviderId)}/items`,
|
||||||
|
)
|
||||||
|
.then((response) => setItems(response.items))
|
||||||
|
.catch(() => setItems([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
actions={<Button onClick={onClose}>Close</Button>}
|
||||||
|
description="Slots run in order and repeat. Repeating one source is one rotation, not several."
|
||||||
|
title="Timeline pattern"
|
||||||
|
>
|
||||||
|
{renderLoadable(
|
||||||
|
detail.state,
|
||||||
|
(value) => (
|
||||||
|
<div className="signage-editor" data-testid="signage-timeline-editor">
|
||||||
|
<h3 className="signage-editor__title">{value.timeline.name}</h3>
|
||||||
|
|
||||||
|
{canManage ? (
|
||||||
|
<div className="signage-editor__add" data-testid="signage-add-source">
|
||||||
|
<Field
|
||||||
|
hint="A provider's own source. Repeat its slot to give it more turns in one rotation."
|
||||||
|
label="Content provider"
|
||||||
|
>
|
||||||
|
{(control) => (
|
||||||
|
<Select
|
||||||
|
{...control}
|
||||||
|
onChange={(event) => loadProvider(event.target.value)}
|
||||||
|
value={providerId}
|
||||||
|
>
|
||||||
|
<option value="">Choose a provider…</option>
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<option key={provider.providerId} value={provider.providerId}>
|
||||||
|
{provider.providerId}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
{sources.map((source) => (
|
||||||
|
<Button
|
||||||
|
disabled={edit.pending}
|
||||||
|
key={source.id}
|
||||||
|
onClick={() =>
|
||||||
|
edit.run(() =>
|
||||||
|
api.send("POST", `timelines/${timelineId}/sources`, {
|
||||||
|
label: source.label,
|
||||||
|
providerId,
|
||||||
|
sourceId: source.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{`Add source: ${source.label}`}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{items.slice(0, 20).map((item) => (
|
||||||
|
<Button
|
||||||
|
disabled={edit.pending}
|
||||||
|
key={item.id}
|
||||||
|
onClick={() =>
|
||||||
|
edit.run(() =>
|
||||||
|
api.send("POST", `timelines/${timelineId}/slots`, {
|
||||||
|
itemId: item.id,
|
||||||
|
providerId,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{`Pin item: ${item.label}`}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{value.sources.length === 0 ? undefined : (
|
||||||
|
<div className="signage-editor__sources">
|
||||||
|
<h4 className="signage-editor__subtitle">Configured sources</h4>
|
||||||
|
{value.sources.map((source) => (
|
||||||
|
<div className="signage-editor__source" key={source.id}>
|
||||||
|
<span>{source.label}</span>
|
||||||
|
<Badge tone="info">{source.providerId}</Badge>
|
||||||
|
{canManage ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
disabled={edit.pending}
|
||||||
|
onClick={() =>
|
||||||
|
edit.run(() =>
|
||||||
|
api.send("POST", `timelines/${timelineId}/slots`, {
|
||||||
|
sourceId: source.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add slot
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={edit.pending}
|
||||||
|
onClick={() =>
|
||||||
|
edit.run(() =>
|
||||||
|
api.send("DELETE", `timelines/${timelineId}/sources/${source.id}`),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{value.slots.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
description="Add a provider source or pin an image, then repeat it as often as the loop needs."
|
||||||
|
title="This timeline has no slots"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table label="Timeline slots">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell header>#</TableCell>
|
||||||
|
<TableCell header>Content</TableCell>
|
||||||
|
<TableCell header>Duration</TableCell>
|
||||||
|
<TableCell header>Enabled</TableCell>
|
||||||
|
<TableCell header>Order</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{value.slots.map((slot, index) => {
|
||||||
|
const source = value.sources.find(
|
||||||
|
(candidate) => candidate.id === slot.sourceId,
|
||||||
|
);
|
||||||
|
const image =
|
||||||
|
slot.providerId === NATIVE_PROVIDER_ID && images.state.status === "ready"
|
||||||
|
? images.state.value.images.find(
|
||||||
|
(candidate) => candidate.id === slot.itemId,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<TableRow key={slot.id}>
|
||||||
|
<TableCell>{slot.position + 1}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{slot.kind === "provider-source"
|
||||||
|
? (source?.label ?? "Removed source")
|
||||||
|
: (image?.displayName ?? `${slot.providerId} · ${slot.itemId}`)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{slot.durationOverrideMs === null
|
||||||
|
? `Provider default (${formatDuration(settings.nativeImageDurationMs)} for images)`
|
||||||
|
: formatDuration(slot.durationOverrideMs)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={slot.enabled}
|
||||||
|
disabled={!canManage || edit.pending}
|
||||||
|
label={slot.enabled ? "On" : "Off"}
|
||||||
|
onChange={(event) =>
|
||||||
|
edit.run(() =>
|
||||||
|
api.send("PATCH", `timelines/${timelineId}/slots/${slot.id}`, {
|
||||||
|
enabled: event.target.checked,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{canManage ? (
|
||||||
|
<div className="signage-editor__order">
|
||||||
|
<Button
|
||||||
|
disabled={index === 0 || edit.pending}
|
||||||
|
onClick={() =>
|
||||||
|
edit.run(() =>
|
||||||
|
api.send("PUT", `timelines/${timelineId}/slot-order`, {
|
||||||
|
slotIds: swap(
|
||||||
|
value.slots.map((candidate) => candidate.id),
|
||||||
|
index,
|
||||||
|
index - 1,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Up
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={index === value.slots.length - 1 || edit.pending}
|
||||||
|
onClick={() =>
|
||||||
|
edit.run(() =>
|
||||||
|
api.send("PUT", `timelines/${timelineId}/slot-order`, {
|
||||||
|
slotIds: swap(
|
||||||
|
value.slots.map((candidate) => candidate.id),
|
||||||
|
index,
|
||||||
|
index + 1,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Down
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={edit.pending}
|
||||||
|
onClick={() =>
|
||||||
|
edit.run(() =>
|
||||||
|
api.send("DELETE", `timelines/${timelineId}/slots/${slot.id}`),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canManage ? (
|
||||||
|
<div className="signage-editor__footer">
|
||||||
|
<Button
|
||||||
|
disabled={edit.pending}
|
||||||
|
onClick={() =>
|
||||||
|
edit.run(() =>
|
||||||
|
api.send("POST", `timelines/${timelineId}/duplicate`, {
|
||||||
|
name: `${value.timeline.name} (copy)`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Duplicate
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={edit.pending}
|
||||||
|
onClick={() =>
|
||||||
|
edit.run(async () => {
|
||||||
|
await api.send("DELETE", `timelines/${timelineId}`);
|
||||||
|
onClose();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
Delete timeline
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ label: "Loading timeline…", onRetry: detail.reload },
|
||||||
|
)}
|
||||||
|
<FailureNote failure={edit.failure} />
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function swap(ids: readonly string[], from: number, to: number): string[] {
|
||||||
|
const next = [...ids];
|
||||||
|
const moved = next[from];
|
||||||
|
const target = next[to];
|
||||||
|
if (moved === undefined || target === undefined) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
next[from] = target;
|
||||||
|
next[to] = moved;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Badge, ErrorState, LoadingState } from "@ekklesia/ui";
|
||||||
|
import type { ReactElement, ReactNode } from "react";
|
||||||
|
import type { SignageApiFailure } from "../api.js";
|
||||||
|
import type { Loadable } from "../hooks.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place a panel's loading, failure, and ready states are turned into elements.
|
||||||
|
*
|
||||||
|
* Every panel uses it, so a screen that cannot load looks the same wherever it happens — and the
|
||||||
|
* failure text is always Signage's own sanitized message, never a caught exception.
|
||||||
|
*/
|
||||||
|
export function renderLoadable<Value>(
|
||||||
|
state: Loadable<Value>,
|
||||||
|
render: (value: Value) => ReactNode,
|
||||||
|
options: { readonly label: string; readonly onRetry?: () => void },
|
||||||
|
): ReactNode {
|
||||||
|
if (state.status === "loading") {
|
||||||
|
return <LoadingState label={options.label} />;
|
||||||
|
}
|
||||||
|
if (state.status === "failed") {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
action={
|
||||||
|
options.onRetry === undefined ? undefined : (
|
||||||
|
<button
|
||||||
|
className="ek-button ek-button--secondary"
|
||||||
|
onClick={options.onRetry}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
description={state.failure.message}
|
||||||
|
title="Signage could not load this"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return render(state.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A refused save, shown where the operator was working. The server's own sanitized message and
|
||||||
|
* nothing else: no status line, no URL, no request detail. */
|
||||||
|
export function FailureNote({ failure }: { readonly failure: SignageApiFailure | undefined }) {
|
||||||
|
if (!failure) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p className="signage-failure" role="alert">
|
||||||
|
{failure.message}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(milliseconds: number): string {
|
||||||
|
const seconds = milliseconds / 1000;
|
||||||
|
return seconds % 1 === 0 ? `${seconds}s` : `${seconds.toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatInstant(value: string | number | null, timezone: string): string {
|
||||||
|
if (value === null) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
const date = typeof value === "number" ? new Date(value) : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
// Always in the installation's own zone, and always labelled with it. A church reading "9:00"
|
||||||
|
// needs to know which nine o'clock, and a browser's local zone is not the answer.
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
timeZone: timezone,
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSince(atMs: number | null, nowMs: number): string {
|
||||||
|
if (atMs === null) {
|
||||||
|
return "never";
|
||||||
|
}
|
||||||
|
const seconds = Math.max(0, Math.round((nowMs - atMs) / 1000));
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${seconds}s ago`;
|
||||||
|
}
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
return minutes < 60 ? `${minutes}m ago` : `${Math.round(minutes / 60)}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnlineBadge({ online }: { readonly online: boolean }): ReactElement {
|
||||||
|
return <Badge tone={online ? "success" : "danger"}>{online ? "Online" : "Offline"}</Badge>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
/*
|
||||||
|
* The Signage management UI's own stylesheet.
|
||||||
|
*
|
||||||
|
* Compiled into the declared browser artifact as text and installed by the host into this artifact's
|
||||||
|
* isolated root at mount (ADR 0040), so these selectors reach nothing outside Signage and nothing
|
||||||
|
* outside Signage reaches in.
|
||||||
|
*
|
||||||
|
* Every value is either a registered Ekklesia token or a private `--signage-*` alias derived from
|
||||||
|
* one. Signage declares no `--ekklesia-*` property: those belong to the platform's global theme, and
|
||||||
|
* the build rejects a stylesheet that invents one (ADR 0039). Nothing here recolours a provider's
|
||||||
|
* output or an uploaded image — the Dashboard's theme is the Dashboard's, and a television's content
|
||||||
|
* belongs to whoever made it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The box model for the tree Signage owns.
|
||||||
|
*
|
||||||
|
* An isolated root inherits nothing from the page around it, and the platform's stylesheet declares
|
||||||
|
* no reset of its own — so under the initial `content-box` a `.ek-control`, which is `width: 100%`,
|
||||||
|
* is laid out wider than the field containing it by its own padding and border, and slides under
|
||||||
|
* whatever sits beside it. Setting the box model once here fixes that everywhere, rather than
|
||||||
|
* subtracting padding by hand in each panel.
|
||||||
|
*/
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host {
|
||||||
|
--signage-gap: var(--ekklesia-spacing-lg);
|
||||||
|
--signage-gap-tight: var(--ekklesia-spacing-sm);
|
||||||
|
--signage-panel-radius: var(--ekklesia-radius-lg);
|
||||||
|
--signage-thumbnail-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-desktop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-narrow-notice {
|
||||||
|
background: var(--ekklesia-background-surface);
|
||||||
|
border: var(--ekklesia-border-width) solid var(--ekklesia-border-base);
|
||||||
|
border-radius: var(--signage-panel-radius);
|
||||||
|
color: var(--ekklesia-text-primary);
|
||||||
|
display: grid;
|
||||||
|
font-family: var(--ekklesia-typography-family-sans);
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
padding: var(--ekklesia-spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-narrow-notice__title {
|
||||||
|
font-size: var(--ekklesia-typography-size-heading-3);
|
||||||
|
font-weight: var(--ekklesia-typography-weight-bold);
|
||||||
|
line-height: var(--ekklesia-typography-line-height-tight);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-narrow-notice__body {
|
||||||
|
color: var(--ekklesia-text-secondary);
|
||||||
|
line-height: var(--ekklesia-typography-line-height-normal);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.signage-narrow-notice {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-desktop {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--signage-gap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-columns {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--signage-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-inline-form {
|
||||||
|
align-items: end;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A labelled control beside the button that submits it. The field takes the width its control asks
|
||||||
|
* for and is allowed to shrink below it, so a narrow header squeezes the control rather than sliding
|
||||||
|
* the button on top of it.
|
||||||
|
*/
|
||||||
|
.signage-inline-form > .ek-field {
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-inline-form .ek-control {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* On one line, a field's hint hangs below the row rather than making that one field taller. The row
|
||||||
|
* is bottom-aligned so the controls share a line with the button, and a hint left in flow would push
|
||||||
|
* its own control above the others.
|
||||||
|
*/
|
||||||
|
.signage-inline-form > .ek-field > .ek-field__hint {
|
||||||
|
inset-block-start: 100%;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
padding-block-start: var(--ekklesia-spacing-2xs);
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-inline-form:has(.ek-field__hint) {
|
||||||
|
padding-block-end: var(--ekklesia-spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-failure {
|
||||||
|
color: var(--ekklesia-text-danger);
|
||||||
|
font-size: var(--ekklesia-typography-size-sm);
|
||||||
|
margin: var(--signage-gap-tight) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A list row that carries a name beside its metadata. The platform's list item is a plain block, so
|
||||||
|
* without this the two run together into one unreadable word — and the row's own children already
|
||||||
|
* assume a flex line.
|
||||||
|
*/
|
||||||
|
.signage-row {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--ekklesia-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-row__name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------------------------
|
||||||
|
* Images.
|
||||||
|
* ------------------------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.signage-upload-trigger {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-upload-input {
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-uploads {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
margin-bottom: var(--signage-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-uploads__message {
|
||||||
|
color: var(--ekklesia-text-secondary);
|
||||||
|
font-size: var(--ekklesia-typography-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-image-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--signage-gap);
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(var(--signage-thumbnail-width), 1fr));
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-image {
|
||||||
|
/* Rows sized to their content, with the slack from a taller neighbour left at the bottom rather
|
||||||
|
than spread between the thumbnail and the name. */
|
||||||
|
align-content: start;
|
||||||
|
background: var(--ekklesia-background-canvas);
|
||||||
|
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
|
||||||
|
border-radius: var(--ekklesia-radius-md);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ekklesia-spacing-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: var(--ekklesia-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One box, whatever shape the image is: a library of mixed aspect ratios still reads as a grid, and
|
||||||
|
a tall image is letterboxed here rather than reflowing the card it sits in. */
|
||||||
|
.signage-image__thumbnail {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: var(--ekklesia-background-inset);
|
||||||
|
border-radius: var(--ekklesia-radius-sm);
|
||||||
|
display: block;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-image__missing {
|
||||||
|
align-items: center;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: var(--ekklesia-background-inset);
|
||||||
|
border-radius: var(--ekklesia-radius-sm);
|
||||||
|
color: var(--ekklesia-text-muted);
|
||||||
|
display: flex;
|
||||||
|
font-size: var(--ekklesia-typography-size-sm);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-image__meta {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ekklesia-spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A badge is sized by what it says, not by the column it lands in. */
|
||||||
|
.signage-image__expiry {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-image__name {
|
||||||
|
font-weight: var(--ekklesia-typography-weight-medium);
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-image__detail {
|
||||||
|
color: var(--ekklesia-text-secondary);
|
||||||
|
font-size: var(--ekklesia-typography-size-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-image__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--ekklesia-spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------------------------
|
||||||
|
* Timelines.
|
||||||
|
* ------------------------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.signage-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--signage-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-editor__title {
|
||||||
|
font-size: var(--ekklesia-typography-size-heading-3);
|
||||||
|
font-weight: var(--ekklesia-typography-weight-bold);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-editor__subtitle {
|
||||||
|
color: var(--ekklesia-text-secondary);
|
||||||
|
font-size: var(--ekklesia-typography-size-sm);
|
||||||
|
font-weight: var(--ekklesia-typography-weight-medium);
|
||||||
|
margin: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-editor__add,
|
||||||
|
.signage-editor__sources,
|
||||||
|
.signage-editor__footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-editor__sources {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ekklesia-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-editor__source {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-editor__order {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--ekklesia-spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------------------------
|
||||||
|
* Playback.
|
||||||
|
* ------------------------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.signage-playback {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--signage-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-playback__status,
|
||||||
|
.signage-playback__controls,
|
||||||
|
.signage-playback__activate {
|
||||||
|
align-items: end;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-playback__current {
|
||||||
|
column-gap: var(--signage-gap);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
margin: 0;
|
||||||
|
row-gap: var(--ekklesia-spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-playback__current dt {
|
||||||
|
color: var(--ekklesia-text-secondary);
|
||||||
|
font-size: var(--ekklesia-typography-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-playback__current dd {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-playback__history-meta {
|
||||||
|
color: var(--ekklesia-text-secondary);
|
||||||
|
font-size: var(--ekklesia-typography-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------------------------
|
||||||
|
* Displays and providers.
|
||||||
|
* ------------------------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.signage-groups {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--signage-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-group {
|
||||||
|
border: var(--ekklesia-border-width) solid var(--ekklesia-border-divider);
|
||||||
|
border-radius: var(--ekklesia-radius-md);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
padding: var(--ekklesia-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-group__header {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-provider__id {
|
||||||
|
font-weight: var(--ekklesia-typography-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-provider__version {
|
||||||
|
color: var(--ekklesia-text-muted);
|
||||||
|
font-size: var(--ekklesia-typography-size-sm);
|
||||||
|
/* Far enough that the version reads as a second word, not the tail of the identifier. */
|
||||||
|
margin-left: var(--ekklesia-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------------------------
|
||||||
|
* Live preview.
|
||||||
|
*
|
||||||
|
* The frame is a 16:9 box with a black ground, because that is what a television is. Inside it the
|
||||||
|
* display's own scene layers render exactly as they do on a wall — this panel supplies a size and
|
||||||
|
* nothing else.
|
||||||
|
* ------------------------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.signage-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--signage-gap-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview__frame {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: #000000;
|
||||||
|
border: var(--ekklesia-border-width) solid var(--ekklesia-border-strong);
|
||||||
|
border-radius: var(--ekklesia-radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-stage {
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
min-height: 0;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-layer {
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
transition-duration: 0ms;
|
||||||
|
transition-property: opacity;
|
||||||
|
transition-timing-function: linear;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-layer__background {
|
||||||
|
background: #000000;
|
||||||
|
inset: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-layer__background-image {
|
||||||
|
display: none;
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
position: absolute;
|
||||||
|
transform: scale(1.08);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-layer__darken {
|
||||||
|
background: #000000;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-layer__foreground {
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-layer__renderer {
|
||||||
|
inset: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-blackout {
|
||||||
|
background: #000000;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
transition: opacity 200ms linear;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview [data-blackout="true"] > .signage-blackout {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-slate-host {
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview [data-slate="true"] > .signage-slate-host {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-slate {
|
||||||
|
align-items: stretch;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-slate__bars {
|
||||||
|
display: grid;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-slate__bar {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-slate__bar--one {
|
||||||
|
background: #6f6f6f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-slate__bar--two {
|
||||||
|
background: #6f6f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-slate__bar--three {
|
||||||
|
background: #2f6f6f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-slate__bar--four {
|
||||||
|
background: #2f6f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-slate__bar--five {
|
||||||
|
background: #6f2f6f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-slate__bar--six {
|
||||||
|
background: #6f2f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signage-preview .signage-slate__message {
|
||||||
|
background: #000000;
|
||||||
|
color: #ffffff;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 2vh;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1.5vh 0;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* The wire shapes Signage's dashboard reads.
|
||||||
|
*
|
||||||
|
* Signage owns both ends of every one of these, which is exactly why they are written down: the
|
||||||
|
* server's route and this file are one contract, and a drift between them is a bug an operator sees
|
||||||
|
* rather than a compiler does.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SettingsResponse {
|
||||||
|
readonly crossfadeMs: number;
|
||||||
|
readonly moduleVersion: string;
|
||||||
|
readonly nativeImageDurationMs: number;
|
||||||
|
readonly nativePresentation: {
|
||||||
|
readonly backgroundMode: string;
|
||||||
|
readonly backgroundColor: string;
|
||||||
|
readonly backgroundDarkened: boolean;
|
||||||
|
readonly blurPixels: number;
|
||||||
|
};
|
||||||
|
readonly rendererInputVersion: string;
|
||||||
|
readonly timezone: string;
|
||||||
|
readonly uploads: {
|
||||||
|
readonly batchMaxFiles: number;
|
||||||
|
readonly concurrency: number;
|
||||||
|
readonly maxBytes: number;
|
||||||
|
readonly maxPixels: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageRendition {
|
||||||
|
readonly kind: string;
|
||||||
|
readonly url: string;
|
||||||
|
readonly expiresAt: string;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageRecord {
|
||||||
|
readonly id: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly originalFilename: string;
|
||||||
|
readonly contentType: string;
|
||||||
|
readonly byteSize: number;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
readonly status: string;
|
||||||
|
readonly expiresAt: string | null;
|
||||||
|
readonly createdAt: string;
|
||||||
|
readonly renditions: readonly ImageRendition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineSummary {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string | null;
|
||||||
|
readonly eligibleFrom: string | null;
|
||||||
|
readonly eligibleUntil: string | null;
|
||||||
|
readonly revision: number;
|
||||||
|
readonly updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineSource {
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly sourceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineSlot {
|
||||||
|
readonly id: string;
|
||||||
|
readonly position: number;
|
||||||
|
readonly kind: "provider-source" | "pinned-item";
|
||||||
|
readonly sourceId: string | null;
|
||||||
|
readonly providerId: string | null;
|
||||||
|
readonly itemId: string | null;
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly durationOverrideMs: number | null;
|
||||||
|
readonly eligibleFrom: string | null;
|
||||||
|
readonly eligibleUntil: string | null;
|
||||||
|
readonly presentation: {
|
||||||
|
readonly backgroundMode: string | null;
|
||||||
|
readonly backgroundColor: string | null;
|
||||||
|
readonly backgroundDarkened: boolean | null;
|
||||||
|
readonly blurPixels: number | null;
|
||||||
|
readonly foregroundShadow: boolean | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineDetail {
|
||||||
|
readonly timeline: TimelineSummary;
|
||||||
|
readonly sources: readonly TimelineSource[];
|
||||||
|
readonly slots: readonly TimelineSlot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderHealth {
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly providerVersion: string;
|
||||||
|
readonly contractVersion: string;
|
||||||
|
readonly renderer: { readonly contributionId: string; readonly contributionVersion: string };
|
||||||
|
readonly defaultDurationMs: number;
|
||||||
|
readonly registeredAt: string;
|
||||||
|
readonly consecutiveFailures: number;
|
||||||
|
readonly lastFailureCode: string | null;
|
||||||
|
readonly lastFailureAt: string | null;
|
||||||
|
readonly lastSuccessAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderSourceDescriptor {
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderItemDescriptor {
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly description?: string;
|
||||||
|
readonly availableUntil?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TransportState = "idle" | "playing" | "paused" | "static";
|
||||||
|
|
||||||
|
export interface PlaybackSnapshotView {
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly revision: number;
|
||||||
|
readonly serverNowMs: number;
|
||||||
|
readonly transport: TransportState;
|
||||||
|
readonly blackout: boolean;
|
||||||
|
readonly activeTimelineId: string | null;
|
||||||
|
readonly pendingTimelineId: string | null;
|
||||||
|
readonly scheduledTimelineId: string | null;
|
||||||
|
readonly scheduledActivationAtMs: number | null;
|
||||||
|
readonly currentSelectionId: string | null;
|
||||||
|
readonly currentSlotId: string | null;
|
||||||
|
readonly startedAtMs: number | null;
|
||||||
|
readonly deadlineAtMs: number | null;
|
||||||
|
readonly remainingMs: number | null;
|
||||||
|
readonly selectionRevision: number | null;
|
||||||
|
readonly staticAssetId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelView {
|
||||||
|
readonly id: string;
|
||||||
|
readonly key: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelGroupView {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly revision: number;
|
||||||
|
readonly channels: readonly ChannelView[];
|
||||||
|
readonly playback: PlaybackSnapshotView;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaybackDetailView {
|
||||||
|
readonly snapshot: PlaybackSnapshotView;
|
||||||
|
readonly current: {
|
||||||
|
readonly id: string;
|
||||||
|
readonly itemId: string;
|
||||||
|
readonly itemRevision: string;
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly rendererContributionId: string | null;
|
||||||
|
readonly revision: number;
|
||||||
|
readonly stale: boolean;
|
||||||
|
readonly withdrawn: boolean;
|
||||||
|
readonly durationMs: number;
|
||||||
|
readonly startedAtMs: number | null;
|
||||||
|
readonly deadlineAtMs: number | null;
|
||||||
|
} | null;
|
||||||
|
readonly history: readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly itemId: string;
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly sequence: number;
|
||||||
|
readonly startedAtMs: number | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewerHealthView {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly online: boolean;
|
||||||
|
readonly channelId: string | null;
|
||||||
|
readonly deviceId: string | null;
|
||||||
|
readonly lastSeenAtMs: number | null;
|
||||||
|
readonly reportedTimelineId: string | null;
|
||||||
|
readonly reportedSelectionId: string | null;
|
||||||
|
readonly reportedProviderId: string | null;
|
||||||
|
readonly reportedContributionId: string | null;
|
||||||
|
readonly offlineReady: boolean;
|
||||||
|
readonly cachedAssetCount: number;
|
||||||
|
readonly lastError: {
|
||||||
|
readonly code: string;
|
||||||
|
readonly detail: string | null;
|
||||||
|
readonly atMs: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { ingestNativeImage } from "../src/server/images/service.js";
|
||||||
|
import {
|
||||||
|
createPlaybackService,
|
||||||
|
type SignagePlaybackService,
|
||||||
|
} from "../src/server/playback/service.js";
|
||||||
|
import {
|
||||||
|
createNativeImageProvider,
|
||||||
|
SIGNAGE_NATIVE_LIBRARY_SOURCE,
|
||||||
|
} from "../src/server/providers/native-provider.js";
|
||||||
|
import {
|
||||||
|
createProviderRegistry,
|
||||||
|
type SignageProviderRegistry,
|
||||||
|
} from "../src/server/providers/registry.js";
|
||||||
|
import { addProviderSource, addSlot, createTimeline } from "../src/server/timelines/service.js";
|
||||||
|
import { SIGNAGE_NATIVE_PROVIDER_ID } from "../src/shared/native-images.js";
|
||||||
|
import { SIGNAGE_DEFAULT_SETTINGS } from "../src/shared/settings.js";
|
||||||
|
import { createFakeStorage, type FakeStorage } from "./support/fake-storage.js";
|
||||||
|
import { makeImage } from "./support/images.js";
|
||||||
|
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scenarios a church would check before trusting this on a wall.
|
||||||
|
*
|
||||||
|
* Most of them are proven in the suite that owns the behaviour — provider policy in
|
||||||
|
* `providers.test.ts`, transport in `playback.test.ts`, transitions in `browser/scene.test.ts`. What
|
||||||
|
* lives here is the handful that cross those seams, plus the two sweeps that are only meaningful
|
||||||
|
* across the whole module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const sourceDirectory = path.join(path.dirname(moduleDirectory), "src");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips comments before scanning.
|
||||||
|
*
|
||||||
|
* These sweeps are about what the code *does*, and this module's comments deliberately name the
|
||||||
|
* things it refuses to use — "never reads `process.env`", "no MQTT" — which is exactly the prose
|
||||||
|
* that makes the refusal legible. Scanning it would punish saying so.
|
||||||
|
*/
|
||||||
|
function withoutComments(text: string): string {
|
||||||
|
return text.replace(/\/\*[\s\S]*?\*\//gu, " ").replace(/(^|[^:])\/\/.*$/gmu, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sourceFiles(directory: string): Promise<string[]> {
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true });
|
||||||
|
const files: string[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...(await sourceFiles(full)));
|
||||||
|
} else if (/\.(?:ts|tsx|css)$/u.test(entry.name)) {
|
||||||
|
files.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Signage on its own, with no content module installed", () => {
|
||||||
|
let store: MemorySignageStore;
|
||||||
|
let storage: FakeStorage;
|
||||||
|
let registry: SignageProviderRegistry;
|
||||||
|
let service: SignagePlaybackService;
|
||||||
|
let clock: number;
|
||||||
|
let counter: number;
|
||||||
|
|
||||||
|
const newId = () => {
|
||||||
|
counter += 1;
|
||||||
|
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
clock = Date.parse("2026-08-30T15:00:00.000Z");
|
||||||
|
counter = 0;
|
||||||
|
store = createMemorySignageStore({ now: () => new Date(clock) });
|
||||||
|
storage = createFakeStorage({ now: () => new Date(clock) });
|
||||||
|
// Nothing but Signage's own image provider. No Announcements, no Trivia, no Scripture.
|
||||||
|
registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 });
|
||||||
|
registry.capability.register(
|
||||||
|
createNativeImageProvider({
|
||||||
|
defaultDurationMs: SIGNAGE_DEFAULT_SETTINGS.nativeImageDurationMs,
|
||||||
|
now: () => new Date(clock),
|
||||||
|
store,
|
||||||
|
}).registration,
|
||||||
|
);
|
||||||
|
service = createPlaybackService({
|
||||||
|
newId,
|
||||||
|
now: () => clock,
|
||||||
|
registry,
|
||||||
|
setTimer: () => undefined,
|
||||||
|
settings: SIGNAGE_DEFAULT_SETTINGS,
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
await service.ensureDefaultGroup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function upload(name: string): Promise<string> {
|
||||||
|
const result = await ingestNativeImage({
|
||||||
|
bytes: await makeImage({ format: "png", height: 1080, width: 1920 }),
|
||||||
|
displayName: name,
|
||||||
|
expiresAt: null,
|
||||||
|
filename: `${name}.png`,
|
||||||
|
storage,
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
return result.image.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("plays a loop of native images, with nothing else installed", async () => {
|
||||||
|
const first = await upload("welcome");
|
||||||
|
// A second image must have different bytes, or content addressing would deduplicate it.
|
||||||
|
const second = (
|
||||||
|
await ingestNativeImage({
|
||||||
|
bytes: await makeImage({ format: "png", height: 900, width: 1600 }),
|
||||||
|
displayName: "giving",
|
||||||
|
expiresAt: null,
|
||||||
|
filename: "giving.png",
|
||||||
|
storage,
|
||||||
|
store,
|
||||||
|
})
|
||||||
|
).image.id;
|
||||||
|
const groups = await store.listChannelGroups();
|
||||||
|
const groupId = groups[0]?.group.id as string;
|
||||||
|
|
||||||
|
let timeline = await createTimeline({ name: "Lobby loop", newId, store });
|
||||||
|
timeline = await addProviderSource({
|
||||||
|
label: "All Signage images",
|
||||||
|
newId,
|
||||||
|
providerId: SIGNAGE_NATIVE_PROVIDER_ID,
|
||||||
|
sourceId: SIGNAGE_NATIVE_LIBRARY_SOURCE,
|
||||||
|
store,
|
||||||
|
timelineId: timeline.timeline.id,
|
||||||
|
});
|
||||||
|
const source = timeline.sources[0];
|
||||||
|
if (!source) {
|
||||||
|
throw new Error("Expected a source.");
|
||||||
|
}
|
||||||
|
await addSlot({
|
||||||
|
newId,
|
||||||
|
source: { sourceId: source.id },
|
||||||
|
store,
|
||||||
|
timelineId: timeline.timeline.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.activate(groupId, timeline.timeline.id, "now");
|
||||||
|
const played: (string | undefined)[] = [];
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
const snapshot = await service.snapshot(groupId);
|
||||||
|
const selection = await store.findSelection(snapshot.currentSelectionId ?? "");
|
||||||
|
played.push(selection?.itemId);
|
||||||
|
clock += SIGNAGE_DEFAULT_SETTINGS.nativeImageDurationMs;
|
||||||
|
await service.reconcile(groupId, "timer");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signage works out of the box: its own provider, its own images, its own rotation.
|
||||||
|
expect(played).toEqual([first, second, first]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives two displays that connected at different moments the same slot and deadline", async () => {
|
||||||
|
await upload("welcome");
|
||||||
|
const groups = await store.listChannelGroups();
|
||||||
|
const groupId = groups[0]?.group.id as string;
|
||||||
|
const timeline = await createTimeline({ name: "Lobby loop", newId, store });
|
||||||
|
await addSlot({
|
||||||
|
newId,
|
||||||
|
pinned: { itemId: await upload("second"), providerId: SIGNAGE_NATIVE_PROVIDER_ID },
|
||||||
|
store,
|
||||||
|
timelineId: timeline.timeline.id,
|
||||||
|
});
|
||||||
|
await service.activate(groupId, timeline.timeline.id, "now");
|
||||||
|
|
||||||
|
// One display asks now; another asks two seconds later, mid-slot.
|
||||||
|
const early = await service.snapshot(groupId);
|
||||||
|
clock += 2000;
|
||||||
|
const late = await service.snapshot(groupId);
|
||||||
|
|
||||||
|
// Both read the same persisted absolute instants. There is no countdown to drift, so two screens
|
||||||
|
// that joined at different moments transition together to the millisecond.
|
||||||
|
expect(late.currentSelectionId).toBe(early.currentSelectionId);
|
||||||
|
expect(late.startedAtMs).toBe(early.startedAtMs);
|
||||||
|
expect(late.deadlineAtMs).toBe(early.deadlineAtMs);
|
||||||
|
// And what each of them has left is computed from that one deadline, not from when it arrived.
|
||||||
|
expect((early.remainingMs ?? 0) - (late.remainingMs ?? 0)).toBe(2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what this release deliberately does not contain", () => {
|
||||||
|
it("names no excluded technology anywhere in its source", async () => {
|
||||||
|
const files = await sourceFiles(sourceDirectory);
|
||||||
|
const contents = await Promise.all(
|
||||||
|
files.map(async (file) => ({ file, text: withoutComments(await readFile(file, "utf8")) })),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every one of these is explicitly out of scope for this release. A single mention would mean
|
||||||
|
// something crept in that an operator would then have to support.
|
||||||
|
const forbidden = [
|
||||||
|
"mqtt",
|
||||||
|
"home-assistant",
|
||||||
|
"homeassistant",
|
||||||
|
"socket.io",
|
||||||
|
"socketio",
|
||||||
|
"sqlite",
|
||||||
|
"better-auth",
|
||||||
|
"@aws-sdk",
|
||||||
|
"s3client",
|
||||||
|
"minio",
|
||||||
|
"seaweedfs",
|
||||||
|
"nginx",
|
||||||
|
"docker-compose",
|
||||||
|
];
|
||||||
|
for (const { file, text } of contents) {
|
||||||
|
const lowered = text.toLowerCase();
|
||||||
|
for (const term of forbidden) {
|
||||||
|
expect(`${path.relative(sourceDirectory, file)}:${term}`).toBe(
|
||||||
|
lowered.includes(term)
|
||||||
|
? `IMPOSSIBLE:${term}`
|
||||||
|
: `${path.relative(sourceDirectory, file)}:${term}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hardcodes no platform URL, origin, or environment variable", async () => {
|
||||||
|
const files = await sourceFiles(sourceDirectory);
|
||||||
|
const contents = await Promise.all(
|
||||||
|
files.map(async (file) => ({ file, text: withoutComments(await readFile(file, "utf8")) })),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const { file, text } of contents) {
|
||||||
|
const relative = path.relative(sourceDirectory, file);
|
||||||
|
// Ekklesia's module API prefix is its private deployment detail; a module that wrote it would
|
||||||
|
// be unhostable anywhere else.
|
||||||
|
expect(`${relative}:${text.includes("/api/v1/")}`).toBe(`${relative}:false`);
|
||||||
|
// Configuration reaches Signage through one contract, and it is not the environment.
|
||||||
|
expect(`${relative}:${/process\.env/u.test(text)}`).toBe(`${relative}:false`);
|
||||||
|
// Only comments and documentation may mention a real host; nothing may fetch one.
|
||||||
|
const fetchesHost = /(?:fetch|request)\(\s*["'`]https?:\/\//u.test(text);
|
||||||
|
expect(`${relative}:${fetchesHost}`).toBe(`${relative}:false`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares no dependency this release excludes", async () => {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
await readFile(path.join(path.dirname(moduleDirectory), "package.json"), "utf8"),
|
||||||
|
) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
|
||||||
|
const names = [
|
||||||
|
...Object.keys(manifest.dependencies ?? {}),
|
||||||
|
...Object.keys(manifest.devDependencies ?? {}),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const forbidden of [
|
||||||
|
"mqtt",
|
||||||
|
"socket.io",
|
||||||
|
"socket.io-client",
|
||||||
|
"better-auth",
|
||||||
|
"better-sqlite3",
|
||||||
|
"@aws-sdk/client-s3",
|
||||||
|
"minio",
|
||||||
|
"pg",
|
||||||
|
]) {
|
||||||
|
expect(names).not.toContain(forbidden);
|
||||||
|
}
|
||||||
|
// The database is reached only through the platform's facade; there is no client of Signage's own.
|
||||||
|
expect(names).toContain("drizzle-orm");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { act } from "react";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createSceneStage, type SceneStage } from "../../src/browser/scene.js";
|
||||||
|
import { rendererInputFor } from "../../src/browser/scene-request.js";
|
||||||
|
import { createMemoryMediaCache, createMemoryStore } from "../../src/browser/storage.js";
|
||||||
|
import {
|
||||||
|
createDisplayController,
|
||||||
|
type DisplayChannelStateView,
|
||||||
|
} from "../../src/display/controller.js";
|
||||||
|
import { createFakeHost } from "./support/fake-host.js";
|
||||||
|
import {
|
||||||
|
createFixtureRenderer,
|
||||||
|
createReadiness,
|
||||||
|
type FixtureRendererCounters,
|
||||||
|
} from "./support/fixture-renderer.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A provider's live React renderer, on a Signage slide.
|
||||||
|
*
|
||||||
|
* The renderer here is test-only and is built with the platform's own `defineModuleContribution`, so
|
||||||
|
* what these prove is the real contract: Signage hands it identity, timing, and an opaque payload,
|
||||||
|
* mounts it through the host, waits for it to say it is ready, updates it in place, and takes it
|
||||||
|
* down deterministically — and never learns anything about what it draws.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
const SELECTION = {
|
||||||
|
availableUntilMs: null,
|
||||||
|
deadlineAtMs: 20_000,
|
||||||
|
durationMs: 20_000,
|
||||||
|
id: "sel-trivia",
|
||||||
|
itemId: "question-1",
|
||||||
|
itemRevision: "1",
|
||||||
|
payload: { answer: "Bethlehem", question: "Where was Jesus born?", revealAfterMs: 2000 },
|
||||||
|
presentation: null,
|
||||||
|
providerId: "fixture.trivia",
|
||||||
|
renderer: {
|
||||||
|
contributionId: "fixture.trivia.slide",
|
||||||
|
contributionVersion: "1.0.0",
|
||||||
|
kind: "contribution" as const,
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
slotId: "slot-trivia",
|
||||||
|
startedAtMs: 0,
|
||||||
|
withdrawn: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTEXT = {
|
||||||
|
audienceKey: "default",
|
||||||
|
localNowMs: 1_000_000,
|
||||||
|
paused: false,
|
||||||
|
remainingMs: null,
|
||||||
|
serverNowMs: 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function settle(): Promise<void> {
|
||||||
|
for (let index = 0; index < 10; index += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("a provider's contributed renderer", () => {
|
||||||
|
let root: HTMLElement;
|
||||||
|
let counters: FixtureRendererCounters;
|
||||||
|
let stage: SceneStage;
|
||||||
|
|
||||||
|
function mountFixture(readiness?: ReturnType<typeof createReadiness>) {
|
||||||
|
const fixture = createFixtureRenderer({ counters, ...(readiness ? { readiness } : {}) });
|
||||||
|
return createSceneStage({
|
||||||
|
crossfadeMs: 0,
|
||||||
|
mountContribution: async ({ container, input }) =>
|
||||||
|
(await fixture.definition.mount(container, {
|
||||||
|
contributionId: fixture.definition.id,
|
||||||
|
input: input as never,
|
||||||
|
})) ?? {},
|
||||||
|
root,
|
||||||
|
setTimer: (handler) => {
|
||||||
|
handler();
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
root = document.createElement("div");
|
||||||
|
document.body.append(root);
|
||||||
|
counters = { mounts: 0, unmounts: 0, updates: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not shown until it says it is ready", async () => {
|
||||||
|
const readiness = createReadiness();
|
||||||
|
stage = mountFixture(readiness);
|
||||||
|
|
||||||
|
const showing = stage.show({
|
||||||
|
contributionId: "fixture.trivia.slide",
|
||||||
|
input: rendererInputFor(SELECTION, CONTEXT),
|
||||||
|
key: "sel-trivia:default",
|
||||||
|
kind: "contribution",
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// Mounted, rendering, and still invisible: the stage waits for the renderer's own promise.
|
||||||
|
expect(counters.mounts).toBe(1);
|
||||||
|
const layers = [...root.querySelectorAll(".signage-layer")] as HTMLElement[];
|
||||||
|
expect(layers.map((layer) => layer.style.opacity)).toEqual(["0", "0"]);
|
||||||
|
|
||||||
|
readiness.ready();
|
||||||
|
await act(async () => {
|
||||||
|
await showing;
|
||||||
|
});
|
||||||
|
expect(layers.filter((layer) => layer.style.opacity === "1")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs its own multi-stage presentation inside one Signage slide", async () => {
|
||||||
|
stage = mountFixture();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await stage.show({
|
||||||
|
contributionId: "fixture.trivia.slide",
|
||||||
|
input: rendererInputFor(SELECTION, CONTEXT),
|
||||||
|
key: "sel-trivia:default",
|
||||||
|
kind: "contribution",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(root.querySelector("[data-testid='fixture-trivia-question']")?.textContent).toBe(
|
||||||
|
"Where was Jesus born?",
|
||||||
|
);
|
||||||
|
// The answer has not been revealed yet: that is the renderer's own timing, inside one slot whose
|
||||||
|
// deadline Signage set and the renderer cannot extend.
|
||||||
|
expect(root.querySelector("[data-testid='fixture-trivia-answer']")).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2100));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(root.querySelector("[data-testid='fixture-trivia-answer']")?.textContent).toBe(
|
||||||
|
"Bethlehem",
|
||||||
|
);
|
||||||
|
// Still one mount, one slide. Signage never learned that anything happened.
|
||||||
|
expect(counters.mounts).toBe(1);
|
||||||
|
expect(counters.updates).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes its countdown from the clock anchor, not the device's clock", async () => {
|
||||||
|
stage = mountFixture();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await stage.show({
|
||||||
|
contributionId: "fixture.trivia.slide",
|
||||||
|
input: rendererInputFor(SELECTION, {
|
||||||
|
...CONTEXT,
|
||||||
|
// A television whose clock is a year out. The anchor is what makes this right.
|
||||||
|
localNowMs: Date.now(),
|
||||||
|
serverNowMs: 5000,
|
||||||
|
}),
|
||||||
|
key: "sel-trivia:default",
|
||||||
|
kind: "contribution",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// Deadline 20s, server now 5s: fifteen seconds left, whatever the device believes.
|
||||||
|
expect(root.querySelector("[data-testid='fixture-trivia-remaining']")?.textContent).toBe("15s");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a different audience differently, from one logical selection", async () => {
|
||||||
|
stage = mountFixture();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await stage.show({
|
||||||
|
contributionId: "fixture.trivia.slide",
|
||||||
|
input: rendererInputFor(SELECTION, { ...CONTEXT, audienceKey: "online" }),
|
||||||
|
key: "sel-trivia:online",
|
||||||
|
kind: "contribution",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(root.querySelector("[data-testid='fixture-trivia-audience']")?.textContent).toBe(
|
||||||
|
"online",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes new content in place, without remounting or restarting its stage", async () => {
|
||||||
|
stage = mountFixture();
|
||||||
|
await act(async () => {
|
||||||
|
await stage.show({
|
||||||
|
contributionId: "fixture.trivia.slide",
|
||||||
|
input: rendererInputFor(SELECTION, CONTEXT),
|
||||||
|
key: "sel-trivia:default",
|
||||||
|
kind: "contribution",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await stage.show({
|
||||||
|
contributionId: "fixture.trivia.slide",
|
||||||
|
input: rendererInputFor(
|
||||||
|
{
|
||||||
|
...SELECTION,
|
||||||
|
payload: { answer: "Bethlehem", question: "Cancelled", revealAfterMs: 2000 },
|
||||||
|
revision: 2,
|
||||||
|
},
|
||||||
|
CONTEXT,
|
||||||
|
),
|
||||||
|
key: "sel-trivia:default",
|
||||||
|
kind: "contribution",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(root.querySelector("[data-testid='fixture-trivia-question']")?.textContent).toBe(
|
||||||
|
"Cancelled",
|
||||||
|
);
|
||||||
|
expect(counters.mounts).toBe(1);
|
||||||
|
expect(counters.updates).toBe(1);
|
||||||
|
expect(counters.unmounts).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts and unmounts repeatedly without leaking", async () => {
|
||||||
|
stage = mountFixture();
|
||||||
|
|
||||||
|
for (let index = 0; index < 4; index += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
await stage.show({
|
||||||
|
contributionId: "fixture.trivia.slide",
|
||||||
|
input: rendererInputFor({ ...SELECTION, id: `sel-${index}` }, CONTEXT),
|
||||||
|
key: `sel-${index}:default`,
|
||||||
|
kind: "contribution",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
await act(async () => {
|
||||||
|
await stage.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Four mounts, four unmounts, and nothing left in the DOM.
|
||||||
|
expect(counters.mounts).toBe(4);
|
||||||
|
expect(counters.unmounts).toBe(4);
|
||||||
|
expect(root.querySelector("[data-testid='fixture-trivia']")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps working from cached state with no network at all", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const media = createMemoryMediaCache();
|
||||||
|
const fixture = createFixtureRenderer({ counters });
|
||||||
|
const host = createFakeHost({
|
||||||
|
contributions: [
|
||||||
|
{ capability: "signage.slide-renderer", id: "fixture.trivia.slide", version: "1.0.0" },
|
||||||
|
],
|
||||||
|
mount: async (options) => {
|
||||||
|
const instance = await fixture.definition.mount(options.container, {
|
||||||
|
contributionId: options.contributionId,
|
||||||
|
input: options.input,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
contributionId: options.contributionId,
|
||||||
|
mounted: true,
|
||||||
|
unmount: async () => {
|
||||||
|
await instance?.unmount?.();
|
||||||
|
},
|
||||||
|
update: async (input) => {
|
||||||
|
await instance?.update?.(input);
|
||||||
|
},
|
||||||
|
version: "1.0.0",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
permissions: [],
|
||||||
|
routes: [],
|
||||||
|
});
|
||||||
|
const state: DisplayChannelStateView = {
|
||||||
|
channel: { groupId: "group-1", id: "chan-1", key: "default" },
|
||||||
|
crossfadeMs: 0,
|
||||||
|
current: SELECTION,
|
||||||
|
playback: {
|
||||||
|
activeTimelineId: "timeline-1",
|
||||||
|
blackout: false,
|
||||||
|
currentSelectionId: SELECTION.id,
|
||||||
|
currentSlotId: SELECTION.slotId,
|
||||||
|
deadlineAtMs: 20_000,
|
||||||
|
pendingTimelineId: null,
|
||||||
|
remainingMs: 20_000,
|
||||||
|
revision: 1,
|
||||||
|
selectionRevision: 1,
|
||||||
|
startedAtMs: 0,
|
||||||
|
transport: "playing",
|
||||||
|
},
|
||||||
|
selections: [SELECTION],
|
||||||
|
serverNowMs: 5000,
|
||||||
|
staticHold: null,
|
||||||
|
timeline: {
|
||||||
|
eligibleFromMs: null,
|
||||||
|
eligibleUntilMs: null,
|
||||||
|
id: "timeline-1",
|
||||||
|
revision: 1,
|
||||||
|
slots: [
|
||||||
|
{
|
||||||
|
eligibleFromMs: null,
|
||||||
|
eligibleUntilMs: null,
|
||||||
|
enabled: true,
|
||||||
|
estimatedDurationMs: 20_000,
|
||||||
|
id: "slot-trivia",
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// One successful online state, persisted.
|
||||||
|
const online = createDisplayController({
|
||||||
|
context: host.context,
|
||||||
|
decodeImage: () => Promise.resolve(),
|
||||||
|
media,
|
||||||
|
now: () => 1_000_000,
|
||||||
|
root,
|
||||||
|
setTimer: () => undefined,
|
||||||
|
store,
|
||||||
|
telemetry: false,
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await online.start();
|
||||||
|
await online.apply(state);
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
await act(async () => {
|
||||||
|
await online.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A fresh controller with the same local storage and no server at all.
|
||||||
|
const offlineRoot = document.createElement("div");
|
||||||
|
document.body.append(offlineRoot);
|
||||||
|
const offline = createDisplayController({
|
||||||
|
context: host.context,
|
||||||
|
decodeImage: () => Promise.resolve(),
|
||||||
|
media,
|
||||||
|
now: () => 1_000_000,
|
||||||
|
root: offlineRoot,
|
||||||
|
setTimer: () => undefined,
|
||||||
|
store,
|
||||||
|
telemetry: false,
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await offline.start();
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// The renderer is on screen, from its cached artifact and this display's cached payload — with
|
||||||
|
// no request made and nothing invented.
|
||||||
|
expect(offlineRoot.querySelector("[data-testid='fixture-trivia-question']")?.textContent).toBe(
|
||||||
|
"Where was Jesus born?",
|
||||||
|
);
|
||||||
|
expect(host.requests).toEqual([]);
|
||||||
|
expect(offline.diagnostics().offline).toBe(true);
|
||||||
|
await act(async () => {
|
||||||
|
await offline.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createMemoryMediaCache, createMemoryStore } from "../../src/browser/storage.js";
|
||||||
|
import {
|
||||||
|
createDisplayController,
|
||||||
|
type DisplayChannelStateView,
|
||||||
|
type DisplayController,
|
||||||
|
} from "../../src/display/controller.js";
|
||||||
|
import { createFakeHost, jsonResponse } from "./support/fake-host.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display, driven end to end against a host: register, fetch state, render, report.
|
||||||
|
*
|
||||||
|
* Every collaborator is injected, so what these prove is the display's own decisions — which frame
|
||||||
|
* it keeps when something fails, what it caches and under what key, what it tells the server, and
|
||||||
|
* what it never puts on a wall.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NATIVE_SCENE = {
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
backgroundImage: false,
|
||||||
|
backgroundMode: "none" as const,
|
||||||
|
blurPixels: 0,
|
||||||
|
darkenOpacity: 0,
|
||||||
|
fit: "fill" as const,
|
||||||
|
shadow: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function nativeSelection(id: string, itemRevision = "rev-1") {
|
||||||
|
return {
|
||||||
|
availableUntilMs: null as number | null,
|
||||||
|
deadlineAtMs: 5000,
|
||||||
|
durationMs: 5000,
|
||||||
|
id,
|
||||||
|
itemId: `image-${id}`,
|
||||||
|
itemRevision,
|
||||||
|
media: {
|
||||||
|
display: {
|
||||||
|
expiresAtMs: 900_000,
|
||||||
|
height: 1080,
|
||||||
|
url: `https://cdn.invalid/${id}?sig=a`,
|
||||||
|
width: 1920,
|
||||||
|
},
|
||||||
|
thumbnail: {
|
||||||
|
expiresAtMs: 900_000,
|
||||||
|
height: 270,
|
||||||
|
url: `https://cdn.invalid/${id}-t?sig=a`,
|
||||||
|
width: 480,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payload: { kind: "native-image" },
|
||||||
|
presentation: null,
|
||||||
|
providerId: "signage.native-images",
|
||||||
|
renderer: { kind: "native-image" as const },
|
||||||
|
revision: 1,
|
||||||
|
scene: NATIVE_SCENE,
|
||||||
|
slotId: "slot-1",
|
||||||
|
stale: false,
|
||||||
|
startedAtMs: 0,
|
||||||
|
withdrawn: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function contributionSelection(id: string, revision = 1) {
|
||||||
|
return {
|
||||||
|
availableUntilMs: null,
|
||||||
|
deadlineAtMs: 5000,
|
||||||
|
durationMs: 5000,
|
||||||
|
id,
|
||||||
|
itemId: "advent",
|
||||||
|
itemRevision: String(revision),
|
||||||
|
payload: { headline: revision === 1 ? "Sunday, 5pm" : "Cancelled" },
|
||||||
|
presentation: null,
|
||||||
|
providerId: "fixture.announcements",
|
||||||
|
renderer: {
|
||||||
|
contributionId: "fixture.announcements.slide",
|
||||||
|
contributionVersion: "1.0.0",
|
||||||
|
kind: "contribution" as const,
|
||||||
|
},
|
||||||
|
revision,
|
||||||
|
slotId: "slot-1",
|
||||||
|
stale: false,
|
||||||
|
startedAtMs: 0,
|
||||||
|
withdrawn: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function state(overrides: Partial<DisplayChannelStateView> = {}): DisplayChannelStateView {
|
||||||
|
// `??` would fall through on an explicit `null`, which is exactly the case these tests care about.
|
||||||
|
const current = "current" in overrides ? overrides.current : nativeSelection("sel-1");
|
||||||
|
return {
|
||||||
|
channel: { groupId: "group-1", id: "chan-1", key: "default" },
|
||||||
|
crossfadeMs: 0,
|
||||||
|
current,
|
||||||
|
playback: {
|
||||||
|
activeTimelineId: "timeline-1" as string | null,
|
||||||
|
blackout: false,
|
||||||
|
currentSelectionId: current?.id ?? null,
|
||||||
|
currentSlotId: current?.slotId ?? null,
|
||||||
|
deadlineAtMs: 5000 as number | null,
|
||||||
|
pendingTimelineId: null as string | null,
|
||||||
|
remainingMs: 5000 as number | null,
|
||||||
|
revision: 3,
|
||||||
|
selectionRevision: current?.revision ?? null,
|
||||||
|
startedAtMs: 0 as number | null,
|
||||||
|
transport: "playing",
|
||||||
|
...overrides.playback,
|
||||||
|
},
|
||||||
|
selections: overrides.selections ?? (current ? [current] : []),
|
||||||
|
serverNowMs: 1000,
|
||||||
|
staticHold: overrides.staticHold ?? null,
|
||||||
|
timeline: overrides.timeline ?? null,
|
||||||
|
...(overrides.channel ? { channel: overrides.channel } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION = {
|
||||||
|
channelId: "chan-1",
|
||||||
|
channelKey: "default",
|
||||||
|
groupId: "group-1",
|
||||||
|
heartbeatIntervalMs: 12_000,
|
||||||
|
viewerId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("display controller", () => {
|
||||||
|
let root: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
root = document.createElement("div");
|
||||||
|
document.body.append(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function settle(): Promise<void> {
|
||||||
|
for (let index = 0; index < 10; index += 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(options: {
|
||||||
|
readonly host: ReturnType<typeof createFakeHost>;
|
||||||
|
readonly media?: ReturnType<typeof createMemoryMediaCache>;
|
||||||
|
readonly store?: ReturnType<typeof createMemoryStore>;
|
||||||
|
readonly telemetry?: boolean;
|
||||||
|
}): DisplayController {
|
||||||
|
return createDisplayController({
|
||||||
|
context: options.host.context,
|
||||||
|
decodeImage: () => Promise.resolve(),
|
||||||
|
media: options.media ?? createMemoryMediaCache(),
|
||||||
|
newViewerKey: () => "viewer-fixed-key-0001",
|
||||||
|
root,
|
||||||
|
// No real timers: the refresh schedule is exercised by calling `apply` directly.
|
||||||
|
setTimer: () => undefined,
|
||||||
|
store: options.store ?? createMemoryStore(),
|
||||||
|
...(options.telemetry === undefined ? {} : { telemetry: options.telemetry }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("registers with a persistent identity and keeps it across restarts", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const keys: unknown[] = [];
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.display.view"],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "viewers/session",
|
||||||
|
respond: ({ init }) => {
|
||||||
|
keys.push(JSON.parse(String(init?.body)));
|
||||||
|
return jsonResponse(SESSION);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "channels/chan-1/display-state",
|
||||||
|
respond: () => jsonResponse(state()),
|
||||||
|
},
|
||||||
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = build({ host, store });
|
||||||
|
await first.start();
|
||||||
|
await settle();
|
||||||
|
await first.stop();
|
||||||
|
|
||||||
|
const second = build({ host, store });
|
||||||
|
await second.start();
|
||||||
|
await settle();
|
||||||
|
await second.stop();
|
||||||
|
|
||||||
|
// The same key both times: a television that reboots is the same screen, not a new one.
|
||||||
|
expect(keys).toEqual([
|
||||||
|
{ viewerKey: "viewer-fixed-key-0001" },
|
||||||
|
{ viewerKey: "viewer-fixed-key-0001" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the server's current selection and reports what it is showing", async () => {
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.display.view"],
|
||||||
|
routes: [
|
||||||
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "channels/chan-1/display-state",
|
||||||
|
respond: () => jsonResponse(state()),
|
||||||
|
},
|
||||||
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const controller = build({ host });
|
||||||
|
|
||||||
|
await controller.start();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const diagnostics = controller.diagnostics();
|
||||||
|
expect(diagnostics.renderedSelectionId).toBe("sel-1");
|
||||||
|
expect(diagnostics.slate).toBe(false);
|
||||||
|
expect(root.querySelectorAll(".signage-layer")).toHaveLength(2);
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches image bytes under a stable key, so a freshly signed URL finds them again", async () => {
|
||||||
|
const media = createMemoryMediaCache();
|
||||||
|
const stored: string[] = [];
|
||||||
|
const wrapped = {
|
||||||
|
...media,
|
||||||
|
store: (key: string, url: string) => {
|
||||||
|
stored.push(key);
|
||||||
|
return media.store(key, url);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const host = createFakeHost({ permissions: [], routes: [] });
|
||||||
|
const controller = build({ host, media: wrapped, telemetry: false });
|
||||||
|
await controller.start();
|
||||||
|
|
||||||
|
await controller.apply(state());
|
||||||
|
// The same bytes, a new signature — exactly what the next refresh carries.
|
||||||
|
const resigned = nativeSelection("sel-1");
|
||||||
|
Object.assign(resigned.media.display, { url: "https://cdn.invalid/sel-1?sig=b" });
|
||||||
|
await controller.apply(state({ current: resigned }));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// One store, because the key is built from provider, item, and item revision — never from the
|
||||||
|
// URL, which changes every time.
|
||||||
|
expect(stored).toEqual(["signage-media/signage.native-images/image-sel-1/rev-1/display"]);
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates a mounted renderer in place when only the payload changed", async () => {
|
||||||
|
const mounts: unknown[] = [];
|
||||||
|
const updates: unknown[] = [];
|
||||||
|
const host = createFakeHost({
|
||||||
|
contributions: [
|
||||||
|
{
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
id: "fixture.announcements.slide",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mount: (options) => {
|
||||||
|
mounts.push(options.contributionId);
|
||||||
|
return {
|
||||||
|
contributionId: options.contributionId,
|
||||||
|
mounted: true,
|
||||||
|
unmount: () => Promise.resolve(),
|
||||||
|
update: (input) => {
|
||||||
|
updates.push(input);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
version: "1.0.0",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
permissions: [],
|
||||||
|
routes: [],
|
||||||
|
});
|
||||||
|
const controller = build({ host, telemetry: false });
|
||||||
|
await controller.start();
|
||||||
|
|
||||||
|
await controller.apply(state({ current: contributionSelection("sel-1", 1) }));
|
||||||
|
await controller.apply(state({ current: contributionSelection("sel-1", 2) }));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// One mount, one update: a cancelled event changes the slide without restarting its slot.
|
||||||
|
expect(mounts).toEqual(["fixture.announcements.slide"]);
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the last good frame and records a code when a renderer is not published", async () => {
|
||||||
|
const host = createFakeHost({ contributions: [], permissions: [], routes: [] });
|
||||||
|
const controller = build({ host, telemetry: false });
|
||||||
|
await controller.start();
|
||||||
|
|
||||||
|
await controller.apply(state());
|
||||||
|
const good = controller.diagnostics().renderedSelectionId;
|
||||||
|
await controller.apply(state({ current: contributionSelection("sel-2", 1) }));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const diagnostics = controller.diagnostics();
|
||||||
|
// Nothing changed on screen, and the reason is a bounded code — never a message on a wall.
|
||||||
|
expect(diagnostics.renderedSelectionId).toBe(good);
|
||||||
|
expect(diagnostics.lastError?.code).toBe("SLIDE_UNRENDERABLE");
|
||||||
|
expect(diagnostics.unusableSelectionIds).toEqual(["sel-2"]);
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry a selection it already knows it cannot render", async () => {
|
||||||
|
let mountAttempts = 0;
|
||||||
|
const host = createFakeHost({
|
||||||
|
contributions: [
|
||||||
|
{
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
id: "fixture.announcements.slide",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mount: () => {
|
||||||
|
mountAttempts += 1;
|
||||||
|
return Promise.reject(new Error("renderer exploded"));
|
||||||
|
},
|
||||||
|
permissions: [],
|
||||||
|
routes: [],
|
||||||
|
});
|
||||||
|
const controller = build({ host, telemetry: false });
|
||||||
|
await controller.start();
|
||||||
|
|
||||||
|
await controller.apply(state({ current: contributionSelection("sel-2", 1) }));
|
||||||
|
await controller.apply(state({ current: contributionSelection("sel-2", 1) }));
|
||||||
|
await controller.apply(state({ current: contributionSelection("sel-2", 1) }));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// One attempt. A broken item never becomes a tight retry loop on an unattended screen.
|
||||||
|
expect(mountAttempts).toBe(1);
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the slate only when it has nothing at all, and never over a good frame", async () => {
|
||||||
|
const host = createFakeHost({ permissions: [], routes: [] });
|
||||||
|
const controller = build({ host, telemetry: false });
|
||||||
|
await controller.start();
|
||||||
|
|
||||||
|
await controller.apply(state({ current: null, selections: [] }));
|
||||||
|
await settle();
|
||||||
|
expect(root.dataset.slate).toBe("true");
|
||||||
|
expect(controller.diagnostics().slate).toBe(true);
|
||||||
|
|
||||||
|
await controller.apply(state());
|
||||||
|
await settle();
|
||||||
|
expect(root.dataset.slate).toBe("false");
|
||||||
|
|
||||||
|
// Content disappears again — but a good frame is up, so the wall keeps it rather than flashing
|
||||||
|
// the slate at a congregation.
|
||||||
|
await controller.apply(state({ current: null, selections: [] }));
|
||||||
|
await settle();
|
||||||
|
expect(root.dataset.slate).toBe("false");
|
||||||
|
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers the output for a blackout while the frame underneath stays prepared", async () => {
|
||||||
|
const host = createFakeHost({ permissions: [], routes: [] });
|
||||||
|
const controller = build({ host, telemetry: false });
|
||||||
|
await controller.start();
|
||||||
|
|
||||||
|
await controller.apply(state());
|
||||||
|
await controller.apply(state({ playback: { blackout: true } as never }));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(root.dataset.blackout).toBe("true");
|
||||||
|
// The hidden timeline is still what it was: ending the blackout shows this frame, not a slate.
|
||||||
|
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a bounded, sanitized heartbeat and nothing else", async () => {
|
||||||
|
const beats: unknown[] = [];
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.display.view"],
|
||||||
|
routes: [
|
||||||
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "channels/chan-1/display-state",
|
||||||
|
respond: () => jsonResponse(state()),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: /^viewers\/.*\/heartbeat$/u,
|
||||||
|
respond: ({ init }) => {
|
||||||
|
beats.push(JSON.parse(String(init?.body)));
|
||||||
|
return jsonResponse({ acknowledged: true, recorded: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const controller = build({ host });
|
||||||
|
|
||||||
|
await controller.start();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(beats.length).toBeGreaterThan(0);
|
||||||
|
const beat = beats[0] as Record<string, unknown>;
|
||||||
|
expect(Object.keys(beat).sort()).toEqual([
|
||||||
|
"cachedAssetCount",
|
||||||
|
"error",
|
||||||
|
"offlineReady",
|
||||||
|
"reportedContributionId",
|
||||||
|
"reportedPlaybackRevision",
|
||||||
|
"reportedProviderId",
|
||||||
|
"reportedSelectionId",
|
||||||
|
"reportedTimelineId",
|
||||||
|
]);
|
||||||
|
// Identity, counts, and a code. No URL, no payload, no exception, no credential.
|
||||||
|
const serialized = JSON.stringify(beat);
|
||||||
|
expect(serialized).not.toContain("https://");
|
||||||
|
expect(serialized).not.toContain("payload");
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the frame when the server cannot be reached, and says so quietly", async () => {
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.display.view"],
|
||||||
|
routes: [
|
||||||
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "channels/chan-1/display-state",
|
||||||
|
respond: () => jsonResponse(state()),
|
||||||
|
},
|
||||||
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const controller = build({ host });
|
||||||
|
await controller.start();
|
||||||
|
await settle();
|
||||||
|
const before = controller.diagnostics().renderedSelectionId;
|
||||||
|
|
||||||
|
// Every request now fails, exactly as a dropped uplink does.
|
||||||
|
Object.assign(host.context.api, {
|
||||||
|
request: () => Promise.reject(new Error("network is gone")),
|
||||||
|
});
|
||||||
|
await controller.apply(state());
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(controller.diagnostics().renderedSelectionId).toBe(before);
|
||||||
|
// Nothing about the failure is on screen: the slate is for having no content, not no network.
|
||||||
|
expect(root.dataset.slate).toBe("false");
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers no viewer and sends no heartbeat when telemetry is off", async () => {
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: [],
|
||||||
|
routes: [
|
||||||
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
||||||
|
{ method: "POST", path: /^viewers\//u, respond: () => jsonResponse({}) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const controller = build({ host, telemetry: false });
|
||||||
|
|
||||||
|
await controller.start();
|
||||||
|
await controller.apply(state());
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// The management preview renders exactly what a wall renders and appears in no roster.
|
||||||
|
expect(host.requests).toEqual([]);
|
||||||
|
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { SignageDashboard } from "../../src/ui/SignageDashboard.js";
|
||||||
|
import { createFakeHost, errorResponse, FAKE_SETTINGS, jsonResponse } from "./support/fake-host.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The management UI's behaviour, mounted as React rather than inspected as source.
|
||||||
|
*
|
||||||
|
* The artifact-level proof lives in `module-ui.test.ts`; this exercises what an operator does — read
|
||||||
|
* a screen, press a control, be told why a save was refused — against a host that answers only the
|
||||||
|
* relative paths Signage actually asks for.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// React needs this to run effects the way a browser does.
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
let container: HTMLElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
async function render(element: React.ReactElement): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(element);
|
||||||
|
});
|
||||||
|
// Let the resource loads settle.
|
||||||
|
for (let index = 0; index < 6; index += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function click(element: Element | null | undefined): Promise<void> {
|
||||||
|
if (!element) {
|
||||||
|
throw new Error("Expected an element to click.");
|
||||||
|
}
|
||||||
|
await act(async () => {
|
||||||
|
(element as HTMLElement).click();
|
||||||
|
});
|
||||||
|
for (let index = 0; index < 6; index += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types into a controlled React input the way a person does.
|
||||||
|
*
|
||||||
|
* Assigning `input.value` directly and dispatching an event is not enough: React tracks the value on
|
||||||
|
* the element and would treat the change as already handled. Going through the prototype's setter is
|
||||||
|
* what makes React see it.
|
||||||
|
*/
|
||||||
|
async function type(input: HTMLInputElement, value: string): Promise<void> {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(
|
||||||
|
globalThis.HTMLInputElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
await act(async () => {
|
||||||
|
setter?.call(input, value);
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonNamed(label: string): HTMLButtonElement | undefined {
|
||||||
|
return [...container.querySelectorAll("button")].find(
|
||||||
|
(button) => button.textContent?.trim() === label,
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP = {
|
||||||
|
channels: [{ id: "chan-1", isDefault: true, key: "default", name: "Default" }],
|
||||||
|
id: "group-1",
|
||||||
|
name: "Main",
|
||||||
|
playback: {
|
||||||
|
activeTimelineId: "timeline-1",
|
||||||
|
blackout: false,
|
||||||
|
currentSelectionId: "sel-1",
|
||||||
|
currentSlotId: "slot-1",
|
||||||
|
deadlineAtMs: 1_700_000_005_000,
|
||||||
|
groupId: "group-1",
|
||||||
|
pendingTimelineId: null,
|
||||||
|
remainingMs: 4000,
|
||||||
|
revision: 7,
|
||||||
|
scheduledActivationAtMs: null,
|
||||||
|
scheduledTimelineId: null,
|
||||||
|
selectionRevision: 1,
|
||||||
|
serverNowMs: 1_700_000_001_000,
|
||||||
|
startedAtMs: 1_700_000_000_000,
|
||||||
|
staticAssetId: null,
|
||||||
|
transport: "playing" as const,
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLAYBACK_DETAIL = {
|
||||||
|
current: {
|
||||||
|
deadlineAtMs: 1_700_000_005_000,
|
||||||
|
durationMs: 5000,
|
||||||
|
id: "sel-1",
|
||||||
|
itemId: "welcome",
|
||||||
|
itemRevision: "1",
|
||||||
|
providerId: "fixture.announcements",
|
||||||
|
rendererContributionId: "fixture.announcements.slide",
|
||||||
|
revision: 1,
|
||||||
|
stale: false,
|
||||||
|
startedAtMs: 1_700_000_000_000,
|
||||||
|
withdrawn: false,
|
||||||
|
},
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: "sel-0",
|
||||||
|
itemId: "giving",
|
||||||
|
providerId: "fixture.announcements",
|
||||||
|
sequence: 1,
|
||||||
|
startedAtMs: 1_699_999_990_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
snapshot: GROUP.playback,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Signage dashboard", () => {
|
||||||
|
it("shows live transport for the default channel group and drives it with one command each", async () => {
|
||||||
|
const commands: string[] = [];
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.view", "signage.playback.control"],
|
||||||
|
routes: [
|
||||||
|
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
|
||||||
|
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
|
||||||
|
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "playback/group-1",
|
||||||
|
respond: () => jsonResponse(PLAYBACK_DETAIL),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: /^playback\/group-1\//u,
|
||||||
|
respond: ({ path }) => {
|
||||||
|
commands.push(path);
|
||||||
|
return jsonResponse(GROUP.playback);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(<SignageDashboard context={host.context} />);
|
||||||
|
|
||||||
|
expect(container.querySelector("[data-testid='signage-current']")?.textContent).toContain(
|
||||||
|
"welcome",
|
||||||
|
);
|
||||||
|
expect(container.querySelector("[data-testid='signage-remaining']")?.textContent).toBe("4s");
|
||||||
|
|
||||||
|
await click(buttonNamed("Pause"));
|
||||||
|
await click(buttonNamed("Next"));
|
||||||
|
await click(buttonNamed("Blackout"));
|
||||||
|
|
||||||
|
expect(commands).toEqual([
|
||||||
|
"playback/group-1/pause",
|
||||||
|
"playback/group-1/next",
|
||||||
|
"playback/group-1/blackout",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no transport controls to an operator who may only view", async () => {
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.view"],
|
||||||
|
routes: [
|
||||||
|
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
|
||||||
|
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
|
||||||
|
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
|
||||||
|
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(<SignageDashboard context={host.context} />);
|
||||||
|
|
||||||
|
// The current slide is still legible; the buttons that would be refused are simply not offered.
|
||||||
|
expect(container.querySelector("[data-testid='signage-current']")).not.toBeNull();
|
||||||
|
expect(container.querySelector("[data-testid='signage-controls']")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Signage's own sanitized reason when a save is refused", async () => {
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.view", "signage.timelines.manage"],
|
||||||
|
routes: [
|
||||||
|
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
|
||||||
|
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
|
||||||
|
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
|
||||||
|
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
|
||||||
|
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "timelines",
|
||||||
|
respond: () => errorResponse("SIGNAGE_INVALID_REQUEST", "The request is not valid.", 400),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(<SignageDashboard context={host.context} />);
|
||||||
|
await click(
|
||||||
|
[...container.querySelectorAll("[role='tab']")].find(
|
||||||
|
(tab) => tab.textContent === "Timelines",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = container.querySelector("input[type='text']") as HTMLInputElement;
|
||||||
|
await type(input, "Sunday morning");
|
||||||
|
await click(buttonNamed("Create"));
|
||||||
|
|
||||||
|
const alert = container.querySelector("[role='alert']");
|
||||||
|
expect(alert?.textContent).toBe("The request is not valid.");
|
||||||
|
// The server's own fixed message, and nothing else: no status code, no URL, no request detail.
|
||||||
|
expect(alert?.textContent).not.toMatch(/400|http|timelines/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a timeline has no slots rather than showing an empty table", async () => {
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.view", "signage.timelines.manage"],
|
||||||
|
routes: [
|
||||||
|
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
|
||||||
|
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
|
||||||
|
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) },
|
||||||
|
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) },
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "timelines",
|
||||||
|
respond: () =>
|
||||||
|
jsonResponse({
|
||||||
|
timelines: [
|
||||||
|
{
|
||||||
|
description: null,
|
||||||
|
eligibleFrom: null,
|
||||||
|
eligibleUntil: null,
|
||||||
|
id: "timeline-1",
|
||||||
|
name: "Sunday morning",
|
||||||
|
revision: 3,
|
||||||
|
updatedAt: "2026-08-30T15:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "timelines/timeline-1",
|
||||||
|
respond: () =>
|
||||||
|
jsonResponse({
|
||||||
|
slots: [],
|
||||||
|
sources: [],
|
||||||
|
timeline: {
|
||||||
|
description: null,
|
||||||
|
eligibleFrom: null,
|
||||||
|
eligibleUntil: null,
|
||||||
|
id: "timeline-1",
|
||||||
|
name: "Sunday morning",
|
||||||
|
revision: 3,
|
||||||
|
updatedAt: "2026-08-30T15:00:00.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(<SignageDashboard context={host.context} />);
|
||||||
|
await click(
|
||||||
|
[...container.querySelectorAll("[role='tab']")].find(
|
||||||
|
(tab) => tab.textContent === "Timelines",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await click(buttonNamed("Sunday morning"));
|
||||||
|
|
||||||
|
expect(container.querySelector("[data-testid='signage-timeline-editor']")).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("This timeline has no slots");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a provider whose renderer the platform did not publish", async () => {
|
||||||
|
const host = createFakeHost({
|
||||||
|
// The platform published nothing against Signage's renderer capability — the contributing
|
||||||
|
// module is absent, disabled, or outside the compatible range.
|
||||||
|
contributions: [],
|
||||||
|
permissions: ["signage.view"],
|
||||||
|
routes: [
|
||||||
|
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
|
||||||
|
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
|
||||||
|
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
|
||||||
|
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "providers",
|
||||||
|
respond: () =>
|
||||||
|
jsonResponse({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
contractVersion: "1.0.0",
|
||||||
|
defaultDurationMs: 8000,
|
||||||
|
lastFailureAt: null,
|
||||||
|
lastFailureCode: null,
|
||||||
|
lastSuccessAt: "2026-08-30T15:00:00.000Z",
|
||||||
|
providerId: "fixture.announcements",
|
||||||
|
providerVersion: "1.0.0",
|
||||||
|
registeredAt: "2026-08-30T14:00:00.000Z",
|
||||||
|
renderer: {
|
||||||
|
contributionId: "fixture.announcements.slide",
|
||||||
|
contributionVersion: "1.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(<SignageDashboard context={host.context} />);
|
||||||
|
await click(
|
||||||
|
[...container.querySelectorAll("[role='tab']")].find(
|
||||||
|
(tab) => tab.textContent === "Providers",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Renderer not published");
|
||||||
|
// Which is a fact only the browser holds: the server cannot know what the host published.
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says Signage works without providers rather than looking broken", async () => {
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.view"],
|
||||||
|
routes: [
|
||||||
|
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
|
||||||
|
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
|
||||||
|
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
|
||||||
|
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
|
||||||
|
{ method: "GET", path: "providers", respond: () => jsonResponse({ providers: [] }) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(<SignageDashboard context={host.context} />);
|
||||||
|
await click(
|
||||||
|
[...container.querySelectorAll("[role='tab']")].find(
|
||||||
|
(tab) => tab.textContent === "Providers",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("No content providers registered");
|
||||||
|
expect(container.textContent).toContain("Signage works on its own with native images");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the display roster with online state and offline readiness", async () => {
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.view", "signage.displays.manage"],
|
||||||
|
routes: [
|
||||||
|
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
|
||||||
|
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
|
||||||
|
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
|
||||||
|
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "viewers",
|
||||||
|
respond: () =>
|
||||||
|
jsonResponse({
|
||||||
|
viewers: [
|
||||||
|
{
|
||||||
|
cachedAssetCount: 12,
|
||||||
|
channelId: "chan-1",
|
||||||
|
deviceId: null,
|
||||||
|
id: "viewer-1",
|
||||||
|
lastError: { atMs: Date.now(), code: "SLIDE_DECODE_FAILED", detail: null },
|
||||||
|
lastSeenAtMs: Date.now() - 4000,
|
||||||
|
name: "Lobby north",
|
||||||
|
offlineReady: true,
|
||||||
|
online: true,
|
||||||
|
reportedContributionId: null,
|
||||||
|
reportedProviderId: "fixture.announcements",
|
||||||
|
reportedSelectionId: "sel-1",
|
||||||
|
reportedTimelineId: "timeline-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(<SignageDashboard context={host.context} />);
|
||||||
|
await click(
|
||||||
|
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Displays"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Online");
|
||||||
|
expect(container.textContent).toContain("12 cached");
|
||||||
|
// A sanitized code, never a stack or a message a browser happened to have.
|
||||||
|
expect(container.textContent).toContain("SLIDE_DECODE_FAILED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uploads one request per file and reports each one's own outcome", async () => {
|
||||||
|
const uploaded: string[] = [];
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.view", "signage.assets.manage"],
|
||||||
|
routes: [
|
||||||
|
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
|
||||||
|
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
|
||||||
|
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
|
||||||
|
{ method: "GET", path: "playback/group-1", respond: () => jsonResponse(PLAYBACK_DETAIL) },
|
||||||
|
{ method: "GET", path: "images", respond: () => jsonResponse({ images: [] }) },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: /^images\?/u,
|
||||||
|
respond: ({ path }) => {
|
||||||
|
uploaded.push(path);
|
||||||
|
return path.includes("broken")
|
||||||
|
? errorResponse(
|
||||||
|
"SIGNAGE_IMAGE_MALFORMED",
|
||||||
|
"The uploaded file is not a decodable image.",
|
||||||
|
422,
|
||||||
|
)
|
||||||
|
: jsonResponse({ deduplicated: false, image: {} }, 201);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(<SignageDashboard context={host.context} />);
|
||||||
|
await click(
|
||||||
|
[...container.querySelectorAll("[role='tab']")].find((tab) => tab.textContent === "Images"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = container.querySelector(
|
||||||
|
"[data-testid='signage-upload-input']",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
// happy-dom's `FileList` is read-only, so the queue is driven through the same change handler a
|
||||||
|
// real picker fires, with file-shaped values.
|
||||||
|
Object.defineProperty(input, "files", {
|
||||||
|
configurable: true,
|
||||||
|
value: [
|
||||||
|
{ name: "welcome.png", size: 1024, type: "image/png" },
|
||||||
|
{ name: "broken.png", size: 1024, type: "image/png" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
for (let index = 0; index < 10; index += 1) {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two files, two requests — each with its own name in the query string and the bytes as the body.
|
||||||
|
expect(uploaded).toHaveLength(2);
|
||||||
|
expect(uploaded.every((path) => path.startsWith("images?"))).toBe(true);
|
||||||
|
const uploads = container.querySelector("[data-testid='signage-uploads']");
|
||||||
|
expect(uploads?.textContent).toContain("done");
|
||||||
|
expect(uploads?.textContent).toContain("failed");
|
||||||
|
expect(uploads?.textContent).toContain("The uploaded file is not a decodable image.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays on screen when one panel's data cannot be loaded", async () => {
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.view"],
|
||||||
|
routes: [
|
||||||
|
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
|
||||||
|
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [GROUP] }) },
|
||||||
|
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "playback/group-1",
|
||||||
|
respond: () =>
|
||||||
|
errorResponse("SIGNAGE_OPERATION_FAILED", "The operation could not be completed.", 500),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(<SignageDashboard context={host.context} />);
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Signage could not load this");
|
||||||
|
expect(container.textContent).toContain("The operation could not be completed.");
|
||||||
|
// The rest of the dashboard is still usable: one failing panel is not a failed screen.
|
||||||
|
expect(container.querySelectorAll("[role='tab']").length).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { parseModuleManifest } from "@ekklesia/module-sdk";
|
||||||
|
import { buildModuleUi } from "@ekklesia/module-ui-build";
|
||||||
|
import { parseModuleUiExport } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { createFakeHost, FAKE_SETTINGS, jsonResponse } from "./support/fake-host.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The end-to-end proof for Signage's browser artifacts:
|
||||||
|
*
|
||||||
|
* React/TSX source -> `pnpm build` -> the artifacts the manifest declares -> `parseModuleUiExport`
|
||||||
|
* -> `mount(container, context)` -> `unmount()`
|
||||||
|
*
|
||||||
|
* Both artifacts are rebuilt from source here rather than read from a checked-in file, so this can
|
||||||
|
* never pass against stale output. `dist/` is gitignored; nothing generated is committed.
|
||||||
|
*/
|
||||||
|
// `happy-dom` replaces the global `URL` with the DOM implementation, which `node:fs` rejects, so
|
||||||
|
// this test works in plain filesystem paths and converts to a file URL only for dynamic import.
|
||||||
|
const moduleDirectory = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
|
||||||
|
|
||||||
|
let identity: { id: string; version: string };
|
||||||
|
let uiArtifactPath: string;
|
||||||
|
let uiArtifactHref: string;
|
||||||
|
let displayArtifactPath: string;
|
||||||
|
let displayArtifactHref: string;
|
||||||
|
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
for (let index = 0; index < 8; index += 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Signage browser artifacts", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const manifest = parseModuleManifest(
|
||||||
|
JSON.parse(
|
||||||
|
await readFile(path.join(moduleDirectory, "ekklesia.module.json"), "utf8"),
|
||||||
|
) as unknown,
|
||||||
|
);
|
||||||
|
identity = { id: manifest.id, version: manifest.version };
|
||||||
|
|
||||||
|
const result = await buildModuleUi({ moduleDirectory });
|
||||||
|
const ui = result.artifacts.find((artifact) => artifact.kind === "ui");
|
||||||
|
const display = result.artifacts.find((artifact) => artifact.kind === "display");
|
||||||
|
if (!ui || !display) {
|
||||||
|
throw new Error("The Signage build must produce both declared browser artifacts.");
|
||||||
|
}
|
||||||
|
uiArtifactPath = ui.outputFile;
|
||||||
|
uiArtifactHref = pathToFileURL(ui.outputFile).href;
|
||||||
|
displayArtifactPath = display.outputFile;
|
||||||
|
displayArtifactHref = pathToFileURL(display.outputFile).href;
|
||||||
|
}, 180_000);
|
||||||
|
|
||||||
|
it("produces self-contained artifacts that bake in no Ekklesia deployment detail", async () => {
|
||||||
|
const sources = await Promise.all([
|
||||||
|
readFile(uiArtifactPath, "utf8"),
|
||||||
|
readFile(displayArtifactPath, "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
// React is bundled, so each artifact resolves nothing at run time (ADR 0023).
|
||||||
|
expect(source).not.toMatch(/^import\s.*from\s*["'][^.]/mu);
|
||||||
|
expect(source).toContain("export {");
|
||||||
|
expect(source).not.toContain("/api/v1/modules");
|
||||||
|
expect(source).not.toContain("/api/v1/module-ui");
|
||||||
|
expect(source).not.toContain("process.env.NODE_ENV");
|
||||||
|
// Nothing this release excludes may reach a browser artifact.
|
||||||
|
for (const forbidden of ["mqtt", "socket.io", "homeassistant", "sqlite"]) {
|
||||||
|
expect(source.toLowerCase()).not.toContain(forbidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the management UI, asks for relative paths only, and unmounts", async () => {
|
||||||
|
const namespace = (await import(uiArtifactHref)) as unknown;
|
||||||
|
const definition = parseModuleUiExport(namespace, identity);
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.view"],
|
||||||
|
routes: [
|
||||||
|
{ method: "GET", path: "settings", respond: () => jsonResponse(FAKE_SETTINGS) },
|
||||||
|
{ method: "GET", path: "channel-groups", respond: () => jsonResponse({ groups: [] }) },
|
||||||
|
{ method: "GET", path: "timelines", respond: () => jsonResponse({ timelines: [] }) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const unmount = await definition.mount(container, host.context);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Playback");
|
||||||
|
// Relative to Signage's own API root, always. A route table keyed on Ekklesia's private prefix
|
||||||
|
// would never have matched.
|
||||||
|
for (const request of host.requests) {
|
||||||
|
expect(request.path.startsWith("api/")).toBe(false);
|
||||||
|
expect(request.path.startsWith("/")).toBe(false);
|
||||||
|
}
|
||||||
|
// The desktop-only gate is a CSS decision, so both branches are always in the accessibility tree.
|
||||||
|
expect(container.querySelector("[data-testid='signage-narrow-notice']")).not.toBeNull();
|
||||||
|
|
||||||
|
await (unmount as () => Promise<void>)();
|
||||||
|
expect(container.textContent).toBe("");
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a denied state from the host's presentation-only permission hint", async () => {
|
||||||
|
const namespace = (await import(uiArtifactHref)) as unknown;
|
||||||
|
const definition = parseModuleUiExport(namespace, identity);
|
||||||
|
const container = document.createElement("div");
|
||||||
|
const host = createFakeHost({ permissions: [], routes: [] });
|
||||||
|
|
||||||
|
const unmount = await definition.mount(container, host.context);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("No access to Signage");
|
||||||
|
// A hidden control is a usability choice, not enforcement: Signage simply did not ask.
|
||||||
|
expect(host.requests).toEqual([]);
|
||||||
|
await (unmount as () => Promise<void>)();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the display through the same ABI and shows the safe slate", async () => {
|
||||||
|
const namespace = (await import(displayArtifactHref)) as unknown;
|
||||||
|
const definition = parseModuleUiExport(namespace, identity);
|
||||||
|
const container = document.createElement("div");
|
||||||
|
const host = createFakeHost({ permissions: ["signage.display.view"], routes: [] });
|
||||||
|
|
||||||
|
const unmount = await definition.mount(container, host.context);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const slate = container.querySelector("[data-testid='signage-slate']");
|
||||||
|
expect(slate?.textContent).toBe("Content unavailable");
|
||||||
|
// The slate must never carry an exception, URL, identifier, or payload of any kind.
|
||||||
|
expect(slate?.textContent).not.toMatch(/https?:|\/api\/|[0-9a-f]{8}-[0-9a-f]{4}/u);
|
||||||
|
|
||||||
|
await (unmount as () => Promise<void>)();
|
||||||
|
expect(container.textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries each artifact's own stylesheet inside the one declared file", async () => {
|
||||||
|
const ui = parseModuleUiExport((await import(uiArtifactHref)) as unknown, identity);
|
||||||
|
const display = parseModuleUiExport((await import(displayArtifactHref)) as unknown, identity);
|
||||||
|
|
||||||
|
expect(ui.styles).toContain(".signage-narrow-notice");
|
||||||
|
expect(ui.styles).toContain(".ek-panel");
|
||||||
|
// Signage references the platform's global theme and never declares part of it (ADR 0039).
|
||||||
|
expect(ui.styles).not.toMatch(/--ekklesia-[a-z0-9-]+\s*:/u);
|
||||||
|
expect(display.styles).not.toMatch(/--ekklesia-[a-z0-9-]+\s*:/u);
|
||||||
|
// Two artifacts of one module: separate roots, separate stylesheets.
|
||||||
|
expect(display.styles).toContain(".signage-slate");
|
||||||
|
expect(display.styles).not.toContain(".signage-narrow-notice");
|
||||||
|
// No sidecar stylesheet is emitted beside either artifact.
|
||||||
|
await expect(readFile(path.join(path.dirname(uiArtifactPath), "index.css"))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets its own box model, because an isolated root inherits none", async () => {
|
||||||
|
const ui = parseModuleUiExport((await import(uiArtifactHref)) as unknown, identity);
|
||||||
|
const display = parseModuleUiExport((await import(displayArtifactHref)) as unknown, identity);
|
||||||
|
|
||||||
|
// The host installs base rules and the platform's components, and neither declares `box-sizing`.
|
||||||
|
// Under the initial `content-box` a control that is `width: 100%` is laid out wider than the
|
||||||
|
// field around it by its own padding and border, and slides under whatever sits beside it. Each
|
||||||
|
// artifact says so for the tree it owns; nothing outside that tree is affected.
|
||||||
|
for (const styles of [ui.styles, display.styles]) {
|
||||||
|
expect(styles).toMatch(/\*,\s*\*::before,\s*\*::after\s*\{[^}]*box-sizing:\s*border-box/u);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
type OfflineSnapshot,
|
||||||
|
resolveOffline,
|
||||||
|
serverNowFrom,
|
||||||
|
snapshotFrom,
|
||||||
|
} from "../../src/browser/offline.js";
|
||||||
|
import type { SceneSelection } from "../../src/browser/scene-request.js";
|
||||||
|
import {
|
||||||
|
createMemoryMediaCache,
|
||||||
|
createMemoryStore,
|
||||||
|
signageMediaKey,
|
||||||
|
} from "../../src/browser/storage.js";
|
||||||
|
import {
|
||||||
|
createDisplayController,
|
||||||
|
type DisplayChannelStateView,
|
||||||
|
} from "../../src/display/controller.js";
|
||||||
|
import { createFakeHost, jsonResponse } from "./support/fake-host.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline continuation: what a screen does when its building's uplink dies.
|
||||||
|
*
|
||||||
|
* Every case here is one an unattended television actually meets — a router reboot during a service,
|
||||||
|
* a switch power-cycled by a janitor, a week with the doors closed — and the property being proven is
|
||||||
|
* always the same one: the display continues *deterministically from the last authoritative anchor*,
|
||||||
|
* replaying only what the server already resolved, and never becomes a second authority.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCENE = {
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
backgroundImage: false,
|
||||||
|
backgroundMode: "none" as const,
|
||||||
|
blurPixels: 0,
|
||||||
|
darkenOpacity: 0,
|
||||||
|
fit: "fill" as const,
|
||||||
|
shadow: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function selection(overrides: Partial<SceneSelection> & { id: string }): SceneSelection {
|
||||||
|
return {
|
||||||
|
availableUntilMs: null,
|
||||||
|
deadlineAtMs: null,
|
||||||
|
durationMs: 5000,
|
||||||
|
itemId: `item-${overrides.id}`,
|
||||||
|
itemRevision: "rev-1",
|
||||||
|
payload: { kind: "native-image" },
|
||||||
|
presentation: null,
|
||||||
|
providerId: "signage.native-images",
|
||||||
|
renderer: { kind: "native-image" },
|
||||||
|
revision: 1,
|
||||||
|
scene: SCENE,
|
||||||
|
slotId: null,
|
||||||
|
startedAtMs: null,
|
||||||
|
withdrawn: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function slot(id: string, position: number, overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
eligibleFromMs: null,
|
||||||
|
eligibleUntilMs: null,
|
||||||
|
enabled: true,
|
||||||
|
estimatedDurationMs: 5000,
|
||||||
|
id,
|
||||||
|
position,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(overrides: Partial<OfflineSnapshot> = {}): OfflineSnapshot {
|
||||||
|
return snapshotFrom({
|
||||||
|
channel: { groupId: "group-1", id: "chan-1", key: "default" },
|
||||||
|
crossfadeMs: 0,
|
||||||
|
localNowMs: 1_000_000,
|
||||||
|
playback: {
|
||||||
|
blackout: false,
|
||||||
|
currentSelectionId: "sel-a",
|
||||||
|
currentSlotId: "slot-a",
|
||||||
|
deadlineAtMs: 5000,
|
||||||
|
pendingTimelineId: null,
|
||||||
|
remainingMs: 5000,
|
||||||
|
revision: 3,
|
||||||
|
startedAtMs: 0,
|
||||||
|
transport: "playing",
|
||||||
|
...overrides.playback,
|
||||||
|
},
|
||||||
|
selections: overrides.selections ?? [
|
||||||
|
selection({ id: "sel-c", slotId: "slot-c" }),
|
||||||
|
selection({ id: "sel-b", slotId: "slot-b" }),
|
||||||
|
selection({ id: "sel-a", slotId: "slot-a" }),
|
||||||
|
],
|
||||||
|
serverNowMs: 0,
|
||||||
|
staticHold: overrides.staticHold ?? null,
|
||||||
|
timeline:
|
||||||
|
overrides.timeline === undefined
|
||||||
|
? {
|
||||||
|
eligibleFromMs: null,
|
||||||
|
eligibleUntilMs: null,
|
||||||
|
id: "timeline-1",
|
||||||
|
revision: 2,
|
||||||
|
slots: [slot("slot-a", 0), slot("slot-b", 1), slot("slot-c", 2)],
|
||||||
|
}
|
||||||
|
: overrides.timeline,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("offline continuation", () => {
|
||||||
|
it("reconstructs the server's clock, however wrong the device's is", () => {
|
||||||
|
const persisted = snapshot();
|
||||||
|
|
||||||
|
// The device's clock jumped forward two hours while the screen was off. The anchor is what makes
|
||||||
|
// the evaluation right anyway: only the *elapsed* local time matters.
|
||||||
|
expect(serverNowFrom(persisted, 1_000_000 + 4000)).toBe(4000);
|
||||||
|
expect(serverNowFrom(persisted, 1_000_000 - 7_200_000 + 4000)).toBe(4000 - 7_200_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues to the next slot at the deadline, using what the server already resolved", () => {
|
||||||
|
const evaluation = resolveOffline({ atMs: 5000, snapshot: snapshot() });
|
||||||
|
|
||||||
|
expect(evaluation.resolution).toMatchObject({
|
||||||
|
kind: "selection",
|
||||||
|
startedAtMs: 5000,
|
||||||
|
});
|
||||||
|
if (evaluation.resolution.kind !== "selection") {
|
||||||
|
throw new Error("Expected a selection.");
|
||||||
|
}
|
||||||
|
// The next slot in the pattern, rendered from its own cached answer. Nothing was invented.
|
||||||
|
expect(evaluation.resolution.selection.id).toBe("sel-b");
|
||||||
|
expect(evaluation.nextBoundaryMs).toBe(10_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches up a week in one evaluation rather than replaying every slide", () => {
|
||||||
|
const week = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const started = Date.now();
|
||||||
|
|
||||||
|
const evaluation = resolveOffline({ atMs: week, snapshot: snapshot() });
|
||||||
|
|
||||||
|
expect(evaluation.resolution.kind).toBe("selection");
|
||||||
|
if (evaluation.resolution.kind !== "selection") {
|
||||||
|
throw new Error("Expected a selection.");
|
||||||
|
}
|
||||||
|
// Landed inside the current slot, on the server's own phase — and the arithmetic took no
|
||||||
|
// measurable time, because it is arithmetic.
|
||||||
|
expect(evaluation.resolution.deadlineAtMs).toBeGreaterThan(week);
|
||||||
|
expect(evaluation.resolution.deadlineAtMs - evaluation.resolution.startedAtMs).toBe(5000);
|
||||||
|
expect(Date.now() - started).toBeLessThan(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays exactly where it was while paused, with its remainder preserved", () => {
|
||||||
|
const paused = snapshot({
|
||||||
|
playback: {
|
||||||
|
blackout: false,
|
||||||
|
currentSelectionId: "sel-a",
|
||||||
|
currentSlotId: "slot-a",
|
||||||
|
deadlineAtMs: null,
|
||||||
|
pendingTimelineId: null,
|
||||||
|
remainingMs: 3000,
|
||||||
|
revision: 4,
|
||||||
|
startedAtMs: null,
|
||||||
|
transport: "paused",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const evaluation = resolveOffline({ atMs: 600_000, snapshot: paused });
|
||||||
|
|
||||||
|
// Ten minutes later, on the same slide. Frozen means frozen, offline as much as online.
|
||||||
|
expect(evaluation.resolution).toMatchObject({ kind: "paused" });
|
||||||
|
if (evaluation.resolution.kind !== "paused") {
|
||||||
|
throw new Error("Expected a paused resolution.");
|
||||||
|
}
|
||||||
|
expect(evaluation.resolution.selection?.id).toBe("sel-a");
|
||||||
|
expect(paused.playback.remainingMs).toBe(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a static hold held, and does not let local age release it", () => {
|
||||||
|
const held = snapshot({ staticHold: selection({ id: "static-1" }) });
|
||||||
|
|
||||||
|
const evaluation = resolveOffline({ atMs: 86_400_000, snapshot: held });
|
||||||
|
|
||||||
|
expect(evaluation.resolution).toMatchObject({ kind: "static" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases a static hold only when the held image genuinely expired", () => {
|
||||||
|
const held = snapshot({
|
||||||
|
staticHold: selection({ availableUntilMs: 10_000, id: "static-1" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const before = resolveOffline({ atMs: 9000, snapshot: held });
|
||||||
|
const after = resolveOffline({ atMs: 11_000, snapshot: held });
|
||||||
|
|
||||||
|
expect(before.resolution.kind).toBe("static");
|
||||||
|
// An explicit expiry the server told it about — never a guess made locally.
|
||||||
|
expect(after.resolution.kind).toBe("selection");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips content the server said was withdrawn or expired", () => {
|
||||||
|
const persisted = snapshot({
|
||||||
|
selections: [
|
||||||
|
selection({ id: "sel-c", slotId: "slot-c" }),
|
||||||
|
selection({ id: "sel-b", slotId: "slot-b", withdrawn: true }),
|
||||||
|
selection({ id: "sel-a", slotId: "slot-a" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted });
|
||||||
|
|
||||||
|
// Slot B's only cached answer is gone, so the slot is skipped exactly as the server skips an
|
||||||
|
// empty provider slot — no gap, no error, and certainly no fabricated replacement.
|
||||||
|
expect(evaluation.resolution).toMatchObject({ kind: "selection" });
|
||||||
|
if (evaluation.resolution.kind !== "selection") {
|
||||||
|
throw new Error("Expected a selection.");
|
||||||
|
}
|
||||||
|
expect(evaluation.resolution.selection.id).toBe("sel-c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds the last good frame rather than continuing into a timeline it never received", () => {
|
||||||
|
const pending = snapshot({
|
||||||
|
playback: {
|
||||||
|
blackout: false,
|
||||||
|
currentSelectionId: "sel-a",
|
||||||
|
currentSlotId: "slot-a",
|
||||||
|
deadlineAtMs: 5000,
|
||||||
|
pendingTimelineId: "timeline-2",
|
||||||
|
remainingMs: 0,
|
||||||
|
revision: 5,
|
||||||
|
startedAtMs: 0,
|
||||||
|
transport: "playing",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const evaluation = resolveOffline({ atMs: 6000, snapshot: pending });
|
||||||
|
|
||||||
|
// Going deeper into the old timeline would show content the operator has already replaced.
|
||||||
|
expect(evaluation.resolution).toEqual({ kind: "hold", reason: "pending-activation" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds when no slot has a cached answer, rather than inventing one", () => {
|
||||||
|
const persisted = snapshot({ selections: [] });
|
||||||
|
|
||||||
|
const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted });
|
||||||
|
|
||||||
|
expect(evaluation.resolution).toMatchObject({ kind: "hold" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("goes black only when the cached timeline genuinely has nothing eligible", () => {
|
||||||
|
const persisted = snapshot({
|
||||||
|
timeline: {
|
||||||
|
eligibleFromMs: null,
|
||||||
|
eligibleUntilMs: null,
|
||||||
|
id: "timeline-1",
|
||||||
|
revision: 2,
|
||||||
|
slots: [slot("slot-a", 0, { enabled: false })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const evaluation = resolveOffline({ atMs: 5000, snapshot: persisted });
|
||||||
|
|
||||||
|
expect(evaluation.resolution).toEqual({ kind: "empty" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists no delivery URL, because a stored one is a credential that stops working", () => {
|
||||||
|
const persisted = snapshotFrom({
|
||||||
|
channel: { groupId: "group-1", id: "chan-1", key: "default" },
|
||||||
|
crossfadeMs: 0,
|
||||||
|
localNowMs: 0,
|
||||||
|
playback: {
|
||||||
|
blackout: false,
|
||||||
|
currentSelectionId: "sel-a",
|
||||||
|
currentSlotId: "slot-a",
|
||||||
|
deadlineAtMs: 5000,
|
||||||
|
pendingTimelineId: null,
|
||||||
|
remainingMs: 5000,
|
||||||
|
revision: 1,
|
||||||
|
startedAtMs: 0,
|
||||||
|
transport: "playing",
|
||||||
|
},
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
...selection({ id: "sel-a", slotId: "slot-a" }),
|
||||||
|
media: {
|
||||||
|
display: { url: "https://cdn.invalid/a?sig=secret" },
|
||||||
|
thumbnail: { url: "https://cdn.invalid/a-t?sig=secret" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
serverNowMs: 0,
|
||||||
|
staticHold: null,
|
||||||
|
timeline: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(JSON.stringify(persisted)).not.toContain("sig=secret");
|
||||||
|
expect(JSON.stringify(persisted)).not.toContain("https://");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a display that loses its network", () => {
|
||||||
|
let root: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
root = document.createElement("div");
|
||||||
|
document.body.append(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function settle(): Promise<void> {
|
||||||
|
for (let index = 0; index < 12; index += 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeSelection(id: string, slotId: string): SceneSelection {
|
||||||
|
return {
|
||||||
|
...selection({ id, slotId }),
|
||||||
|
media: {
|
||||||
|
display: { url: `https://cdn.invalid/${id}?sig=a` },
|
||||||
|
thumbnail: { url: `https://cdn.invalid/${id}-t?sig=a` },
|
||||||
|
},
|
||||||
|
} as SceneSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function state(currentId: string): DisplayChannelStateView {
|
||||||
|
const selections = [
|
||||||
|
nativeSelection("sel-c", "slot-c"),
|
||||||
|
nativeSelection("sel-b", "slot-b"),
|
||||||
|
nativeSelection("sel-a", "slot-a"),
|
||||||
|
];
|
||||||
|
const current = selections.find((candidate) => candidate.id === currentId);
|
||||||
|
return {
|
||||||
|
channel: { groupId: "group-1", id: "chan-1", key: "default" },
|
||||||
|
crossfadeMs: 0,
|
||||||
|
current: current ?? null,
|
||||||
|
playback: {
|
||||||
|
activeTimelineId: "timeline-1",
|
||||||
|
blackout: false,
|
||||||
|
currentSelectionId: currentId,
|
||||||
|
currentSlotId: current?.slotId ?? null,
|
||||||
|
deadlineAtMs: 5000,
|
||||||
|
pendingTimelineId: null,
|
||||||
|
remainingMs: 5000,
|
||||||
|
revision: 3,
|
||||||
|
selectionRevision: 1,
|
||||||
|
startedAtMs: 0,
|
||||||
|
transport: "playing",
|
||||||
|
},
|
||||||
|
selections,
|
||||||
|
serverNowMs: 0,
|
||||||
|
staticHold: null,
|
||||||
|
timeline: {
|
||||||
|
eligibleFromMs: null,
|
||||||
|
eligibleUntilMs: null,
|
||||||
|
id: "timeline-1",
|
||||||
|
revision: 2,
|
||||||
|
slots: [slot("slot-a", 0), slot("slot-b", 1), slot("slot-c", 2)],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps rendering from cached bytes and cached answers when the server goes away", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const media = createMemoryMediaCache();
|
||||||
|
let reachable = true;
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.display.view"],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "viewers/session",
|
||||||
|
respond: () =>
|
||||||
|
jsonResponse({
|
||||||
|
channelId: "chan-1",
|
||||||
|
channelKey: "default",
|
||||||
|
groupId: "group-1",
|
||||||
|
heartbeatIntervalMs: 12_000,
|
||||||
|
viewerId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: "channels/chan-1/display-state",
|
||||||
|
respond: () => {
|
||||||
|
if (!reachable) {
|
||||||
|
throw new Error("network is gone");
|
||||||
|
}
|
||||||
|
return jsonResponse(state("sel-a"));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let clock = 1_000_000;
|
||||||
|
// The armed timer is captured rather than run, so the test decides when the safety-net refresh
|
||||||
|
// and the offline evaluation happen — no real time passes.
|
||||||
|
let armed: (() => void) | undefined;
|
||||||
|
const controller = createDisplayController({
|
||||||
|
context: host.context,
|
||||||
|
decodeImage: () => Promise.resolve(),
|
||||||
|
media,
|
||||||
|
newViewerKey: () => "viewer-fixed-key-0001",
|
||||||
|
now: () => clock,
|
||||||
|
root,
|
||||||
|
setTimer: (handler) => {
|
||||||
|
armed = handler;
|
||||||
|
return handler;
|
||||||
|
},
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.start();
|
||||||
|
await settle();
|
||||||
|
expect(controller.diagnostics().renderedSelectionId).toBe("sel-a");
|
||||||
|
expect(controller.diagnostics().offline).toBe(false);
|
||||||
|
expect(controller.diagnostics().offlineReady).toBe(true);
|
||||||
|
|
||||||
|
// The uplink dies, and the current slot's deadline passes.
|
||||||
|
reachable = false;
|
||||||
|
clock += 6000;
|
||||||
|
armed?.();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const diagnostics = controller.diagnostics();
|
||||||
|
// Local continuation took over: the next slot in the pattern, from bytes and answers this screen
|
||||||
|
// already had. Nothing was invented, and the slate never appeared.
|
||||||
|
expect(diagnostics.offline).toBe(true);
|
||||||
|
expect(diagnostics.renderedSelectionId).toBe("sel-b");
|
||||||
|
expect(root.dataset.slate).toBe("false");
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the last known good frame up before the first request returns", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const media = createMemoryMediaCache();
|
||||||
|
await media.store(
|
||||||
|
signageMediaKey({
|
||||||
|
itemId: "item-sel-a",
|
||||||
|
itemRevision: "rev-1",
|
||||||
|
providerId: "signage.native-images",
|
||||||
|
rendition: "display",
|
||||||
|
}),
|
||||||
|
"https://cdn.invalid/sel-a?sig=a",
|
||||||
|
);
|
||||||
|
await store.write("last-known-good", snapshot());
|
||||||
|
|
||||||
|
let requested = 0;
|
||||||
|
const host = createFakeHost({
|
||||||
|
permissions: ["signage.display.view"],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "viewers/session",
|
||||||
|
respond: () => {
|
||||||
|
requested += 1;
|
||||||
|
throw new Error("network is gone");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const controller = createDisplayController({
|
||||||
|
context: host.context,
|
||||||
|
decodeImage: () => Promise.resolve(),
|
||||||
|
media,
|
||||||
|
now: () => 1_000_000,
|
||||||
|
root,
|
||||||
|
setTimer: () => undefined,
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.start();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// A frame is up, from persisted state, with no server involved — and the slate never appeared.
|
||||||
|
expect(controller.diagnostics().renderedSelectionId).toBe("sel-a");
|
||||||
|
expect(root.dataset.slate).toBe("false");
|
||||||
|
expect(requested).toBe(1);
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands authority straight back to the server on reconnect, with no replay and no black frame", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const media = createMemoryMediaCache();
|
||||||
|
await store.write("last-known-good", snapshot());
|
||||||
|
for (const id of ["sel-a", "sel-b", "sel-c"]) {
|
||||||
|
await media.store(
|
||||||
|
signageMediaKey({
|
||||||
|
itemId: `item-${id}`,
|
||||||
|
itemRevision: "rev-1",
|
||||||
|
providerId: "signage.native-images",
|
||||||
|
rendition: "display",
|
||||||
|
}),
|
||||||
|
`https://cdn.invalid/${id}?sig=a`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const host = createFakeHost({ permissions: [], routes: [] });
|
||||||
|
const controller = createDisplayController({
|
||||||
|
context: host.context,
|
||||||
|
decodeImage: () => Promise.resolve(),
|
||||||
|
media,
|
||||||
|
now: () => 1_000_000,
|
||||||
|
root,
|
||||||
|
setTimer: () => undefined,
|
||||||
|
store,
|
||||||
|
telemetry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.start();
|
||||||
|
await settle();
|
||||||
|
const offlineFrame = controller.diagnostics().renderedSelectionId;
|
||||||
|
expect(offlineFrame).toBe("sel-a");
|
||||||
|
expect(controller.diagnostics().offline).toBe(true);
|
||||||
|
|
||||||
|
// The server comes back and says something different. It wins immediately.
|
||||||
|
await controller.apply(state("sel-c"));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const diagnostics = controller.diagnostics();
|
||||||
|
expect(diagnostics.renderedSelectionId).toBe("sel-c");
|
||||||
|
expect(diagnostics.offline).toBe(false);
|
||||||
|
// Straight to what the server says. Nothing in between was replayed, and the slate never showed.
|
||||||
|
expect(root.dataset.slate).toBe("false");
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prunes superseded bytes only after the new state is usable", async () => {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const media = createMemoryMediaCache();
|
||||||
|
const pruned: readonly string[][] = [];
|
||||||
|
const order: string[] = [];
|
||||||
|
const wrapped = {
|
||||||
|
...media,
|
||||||
|
prune: (keep: readonly string[]) => {
|
||||||
|
order.push("prune");
|
||||||
|
(pruned as string[][]).push([...keep]);
|
||||||
|
return media.prune(keep);
|
||||||
|
},
|
||||||
|
store: (key: string, url: string) => {
|
||||||
|
order.push(`store:${key}`);
|
||||||
|
return media.store(key, url);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const host = createFakeHost({ permissions: [], routes: [] });
|
||||||
|
const controller = createDisplayController({
|
||||||
|
context: host.context,
|
||||||
|
decodeImage: () => Promise.resolve(),
|
||||||
|
media: wrapped,
|
||||||
|
now: () => 1_000_000,
|
||||||
|
root,
|
||||||
|
setTimer: () => undefined,
|
||||||
|
store,
|
||||||
|
telemetry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.start();
|
||||||
|
await controller.apply(state("sel-a"));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// Every store happens before the prune: a screen never has less than it had.
|
||||||
|
expect(order.at(-1)).toBe("prune");
|
||||||
|
expect(order.filter((entry) => entry.startsWith("store:"))).toHaveLength(3);
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import type { SceneRequest } from "../../src/browser/scene.js";
|
||||||
|
import { createSceneStage, type SceneStage } from "../../src/browser/scene.js";
|
||||||
|
import { rendererInputFor, sceneRequestFor } from "../../src/browser/scene-request.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The transition, exercised rather than asserted.
|
||||||
|
*
|
||||||
|
* Every collaborator is injected — the decode, the timer, the contribution mount — so what these
|
||||||
|
* prove is the sequence itself: readiness is awaited, the outgoing layer is never faded, a stale
|
||||||
|
* completion is discarded, and a failure leaves the last good frame exactly where it was.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCENE = {
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
backgroundImage: false,
|
||||||
|
backgroundMode: "none" as const,
|
||||||
|
blurPixels: 0,
|
||||||
|
darkenOpacity: 0,
|
||||||
|
fit: "fill" as const,
|
||||||
|
shadow: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function image(key: string, url = `https://cdn.invalid/${key}.webp`): SceneRequest {
|
||||||
|
return { alt: "", key, kind: "native-image", scene: SCENE, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("scene stage", () => {
|
||||||
|
let root: HTMLElement;
|
||||||
|
let decodes: { url: string; resolve: () => void; reject: (error: Error) => void }[];
|
||||||
|
let stage: SceneStage;
|
||||||
|
|
||||||
|
function layers(): HTMLElement[] {
|
||||||
|
return [...root.querySelectorAll(".signage-layer")] as HTMLElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function opacities(): string[] {
|
||||||
|
return layers().map((layer) => layer.style.opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle(): Promise<void> {
|
||||||
|
for (let index = 0; index < 8; index += 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDecodes(): void {
|
||||||
|
while (decodes.length > 0) {
|
||||||
|
decodes.shift()?.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
root = document.createElement("div");
|
||||||
|
document.body.append(root);
|
||||||
|
decodes = [];
|
||||||
|
stage = createSceneStage({
|
||||||
|
crossfadeMs: 1000,
|
||||||
|
decodeImage: (element) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
decodes.push({
|
||||||
|
reject,
|
||||||
|
resolve,
|
||||||
|
url: element.getAttribute("src") ?? "",
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
root,
|
||||||
|
// Fires immediately, so a crossfade completes without the test waiting real time. The
|
||||||
|
// ordering the transition depends on is unaffected.
|
||||||
|
setTimer: (handler) => {
|
||||||
|
handler();
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates exactly two layers, once, and never rebuilds them", async () => {
|
||||||
|
const before = layers();
|
||||||
|
expect(before).toHaveLength(2);
|
||||||
|
|
||||||
|
const first = stage.show(image("a"));
|
||||||
|
await settle();
|
||||||
|
resolveDecodes();
|
||||||
|
await first;
|
||||||
|
|
||||||
|
const second = stage.show(image("b"));
|
||||||
|
await settle();
|
||||||
|
resolveDecodes();
|
||||||
|
await second;
|
||||||
|
|
||||||
|
// The same two elements. A renderer that rebuilt them per slide is the `v1` defect.
|
||||||
|
expect(layers()[0]).toBe(before[0]);
|
||||||
|
expect(layers()[1]).toBe(before[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for a real decode before anything becomes visible", async () => {
|
||||||
|
const shown = stage.show(image("a"));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// The image is in the DOM and the decode has not resolved: nothing is visible yet.
|
||||||
|
expect(decodes).toHaveLength(1);
|
||||||
|
expect(opacities()).toEqual(["0", "0"]);
|
||||||
|
|
||||||
|
resolveDecodes();
|
||||||
|
await shown;
|
||||||
|
expect(opacities().filter((opacity) => opacity === "1")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never fades the outgoing layer, so the black root cannot show through", async () => {
|
||||||
|
const first = stage.show(image("a"));
|
||||||
|
await settle();
|
||||||
|
resolveDecodes();
|
||||||
|
await first;
|
||||||
|
const outgoing = layers().find((layer) => layer.style.opacity === "1") as HTMLElement;
|
||||||
|
|
||||||
|
const second = stage.show(image("b"));
|
||||||
|
await settle();
|
||||||
|
// The incoming layer is prepared; the outgoing one is still fully opaque, underneath.
|
||||||
|
expect(outgoing.style.opacity).toBe("1");
|
||||||
|
resolveDecodes();
|
||||||
|
await second;
|
||||||
|
|
||||||
|
// Only after the fade completes does the old layer go to zero — and it is now behind, and empty.
|
||||||
|
expect(outgoing.style.opacity).toBe("0");
|
||||||
|
expect(outgoing.style.zIndex).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the last good frame when incoming content cannot be decoded", async () => {
|
||||||
|
const first = stage.show(image("a"));
|
||||||
|
await settle();
|
||||||
|
resolveDecodes();
|
||||||
|
await first;
|
||||||
|
const goodKey = stage.currentKey();
|
||||||
|
|
||||||
|
const failing = stage.show(image("broken"));
|
||||||
|
await settle();
|
||||||
|
decodes.shift()?.reject(new Error("decode failed"));
|
||||||
|
const outcome = await failing;
|
||||||
|
|
||||||
|
expect(outcome).toEqual({ code: "SLIDE_DECODE_FAILED", kind: "failed" });
|
||||||
|
// The wall still shows what it showed. A broken slide costs one slide.
|
||||||
|
expect(stage.currentKey()).toBe(goodKey);
|
||||||
|
expect(opacities().filter((opacity) => opacity === "1")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards completion from a superseded transition", async () => {
|
||||||
|
const first = stage.show(image("a"));
|
||||||
|
await settle();
|
||||||
|
resolveDecodes();
|
||||||
|
await first;
|
||||||
|
|
||||||
|
const slow = stage.show(image("b"));
|
||||||
|
await settle();
|
||||||
|
const fast = stage.show(image("c"));
|
||||||
|
await settle();
|
||||||
|
resolveDecodes();
|
||||||
|
const [slowOutcome, fastOutcome] = await Promise.all([slow, fast]);
|
||||||
|
|
||||||
|
// The newer transition owns the layers; the older one changes nothing.
|
||||||
|
expect(slowOutcome.kind).toBe("superseded");
|
||||||
|
expect(fastOutcome.kind).toBe("shown");
|
||||||
|
expect(stage.currentKey()).toBe("c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates a mounted renderer in place rather than remounting it", async () => {
|
||||||
|
const updates: unknown[] = [];
|
||||||
|
let mounts = 0;
|
||||||
|
const contributionStage = createSceneStage({
|
||||||
|
crossfadeMs: 0,
|
||||||
|
mountContribution: () => {
|
||||||
|
mounts += 1;
|
||||||
|
return Promise.resolve({
|
||||||
|
unmount: () => undefined,
|
||||||
|
update: (input: unknown) => {
|
||||||
|
updates.push(input);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
root,
|
||||||
|
// Fires immediately, so a crossfade completes without the test waiting real time. The
|
||||||
|
// ordering the transition depends on is unaffected.
|
||||||
|
setTimer: (handler) => {
|
||||||
|
handler();
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = rendererInputFor(
|
||||||
|
{
|
||||||
|
availableUntilMs: null,
|
||||||
|
deadlineAtMs: 5000,
|
||||||
|
durationMs: 5000,
|
||||||
|
id: "sel-1",
|
||||||
|
itemId: "advent",
|
||||||
|
itemRevision: "1",
|
||||||
|
payload: { headline: "Sunday" },
|
||||||
|
presentation: null,
|
||||||
|
providerId: "fixture.announcements",
|
||||||
|
renderer: {
|
||||||
|
contributionId: "fixture.announcements.slide",
|
||||||
|
contributionVersion: "1.0.0",
|
||||||
|
kind: "contribution",
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
slotId: "slot-1",
|
||||||
|
startedAtMs: 0,
|
||||||
|
withdrawn: false,
|
||||||
|
},
|
||||||
|
{ audienceKey: "default", localNowMs: 0, paused: false, remainingMs: null, serverNowMs: 0 },
|
||||||
|
);
|
||||||
|
const request: SceneRequest = {
|
||||||
|
contributionId: "fixture.announcements.slide",
|
||||||
|
input,
|
||||||
|
key: "sel-1:1:default:r",
|
||||||
|
kind: "contribution",
|
||||||
|
};
|
||||||
|
|
||||||
|
await contributionStage.show(request);
|
||||||
|
const updated = await contributionStage.show({
|
||||||
|
...request,
|
||||||
|
input: { ...input, payload: { headline: "Cancelled" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same key, new input: one mount, one update, no transition — which is what lets a long-running
|
||||||
|
// screen change content without restarting animations or reloading media.
|
||||||
|
expect(updated).toEqual({ kind: "updated" });
|
||||||
|
expect(mounts).toBe(1);
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
await contributionStage.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the last good frame when a renderer refuses to mount", async () => {
|
||||||
|
const contributionStage = createSceneStage({
|
||||||
|
crossfadeMs: 0,
|
||||||
|
mountContribution: () => Promise.reject(new Error("renderer exploded")),
|
||||||
|
root,
|
||||||
|
// Fires immediately, so a crossfade completes without the test waiting real time. The
|
||||||
|
// ordering the transition depends on is unaffected.
|
||||||
|
setTimer: (handler) => {
|
||||||
|
handler();
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const outcome = await contributionStage.show({
|
||||||
|
contributionId: "fixture.announcements.slide",
|
||||||
|
input: {} as never,
|
||||||
|
key: "sel-9",
|
||||||
|
kind: "contribution",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(outcome).toEqual({ code: "RENDERER_MOUNT_FAILED", kind: "failed" });
|
||||||
|
expect(contributionStage.currentKey()).toBeUndefined();
|
||||||
|
await contributionStage.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unmounts a renderer deterministically when its layer is reused", async () => {
|
||||||
|
let unmounts = 0;
|
||||||
|
const contributionStage = createSceneStage({
|
||||||
|
crossfadeMs: 0,
|
||||||
|
mountContribution: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
unmount: () => {
|
||||||
|
unmounts += 1;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
root,
|
||||||
|
// Fires immediately, so a crossfade completes without the test waiting real time. The
|
||||||
|
// ordering the transition depends on is unaffected.
|
||||||
|
setTimer: (handler) => {
|
||||||
|
handler();
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await contributionStage.show({
|
||||||
|
contributionId: "a.slide",
|
||||||
|
input: {} as never,
|
||||||
|
key: "one",
|
||||||
|
kind: "contribution",
|
||||||
|
});
|
||||||
|
await contributionStage.show({
|
||||||
|
contributionId: "a.slide",
|
||||||
|
input: {} as never,
|
||||||
|
key: "two",
|
||||||
|
kind: "contribution",
|
||||||
|
});
|
||||||
|
await contributionStage.show({
|
||||||
|
contributionId: "a.slide",
|
||||||
|
input: {} as never,
|
||||||
|
key: "three",
|
||||||
|
kind: "contribution",
|
||||||
|
});
|
||||||
|
await contributionStage.destroy();
|
||||||
|
|
||||||
|
// Three mounts, three unmounts: repeated mount and unmount leaks nothing.
|
||||||
|
expect(unmounts).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports blackout and slate as states of the root, so one slate implementation renders both", () => {
|
||||||
|
stage.setBlackout(true);
|
||||||
|
expect(root.dataset.blackout).toBe("true");
|
||||||
|
stage.setBlackout(false);
|
||||||
|
expect(root.dataset.blackout).toBe("false");
|
||||||
|
|
||||||
|
stage.setSlate(true);
|
||||||
|
expect(root.dataset.slate).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scene requests", () => {
|
||||||
|
const base = {
|
||||||
|
availableUntilMs: null,
|
||||||
|
deadlineAtMs: 5000,
|
||||||
|
durationMs: 5000,
|
||||||
|
id: "sel-1",
|
||||||
|
itemId: "welcome",
|
||||||
|
itemRevision: "abc",
|
||||||
|
payload: { headline: "Welcome" },
|
||||||
|
presentation: null,
|
||||||
|
providerId: "fixture.announcements",
|
||||||
|
revision: 1,
|
||||||
|
slotId: "slot-1",
|
||||||
|
startedAtMs: 0,
|
||||||
|
withdrawn: false,
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
audienceKey: "default",
|
||||||
|
localNowMs: 1000,
|
||||||
|
paused: false,
|
||||||
|
remainingMs: null,
|
||||||
|
serverNowMs: 9000,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("gives a renderer both clocks, so a television with a wrong clock still counts correctly", () => {
|
||||||
|
const input = rendererInputFor(
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
renderer: { contributionId: "a.slide", contributionVersion: "1.0.0", kind: "contribution" },
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(input.clock).toEqual({ localNowMs: 1000, serverNowMs: 9000 });
|
||||||
|
expect(input.timing).toMatchObject({ deadlineAtMs: 5000, paused: false, startedAtMs: 0 });
|
||||||
|
expect(input.contract).toBe("signage.slide-renderer");
|
||||||
|
// Everything the renderer is given, and nothing else: no API client, no principal, no
|
||||||
|
// credential, no URL.
|
||||||
|
expect(Object.keys(input).sort()).toEqual([
|
||||||
|
"audienceKey",
|
||||||
|
"clock",
|
||||||
|
"contract",
|
||||||
|
"generation",
|
||||||
|
"itemId",
|
||||||
|
"itemRevision",
|
||||||
|
"payload",
|
||||||
|
"presentation",
|
||||||
|
"providerId",
|
||||||
|
"timing",
|
||||||
|
"version",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes the key when the payload revision or pause state changes, and not when the clock does", () => {
|
||||||
|
const selection = {
|
||||||
|
...base,
|
||||||
|
renderer: { contributionId: "a.slide", contributionVersion: "1.0.0", kind: "contribution" },
|
||||||
|
} as const;
|
||||||
|
const first = rendererInputFor(selection, context).generation;
|
||||||
|
const reAnchored = rendererInputFor(selection, {
|
||||||
|
...context,
|
||||||
|
localNowMs: 99_999,
|
||||||
|
serverNowMs: 99_999,
|
||||||
|
}).generation;
|
||||||
|
const revised = rendererInputFor({ ...selection, revision: 2 }, context).generation;
|
||||||
|
const paused = rendererInputFor(selection, { ...context, paused: true }).generation;
|
||||||
|
|
||||||
|
// A reconnect re-anchors the clock. Remounting a renderer for that would restart every
|
||||||
|
// animation on the wall for no reason.
|
||||||
|
expect(reAnchored).toBe(first);
|
||||||
|
expect(revised).not.toBe(first);
|
||||||
|
expect(paused).not.toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declines to build a scene for a renderer the platform did not publish", () => {
|
||||||
|
const request = sceneRequestFor(
|
||||||
|
{
|
||||||
|
...base,
|
||||||
|
renderer: {
|
||||||
|
contributionId: "missing.slide",
|
||||||
|
contributionVersion: "1.0.0",
|
||||||
|
kind: "contribution",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
{ canMountContribution: () => false },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ordinary rather than exceptional: the contributing module is absent, disabled, or outside
|
||||||
|
// Signage's compatible range, and the caller keeps its last good frame.
|
||||||
|
expect(request).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declines to build a native scene with no delivery URL", () => {
|
||||||
|
const request = sceneRequestFor({ ...base, renderer: { kind: "native-image" } }, context, {
|
||||||
|
canMountContribution: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type {
|
||||||
|
ModuleContributionDescriptor,
|
||||||
|
ModuleUiApiClient,
|
||||||
|
ModuleUiContributions,
|
||||||
|
ModuleUiHostContext,
|
||||||
|
MountedModuleContribution,
|
||||||
|
MountModuleContributionOptions,
|
||||||
|
} from "@ekklesia/module-ui-sdk";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A host, as a module's artifact actually experiences one.
|
||||||
|
*
|
||||||
|
* Requests are matched on the **relative** path a module asks for, which is what proves the artifact
|
||||||
|
* never builds `/api/v1/modules/signage/...`: a route table keyed on Ekklesia's private prefix would
|
||||||
|
* simply never match.
|
||||||
|
*/
|
||||||
|
export interface FakeHostRoute {
|
||||||
|
readonly method: string;
|
||||||
|
readonly path: string | RegExp;
|
||||||
|
readonly respond: (request: {
|
||||||
|
readonly path: string;
|
||||||
|
readonly init: RequestInit | undefined;
|
||||||
|
}) => Response | Promise<Response>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FakeHost {
|
||||||
|
readonly context: ModuleUiHostContext;
|
||||||
|
readonly requests: { method: string; path: string }[];
|
||||||
|
/** Contributions this host published against Signage's renderer capability. */
|
||||||
|
readonly mounted: { contributionId: string; input: unknown }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorResponse(code: string, message: string, status = 400): Response {
|
||||||
|
return jsonResponse({ code, message }, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FakeHostOptions {
|
||||||
|
readonly permissions: readonly string[];
|
||||||
|
readonly routes: readonly FakeHostRoute[];
|
||||||
|
readonly contributions?: readonly ModuleContributionDescriptor[];
|
||||||
|
readonly principal?: ModuleUiHostContext["principal"];
|
||||||
|
readonly mount?: (
|
||||||
|
options: MountModuleContributionOptions,
|
||||||
|
) => Promise<MountedModuleContribution> | MountedModuleContribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFakeHost(options: FakeHostOptions): FakeHost {
|
||||||
|
const requests: { method: string; path: string }[] = [];
|
||||||
|
const mounted: { contributionId: string; input: unknown }[] = [];
|
||||||
|
|
||||||
|
const api: ModuleUiApiClient = {
|
||||||
|
async request(path, init) {
|
||||||
|
const method = (init?.method ?? "GET").toUpperCase();
|
||||||
|
const normalized = path.replace(/^\//u, "");
|
||||||
|
requests.push({ method, path: normalized });
|
||||||
|
for (const route of options.routes) {
|
||||||
|
const matches =
|
||||||
|
typeof route.path === "string"
|
||||||
|
? route.path === normalized.split("?")[0]
|
||||||
|
: route.path.test(normalized);
|
||||||
|
if (route.method === method && matches) {
|
||||||
|
return route.respond({ init, path: normalized });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errorResponse("SIGNAGE_NOT_FOUND", "The requested resource was not found.", 404);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const contributions: ModuleUiContributions = {
|
||||||
|
list: (capabilityId) =>
|
||||||
|
capabilityId === "signage.slide-renderer" ? (options.contributions ?? []) : [],
|
||||||
|
async mount(mountOptions) {
|
||||||
|
mounted.push({ contributionId: mountOptions.contributionId, input: mountOptions.input });
|
||||||
|
if (options.mount) {
|
||||||
|
return options.mount(mountOptions);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
contributionId: mountOptions.contributionId,
|
||||||
|
mounted: true,
|
||||||
|
unmount: () => Promise.resolve(),
|
||||||
|
update: () => Promise.resolve(),
|
||||||
|
version: "1.0.0",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: {
|
||||||
|
api,
|
||||||
|
contributions,
|
||||||
|
moduleId: "signage",
|
||||||
|
permissions: { has: (permission) => options.permissions.includes(permission) },
|
||||||
|
principal: options.principal ?? { kind: "anonymous" },
|
||||||
|
realtime: { subscribe: () => ({ close: () => undefined }) },
|
||||||
|
},
|
||||||
|
mounted,
|
||||||
|
requests,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The settings payload every screen loads first. */
|
||||||
|
export const FAKE_SETTINGS = Object.freeze({
|
||||||
|
crossfadeMs: 1000,
|
||||||
|
moduleVersion: "0.1.0",
|
||||||
|
nativeImageDurationMs: 7000,
|
||||||
|
nativePresentation: {
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
backgroundDarkened: false,
|
||||||
|
backgroundMode: "blurred",
|
||||||
|
blurPixels: 20,
|
||||||
|
},
|
||||||
|
rendererInputVersion: "1.0.0",
|
||||||
|
timezone: "America/Chicago",
|
||||||
|
uploads: { batchMaxFiles: 20, concurrency: 3, maxBytes: 26_214_400, maxPixels: 40_000_000 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { defineModuleContribution } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { type ReactElement, useEffect, useState } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import type { SignageSlideRendererInput } from "../../../src/shared/renderer-input.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A **test-only** browser renderer, standing in for the contribution a real content module will
|
||||||
|
* publish.
|
||||||
|
*
|
||||||
|
* It is not an installed module and has no manifest: it lives under `tests/`, and the only way it
|
||||||
|
* reaches a screen is a test handing it to the stage the way the platform's contribution loader
|
||||||
|
* would. It is built with the platform's own `defineModuleContribution`, so what it proves is the
|
||||||
|
* actual contract rather than a convenient approximation.
|
||||||
|
*
|
||||||
|
* It deliberately does everything a demanding real renderer would:
|
||||||
|
*
|
||||||
|
* - resolves `mount` only when it is genuinely ready, so the stage's fade waits for it;
|
||||||
|
* - presents **internal multiple stages** — a question, then its answer — inside one Signage slide,
|
||||||
|
* which Signage neither knows about nor times;
|
||||||
|
* - renders time-based text from the input's clock anchor rather than the device's clock;
|
||||||
|
* - renders differently per audience;
|
||||||
|
* - updates in place, without remounting;
|
||||||
|
* - unmounts deterministically, releasing its timer and its React root.
|
||||||
|
*
|
||||||
|
* It receives no API client, no realtime, no principal, no storage, and no credential — because the
|
||||||
|
* contract gives a renderer none of those. Everything it draws comes from the consumer's input.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TriviaPayload {
|
||||||
|
readonly question?: unknown;
|
||||||
|
readonly answer?: unknown;
|
||||||
|
readonly revealAfterMs?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TriviaSlide({ input }: { readonly input: SignageSlideRendererInput }): ReactElement {
|
||||||
|
const payload = input.payload as TriviaPayload;
|
||||||
|
const question = typeof payload.question === "string" ? payload.question : "…";
|
||||||
|
const answer = typeof payload.answer === "string" ? payload.answer : "";
|
||||||
|
const revealAfterMs = typeof payload.revealAfterMs === "number" ? payload.revealAfterMs : 2000;
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
|
||||||
|
// The authoritative now, computed from both clocks. A television's own clock is routinely wrong;
|
||||||
|
// this is what makes a countdown on two screens agree.
|
||||||
|
const serverNow = input.clock.serverNowMs + (Date.now() - input.clock.localNowMs);
|
||||||
|
const remainingSeconds = Math.max(0, Math.ceil((input.timing.deadlineAtMs - serverNow) / 1000));
|
||||||
|
|
||||||
|
// `generation` is the dependency that matters and the linter cannot see why: it changes when the
|
||||||
|
// slide's content changes, which is exactly when this stage should restart — and it does *not*
|
||||||
|
// change when the clock is re-anchored on reconnect, which is exactly when it should not.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the generation token is the point.
|
||||||
|
useEffect(() => {
|
||||||
|
if (input.timing.paused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Internal multi-stage behaviour: Signage sees one slide with one deadline, and this decides
|
||||||
|
// what happens *within* it. It cannot extend that deadline and does not try.
|
||||||
|
setRevealed(false);
|
||||||
|
const handle = setTimeout(() => setRevealed(true), revealAfterMs);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
}, [input.generation, input.timing.paused, revealAfterMs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="fixture-trivia" data-testid="fixture-trivia">
|
||||||
|
<h1 data-testid="fixture-trivia-question">{question}</h1>
|
||||||
|
{revealed ? <p data-testid="fixture-trivia-answer">{answer}</p> : undefined}
|
||||||
|
<p data-testid="fixture-trivia-audience">{input.audienceKey}</p>
|
||||||
|
<p data-testid="fixture-trivia-remaining">{`${remainingSeconds}s`}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lets a test decide exactly when the renderer becomes ready, so "the stage waits for readiness"
|
||||||
|
* is a property that can be observed rather than assumed. */
|
||||||
|
export interface RendererReadiness {
|
||||||
|
readonly whenReady: Promise<void>;
|
||||||
|
ready(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReadiness(): RendererReadiness {
|
||||||
|
let settle: (() => void) | undefined;
|
||||||
|
const whenReady = new Promise<void>((resolve) => {
|
||||||
|
settle = resolve;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ready(): void {
|
||||||
|
settle?.();
|
||||||
|
},
|
||||||
|
whenReady,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FixtureRendererCounters {
|
||||||
|
mounts: number;
|
||||||
|
updates: number;
|
||||||
|
unmounts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFixtureRenderer(options: {
|
||||||
|
readonly readiness?: RendererReadiness;
|
||||||
|
readonly counters?: FixtureRendererCounters;
|
||||||
|
}) {
|
||||||
|
const counters = options.counters ?? { mounts: 0, unmounts: 0, updates: 0 };
|
||||||
|
|
||||||
|
const definition = defineModuleContribution({
|
||||||
|
id: "fixture.trivia.slide",
|
||||||
|
version: "1.0.0",
|
||||||
|
async mount(container, context) {
|
||||||
|
counters.mounts += 1;
|
||||||
|
const root: Root = createRoot(container as Element);
|
||||||
|
root.render(<TriviaSlide input={context.input as unknown as SignageSlideRendererInput} />);
|
||||||
|
// Asynchronous by contract: a renderer that has to decode media, measure a layout, or wait for
|
||||||
|
// a font resolves only when it is honestly ready, and the stage's fade waits for it.
|
||||||
|
if (options.readiness) {
|
||||||
|
await options.readiness.whenReady;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
unmount() {
|
||||||
|
counters.unmounts += 1;
|
||||||
|
root.unmount();
|
||||||
|
},
|
||||||
|
update(input) {
|
||||||
|
counters.updates += 1;
|
||||||
|
root.render(<TriviaSlide input={input as unknown as SignageSlideRendererInput} />);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { counters, definition };
|
||||||
|
}
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
import sharp from "sharp";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { SignageError } from "../src/server/errors.js";
|
||||||
|
import { SIGNAGE_IMAGE_MAX_BYTES, SIGNAGE_IMAGE_MAX_PIXELS } from "../src/server/images/limits.js";
|
||||||
|
import { processSignageImage, sha256Hex } from "../src/server/images/processing.js";
|
||||||
|
import {
|
||||||
|
deleteNativeImage,
|
||||||
|
ingestNativeImage,
|
||||||
|
updateNativeImage,
|
||||||
|
} from "../src/server/images/service.js";
|
||||||
|
import { readBoundedBody } from "../src/server/images/streaming.js";
|
||||||
|
import { isSixteenByNine, resolveNativeScene } from "../src/shared/native-images.js";
|
||||||
|
import { createFakeStorage, type FakeStorage } from "./support/fake-storage.js";
|
||||||
|
import {
|
||||||
|
bytesOf,
|
||||||
|
GIF_DOCUMENT_BASE64,
|
||||||
|
makeAnimatedGif,
|
||||||
|
makeAnimatedWebp,
|
||||||
|
makeImage,
|
||||||
|
PDF_DOCUMENT,
|
||||||
|
SVG_DOCUMENT,
|
||||||
|
} from "./support/images.js";
|
||||||
|
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
|
||||||
|
|
||||||
|
async function expectSignageCode(promise: Promise<unknown>, code: string): Promise<SignageError> {
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(SignageError);
|
||||||
|
const signageError = error as SignageError;
|
||||||
|
expect(signageError.code).toBe(code);
|
||||||
|
return signageError;
|
||||||
|
}
|
||||||
|
throw new Error(`Expected ${code}, but the operation succeeded.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("native image processing", () => {
|
||||||
|
it("accepts still PNG, JPEG, and WebP and reports the format the bytes actually are", async () => {
|
||||||
|
for (const format of ["png", "jpeg", "webp"] as const) {
|
||||||
|
const processed = await processSignageImage(
|
||||||
|
await makeImage({ format, height: 720, width: 1280 }),
|
||||||
|
);
|
||||||
|
expect(processed.format).toBe(format);
|
||||||
|
expect(processed.width).toBe(1280);
|
||||||
|
expect(processed.height).toBe(720);
|
||||||
|
expect(processed.renditions.map((rendition) => rendition.kind)).toEqual([
|
||||||
|
"display",
|
||||||
|
"thumbnail",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses SVG, PDF, GIF, animation, and anything that is not an image at all", async () => {
|
||||||
|
await expectSignageCode(
|
||||||
|
processSignageImage(bytesOf(SVG_DOCUMENT)),
|
||||||
|
"SIGNAGE_IMAGE_UNSUPPORTED_FORMAT",
|
||||||
|
);
|
||||||
|
await expectSignageCode(processSignageImage(bytesOf(PDF_DOCUMENT)), "SIGNAGE_IMAGE_MALFORMED");
|
||||||
|
await expectSignageCode(
|
||||||
|
processSignageImage(new Uint8Array(Buffer.from(GIF_DOCUMENT_BASE64, "base64"))),
|
||||||
|
"SIGNAGE_IMAGE_UNSUPPORTED_FORMAT",
|
||||||
|
);
|
||||||
|
await expectSignageCode(
|
||||||
|
processSignageImage(bytesOf("this is a text file, not a slide")),
|
||||||
|
"SIGNAGE_IMAGE_MALFORMED",
|
||||||
|
);
|
||||||
|
// Animation is detected before format, so an animated GIF reports why it is really refused.
|
||||||
|
await expectSignageCode(
|
||||||
|
processSignageImage(await makeAnimatedWebp()),
|
||||||
|
"SIGNAGE_IMAGE_ANIMATED",
|
||||||
|
);
|
||||||
|
await expectSignageCode(processSignageImage(await makeAnimatedGif()), "SIGNAGE_IMAGE_ANIMATED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an image whose decoded pixels exceed the budget, whatever the file size is", async () => {
|
||||||
|
// A decompression bomb is small on disk and enormous decoded, which is exactly why the budget
|
||||||
|
// is measured in pixels rather than in bytes.
|
||||||
|
const bomb = await makeImage({ format: "png", height: 9000, width: 9000 });
|
||||||
|
expect(bomb.byteLength).toBeLessThan(SIGNAGE_IMAGE_MAX_BYTES);
|
||||||
|
expect(9000 * 9000).toBeGreaterThan(SIGNAGE_IMAGE_MAX_PIXELS);
|
||||||
|
|
||||||
|
const error = await expectSignageCode(processSignageImage(bomb), "SIGNAGE_IMAGE_PIXEL_LIMIT");
|
||||||
|
// The caller learns the limit Signage declared, and nothing about the decoder that enforced it.
|
||||||
|
expect(error.detail).toEqual({ maxPixels: SIGNAGE_IMAGE_MAX_PIXELS });
|
||||||
|
expect(error.message).not.toMatch(/sharp|vips|pixel limit exceeded/iu);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fits renditions inside their bounds, and never crops or upscales", async () => {
|
||||||
|
const tall = await processSignageImage(
|
||||||
|
await makeImage({ format: "png", height: 2400, width: 1000 }),
|
||||||
|
);
|
||||||
|
const display = tall.renditions.find((rendition) => rendition.kind === "display");
|
||||||
|
// Inside-fit: the long edge reaches the bound, the short edge is proportional, nothing is cut.
|
||||||
|
expect(display?.height).toBe(1080);
|
||||||
|
expect(display?.width).toBe(450);
|
||||||
|
|
||||||
|
const small = await processSignageImage(
|
||||||
|
await makeImage({ format: "png", height: 90, width: 160 }),
|
||||||
|
);
|
||||||
|
const smallDisplay = small.renditions.find((rendition) => rendition.kind === "display");
|
||||||
|
// Never upscaled: blowing a small image up to 1920 makes it soft, not better.
|
||||||
|
expect(smallDisplay?.width).toBe(160);
|
||||||
|
expect(smallDisplay?.height).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips metadata from renditions and flattens transparency against black", async () => {
|
||||||
|
const transparent = await processSignageImage(
|
||||||
|
await makeImage({ alpha: true, format: "png", height: 200, width: 400 }),
|
||||||
|
);
|
||||||
|
const display = transparent.renditions.find((rendition) => rendition.kind === "display");
|
||||||
|
if (!display) {
|
||||||
|
throw new Error("Expected a display rendition.");
|
||||||
|
}
|
||||||
|
const metadata = await sharp(display.bytes).metadata();
|
||||||
|
|
||||||
|
expect(metadata.format).toBe("webp");
|
||||||
|
expect(metadata.hasAlpha).toBe(false);
|
||||||
|
// EXIF, GPS coordinates, and camera serial numbers never travel with a published rendition.
|
||||||
|
expect(metadata.exif).toBeUndefined();
|
||||||
|
expect(metadata.icc).toBeUndefined();
|
||||||
|
expect(metadata.xmp).toBeUndefined();
|
||||||
|
// The original's own facts are preserved in PostgreSQL instead.
|
||||||
|
expect(transparent.hasAlpha).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic, so a retry of the same bytes produces the same renditions", async () => {
|
||||||
|
const bytes = await makeImage({ format: "png", height: 600, width: 800 });
|
||||||
|
const first = await processSignageImage(bytes);
|
||||||
|
const second = await processSignageImage(bytes);
|
||||||
|
|
||||||
|
expect(first.contentSha256).toBe(second.contentSha256);
|
||||||
|
expect(first.contentSha256).toBe(sha256Hex(bytes));
|
||||||
|
expect(first.renditions.map((rendition) => rendition.contentSha256)).toEqual(
|
||||||
|
second.renditions.map((rendition) => rendition.contentSha256),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("native image presentation", () => {
|
||||||
|
it("lets a true 16:9 image fill the frame, within a documented tolerance", () => {
|
||||||
|
expect(isSixteenByNine(1920, 1080)).toBe(true);
|
||||||
|
expect(isSixteenByNine(1280, 720)).toBe(true);
|
||||||
|
// A one-or-two-pixel export artefact is still 16:9 to a person looking at the wall.
|
||||||
|
expect(isSixteenByNine(1918, 1080)).toBe(true);
|
||||||
|
expect(isSixteenByNine(1080, 1080)).toBe(false);
|
||||||
|
expect(isSixteenByNine(2560, 1080)).toBe(false);
|
||||||
|
expect(isSixteenByNine(0, 0)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a 16:9 image whole with no background, blur, or shadow", () => {
|
||||||
|
const scene = resolveNativeScene(
|
||||||
|
{ height: 1080, width: 1920 },
|
||||||
|
{
|
||||||
|
backgroundColor: "#112233",
|
||||||
|
backgroundDarkened: true,
|
||||||
|
backgroundMode: "blurred",
|
||||||
|
blurPixels: 20,
|
||||||
|
foregroundShadow: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// An image that already fits needs none of the treatments that exist to frame one that does not.
|
||||||
|
expect(scene).toEqual({
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
backgroundImage: false,
|
||||||
|
backgroundMode: "none",
|
||||||
|
blurPixels: 0,
|
||||||
|
darkenOpacity: 0,
|
||||||
|
fit: "fill",
|
||||||
|
shadow: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows every other ratio whole, on the background the slot asked for", () => {
|
||||||
|
const blurred = resolveNativeScene(
|
||||||
|
{ height: 1080, width: 1080 },
|
||||||
|
{
|
||||||
|
backgroundColor: "#112233",
|
||||||
|
backgroundDarkened: true,
|
||||||
|
backgroundMode: "blurred",
|
||||||
|
blurPixels: 24,
|
||||||
|
foregroundShadow: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(blurred).toMatchObject({
|
||||||
|
backgroundImage: true,
|
||||||
|
blurPixels: 24,
|
||||||
|
darkenOpacity: 0.25,
|
||||||
|
fit: "contain",
|
||||||
|
shadow: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const solid = resolveNativeScene(
|
||||||
|
{ height: 1080, width: 1080 },
|
||||||
|
{
|
||||||
|
backgroundColor: "#112233",
|
||||||
|
backgroundDarkened: false,
|
||||||
|
backgroundMode: "solid",
|
||||||
|
blurPixels: 24,
|
||||||
|
foregroundShadow: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// A solid background is not blurred, and an undarkened one is not dimmed.
|
||||||
|
expect(solid).toMatchObject({
|
||||||
|
backgroundColor: "#112233",
|
||||||
|
backgroundImage: false,
|
||||||
|
blurPixels: 0,
|
||||||
|
darkenOpacity: 0,
|
||||||
|
fit: "contain",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bounded upload reading", () => {
|
||||||
|
function streamedRequest(chunks: readonly Uint8Array[], contentLength?: number): Request {
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Request("http://localhost/images?filename=a.png", {
|
||||||
|
body,
|
||||||
|
// Required by Node's fetch for a streaming request body.
|
||||||
|
duplex: "half",
|
||||||
|
headers: contentLength === undefined ? {} : { "content-length": String(contentLength) },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("refuses a declared length over the limit before the body is even touched", async () => {
|
||||||
|
let bodyAccesses = 0;
|
||||||
|
// A stand-in rather than a real `Request`, and `body` is a getter: a `ReadableStream` pulls its
|
||||||
|
// first chunk as soon as it is constructed, so counting reads would measure the stream rather
|
||||||
|
// than the check. Counting *accesses to the body* measures exactly the property under test.
|
||||||
|
const request = {
|
||||||
|
get body(): ReadableStream<Uint8Array> {
|
||||||
|
bodyAccesses += 1;
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new Uint8Array(16));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
headers: new Headers({ "content-length": String(SIGNAGE_IMAGE_MAX_BYTES + 1) }),
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
await expectSignageCode(
|
||||||
|
readBoundedBody(request, SIGNAGE_IMAGE_MAX_BYTES),
|
||||||
|
"SIGNAGE_IMAGE_TOO_LARGE",
|
||||||
|
);
|
||||||
|
expect(bodyAccesses).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts what actually arrives, so a missing or lying declared length bounds nothing", async () => {
|
||||||
|
const chunk = new Uint8Array(1024);
|
||||||
|
const chunks = Array.from({ length: 8 }, () => chunk);
|
||||||
|
|
||||||
|
// No `content-length` at all, and a total over a small limit.
|
||||||
|
await expectSignageCode(
|
||||||
|
readBoundedBody(streamedRequest(chunks), 4096),
|
||||||
|
"SIGNAGE_IMAGE_TOO_LARGE",
|
||||||
|
);
|
||||||
|
// A declared length that lies about being small.
|
||||||
|
await expectSignageCode(
|
||||||
|
readBoundedBody(streamedRequest(chunks, 10), 4096),
|
||||||
|
"SIGNAGE_IMAGE_TOO_LARGE",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reassembles a chunked body exactly", async () => {
|
||||||
|
const image = await makeImage({ format: "png", height: 90, width: 160 });
|
||||||
|
const half = Math.floor(image.byteLength / 2);
|
||||||
|
const bytes = await readBoundedBody(
|
||||||
|
streamedRequest([image.slice(0, half), image.slice(half)]),
|
||||||
|
SIGNAGE_IMAGE_MAX_BYTES,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sha256Hex(bytes)).toBe(sha256Hex(image));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an empty body rather than treating it as an image", async () => {
|
||||||
|
await expectSignageCode(
|
||||||
|
readBoundedBody(streamedRequest([]), SIGNAGE_IMAGE_MAX_BYTES),
|
||||||
|
"SIGNAGE_INVALID_REQUEST",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("native image ingest", () => {
|
||||||
|
let store: MemorySignageStore;
|
||||||
|
let storage: FakeStorage;
|
||||||
|
let counter: number;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = createMemorySignageStore();
|
||||||
|
storage = createFakeStorage();
|
||||||
|
counter = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const newId = () => {
|
||||||
|
counter += 1;
|
||||||
|
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function ingest(bytes: Uint8Array, filename = "welcome.png") {
|
||||||
|
return ingestNativeImage({
|
||||||
|
bytes,
|
||||||
|
displayName: filename,
|
||||||
|
expiresAt: null,
|
||||||
|
filename,
|
||||||
|
newId,
|
||||||
|
storage,
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("archives the original unchanged and stores both renditions beside it", async () => {
|
||||||
|
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
|
||||||
|
const result = await ingest(bytes);
|
||||||
|
|
||||||
|
expect(result.deduplicated).toBe(false);
|
||||||
|
expect([...storage.objects.keys()].sort()).toEqual([
|
||||||
|
`native-images/assets/${result.image.id}/display.webp`,
|
||||||
|
`native-images/assets/${result.image.id}/original.png`,
|
||||||
|
`native-images/assets/${result.image.id}/thumbnail.webp`,
|
||||||
|
]);
|
||||||
|
// Byte for byte. It is the church's file, and Signage is not entitled to rewrite it.
|
||||||
|
const archived = storage.objects.get(`native-images/assets/${result.image.id}/original.png`);
|
||||||
|
expect(sha256Hex(archived?.bytes ?? new Uint8Array())).toBe(sha256Hex(bytes));
|
||||||
|
expect(result.image.originalContentType).toBe("image/png");
|
||||||
|
expect(result.image.renditions.map((rendition) => rendition.contentType)).toEqual([
|
||||||
|
"image/webp",
|
||||||
|
"image/webp",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the existing asset for identical bytes, and writes nothing new", async () => {
|
||||||
|
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
|
||||||
|
const first = await ingest(bytes, "welcome.png");
|
||||||
|
const objectCount = storage.objects.size;
|
||||||
|
|
||||||
|
const second = await ingest(bytes, "welcome-copy.png");
|
||||||
|
|
||||||
|
expect(second.deduplicated).toBe(true);
|
||||||
|
expect(second.image.id).toBe(first.image.id);
|
||||||
|
expect(storage.objects.size).toBe(objectCount);
|
||||||
|
expect(store.images.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes nothing at all when the bytes are not an image Signage accepts", async () => {
|
||||||
|
await expectSignageCode(
|
||||||
|
ingest(bytesOf(SVG_DOCUMENT), "logo.svg"),
|
||||||
|
"SIGNAGE_IMAGE_UNSUPPORTED_FORMAT",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(storage.objects.size).toBe(0);
|
||||||
|
expect(store.images.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes every object it wrote when a later write fails", async () => {
|
||||||
|
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
|
||||||
|
storage.failPut(`assets/00000000-0000-4000-8000-000000000001/thumbnail.webp`);
|
||||||
|
|
||||||
|
await expectSignageCode(ingest(bytes), "SIGNAGE_STORAGE_UNAVAILABLE");
|
||||||
|
|
||||||
|
// An aborted ingest leaves no orphan bytes and no half-formed asset.
|
||||||
|
expect(storage.objects.size).toBe(0);
|
||||||
|
expect(store.images.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a race with an identical upload as a success rather than a failure", async () => {
|
||||||
|
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
|
||||||
|
const first = await ingest(bytes);
|
||||||
|
// The second ingest is forced past the dedup check by clearing the hash lookup once, exactly as
|
||||||
|
// a concurrent commit would: the write happens, and the unique index refuses the insert.
|
||||||
|
const originalLookup = store.findReadyImageByContentHash.bind(store);
|
||||||
|
let lookups = 0;
|
||||||
|
store.findReadyImageByContentHash = (hash: string) => {
|
||||||
|
lookups += 1;
|
||||||
|
return lookups === 1 ? Promise.resolve(undefined) : originalLookup(hash);
|
||||||
|
};
|
||||||
|
|
||||||
|
const second = await ingest(bytes);
|
||||||
|
|
||||||
|
expect(second.deduplicated).toBe(true);
|
||||||
|
expect(second.image.id).toBe(first.image.id);
|
||||||
|
// The losing attempt cleaned up after itself.
|
||||||
|
expect(storage.objects.size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses bytes past Signage's own limit even if the caller read them", async () => {
|
||||||
|
await expectSignageCode(
|
||||||
|
ingestNativeImage({
|
||||||
|
bytes: new Uint8Array(SIGNAGE_IMAGE_MAX_BYTES + 1),
|
||||||
|
displayName: "huge.png",
|
||||||
|
expiresAt: null,
|
||||||
|
filename: "huge.png",
|
||||||
|
storage,
|
||||||
|
store,
|
||||||
|
}),
|
||||||
|
"SIGNAGE_IMAGE_TOO_LARGE",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("native image maintenance", () => {
|
||||||
|
let store: MemorySignageStore;
|
||||||
|
let storage: FakeStorage;
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
return ingestNativeImage({
|
||||||
|
bytes: await makeImage({ format: "png", height: 1080, width: 1920 }),
|
||||||
|
displayName: "welcome.png",
|
||||||
|
expiresAt: null,
|
||||||
|
filename: "welcome.png",
|
||||||
|
storage,
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = createMemorySignageStore();
|
||||||
|
storage = createFakeStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes nothing for an exact no-op", async () => {
|
||||||
|
const { image } = await seed();
|
||||||
|
let writes = 0;
|
||||||
|
const original = store.updateImage.bind(store);
|
||||||
|
store.updateImage = (id, changes) => {
|
||||||
|
writes += 1;
|
||||||
|
return original(id, changes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unchanged = await updateNativeImage({
|
||||||
|
assetId: image.id,
|
||||||
|
displayName: image.displayName,
|
||||||
|
expiresAt: null,
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(unchanged.displayName).toBe(image.displayName);
|
||||||
|
expect(writes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renames and expires an image, and treats a null expiration as permanent", async () => {
|
||||||
|
const { image } = await seed();
|
||||||
|
const expires = new Date("2027-01-01T00:00:00.000Z");
|
||||||
|
|
||||||
|
const renamed = await updateNativeImage({
|
||||||
|
assetId: image.id,
|
||||||
|
displayName: "Welcome slide",
|
||||||
|
expiresAt: expires,
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
expect(renamed.displayName).toBe("Welcome slide");
|
||||||
|
expect(renamed.expiresAt?.toISOString()).toBe(expires.toISOString());
|
||||||
|
|
||||||
|
const permanent = await updateNativeImage({ assetId: image.id, expiresAt: null, store });
|
||||||
|
expect(permanent.expiresAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes an unreferenced image and every object it owned", async () => {
|
||||||
|
const { image } = await seed();
|
||||||
|
|
||||||
|
await deleteNativeImage({ assetId: image.id, storage, store });
|
||||||
|
|
||||||
|
expect(store.images.size).toBe(0);
|
||||||
|
expect(storage.objects.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to delete an image a slot or a live static hold still refers to", async () => {
|
||||||
|
const { image } = await seed();
|
||||||
|
store.addImageReference(image.id, "slot");
|
||||||
|
store.addImageReference(image.id, "static-hold");
|
||||||
|
|
||||||
|
const error = await expectSignageCode(
|
||||||
|
deleteNativeImage({ assetId: image.id, storage, store }),
|
||||||
|
"SIGNAGE_ASSET_IN_USE",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Counts an operator can act on — never a slot id, a timeline name, or a channel group's name.
|
||||||
|
expect(error.detail).toEqual({ heldByChannelGroups: 1, referencedBySlots: 1 });
|
||||||
|
expect(store.images.size).toBe(1);
|
||||||
|
expect(storage.objects.size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an unknown image without saying whether it ever existed", async () => {
|
||||||
|
await expectSignageCode(
|
||||||
|
deleteNativeImage({
|
||||||
|
assetId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
storage,
|
||||||
|
store,
|
||||||
|
}),
|
||||||
|
"SIGNAGE_NOT_FOUND",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { EMPTY_MODULE_CONFIGURATION, parseModuleManifest } from "@ekklesia/module-sdk";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import moduleRuntime from "../src/server/index.js";
|
||||||
|
import { readSignageSettings } from "../src/server/settings.js";
|
||||||
|
import { SIGNAGE_DEFAULT_SETTINGS } from "../src/shared/settings.js";
|
||||||
|
import {
|
||||||
|
fakeConfiguration,
|
||||||
|
fakeHandlerContext,
|
||||||
|
findRoute,
|
||||||
|
registerModule,
|
||||||
|
} from "./support/module-harness.js";
|
||||||
|
|
||||||
|
/** Signage's own copy of Core's overlap rule: two routes of one module under one method must never
|
||||||
|
* be able to match the same request path, because they may carry different permissions. */
|
||||||
|
function overlappingRoutes(routes: readonly { method: string; path: string }[]): readonly string[] {
|
||||||
|
const segmentsOf = (path: string) => (path === "/" ? [] : path.slice(1).split("/"));
|
||||||
|
const collisions: string[] = [];
|
||||||
|
for (let left = 0; left < routes.length; left += 1) {
|
||||||
|
for (let right = left + 1; right < routes.length; right += 1) {
|
||||||
|
const first = routes[left];
|
||||||
|
const second = routes[right];
|
||||||
|
if (!first || !second || first.method !== second.method) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const a = segmentsOf(first.path);
|
||||||
|
const b = segmentsOf(second.path);
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const overlaps = a.every((segment, index) => {
|
||||||
|
const other = b[index] as string;
|
||||||
|
return segment.startsWith(":") || other.startsWith(":") || segment === other;
|
||||||
|
});
|
||||||
|
if (overlaps) {
|
||||||
|
collisions.push(`${first.method} ${first.path} ~ ${second.path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return collisions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readManifest() {
|
||||||
|
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
|
||||||
|
return parseModuleManifest(JSON.parse(await readFile(manifestUrl, "utf8")) as unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Signage module manifest", () => {
|
||||||
|
it("declares the extension seams other modules reach Signage through", async () => {
|
||||||
|
const manifest = await readManifest();
|
||||||
|
|
||||||
|
// The backend seam and the browser seam are versioned separately on purpose: a provider's
|
||||||
|
// registration contract and its renderer's input contract have different lifecycles, and
|
||||||
|
// versioning them together would force every provider to rebuild whenever either moved.
|
||||||
|
expect(manifest.capabilities).toEqual([
|
||||||
|
{ id: "signage.content-providers", version: "1.0.0" },
|
||||||
|
{ id: "signage.slide-renderer", version: "1.0.0" },
|
||||||
|
{ id: "signage.playback", version: "1.0.0" },
|
||||||
|
{ id: "signage.displays", version: "1.0.0" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares the module's product identity at the current manifest version", async () => {
|
||||||
|
const manifest = await readManifest();
|
||||||
|
|
||||||
|
expect(manifest.manifestVersion).toBe(7);
|
||||||
|
expect(manifest.id).toBe("signage");
|
||||||
|
expect(manifest.name).toBe("Signage");
|
||||||
|
expect(manifest.version).toBe("0.1.0");
|
||||||
|
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares every Signage permission, and none of them publicly", async () => {
|
||||||
|
const manifest = await readManifest();
|
||||||
|
|
||||||
|
expect(manifest.permissions.map((permission) => permission.id)).toEqual([
|
||||||
|
"signage.view",
|
||||||
|
"signage.assets.manage",
|
||||||
|
"signage.timelines.manage",
|
||||||
|
"signage.playback.control",
|
||||||
|
"signage.displays.manage",
|
||||||
|
"signage.display.view",
|
||||||
|
]);
|
||||||
|
// Nothing Signage owns is readable by an unauthenticated caller: a timeline names a church's
|
||||||
|
// schedule, and a display is a screen someone paired.
|
||||||
|
expect(manifest.permissions.every((permission) => permission.baseline === "restricted")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
for (const permission of manifest.permissions) {
|
||||||
|
expect(permission.description.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares one logical storage bucket and names no storage service anywhere", async () => {
|
||||||
|
const manifest = await readManifest();
|
||||||
|
const raw = await readFile(new URL("../ekklesia.module.json", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(manifest.storage.buckets).toEqual([
|
||||||
|
{
|
||||||
|
id: "native-images",
|
||||||
|
description:
|
||||||
|
"Archived original uploads and generated renditions for Signage's native images.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// A module that configured its own object store would be welded to one deployment (ADR 0030).
|
||||||
|
for (const forbidden of ["s3", "seaweed", "minio", "endpoint", "accessKey", "region"]) {
|
||||||
|
expect(raw.toLowerCase()).not.toContain(forbidden.toLowerCase());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares both browser artifacts, with the display gated on a non-public permission", async () => {
|
||||||
|
const manifest = await readManifest();
|
||||||
|
|
||||||
|
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
|
||||||
|
expect(manifest.entrypoints.ui).toBe("./dist/ui/index.js");
|
||||||
|
expect(manifest.entrypoints.display).toEqual({
|
||||||
|
artifact: "./dist/display/index.js",
|
||||||
|
permission: "signage.display.view",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares only operator settings, every one of them with a safe default and no secret", async () => {
|
||||||
|
const manifest = await readManifest();
|
||||||
|
|
||||||
|
expect(manifest.configuration.map((entry) => entry.key)).toEqual([
|
||||||
|
"timezone",
|
||||||
|
"nativeImageDurationSeconds",
|
||||||
|
"crossfadeMilliseconds",
|
||||||
|
"defaultBackgroundMode",
|
||||||
|
"defaultBackgroundColor",
|
||||||
|
"defaultBackgroundDarkened",
|
||||||
|
"defaultBlurPixels",
|
||||||
|
"providerTimeoutMilliseconds",
|
||||||
|
"playbackHistoryLimit",
|
||||||
|
"viewerHeartbeatWriteSeconds",
|
||||||
|
]);
|
||||||
|
// Nothing Signage needs configured has no safe default, so nothing can block it from starting.
|
||||||
|
expect(manifest.configuration.every((entry) => entry.required === false)).toBe(true);
|
||||||
|
expect(manifest.configuration.some((entry) => entry.type === "secret")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Signage server runtime", () => {
|
||||||
|
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => {
|
||||||
|
expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.0" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers its routes declaratively, with no database, storage, or realtime work", async () => {
|
||||||
|
const registration = await registerModule();
|
||||||
|
|
||||||
|
expect(registration.routes.map((route) => `${route.method} ${route.path}`).sort()).toEqual([
|
||||||
|
"DELETE /channel-groups/:groupId",
|
||||||
|
"DELETE /channels/:channelId",
|
||||||
|
"DELETE /images/:imageId",
|
||||||
|
"DELETE /timelines/:timelineId",
|
||||||
|
"DELETE /timelines/:timelineId/slots/:slotId",
|
||||||
|
"DELETE /timelines/:timelineId/sources/:sourceId",
|
||||||
|
"DELETE /viewers/:viewerId",
|
||||||
|
"GET /channel-groups",
|
||||||
|
"GET /channels/:channelId/display-state",
|
||||||
|
"GET /images",
|
||||||
|
"GET /images/:imageId",
|
||||||
|
"GET /playback/:groupId",
|
||||||
|
"GET /providers",
|
||||||
|
"GET /providers/:providerId/items",
|
||||||
|
"GET /providers/:providerId/sources",
|
||||||
|
"GET /settings",
|
||||||
|
"GET /timelines",
|
||||||
|
"GET /timelines/:timelineId",
|
||||||
|
"GET /viewers",
|
||||||
|
"PATCH /images/:imageId",
|
||||||
|
"PATCH /timelines/:timelineId",
|
||||||
|
"PATCH /timelines/:timelineId/slots/:slotId",
|
||||||
|
"PATCH /viewers/:viewerId",
|
||||||
|
"POST /channel-groups",
|
||||||
|
"POST /channel-groups/:groupId/channels",
|
||||||
|
"POST /images",
|
||||||
|
"POST /playback/:groupId/activate",
|
||||||
|
"POST /playback/:groupId/blackout",
|
||||||
|
"POST /playback/:groupId/next",
|
||||||
|
"POST /playback/:groupId/pause",
|
||||||
|
"POST /playback/:groupId/previous",
|
||||||
|
"POST /playback/:groupId/reload",
|
||||||
|
"POST /playback/:groupId/resume",
|
||||||
|
"POST /playback/:groupId/static",
|
||||||
|
"POST /timelines",
|
||||||
|
"POST /timelines/:timelineId/duplicate",
|
||||||
|
"POST /timelines/:timelineId/slots",
|
||||||
|
"POST /timelines/:timelineId/sources",
|
||||||
|
"POST /viewers/:viewerId/heartbeat",
|
||||||
|
"POST /viewers/session",
|
||||||
|
"PUT /timelines/:timelineId/slot-order",
|
||||||
|
]);
|
||||||
|
// Every route makes the body decision consciously, names a permission Signage's own manifest
|
||||||
|
// declares, and cannot collide with another — Core refuses a module whose routes overlap, and
|
||||||
|
// discovering that at API startup is discovering it in production.
|
||||||
|
for (const route of registration.routes) {
|
||||||
|
expect(route.permission.startsWith("signage.")).toBe(true);
|
||||||
|
expect(Number.isSafeInteger(route.maxBodyBytes)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(overlappingRoutes(registration.routes)).toEqual([]);
|
||||||
|
// Every declared capability is registered during `register`, because Core refuses a module that
|
||||||
|
// declares one and does not.
|
||||||
|
expect([...registration.provided.keys()].sort()).toEqual([
|
||||||
|
"signage.content-providers",
|
||||||
|
"signage.displays",
|
||||||
|
"signage.playback",
|
||||||
|
"signage.slide-renderer",
|
||||||
|
]);
|
||||||
|
// Two realtime channels, each gated on a permission Signage owns: what a display needs, and
|
||||||
|
// what the dashboard needs, are different audiences and different permissions.
|
||||||
|
expect(registration.channels).toEqual([
|
||||||
|
{ channel: "display-state", permission: "signage.display.view" },
|
||||||
|
{ channel: "management-state", permission: "signage.view" },
|
||||||
|
]);
|
||||||
|
const upload = findRoute(registration, "POST", "/images");
|
||||||
|
expect(upload).toMatchObject({
|
||||||
|
bodyMode: "streamed",
|
||||||
|
maxBodyBytes: 26_214_400,
|
||||||
|
permission: "signage.assets.manage",
|
||||||
|
});
|
||||||
|
// Exactly one route streams. Everything else takes a small buffered body, or none at all.
|
||||||
|
expect(registration.routes.filter((route) => route.bodyMode === "streamed")).toHaveLength(1);
|
||||||
|
expect(findRoute(registration, "GET", "/settings").maxBodyBytes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves its own effective settings, and never a configuration value it did not choose to expose", async () => {
|
||||||
|
const route = findRoute(await registerModule(), "GET", "/settings");
|
||||||
|
|
||||||
|
const response = await route.handler(
|
||||||
|
new Request("http://localhost/settings"),
|
||||||
|
fakeHandlerContext({
|
||||||
|
config: fakeConfiguration({
|
||||||
|
crossfadeMilliseconds: 400,
|
||||||
|
nativeImageDurationSeconds: 12,
|
||||||
|
timezone: "America/Denver",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
crossfadeMs: 400,
|
||||||
|
moduleVersion: "0.1.0",
|
||||||
|
nativeImageDurationMs: 12_000,
|
||||||
|
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
|
||||||
|
rendererInputVersion: "1.0.0",
|
||||||
|
timezone: "America/Denver",
|
||||||
|
// The dashboard is told the limits the server enforces rather than hardcoding them: a UI that
|
||||||
|
// believed a stale limit would offer an upload the server refuses.
|
||||||
|
uploads: { batchMaxFiles: 20, concurrency: 3, maxBytes: 26_214_400, maxPixels: 40_000_000 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Signage settings", () => {
|
||||||
|
it("falls back to exactly the defaults the manifest declares", async () => {
|
||||||
|
const manifest = await readManifest();
|
||||||
|
const settings = readSignageSettings(EMPTY_MODULE_CONFIGURATION);
|
||||||
|
const declared = new Map(manifest.configuration.map((entry) => [entry.key, entry]));
|
||||||
|
|
||||||
|
expect(settings).toEqual(SIGNAGE_DEFAULT_SETTINGS);
|
||||||
|
// The one place these two could drift is exactly the place a stale default would be invisible.
|
||||||
|
expect(declared.get("timezone")).toMatchObject({ default: settings.timezone });
|
||||||
|
expect(declared.get("nativeImageDurationSeconds")).toMatchObject({
|
||||||
|
default: settings.nativeImageDurationMs / 1000,
|
||||||
|
});
|
||||||
|
expect(declared.get("crossfadeMilliseconds")).toMatchObject({ default: settings.crossfadeMs });
|
||||||
|
expect(declared.get("defaultBlurPixels")).toMatchObject({
|
||||||
|
default: settings.nativePresentation.blurPixels,
|
||||||
|
});
|
||||||
|
expect(declared.get("playbackHistoryLimit")).toMatchObject({
|
||||||
|
default: settings.playbackHistoryLimit,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a background colour that is not a plain six-digit hex value", () => {
|
||||||
|
const settings = readSignageSettings(
|
||||||
|
fakeConfiguration({ defaultBackgroundColor: "red; background: url(x)" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(settings.nativePresentation.backgroundColor).toBe("#000000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a background mode Signage does not implement", () => {
|
||||||
|
const settings = readSignageSettings(fakeConfiguration({ defaultBackgroundMode: "rainbow" }));
|
||||||
|
|
||||||
|
expect(settings.nativePresentation.backgroundMode).toBe("blurred");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,622 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createPlaybackService,
|
||||||
|
type PlaybackSnapshot,
|
||||||
|
type SignagePlaybackService,
|
||||||
|
} from "../src/server/playback/service.js";
|
||||||
|
import {
|
||||||
|
createProviderRegistry,
|
||||||
|
type SignageProviderRegistry,
|
||||||
|
} from "../src/server/providers/registry.js";
|
||||||
|
import type { StoredChannelGroupDetail, StoredTimelineDetail } from "../src/server/store/types.js";
|
||||||
|
import { addProviderSource, addSlot, createTimeline } from "../src/server/timelines/service.js";
|
||||||
|
import { SIGNAGE_DEFAULT_SETTINGS } from "../src/shared/settings.js";
|
||||||
|
import {
|
||||||
|
announcementsFixture,
|
||||||
|
type FixtureProvider,
|
||||||
|
scriptureFixture,
|
||||||
|
triviaFixture,
|
||||||
|
} from "./support/fixture-provider.js";
|
||||||
|
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
|
||||||
|
|
||||||
|
const START = Date.parse("2026-08-30T15:00:00.000Z");
|
||||||
|
|
||||||
|
describe("authoritative playback", () => {
|
||||||
|
let store: MemorySignageStore;
|
||||||
|
let registry: SignageProviderRegistry;
|
||||||
|
let service: SignagePlaybackService;
|
||||||
|
let announcements: FixtureProvider;
|
||||||
|
let trivia: FixtureProvider;
|
||||||
|
let scripture: FixtureProvider;
|
||||||
|
let clock: number;
|
||||||
|
let counter: number;
|
||||||
|
let published: { snapshot: PlaybackSnapshot; reason: string }[];
|
||||||
|
/** Timers are captured rather than run: a test drives the clock, so nothing depends on real time
|
||||||
|
* and a reconciliation is never racing a test's assertions. */
|
||||||
|
let armed: { handler: () => void; delayMs: number } | undefined;
|
||||||
|
|
||||||
|
const newId = () => {
|
||||||
|
counter += 1;
|
||||||
|
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clock = START;
|
||||||
|
counter = 0;
|
||||||
|
published = [];
|
||||||
|
armed = undefined;
|
||||||
|
store = createMemorySignageStore({ now: () => new Date(clock) });
|
||||||
|
registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 });
|
||||||
|
announcements = announcementsFixture();
|
||||||
|
trivia = triviaFixture();
|
||||||
|
scripture = scriptureFixture();
|
||||||
|
registry.capability.register(announcements.registration);
|
||||||
|
registry.capability.register(trivia.registration);
|
||||||
|
registry.capability.register(scripture.registration);
|
||||||
|
service = createPlaybackService({
|
||||||
|
clearTimer: () => {
|
||||||
|
armed = undefined;
|
||||||
|
},
|
||||||
|
newId,
|
||||||
|
now: () => clock,
|
||||||
|
onSnapshot: (snapshot, reason) => published.push({ reason, snapshot }),
|
||||||
|
registry,
|
||||||
|
setTimer: (handler, delayMs) => {
|
||||||
|
armed = { delayMs, handler };
|
||||||
|
return armed;
|
||||||
|
},
|
||||||
|
settings: { ...SIGNAGE_DEFAULT_SETTINGS, playbackHistoryLimit: 20 },
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Advances the clock and fires whatever timer was armed for that moment, the way a real one
|
||||||
|
* would. Nothing here reaches a real timer, so a long outage costs nothing but arithmetic. */
|
||||||
|
async function tick(ms: number): Promise<void> {
|
||||||
|
clock += ms;
|
||||||
|
const pending = armed;
|
||||||
|
armed = undefined;
|
||||||
|
if (pending) {
|
||||||
|
pending.handler();
|
||||||
|
// Let the reconciliation the timer started settle.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedGroup(): Promise<StoredChannelGroupDetail> {
|
||||||
|
await service.start();
|
||||||
|
return service.ensureDefaultGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pattern from the product definition: three announcements, one trivia, two announcements,
|
||||||
|
* one scripture — the five announcement occurrences sharing one source. */
|
||||||
|
async function seedPattern(): Promise<StoredTimelineDetail> {
|
||||||
|
let detail = await createTimeline({ name: "Sunday morning", newId, store });
|
||||||
|
const timelineId = detail.timeline.id;
|
||||||
|
for (const [providerId, sourceId, label] of [
|
||||||
|
["fixture.announcements", "active-slides", "Announcements"],
|
||||||
|
["fixture.trivia", "open-questions", "Trivia"],
|
||||||
|
["fixture.scripture", "reading-plan", "Scripture"],
|
||||||
|
] as const) {
|
||||||
|
detail = await addProviderSource({ label, newId, providerId, sourceId, store, timelineId });
|
||||||
|
}
|
||||||
|
const [a, t, s] = detail.sources;
|
||||||
|
if (!a || !t || !s) {
|
||||||
|
throw new Error("Expected three sources.");
|
||||||
|
}
|
||||||
|
for (const source of [a, a, a, t, a, a, s]) {
|
||||||
|
detail = await addSlot({ newId, source: { sourceId: source.id }, store, timelineId });
|
||||||
|
}
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function currentItem(groupId: string): Promise<string | null> {
|
||||||
|
const snapshot = await service.snapshot(groupId);
|
||||||
|
if (snapshot.currentSelectionId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const selection = await store.findSelection(snapshot.currentSelectionId);
|
||||||
|
return selection?.itemId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("creates exactly one default channel group with one default channel, and nothing else", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
|
||||||
|
expect(group.channels).toHaveLength(1);
|
||||||
|
expect(group.channels[0]).toMatchObject({ isDefault: true, key: "default" });
|
||||||
|
// No `in-person`, no `online`. Those are an administrator's decision, not an install-time one.
|
||||||
|
expect(group.channels.map((channel) => channel.key)).toEqual(["default"]);
|
||||||
|
// Calling again is idempotent: an installation has one default group, not one per call.
|
||||||
|
const again = await service.ensureDefaultGroup();
|
||||||
|
expect(again.group.id).toBe(group.group.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plays the operator's pattern, asking each provider once per slot occurrence", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
const played: (string | null)[] = [await currentItem(group.group.id)];
|
||||||
|
for (let index = 0; index < 6; index += 1) {
|
||||||
|
await tick(5000);
|
||||||
|
played.push(await currentItem(group.group.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three announcements, one trivia, two announcements, one scripture — with the five
|
||||||
|
// announcement occurrences walking **one** rotation, because they share one selection scope.
|
||||||
|
expect(played).toEqual(["welcome", "greeting", "giving", "q1", "kids", "welcome", "psalm-23"]);
|
||||||
|
expect(announcements.served).toEqual(["welcome", "greeting", "giving", "kids", "welcome"]);
|
||||||
|
expect(trivia.served).toEqual(["q1"]);
|
||||||
|
expect(scripture.served).toEqual(["psalm-23"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves one logical selection for every channel in the group, and one variant each", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
await store.addChannel({
|
||||||
|
groupId: group.group.id,
|
||||||
|
id: newId(),
|
||||||
|
isDefault: false,
|
||||||
|
key: "online",
|
||||||
|
name: "Online",
|
||||||
|
});
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
// The second announcement is the one with an audience-specific variant.
|
||||||
|
await tick(5000);
|
||||||
|
const snapshot = await service.snapshot(group.group.id);
|
||||||
|
const selection = await store.findSelection(snapshot.currentSelectionId ?? "");
|
||||||
|
|
||||||
|
expect(selection?.itemId).toBe("greeting");
|
||||||
|
// Asked once — never once per channel — which is what keeps two channels on the same slide.
|
||||||
|
expect(
|
||||||
|
announcements.resolveRequests.filter((request) => request.audienceKeys.includes("online")),
|
||||||
|
).toHaveLength(announcements.resolveRequests.length);
|
||||||
|
expect(selection?.variants.map((variant) => variant.audienceKey).sort()).toEqual([
|
||||||
|
"default",
|
||||||
|
"online",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
selection?.variants.find((variant) => variant.audienceKey === "online")?.payload,
|
||||||
|
).toEqual({ headline: "Watching from home" });
|
||||||
|
// Both channels read the same start and the same deadline. There is one clock.
|
||||||
|
expect(snapshot.startedAtMs).not.toBeNull();
|
||||||
|
expect(snapshot.deadlineAtMs).toBe((snapshot.startedAtMs ?? 0) + 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default variant for an audience the provider said nothing about", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
await store.addChannel({
|
||||||
|
groupId: group.group.id,
|
||||||
|
id: newId(),
|
||||||
|
isDefault: false,
|
||||||
|
key: "in-person",
|
||||||
|
name: "In person",
|
||||||
|
});
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
const snapshot = await service.snapshot(group.group.id);
|
||||||
|
const selection = await store.findSelection(snapshot.currentSelectionId ?? "");
|
||||||
|
|
||||||
|
// "welcome" has no audience-specific payload at all. That is the contract, not a gap.
|
||||||
|
expect(selection?.variants).toHaveLength(1);
|
||||||
|
expect(selection?.variants[0]).toMatchObject({ audienceKey: "default", isDefault: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("chains each slot from the previous deadline, so a late timer costs nothing twice", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
const first = await service.snapshot(group.group.id);
|
||||||
|
|
||||||
|
// The timer fires 900 ms late, as a busy process's timer does.
|
||||||
|
await tick(5900);
|
||||||
|
const second = await service.snapshot(group.group.id);
|
||||||
|
|
||||||
|
// The new slot starts at the old slot's deadline — not at "now" — so the lateness is absorbed
|
||||||
|
// once instead of stretching the loop by 900 ms on every slide for the rest of the day.
|
||||||
|
expect(second.startedAtMs).toBe(first.deadlineAtMs);
|
||||||
|
expect(second.deadlineAtMs).toBe((first.deadlineAtMs ?? 0) + 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches up a long outage with arithmetic, not by replaying every missed slide", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
const callsBefore =
|
||||||
|
announcements.resolveRequests.length +
|
||||||
|
trivia.resolveRequests.length +
|
||||||
|
scripture.resolveRequests.length;
|
||||||
|
|
||||||
|
// A week offline. Seven slots at five seconds each is more than a hundred thousand slides.
|
||||||
|
clock += 7 * 24 * 60 * 60 * 1000;
|
||||||
|
await service.reconcile(group.group.id, "startup");
|
||||||
|
|
||||||
|
const callsAfter =
|
||||||
|
announcements.resolveRequests.length +
|
||||||
|
trivia.resolveRequests.length +
|
||||||
|
scripture.resolveRequests.length;
|
||||||
|
// Exactly one provider call: the slot actually landed on. A replay would have burned the
|
||||||
|
// providers' rotations and taken longer than the outage.
|
||||||
|
expect(callsAfter - callsBefore).toBe(1);
|
||||||
|
const snapshot = await service.snapshot(group.group.id);
|
||||||
|
expect(snapshot.currentSelectionId).not.toBeNull();
|
||||||
|
// And the slot it landed on is genuinely current rather than in the past.
|
||||||
|
expect(snapshot.deadlineAtMs).toBeGreaterThan(clock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a slot whose provider has nothing right now, immediately and without an error", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
trivia.setItems([]);
|
||||||
|
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
await tick(5000);
|
||||||
|
await tick(5000);
|
||||||
|
// The fourth slot is Trivia, which has nothing. It is skipped in the same instant.
|
||||||
|
const snapshot = await service.snapshot(group.group.id);
|
||||||
|
const selection = await store.findSelection(snapshot.currentSelectionId ?? "");
|
||||||
|
|
||||||
|
await tick(5000);
|
||||||
|
const afterTrivia = await service.snapshot(group.group.id);
|
||||||
|
const next = await store.findSelection(afterTrivia.currentSelectionId ?? "");
|
||||||
|
|
||||||
|
expect(selection?.itemId).toBe("giving");
|
||||||
|
// Straight past Trivia to the next announcement. No gap, no black frame, no error.
|
||||||
|
expect(next?.itemId).toBe("kids");
|
||||||
|
expect(next?.startedAt?.getTime()).toBe(selection?.deadlineAt?.getTime());
|
||||||
|
expect(
|
||||||
|
registry.health().find((p) => p.providerId === "fixture.trivia")?.consecutiveFailures,
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("goes idle rather than inventing content when nothing at all is available", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
announcements.setItems([]);
|
||||||
|
trivia.setItems([]);
|
||||||
|
scripture.setItems([]);
|
||||||
|
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
const snapshot = await service.snapshot(group.group.id);
|
||||||
|
|
||||||
|
expect(snapshot.transport).toBe("idle");
|
||||||
|
expect(snapshot.currentSelectionId).toBeNull();
|
||||||
|
// The display shows its slate. Signage did not fabricate a slide to fill the gap.
|
||||||
|
});
|
||||||
|
|
||||||
|
it("freezes on pause and continues the same slide from its remainder on resume", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
const item = await currentItem(group.group.id);
|
||||||
|
|
||||||
|
clock += 2000;
|
||||||
|
const paused = await service.pause(group.group.id);
|
||||||
|
|
||||||
|
expect(paused.transport).toBe("paused");
|
||||||
|
expect(paused.remainingMs).toBe(3000);
|
||||||
|
expect(paused.deadlineAtMs).toBeNull();
|
||||||
|
|
||||||
|
// Two minutes of a paused screen must not advance anything.
|
||||||
|
clock += 120_000;
|
||||||
|
expect(await currentItem(group.group.id)).toBe(item);
|
||||||
|
|
||||||
|
const resumed = await service.resume(group.group.id);
|
||||||
|
expect(resumed.transport).toBe("playing");
|
||||||
|
expect(await currentItem(group.group.id)).toBe(item);
|
||||||
|
// Three seconds left, and the reconstructed window is still a whole slot long.
|
||||||
|
expect(resumed.deadlineAtMs).toBe(clock + 3000);
|
||||||
|
expect((resumed.deadlineAtMs ?? 0) - (resumed.startedAtMs ?? 0)).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds a static image while preserving the cursor underneath, and continues on release", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
const item = await currentItem(group.group.id);
|
||||||
|
const imageId = newId();
|
||||||
|
store.images.set(imageId, {
|
||||||
|
contentSha256: "a".repeat(64),
|
||||||
|
createdAt: new Date(clock),
|
||||||
|
displayName: "Hold",
|
||||||
|
encodedHeight: 1080,
|
||||||
|
encodedWidth: 1920,
|
||||||
|
expiresAt: null,
|
||||||
|
hasAlpha: false,
|
||||||
|
height: 1080,
|
||||||
|
id: imageId,
|
||||||
|
orientation: null,
|
||||||
|
originalByteSize: 1,
|
||||||
|
originalContentType: "image/png",
|
||||||
|
originalFilename: "hold.png",
|
||||||
|
originalObjectKey: "assets/hold/original.png",
|
||||||
|
renditions: [],
|
||||||
|
status: "ready",
|
||||||
|
width: 1920,
|
||||||
|
});
|
||||||
|
|
||||||
|
clock += 1000;
|
||||||
|
const held = await service.setStaticHold(group.group.id, imageId);
|
||||||
|
expect(held.transport).toBe("static");
|
||||||
|
expect(held.staticAssetId).toBe(imageId);
|
||||||
|
// The normal cursor is preserved, not discarded.
|
||||||
|
expect(held.remainingMs).toBe(4000);
|
||||||
|
expect(held.currentSelectionId).not.toBeNull();
|
||||||
|
|
||||||
|
clock += 60_000;
|
||||||
|
const released = await service.setStaticHold(group.group.id, null);
|
||||||
|
|
||||||
|
expect(released.transport).toBe("playing");
|
||||||
|
// The same slide continues with the remainder it had, rather than the loop restarting.
|
||||||
|
expect(await currentItem(group.group.id)).toBe(item);
|
||||||
|
expect(released.deadlineAtMs).toBe(clock + 4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the hidden timeline running through a blackout", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
|
||||||
|
const dark = await service.setBlackout(group.group.id, true);
|
||||||
|
expect(dark.blackout).toBe(true);
|
||||||
|
// Blackout covers output. It does not pause, reset, or stop resolving.
|
||||||
|
expect(dark.transport).toBe("playing");
|
||||||
|
expect(dark.deadlineAtMs).not.toBeNull();
|
||||||
|
|
||||||
|
await tick(5000);
|
||||||
|
const later = await service.snapshot(group.group.id);
|
||||||
|
expect(later.blackout).toBe(true);
|
||||||
|
expect(await currentItem(group.group.id)).toBe("greeting");
|
||||||
|
|
||||||
|
const lit = await service.setBlackout(group.group.id, false);
|
||||||
|
// Clearing it lands exactly where the hidden timeline had got to.
|
||||||
|
expect(lit.blackout).toBe(false);
|
||||||
|
expect(await currentItem(group.group.id)).toBe("greeting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks actual resolved playback with previous, including past a skipped slot", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
trivia.setItems([]);
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
await tick(5000);
|
||||||
|
await tick(5000);
|
||||||
|
await tick(5000);
|
||||||
|
expect(await currentItem(group.group.id)).toBe("kids");
|
||||||
|
|
||||||
|
const back = await service.previous(group.group.id);
|
||||||
|
// Trivia was never resolved, so it is not in history. "Previous" means the slide that was
|
||||||
|
// actually on screen, which is the third announcement.
|
||||||
|
expect(await currentItem(group.group.id)).toBe("giving");
|
||||||
|
expect(back.deadlineAtMs).toBe(clock + 5000);
|
||||||
|
|
||||||
|
await service.previous(group.group.id);
|
||||||
|
// And again, further back — not oscillating between two slides.
|
||||||
|
expect(await currentItem(group.group.id)).toBe("greeting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends the current slot immediately on next, and still applies eligibility and wraparound", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
|
||||||
|
clock += 1000;
|
||||||
|
const skipped = await service.next(group.group.id);
|
||||||
|
|
||||||
|
expect(await currentItem(group.group.id)).toBe("greeting");
|
||||||
|
expect(skipped.startedAtMs).toBe(clock);
|
||||||
|
expect(skipped.deadlineAtMs).toBe(clock + 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the last known good item and marks it stale when a provider fails", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
const before = await service.snapshot(group.group.id);
|
||||||
|
announcements.failNext(5);
|
||||||
|
|
||||||
|
await service.reload(group.group.id);
|
||||||
|
const after = await service.snapshot(group.group.id);
|
||||||
|
const selection = await store.findSelection(after.currentSelectionId ?? "");
|
||||||
|
|
||||||
|
// The slide stays up, and the deadline is untouched: a failure is not a reason to blank a wall.
|
||||||
|
expect(after.currentSelectionId).toBe(before.currentSelectionId);
|
||||||
|
expect(after.deadlineAtMs).toBe(before.deadlineAtMs);
|
||||||
|
expect(selection?.stale).toBe(true);
|
||||||
|
expect(selection?.itemId).toBe("welcome");
|
||||||
|
expect(await currentItem(group.group.id)).toBe("welcome");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the current slide in place when a provider invalidates it, without restarting the slot", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
const before = await service.snapshot(group.group.id);
|
||||||
|
const beforeSelection = await store.findSelection(before.currentSelectionId ?? "");
|
||||||
|
|
||||||
|
announcements.reviseItem("welcome", { headline: "Cancelled" });
|
||||||
|
clock += 1500;
|
||||||
|
await service.invalidate({ itemId: "welcome", providerId: "fixture.announcements" });
|
||||||
|
|
||||||
|
const after = await service.snapshot(group.group.id);
|
||||||
|
const afterSelection = await store.findSelection(after.currentSelectionId ?? "");
|
||||||
|
|
||||||
|
// Same slide, same slot, same deadline — new payload and a new revision, which is exactly what
|
||||||
|
// updates a mounted React renderer in place.
|
||||||
|
expect(after.currentSelectionId).toBe(before.currentSelectionId);
|
||||||
|
expect(after.deadlineAtMs).toBe(before.deadlineAtMs);
|
||||||
|
expect(after.selectionRevision).toBe((before.selectionRevision ?? 0) + 1);
|
||||||
|
expect(afterSelection?.variants[0]?.payload).toEqual({ headline: "Cancelled" });
|
||||||
|
expect(beforeSelection?.variants[0]?.payload).toEqual({ headline: "Welcome" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not bump a revision when an invalidation changed nothing", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
const before = await service.snapshot(group.group.id);
|
||||||
|
|
||||||
|
await service.invalidate({ itemId: "welcome", providerId: "fixture.announcements" });
|
||||||
|
|
||||||
|
const after = await service.snapshot(group.group.id);
|
||||||
|
// A client polling on revision must not be woken by nothing.
|
||||||
|
expect(after.selectionRevision).toBe(before.selectionRevision);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an explicitly withdrawn item and removes it at the next boundary, not mid-slide", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
const before = await service.snapshot(group.group.id);
|
||||||
|
|
||||||
|
announcements.withdraw("welcome");
|
||||||
|
clock += 1000;
|
||||||
|
await service.invalidate({ itemId: "welcome", providerId: "fixture.announcements" });
|
||||||
|
const during = await service.snapshot(group.group.id);
|
||||||
|
const selection = await store.findSelection(during.currentSelectionId ?? "");
|
||||||
|
|
||||||
|
// Still on screen, still on its own deadline — marked, not cut.
|
||||||
|
expect(during.currentSelectionId).toBe(before.currentSelectionId);
|
||||||
|
expect(during.deadlineAtMs).toBe(before.deadlineAtMs);
|
||||||
|
expect(selection?.withdrawn).toBe(true);
|
||||||
|
|
||||||
|
await tick(4000);
|
||||||
|
// Gone at the boundary — and *which* item follows is the provider's decision, made from a pool
|
||||||
|
// that no longer contains the withdrawn one. Signage neither chose it nor knows why.
|
||||||
|
const next = await currentItem(group.group.id);
|
||||||
|
expect(next).not.toBe("welcome");
|
||||||
|
expect(["greeting", "giving", "kids"]).toContain(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activates immediately, or at the next slot boundary, exactly as asked", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const first = await seedPattern();
|
||||||
|
const second = await createTimeline({ name: "Evening", newId, store });
|
||||||
|
const withSource = await addProviderSource({
|
||||||
|
label: "Scripture",
|
||||||
|
newId,
|
||||||
|
providerId: "fixture.scripture",
|
||||||
|
sourceId: "reading-plan",
|
||||||
|
store,
|
||||||
|
timelineId: second.timeline.id,
|
||||||
|
});
|
||||||
|
const source = withSource.sources[0];
|
||||||
|
if (!source) {
|
||||||
|
throw new Error("Expected a source.");
|
||||||
|
}
|
||||||
|
await addSlot({
|
||||||
|
newId,
|
||||||
|
source: { sourceId: source.id },
|
||||||
|
store,
|
||||||
|
timelineId: second.timeline.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.activate(group.group.id, first.timeline.id, "now");
|
||||||
|
expect(await currentItem(group.group.id)).toBe("welcome");
|
||||||
|
|
||||||
|
clock += 1000;
|
||||||
|
const queued = await service.activate(group.group.id, second.timeline.id, "next-boundary");
|
||||||
|
// Queued, and the current slide is not cut short.
|
||||||
|
expect(queued.pendingTimelineId).toBe(second.timeline.id);
|
||||||
|
expect(queued.activeTimelineId).toBe(first.timeline.id);
|
||||||
|
expect(await currentItem(group.group.id)).toBe("welcome");
|
||||||
|
|
||||||
|
await tick(4000);
|
||||||
|
const switched = await service.snapshot(group.group.id);
|
||||||
|
expect(switched.activeTimelineId).toBe(second.timeline.id);
|
||||||
|
expect(switched.pendingTimelineId).toBeNull();
|
||||||
|
expect(await currentItem(group.group.id)).toBe("psalm-23");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a scheduled activation at its own instant, not when the timer happened to fire", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
const at = new Date(clock + 60_000);
|
||||||
|
|
||||||
|
const scheduled = await service.activate(group.group.id, timeline.timeline.id, { at });
|
||||||
|
expect(scheduled.scheduledTimelineId).toBe(timeline.timeline.id);
|
||||||
|
expect(scheduled.activeTimelineId).toBeNull();
|
||||||
|
|
||||||
|
// The callback arrives two seconds late, as a real one does.
|
||||||
|
clock += 62_000;
|
||||||
|
const after = await service.reconcile(group.group.id, "timer");
|
||||||
|
|
||||||
|
expect(after.activeTimelineId).toBe(timeline.timeline.id);
|
||||||
|
expect(after.scheduledTimelineId).toBeNull();
|
||||||
|
expect(await currentItem(group.group.id)).toBe("welcome");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a scheduled activation in the past rather than firing it instantly", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.activate(group.group.id, timeline.timeline.id, { at: new Date(clock - 1000) }),
|
||||||
|
).rejects.toMatchObject({ code: "SIGNAGE_INVALID_REQUEST" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps history bounded and still lets previous work", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
for (let index = 0; index < 30; index += 1) {
|
||||||
|
await tick(5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await store.listSelections(group.group.id, 100);
|
||||||
|
expect(history.length).toBeLessThanOrEqual(20);
|
||||||
|
const back = await service.previous(group.group.id);
|
||||||
|
expect(back.currentSelectionId).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers a restart onto the item that was already showing, not a new one", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
const before = await service.snapshot(group.group.id);
|
||||||
|
const callsBefore = announcements.resolveRequests.length;
|
||||||
|
|
||||||
|
// A restart two seconds into a five-second slide: reconciliation runs with the persisted state.
|
||||||
|
clock += 2000;
|
||||||
|
const after = await service.reconcile(group.group.id, "startup");
|
||||||
|
|
||||||
|
expect(after.currentSelectionId).toBe(before.currentSelectionId);
|
||||||
|
expect(after.deadlineAtMs).toBe(before.deadlineAtMs);
|
||||||
|
// And no provider was asked, so no rotation was consumed by a restart.
|
||||||
|
expect(announcements.resolveRequests.length).toBe(callsBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes a compact snapshot, with no payload or timeline in it", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
|
||||||
|
const latest = published.at(-1);
|
||||||
|
expect(latest?.reason).toBe("activated");
|
||||||
|
const serialized = JSON.stringify(latest?.snapshot);
|
||||||
|
// Compact enough for the platform's realtime ceiling many times over, and carrying identity and
|
||||||
|
// revisions rather than content: large state is fetched over authorized HTTP keyed by revision.
|
||||||
|
expect(serialized.length).toBeLessThan(1024);
|
||||||
|
expect(serialized).not.toContain("Welcome");
|
||||||
|
expect(serialized).not.toContain("headline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes commands so two at once cannot interleave", async () => {
|
||||||
|
const group = await seedGroup();
|
||||||
|
const timeline = await seedPattern();
|
||||||
|
await service.activate(group.group.id, timeline.timeline.id, "now");
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
service.next(group.group.id),
|
||||||
|
service.next(group.group.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Two "next" presses land on two different slides, in order — never on the same one, and never
|
||||||
|
// on a state neither command asked for.
|
||||||
|
expect(first.currentSelectionId).not.toBe(second.currentSelectionId);
|
||||||
|
expect(second.revision).toBeGreaterThan(first.revision);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { SIGNAGE_PROVIDER_CONTRACT_VERSION } from "../src/server/providers/contract.js";
|
||||||
|
import { createNativeImageProvider } from "../src/server/providers/native-provider.js";
|
||||||
|
import {
|
||||||
|
createProviderRegistry,
|
||||||
|
type SignageProviderRegistry,
|
||||||
|
} from "../src/server/providers/registry.js";
|
||||||
|
import { SignageProviderError } from "../src/server/providers/validation.js";
|
||||||
|
import { SIGNAGE_NATIVE_PROVIDER_ID } from "../src/shared/native-images.js";
|
||||||
|
import { createFakeStorage } from "./support/fake-storage.js";
|
||||||
|
import {
|
||||||
|
announcementsFixture,
|
||||||
|
createFixtureProvider,
|
||||||
|
scriptureFixture,
|
||||||
|
triviaFixture,
|
||||||
|
} from "./support/fixture-provider.js";
|
||||||
|
import { makeImage } from "./support/images.js";
|
||||||
|
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
|
||||||
|
|
||||||
|
const AT = "2026-08-27T15:00:00.000Z";
|
||||||
|
|
||||||
|
function request(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
at: AT,
|
||||||
|
audienceKeys: ["default"],
|
||||||
|
channelGroupId: "group-1",
|
||||||
|
selectionScopeId: "scope-1",
|
||||||
|
sourceId: "active-slides",
|
||||||
|
timelineId: "timeline-1",
|
||||||
|
timezone: "America/Chicago",
|
||||||
|
...overrides,
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("content provider registry", () => {
|
||||||
|
let registry: SignageProviderRegistry;
|
||||||
|
let invalidations: { providerId: string; itemId?: string; sourceId?: string }[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
invalidations = [];
|
||||||
|
registry = createProviderRegistry({
|
||||||
|
onInvalidate: (notice) => invalidations.push(notice),
|
||||||
|
timeoutMs: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers a provider, reports it as healthy, and unregisters it cleanly", () => {
|
||||||
|
const announcements = announcementsFixture();
|
||||||
|
|
||||||
|
const handle = registry.capability.register(announcements.registration);
|
||||||
|
|
||||||
|
expect(registry.capability.contractVersion).toBe(SIGNAGE_PROVIDER_CONTRACT_VERSION);
|
||||||
|
expect(registry.has("fixture.announcements")).toBe(true);
|
||||||
|
expect(registry.health()).toEqual([
|
||||||
|
{
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
contractVersion: "1.0.0",
|
||||||
|
defaultDurationMs: 5000,
|
||||||
|
lastFailureAt: null,
|
||||||
|
lastFailureCode: null,
|
||||||
|
lastSuccessAt: null,
|
||||||
|
providerId: "fixture.announcements",
|
||||||
|
providerVersion: "1.0.0",
|
||||||
|
registeredAt: expect.any(String),
|
||||||
|
renderer: { contributionId: "fixture.announcements.slide", contributionVersion: "1.0.0" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
handle.unregister();
|
||||||
|
|
||||||
|
expect(registry.has("fixture.announcements")).toBe(false);
|
||||||
|
expect(registry.health()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unregisters idempotently, so a rollback after a partial start is safe", () => {
|
||||||
|
const handle = registry.capability.register(announcementsFixture().registration);
|
||||||
|
|
||||||
|
handle.unregister();
|
||||||
|
handle.unregister();
|
||||||
|
// A late invalidation from a module being torn down is ordinary, not an error.
|
||||||
|
expect(() => handle.invalidate({ itemId: "welcome" })).not.toThrow();
|
||||||
|
|
||||||
|
expect(invalidations).toEqual([]);
|
||||||
|
// And the same provider may register again — a module restored after being disabled.
|
||||||
|
expect(() => registry.capability.register(announcementsFixture().registration)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a second live registration of the same provider id", () => {
|
||||||
|
registry.capability.register(announcementsFixture().registration);
|
||||||
|
|
||||||
|
expect(() => registry.capability.register(announcementsFixture().registration)).toThrowError(
|
||||||
|
expect.objectContaining({ code: "SIGNAGE_PROVIDER_DUPLICATE" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed registration with a sanitized code carrying none of the value", () => {
|
||||||
|
const cases: readonly [unknown, string][] = [
|
||||||
|
[undefined, "SIGNAGE_PROVIDER_REGISTRATION_INVALID"],
|
||||||
|
["announcements", "SIGNAGE_PROVIDER_REGISTRATION_INVALID"],
|
||||||
|
[
|
||||||
|
{ ...announcementsFixture().registration, providerId: "" },
|
||||||
|
"SIGNAGE_PROVIDER_REGISTRATION_INVALID",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ...announcementsFixture().registration, providerVersion: "one" },
|
||||||
|
"SIGNAGE_PROVIDER_REGISTRATION_INVALID",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ...announcementsFixture().registration, resolveNext: "not a function" },
|
||||||
|
"SIGNAGE_PROVIDER_REGISTRATION_INVALID",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ...announcementsFixture().registration, defaultDurationMs: Number.NaN },
|
||||||
|
"SIGNAGE_PROVIDER_REGISTRATION_INVALID",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ...announcementsFixture().registration, renderer: { contributionId: "x" } },
|
||||||
|
"SIGNAGE_PROVIDER_REGISTRATION_INVALID",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ...announcementsFixture().registration, contractVersion: "2.0.0" },
|
||||||
|
"SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [value, code] of cases) {
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
registry.capability.register(value);
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
expect(thrown).toBeInstanceOf(SignageProviderError);
|
||||||
|
const error = thrown as SignageProviderError;
|
||||||
|
expect(error.code).toBe(code);
|
||||||
|
// The message is fixed, and nothing of the rejected registration is in it.
|
||||||
|
expect(error.message).not.toMatch(/announcements|function|NaN|2\.0\.0/u);
|
||||||
|
}
|
||||||
|
expect(registry.health()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("separates a contract-version mismatch from a malformed registration, so an operator can act", () => {
|
||||||
|
const outdated = createFixtureProvider({
|
||||||
|
contractVersion: "2.0.0",
|
||||||
|
items: [{ id: "a", label: "A", payload: {} }],
|
||||||
|
policy: "cycle",
|
||||||
|
providerId: "fixture.future",
|
||||||
|
sourceId: "s",
|
||||||
|
sourceLabel: "S",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => registry.capability.register(outdated.registration)).toThrowError(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED",
|
||||||
|
providerId: "fixture.future",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes invalidation through as a notification, never as a command", () => {
|
||||||
|
const handle = registry.capability.register(announcementsFixture().registration);
|
||||||
|
|
||||||
|
handle.invalidate({ itemId: "greeting" });
|
||||||
|
handle.invalidate({ sourceId: "active-slides" });
|
||||||
|
handle.invalidate();
|
||||||
|
|
||||||
|
expect(invalidations).toEqual([
|
||||||
|
{ itemId: "greeting", providerId: "fixture.announcements" },
|
||||||
|
{ providerId: "fixture.announcements", sourceId: "active-slides" },
|
||||||
|
{ providerId: "fixture.announcements" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("provider selection", () => {
|
||||||
|
let registry: SignageProviderRegistry;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = createProviderRegistry({ timeoutMs: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supplies a provider with a scope, a time, and an audience — and nothing else", async () => {
|
||||||
|
const announcements = announcementsFixture();
|
||||||
|
registry.capability.register(announcements.registration);
|
||||||
|
|
||||||
|
await registry.resolveNext(
|
||||||
|
"fixture.announcements",
|
||||||
|
request({ audienceKeys: ["default", "online"] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(announcements.resolveRequests).toHaveLength(1);
|
||||||
|
expect(Object.keys(announcements.resolveRequests[0] ?? {}).sort()).toEqual([
|
||||||
|
"at",
|
||||||
|
"audienceKeys",
|
||||||
|
"channelGroupId",
|
||||||
|
"selectionScopeId",
|
||||||
|
"sourceId",
|
||||||
|
"timelineId",
|
||||||
|
"timezone",
|
||||||
|
]);
|
||||||
|
// No principal, no session, no database, no storage, no URL, no permission.
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an announcements-shaped provider cycle its own active set forever", async () => {
|
||||||
|
const announcements = announcementsFixture();
|
||||||
|
registry.capability.register(announcements.registration);
|
||||||
|
|
||||||
|
for (let index = 0; index < 6; index += 1) {
|
||||||
|
await registry.resolveNext("fixture.announcements", request());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signage asked six times and got the provider's own rotation, wrapping where the provider
|
||||||
|
// decided to wrap. Signage implements no repeat policy at all.
|
||||||
|
expect(announcements.served).toEqual([
|
||||||
|
"welcome",
|
||||||
|
"greeting",
|
||||||
|
"giving",
|
||||||
|
"kids",
|
||||||
|
"welcome",
|
||||||
|
"greeting",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets trivia and scripture refuse to repeat until their pools are exhausted", async () => {
|
||||||
|
const trivia = triviaFixture();
|
||||||
|
const scripture = scriptureFixture();
|
||||||
|
registry.capability.register(trivia.registration);
|
||||||
|
registry.capability.register(scripture.registration);
|
||||||
|
|
||||||
|
for (let index = 0; index < 4; index += 1) {
|
||||||
|
await registry.resolveNext("fixture.trivia", request({ sourceId: "open-questions" }));
|
||||||
|
await registry.resolveNext("fixture.scripture", request({ sourceId: "reading-plan" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every question appears once before any repeats; the fourth call starts a new pass.
|
||||||
|
expect(trivia.served).toEqual(["q1", "q2", "q3", "q1"]);
|
||||||
|
expect(scripture.served).toEqual(["psalm-23", "john-3", "psalm-23", "john-3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps one rotation per selection scope, and two scopes independent", async () => {
|
||||||
|
const announcements = announcementsFixture();
|
||||||
|
registry.capability.register(announcements.registration);
|
||||||
|
|
||||||
|
await registry.resolveNext("fixture.announcements", request({ selectionScopeId: "lobby" }));
|
||||||
|
await registry.resolveNext("fixture.announcements", request({ selectionScopeId: "lobby" }));
|
||||||
|
await registry.resolveNext("fixture.announcements", request({ selectionScopeId: "atrium" }));
|
||||||
|
|
||||||
|
// Two source instances an operator deliberately created as separate rotations stay separate.
|
||||||
|
expect(announcements.served).toEqual(["welcome", "greeting", "welcome"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries one logical selection with a variant per audience, and a required default", async () => {
|
||||||
|
const announcements = announcementsFixture();
|
||||||
|
registry.capability.register(announcements.registration);
|
||||||
|
await registry.resolveNext("fixture.announcements", request());
|
||||||
|
|
||||||
|
const outcome = await registry.resolveNext("fixture.announcements", request());
|
||||||
|
|
||||||
|
expect(outcome.kind).toBe("resolved");
|
||||||
|
if (outcome.kind !== "resolved" || !outcome.resolution) {
|
||||||
|
throw new Error("Expected a resolution.");
|
||||||
|
}
|
||||||
|
expect(outcome.resolution.itemId).toBe("greeting");
|
||||||
|
// One resolution, two renderings. The provider was asked once.
|
||||||
|
expect([...outcome.resolution.variants.keys()].sort()).toEqual(["default", "online"]);
|
||||||
|
expect(outcome.resolution.variants.get("online")).toEqual({ headline: "Watching from home" });
|
||||||
|
expect(outcome.resolution.variants.get("default")).toEqual({ headline: "Good morning" });
|
||||||
|
expect(announcements.resolveRequests).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an empty pool as nothing right now, not as a failure", async () => {
|
||||||
|
const trivia = triviaFixture();
|
||||||
|
trivia.setItems([]);
|
||||||
|
registry.capability.register(trivia.registration);
|
||||||
|
|
||||||
|
const outcome = await registry.resolveNext(
|
||||||
|
"fixture.trivia",
|
||||||
|
request({ sourceId: "open-questions" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(outcome).toEqual({ kind: "resolved", resolution: null });
|
||||||
|
// A skip is not a failure, so nothing about provider health changes.
|
||||||
|
expect(registry.health()[0]?.consecutiveFailures).toBe(0);
|
||||||
|
expect(registry.health()[0]?.lastFailureCode).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a provider that throws as temporarily failed, and recovers when it stops", async () => {
|
||||||
|
const announcements = announcementsFixture();
|
||||||
|
registry.capability.register(announcements.registration);
|
||||||
|
announcements.failNext(2);
|
||||||
|
|
||||||
|
const first = await registry.resolveNext("fixture.announcements", request());
|
||||||
|
const second = await registry.resolveNext("fixture.announcements", request());
|
||||||
|
|
||||||
|
expect(first).toEqual({ code: "SIGNAGE_PROVIDER_UNAVAILABLE", kind: "failed" });
|
||||||
|
expect(second).toEqual({ code: "SIGNAGE_PROVIDER_UNAVAILABLE", kind: "failed" });
|
||||||
|
expect(registry.health()[0]).toMatchObject({
|
||||||
|
consecutiveFailures: 2,
|
||||||
|
lastFailureCode: "SIGNAGE_PROVIDER_UNAVAILABLE",
|
||||||
|
});
|
||||||
|
|
||||||
|
const recovered = await registry.resolveNext("fixture.announcements", request());
|
||||||
|
|
||||||
|
expect(recovered.kind).toBe("resolved");
|
||||||
|
// Restoring a compatible provider allows a safe refresh without restarting the module.
|
||||||
|
expect(registry.health()[0]).toMatchObject({ consecutiveFailures: 0, lastFailureCode: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds a provider that never answers, so one provider cannot freeze a channel group", async () => {
|
||||||
|
const announcements = announcementsFixture();
|
||||||
|
registry.capability.register(announcements.registration);
|
||||||
|
announcements.hang(true);
|
||||||
|
|
||||||
|
const outcome = await registry.resolveNext("fixture.announcements", request());
|
||||||
|
|
||||||
|
expect(outcome).toEqual({ code: "SIGNAGE_PROVIDER_UNAVAILABLE", kind: "failed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes an explicit withdrawal from an item that is merely unavailable", async () => {
|
||||||
|
const announcements = announcementsFixture();
|
||||||
|
registry.capability.register(announcements.registration);
|
||||||
|
|
||||||
|
const missing = await registry.refresh(
|
||||||
|
"fixture.announcements",
|
||||||
|
request({ itemId: "not-a-real-item" }),
|
||||||
|
);
|
||||||
|
announcements.withdraw("welcome");
|
||||||
|
const gone = await registry.refresh("fixture.announcements", request({ itemId: "welcome" }));
|
||||||
|
|
||||||
|
expect(missing).toEqual({ kind: "resolved", resolution: null });
|
||||||
|
expect(gone.kind).toBe("resolved");
|
||||||
|
if (gone.kind !== "resolved") {
|
||||||
|
throw new Error("Expected a resolution.");
|
||||||
|
}
|
||||||
|
expect(gone.resolution?.withdrawn).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an unregistered provider as unregistered, never as failed", async () => {
|
||||||
|
const outcome = await registry.resolveNext("fixture.nothing", request());
|
||||||
|
|
||||||
|
// A temporarily missing registration is not an error, and must not erase anything Signage has
|
||||||
|
// already persisted about what that provider resolved.
|
||||||
|
expect(outcome).toEqual({ kind: "unregistered" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a resolution this contract cannot store or render, without echoing it", async () => {
|
||||||
|
const cyclic: Record<string, unknown> = {};
|
||||||
|
cyclic.self = cyclic;
|
||||||
|
const malformed = [
|
||||||
|
{ itemId: "a", itemRevision: "1", variants: [] },
|
||||||
|
{ itemId: "a", itemRevision: "1", variants: [{ audienceKey: "online", payload: {} }] },
|
||||||
|
{ itemId: "a", itemRevision: "1", variants: [{ audienceKey: null, payload: cyclic }] },
|
||||||
|
{
|
||||||
|
durationMs: Number.POSITIVE_INFINITY,
|
||||||
|
itemId: "a",
|
||||||
|
itemRevision: "1",
|
||||||
|
variants: [{ audienceKey: null, payload: {} }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemId: "a",
|
||||||
|
itemRevision: "1",
|
||||||
|
variants: [{ audienceKey: null, payload: { when: new Date() } }],
|
||||||
|
},
|
||||||
|
{ itemId: "", itemRevision: "1", variants: [{ audienceKey: null, payload: {} }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const resolution of malformed) {
|
||||||
|
const provider = createFixtureProvider({
|
||||||
|
items: [],
|
||||||
|
policy: "cycle",
|
||||||
|
providerId: "fixture.malformed",
|
||||||
|
sourceId: "s",
|
||||||
|
sourceLabel: "S",
|
||||||
|
});
|
||||||
|
const registration = {
|
||||||
|
...provider.registration,
|
||||||
|
resolveNext: () => resolution as never,
|
||||||
|
};
|
||||||
|
const handle = registry.capability.register(registration);
|
||||||
|
|
||||||
|
const outcome = await registry.resolveNext("fixture.malformed", request({ sourceId: "s" }));
|
||||||
|
|
||||||
|
expect(outcome).toEqual({ code: "SIGNAGE_PROVIDER_RESULT_INVALID", kind: "failed" });
|
||||||
|
handle.unregister();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing at all for discovery when a provider is absent or failing", async () => {
|
||||||
|
expect(await registry.listSources("fixture.absent", { at: AT, timezone: "UTC" })).toEqual([]);
|
||||||
|
|
||||||
|
const announcements = announcementsFixture();
|
||||||
|
registry.capability.register(announcements.registration);
|
||||||
|
announcements.failNext(1);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await registry.listSources("fixture.announcements", { at: AT, timezone: "UTC" }),
|
||||||
|
).toEqual([]);
|
||||||
|
// The editor says "no sources right now"; provider health says why.
|
||||||
|
expect(registry.health()[0]?.consecutiveFailures).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discovers a provider's own sources and items for the timeline editor", async () => {
|
||||||
|
const announcements = announcementsFixture();
|
||||||
|
registry.capability.register(announcements.registration);
|
||||||
|
|
||||||
|
const sources = await registry.listSources("fixture.announcements", {
|
||||||
|
at: AT,
|
||||||
|
timezone: "UTC",
|
||||||
|
});
|
||||||
|
const items = await registry.listItems("fixture.announcements", { at: AT, timezone: "UTC" });
|
||||||
|
|
||||||
|
expect(sources).toEqual([{ id: "active-slides", label: "Active announcements" }]);
|
||||||
|
expect(items.map((item) => item.id)).toEqual(["welcome", "greeting", "giving", "kids"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Signage's own image provider", () => {
|
||||||
|
let store: MemorySignageStore;
|
||||||
|
|
||||||
|
async function seedImage(name: string, width = 1920, height = 1080): Promise<string> {
|
||||||
|
const { ingestNativeImage } = await import("../src/server/images/service.js");
|
||||||
|
const result = await ingestNativeImage({
|
||||||
|
bytes: await makeImage({ format: "png", height, width }),
|
||||||
|
displayName: name,
|
||||||
|
expiresAt: null,
|
||||||
|
filename: `${name}.png`,
|
||||||
|
storage: createFakeStorage(),
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
return result.image.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = createMemorySignageStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers through the identical capability an external provider uses", async () => {
|
||||||
|
const registry = createProviderRegistry({ timeoutMs: 200 });
|
||||||
|
const native = createNativeImageProvider({ defaultDurationMs: 7000, store });
|
||||||
|
|
||||||
|
registry.capability.register(native.registration);
|
||||||
|
|
||||||
|
expect(registry.health()).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
contractVersion: SIGNAGE_PROVIDER_CONTRACT_VERSION,
|
||||||
|
defaultDurationMs: 7000,
|
||||||
|
providerId: SIGNAGE_NATIVE_PROVIDER_ID,
|
||||||
|
// Not a browser contribution: a display seeing this identity paints an image itself.
|
||||||
|
renderer: { contributionId: "signage.native-image", contributionVersion: "1.0.0" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cycles the library in the order images were added", async () => {
|
||||||
|
const first = await seedImage("first");
|
||||||
|
const second = await seedImage("second", 1600, 900);
|
||||||
|
const registry = createProviderRegistry({ timeoutMs: 500 });
|
||||||
|
registry.capability.register(
|
||||||
|
createNativeImageProvider({ defaultDurationMs: 7000, store }).registration,
|
||||||
|
);
|
||||||
|
|
||||||
|
const served: (string | undefined)[] = [];
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
const outcome = await registry.resolveNext(
|
||||||
|
SIGNAGE_NATIVE_PROVIDER_ID,
|
||||||
|
request({ sourceId: "library" }),
|
||||||
|
);
|
||||||
|
served.push(outcome.kind === "resolved" ? outcome.resolution?.itemId : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(served).toEqual([first, second, first]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries object keys in its payload, never a delivery URL", async () => {
|
||||||
|
const imageId = await seedImage("welcome");
|
||||||
|
const registry = createProviderRegistry({ timeoutMs: 500 });
|
||||||
|
registry.capability.register(
|
||||||
|
createNativeImageProvider({ defaultDurationMs: 7000, store }).registration,
|
||||||
|
);
|
||||||
|
|
||||||
|
const outcome = await registry.refresh(
|
||||||
|
SIGNAGE_NATIVE_PROVIDER_ID,
|
||||||
|
request({ itemId: imageId }),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (outcome.kind !== "resolved" || !outcome.resolution) {
|
||||||
|
throw new Error("Expected a resolution.");
|
||||||
|
}
|
||||||
|
const payload = outcome.resolution.variants.get("default") as Record<string, unknown>;
|
||||||
|
expect(payload).toMatchObject({ imageId, kind: "native-image" });
|
||||||
|
expect(JSON.stringify(payload)).not.toMatch(/https?:|token=/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a deleted image as an explicit withdrawal and an expired one as nothing right now", async () => {
|
||||||
|
const imageId = await seedImage("welcome");
|
||||||
|
const registry = createProviderRegistry({ timeoutMs: 500 });
|
||||||
|
registry.capability.register(
|
||||||
|
createNativeImageProvider({ defaultDurationMs: 7000, store }).registration,
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.updateImage(imageId, { expiresAt: new Date("2020-01-01T00:00:00.000Z") });
|
||||||
|
const expired = await registry.refresh(
|
||||||
|
SIGNAGE_NATIVE_PROVIDER_ID,
|
||||||
|
request({ itemId: imageId }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.deleteImage(imageId);
|
||||||
|
const deleted = await registry.refresh(
|
||||||
|
SIGNAGE_NATIVE_PROVIDER_ID,
|
||||||
|
request({ itemId: imageId }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(expired).toEqual({ kind: "resolved", resolution: null });
|
||||||
|
expect(deleted.kind === "resolved" && deleted.resolution?.withdrawn).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import {
|
||||||
|
parseDrizzleMigrationJournal,
|
||||||
|
parseModuleManifest,
|
||||||
|
parseModuleMigrationPolicy,
|
||||||
|
} from "@ekklesia/module-sdk";
|
||||||
|
import { getTableConfig, type PgTable } from "drizzle-orm/pg-core";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import moduleSchema from "../src/schema/index.js";
|
||||||
|
|
||||||
|
const tables = moduleSchema.tables as Readonly<Record<string, PgTable>>;
|
||||||
|
|
||||||
|
async function readSql(): Promise<string> {
|
||||||
|
return readFile(new URL("../drizzle/0000_initial.sql", import.meta.url), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Signage schema", () => {
|
||||||
|
it("declares every table Signage's data model needs, and nothing outside its namespace", () => {
|
||||||
|
const physicalNames = Object.values(tables)
|
||||||
|
.map((table) => getTableConfig(table).name)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
expect(physicalNames).toEqual([
|
||||||
|
"signage_channel_groups",
|
||||||
|
"signage_channels",
|
||||||
|
"signage_image_assets",
|
||||||
|
"signage_image_renditions",
|
||||||
|
"signage_playback_states",
|
||||||
|
"signage_provider_sources",
|
||||||
|
"signage_selection_variants",
|
||||||
|
"signage_selections",
|
||||||
|
"signage_timeline_slots",
|
||||||
|
"signage_timelines",
|
||||||
|
"signage_viewers",
|
||||||
|
]);
|
||||||
|
for (const table of Object.values(tables)) {
|
||||||
|
const config = getTableConfig(table);
|
||||||
|
// The platform-managed default schema, never a custom PostgreSQL schema.
|
||||||
|
expect(config.schema).toBeUndefined();
|
||||||
|
expect(config.name.length).toBeLessThanOrEqual(63);
|
||||||
|
}
|
||||||
|
expect(moduleSchema.moduleId).toBe("signage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never points a foreign key outside this module", () => {
|
||||||
|
const owned = new Set(Object.values(tables).map((table) => getTableConfig(table).name));
|
||||||
|
|
||||||
|
for (const table of Object.values(tables)) {
|
||||||
|
for (const foreignKey of getTableConfig(table).foreignKeys) {
|
||||||
|
const reference = foreignKey.reference();
|
||||||
|
expect(owned).toContain(getTableConfig(reference.foreignTable).name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores another module's identities as opaque text rather than as a reference", () => {
|
||||||
|
// A provider, its sources, and its items belong to another module. Signage stores what it was
|
||||||
|
// told and can never join to it — which is exactly what keeps the two independently installable.
|
||||||
|
const opaque = [
|
||||||
|
["providerSources", "provider_id"],
|
||||||
|
["providerSources", "source_id"],
|
||||||
|
["timelineSlots", "provider_id"],
|
||||||
|
["timelineSlots", "item_id"],
|
||||||
|
["selections", "provider_id"],
|
||||||
|
["selections", "item_id"],
|
||||||
|
["viewers", "device_id"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const [tableName, columnName] of opaque) {
|
||||||
|
const table = tables[tableName];
|
||||||
|
if (!table) {
|
||||||
|
throw new Error(`Signage's schema is missing ${tableName}.`);
|
||||||
|
}
|
||||||
|
const config = getTableConfig(table);
|
||||||
|
const column = config.columns.find((candidate) => candidate.name === columnName);
|
||||||
|
expect(column?.getSQLType()).toBe("text");
|
||||||
|
const referenced = config.foreignKeys.some((foreignKey) =>
|
||||||
|
foreignKey.reference().columns.some((candidate) => candidate.name === columnName),
|
||||||
|
);
|
||||||
|
expect(referenced).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores logical object keys and never a signed delivery URL", async () => {
|
||||||
|
const sql = await readSql();
|
||||||
|
|
||||||
|
expect(sql).toContain('"original_object_key" text NOT NULL');
|
||||||
|
expect(sql).toContain('"object_key" text NOT NULL');
|
||||||
|
// A read URL is a bearer capability with an expiry in it. Nothing in this schema can hold one.
|
||||||
|
for (const forbidden of ["url", "signed_url", "presigned", "delivery_url"]) {
|
||||||
|
expect(sql.toLowerCase()).not.toContain(`"${forbidden}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes the invariants playback depends on the database's job, not a convention", async () => {
|
||||||
|
const sql = await readSql();
|
||||||
|
|
||||||
|
// Content-addressed identity: the same bytes are the same asset.
|
||||||
|
expect(sql).toContain("signage_image_assets_content_sha256_key");
|
||||||
|
// A slot position is unique within its timeline.
|
||||||
|
expect(sql).toContain("signage_timeline_slots_position_key");
|
||||||
|
// Exactly one default channel per group.
|
||||||
|
expect(sql).toContain("signage_channels_group_default_key");
|
||||||
|
// A slot is a provider source or a pinned item, never both and never neither.
|
||||||
|
expect(sql).toContain("signage_timeline_slots_reference_check");
|
||||||
|
// History is ordered and gapless per group.
|
||||||
|
expect(sql).toContain("signage_selections_group_sequence_key");
|
||||||
|
// One payload per audience per selection.
|
||||||
|
expect(sql).toContain("signage_selection_variants_selection_audience_key");
|
||||||
|
// Deleting a slot must not erase what already happened on screen.
|
||||||
|
expect(sql).toMatch(/signage_selections_slot_id_.*ON DELETE set null/su);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("packages one reviewed, non-destructive, forward-only migration", async () => {
|
||||||
|
const [journalInput, policyInput, manifestInput, bytes] = await Promise.all([
|
||||||
|
readFile(new URL("../drizzle/meta/_journal.json", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../drizzle/meta/_ekklesia.json", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../ekklesia.module.json", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../drizzle/0000_initial.sql", import.meta.url)),
|
||||||
|
]);
|
||||||
|
const journal = parseDrizzleMigrationJournal(JSON.parse(journalInput) as unknown);
|
||||||
|
const policy = parseModuleMigrationPolicy(JSON.parse(policyInput) as unknown);
|
||||||
|
const manifest = parseModuleManifest(JSON.parse(manifestInput) as unknown);
|
||||||
|
|
||||||
|
expect(journal.entries.map((entry) => entry.tag)).toEqual(["0000_initial"]);
|
||||||
|
expect(policy).toMatchObject({
|
||||||
|
moduleId: "signage",
|
||||||
|
entries: [{ tag: "0000_initial", destructive: { mode: "none" } }],
|
||||||
|
});
|
||||||
|
expect(manifest.entrypoints.migrations).toBe("./drizzle");
|
||||||
|
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
|
||||||
|
// Pinned deliberately. Core compares the exact bytes against its applied history and refuses a
|
||||||
|
// changed migration, so an accidental edit here must fail in this repository first.
|
||||||
|
expect(createHash("sha256").update(bytes).digest("hex")).toBe(
|
||||||
|
"fbbc448f9a951818bf37e89849ccdbfb55f3221260db0f59d1a2b245cc126838",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import {
|
||||||
|
type ModuleStorage,
|
||||||
|
type ModuleStorageBucket,
|
||||||
|
ModuleStorageError,
|
||||||
|
type ModuleStorageListing,
|
||||||
|
type ModuleStorageObject,
|
||||||
|
type ModuleStorageObjectMetadata,
|
||||||
|
type ModuleStorageReadUrl,
|
||||||
|
} from "@ekklesia/module-sdk";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An in-memory `ModuleStorage`, scoped exactly as the real one is.
|
||||||
|
*
|
||||||
|
* `ModuleStorage` is a `@ekklesia/contracts` interface, so a module-layer test may legitimately
|
||||||
|
* construct one — and doing so demonstrates the portability claim directly: the host supplying this
|
||||||
|
* object could be Ekklesia over S3-compatible storage, or a future standalone host over something
|
||||||
|
* else entirely, and Signage cannot tell.
|
||||||
|
*
|
||||||
|
* It refuses an undeclared bucket exactly as the real facade does, so a test cannot accidentally
|
||||||
|
* pass by reaching a bucket the manifest never declared.
|
||||||
|
*/
|
||||||
|
export interface FakeStorage extends ModuleStorage {
|
||||||
|
readonly objects: Map<string, { bytes: Uint8Array; contentType?: string }>;
|
||||||
|
/** Test-only: make the next `put` for this key fail, so cleanup can be exercised. */
|
||||||
|
failPut(key: string): void;
|
||||||
|
readonly readUrlsIssued: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFakeStorage(
|
||||||
|
options: { readonly declared?: readonly string[]; readonly now?: () => Date } = {},
|
||||||
|
): FakeStorage {
|
||||||
|
const declared = new Set(options.declared ?? ["native-images"]);
|
||||||
|
const now = options.now ?? (() => new Date());
|
||||||
|
const objects = new Map<string, { bytes: Uint8Array; contentType?: string }>();
|
||||||
|
const failingPuts = new Set<string>();
|
||||||
|
const readUrlsIssued: string[] = [];
|
||||||
|
|
||||||
|
function makeBucket(id: string): ModuleStorageBucket {
|
||||||
|
return {
|
||||||
|
createReadUrl(key: string): Promise<ModuleStorageReadUrl> {
|
||||||
|
readUrlsIssued.push(key);
|
||||||
|
// Opaque by contract: shape, host, and query are the host's business. A module that parsed
|
||||||
|
// one would be welded to a deployment.
|
||||||
|
return Promise.resolve({
|
||||||
|
expiresAt: new Date(now().getTime() + 15 * 60 * 1000),
|
||||||
|
url: `https://storage.invalid/${encodeURIComponent(key)}?token=opaque`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
delete(key: string): Promise<void> {
|
||||||
|
objects.delete(`${id}/${key}`);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
get(key: string): Promise<ModuleStorageObject> {
|
||||||
|
const stored = objects.get(`${id}/${key}`);
|
||||||
|
if (!stored) {
|
||||||
|
return Promise.reject(new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key));
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
body: new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(stored.bytes);
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
contentLength: stored.bytes.byteLength,
|
||||||
|
key,
|
||||||
|
...(stored.contentType === undefined ? {} : { contentType: stored.contentType }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
head(key: string): Promise<ModuleStorageObjectMetadata> {
|
||||||
|
const stored = objects.get(`${id}/${key}`);
|
||||||
|
if (!stored) {
|
||||||
|
return Promise.reject(new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key));
|
||||||
|
}
|
||||||
|
return Promise.resolve({ contentLength: stored.bytes.byteLength, key });
|
||||||
|
},
|
||||||
|
|
||||||
|
id,
|
||||||
|
|
||||||
|
list(options_?: { readonly prefix?: string }): Promise<ModuleStorageListing> {
|
||||||
|
const prefix = `${id}/${options_?.prefix ?? ""}`;
|
||||||
|
return Promise.resolve({
|
||||||
|
objects: [...objects.entries()]
|
||||||
|
.filter(([key]) => key.startsWith(prefix))
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
contentLength: value.bytes.byteLength,
|
||||||
|
key: key.slice(id.length + 1),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
put(
|
||||||
|
key: string,
|
||||||
|
body: unknown,
|
||||||
|
putOptions?: { readonly contentType?: string },
|
||||||
|
): Promise<ModuleStorageObjectMetadata> {
|
||||||
|
if (failingPuts.has(key)) {
|
||||||
|
failingPuts.delete(key);
|
||||||
|
return Promise.reject(new ModuleStorageError("STORAGE_PROVIDER_UNAVAILABLE", key));
|
||||||
|
}
|
||||||
|
if (!(body instanceof Uint8Array)) {
|
||||||
|
return Promise.reject(new ModuleStorageError("STORAGE_OPERATION_FAILED", key));
|
||||||
|
}
|
||||||
|
objects.set(`${id}/${key}`, {
|
||||||
|
bytes: body,
|
||||||
|
...(putOptions?.contentType === undefined ? {} : { contentType: putOptions.contentType }),
|
||||||
|
});
|
||||||
|
return Promise.resolve({ contentLength: body.byteLength, key });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bucket(logicalBucketId: string): ModuleStorageBucket {
|
||||||
|
if (!declared.has(logicalBucketId)) {
|
||||||
|
throw new ModuleStorageError("STORAGE_BUCKET_UNDECLARED", logicalBucketId);
|
||||||
|
}
|
||||||
|
return makeBucket(logicalBucketId);
|
||||||
|
},
|
||||||
|
buckets: () => [...declared].sort(),
|
||||||
|
failPut(key: string): void {
|
||||||
|
failingPuts.add(key);
|
||||||
|
},
|
||||||
|
objects,
|
||||||
|
readUrlsIssued,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import type {
|
||||||
|
SignageJsonValue,
|
||||||
|
SignageProviderDiscoveryRequest,
|
||||||
|
SignageProviderItemDescriptor,
|
||||||
|
SignageProviderRefreshRequest,
|
||||||
|
SignageProviderRegistration,
|
||||||
|
SignageProviderResolution,
|
||||||
|
SignageProviderResolveRequest,
|
||||||
|
SignageProviderSourceDescriptor,
|
||||||
|
} from "../../src/server/providers/contract.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A **test-only** content provider, standing in for the real modules that do not exist yet.
|
||||||
|
*
|
||||||
|
* It is not an installed module, it has no manifest, and nothing about it is reachable from a
|
||||||
|
* running Signage: it lives under `tests/`, and the only way it enters the system is a test calling
|
||||||
|
* the same `register` a real provider module would call from its own `start` hook.
|
||||||
|
*
|
||||||
|
* The point is to prove the seam with something that behaves like the three real providers will:
|
||||||
|
* an announcements-shaped provider that cycles continuously, and trivia- and scripture-shaped
|
||||||
|
* providers that refuse to repeat until their pool is exhausted. **Signage implements none of those
|
||||||
|
* policies** — that is the whole demonstration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FixtureItem {
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
/** Payload for the default audience. */
|
||||||
|
readonly payload: Record<string, SignageJsonValue>;
|
||||||
|
/** Payload for one specific audience, when this item has one. */
|
||||||
|
readonly audiencePayloads?: Readonly<Record<string, Record<string, SignageJsonValue>>>;
|
||||||
|
readonly availableUntil?: string | null;
|
||||||
|
readonly durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FixtureRepeatPolicy =
|
||||||
|
/** Announcements: cycle the active set forever, in order, wrapping when it runs out. */
|
||||||
|
| "cycle"
|
||||||
|
/** Trivia and Scripture: never repeat until every currently available item has appeared. */
|
||||||
|
| "no-repeat-until-exhausted";
|
||||||
|
|
||||||
|
export interface FixtureProviderOptions {
|
||||||
|
readonly providerId: string;
|
||||||
|
readonly sourceId: string;
|
||||||
|
readonly sourceLabel: string;
|
||||||
|
readonly items: readonly FixtureItem[];
|
||||||
|
readonly policy: FixtureRepeatPolicy;
|
||||||
|
readonly defaultDurationMs?: number;
|
||||||
|
readonly contractVersion?: string;
|
||||||
|
readonly rendererVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FixtureProvider {
|
||||||
|
readonly registration: SignageProviderRegistration;
|
||||||
|
/** Every item id this provider has handed out, in order, across every scope. */
|
||||||
|
readonly served: string[];
|
||||||
|
/** Every `resolveNext` request it received, so a test can prove what Signage supplied. */
|
||||||
|
readonly resolveRequests: SignageProviderResolveRequest[];
|
||||||
|
readonly refreshRequests: SignageProviderRefreshRequest[];
|
||||||
|
/** Replace the available set at run time — an announcement expiring, a question pool refilling. */
|
||||||
|
setItems(items: readonly FixtureItem[]): void;
|
||||||
|
/** Make the next `n` calls fail the way a briefly unavailable provider does. */
|
||||||
|
failNext(count: number): void;
|
||||||
|
/** Make every call hang, so the deadline is what ends it. */
|
||||||
|
hang(hanging: boolean): void;
|
||||||
|
/** Declare one item explicitly gone. */
|
||||||
|
withdraw(itemId: string): void;
|
||||||
|
/** Change an item's payload without changing which item it is — a cancellation, a countdown. */
|
||||||
|
reviseItem(itemId: string, payload: Record<string, SignageJsonValue>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFixtureProvider(options: FixtureProviderOptions): FixtureProvider {
|
||||||
|
let items = [...options.items];
|
||||||
|
const revisions = new Map<string, number>();
|
||||||
|
const withdrawn = new Set<string>();
|
||||||
|
/** Per selection scope: which items have already been served in the current pass. This is the
|
||||||
|
* provider's own state, exactly where the contract says it belongs — Signage stores none of it. */
|
||||||
|
const seen = new Map<string, Set<string>>();
|
||||||
|
const cursors = new Map<string, number>();
|
||||||
|
const served: string[] = [];
|
||||||
|
const resolveRequests: SignageProviderResolveRequest[] = [];
|
||||||
|
const refreshRequests: SignageProviderRefreshRequest[] = [];
|
||||||
|
let failures = 0;
|
||||||
|
let hanging = false;
|
||||||
|
|
||||||
|
function guard(): void {
|
||||||
|
if (failures > 0) {
|
||||||
|
failures -= 1;
|
||||||
|
throw new Error("fixture provider is briefly unavailable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function available(at: string): readonly FixtureItem[] {
|
||||||
|
const instant = Date.parse(at);
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (withdrawn.has(item.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof item.availableUntil !== "string") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return instant < Date.parse(item.availableUntil);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function revisionOf(item: FixtureItem): string {
|
||||||
|
return String(revisions.get(item.id) ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolutionFor(item: FixtureItem): SignageProviderResolution {
|
||||||
|
const variants = [
|
||||||
|
{ audienceKey: null, payload: item.payload },
|
||||||
|
...Object.entries(item.audiencePayloads ?? {}).map(([audienceKey, payload]) => ({
|
||||||
|
audienceKey,
|
||||||
|
payload,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
availableUntil: item.availableUntil ?? null,
|
||||||
|
itemId: item.id,
|
||||||
|
itemRevision: revisionOf(item),
|
||||||
|
variants,
|
||||||
|
...(item.durationMs === undefined ? {} : { durationMs: item.durationMs }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNext(scope: string, at: string): FixtureItem | null {
|
||||||
|
const pool = available(at);
|
||||||
|
if (pool.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (options.policy === "cycle") {
|
||||||
|
const index = cursors.get(scope) ?? 0;
|
||||||
|
const item = pool[index % pool.length] as FixtureItem;
|
||||||
|
cursors.set(scope, index + 1);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
// No repeat until exhausted: only when every currently available item has appeared does the
|
||||||
|
// pass reset. An item added mid-pass joins the current pass rather than waiting for the next.
|
||||||
|
let usedInPass = seen.get(scope);
|
||||||
|
if (!usedInPass) {
|
||||||
|
usedInPass = new Set<string>();
|
||||||
|
seen.set(scope, usedInPass);
|
||||||
|
}
|
||||||
|
let remaining = pool.filter((item) => !usedInPass.has(item.id));
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
usedInPass.clear();
|
||||||
|
remaining = [...pool];
|
||||||
|
}
|
||||||
|
const item = remaining[0] as FixtureItem;
|
||||||
|
usedInPass.add(item.id);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration: SignageProviderRegistration = {
|
||||||
|
contractVersion: options.contractVersion ?? "1.0.0",
|
||||||
|
defaultDurationMs: options.defaultDurationMs ?? 5000,
|
||||||
|
|
||||||
|
listItems(request: SignageProviderDiscoveryRequest): readonly SignageProviderItemDescriptor[] {
|
||||||
|
guard();
|
||||||
|
return available(request.at).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
...(item.availableUntil === undefined ? {} : { availableUntil: item.availableUntil }),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
listSources(): readonly SignageProviderSourceDescriptor[] {
|
||||||
|
guard();
|
||||||
|
return [{ id: options.sourceId, label: options.sourceLabel }];
|
||||||
|
},
|
||||||
|
|
||||||
|
providerId: options.providerId,
|
||||||
|
providerVersion: "1.0.0",
|
||||||
|
|
||||||
|
async refresh(
|
||||||
|
request: SignageProviderRefreshRequest,
|
||||||
|
): Promise<SignageProviderResolution | null> {
|
||||||
|
refreshRequests.push(request);
|
||||||
|
if (hanging) {
|
||||||
|
await new Promise(() => undefined);
|
||||||
|
}
|
||||||
|
guard();
|
||||||
|
if (withdrawn.has(request.itemId)) {
|
||||||
|
// Explicitly gone — distinct from "nothing right now", and Signage removes it at the next
|
||||||
|
// safe boundary rather than mid-slide.
|
||||||
|
return {
|
||||||
|
itemId: request.itemId,
|
||||||
|
itemRevision: revisionOf({ id: request.itemId } as FixtureItem),
|
||||||
|
variants: [{ audienceKey: null, payload: { withdrawn: true } }],
|
||||||
|
withdrawn: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const item = available(request.at).find((candidate) => candidate.id === request.itemId);
|
||||||
|
return item ? resolutionFor(item) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderer: {
|
||||||
|
contributionId: `${options.providerId}.slide`,
|
||||||
|
contributionVersion: options.rendererVersion ?? "1.0.0",
|
||||||
|
},
|
||||||
|
|
||||||
|
async resolveNext(
|
||||||
|
request: SignageProviderResolveRequest,
|
||||||
|
): Promise<SignageProviderResolution | null> {
|
||||||
|
resolveRequests.push(request);
|
||||||
|
if (hanging) {
|
||||||
|
await new Promise(() => undefined);
|
||||||
|
}
|
||||||
|
guard();
|
||||||
|
if (request.sourceId !== options.sourceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const item = pickNext(request.selectionScopeId, request.at);
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
served.push(item.id);
|
||||||
|
return resolutionFor(item);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
failNext(count: number): void {
|
||||||
|
failures = count;
|
||||||
|
},
|
||||||
|
hang(value: boolean): void {
|
||||||
|
hanging = value;
|
||||||
|
},
|
||||||
|
refreshRequests,
|
||||||
|
registration,
|
||||||
|
resolveRequests,
|
||||||
|
reviseItem(itemId: string, payload: Record<string, SignageJsonValue>): void {
|
||||||
|
items = items.map((item) => (item.id === itemId ? { ...item, payload } : item));
|
||||||
|
revisions.set(itemId, (revisions.get(itemId) ?? 1) + 1);
|
||||||
|
},
|
||||||
|
served,
|
||||||
|
setItems(next: readonly FixtureItem[]): void {
|
||||||
|
items = [...next];
|
||||||
|
},
|
||||||
|
withdraw(itemId: string): void {
|
||||||
|
withdrawn.add(itemId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The three provider shapes the real modules will have, so a test can name them the way an operator
|
||||||
|
* would. Nothing about their policies lives in Signage. */
|
||||||
|
export function announcementsFixture(): FixtureProvider {
|
||||||
|
return createFixtureProvider({
|
||||||
|
items: [
|
||||||
|
{ id: "welcome", label: "Welcome", payload: { headline: "Welcome" } },
|
||||||
|
{
|
||||||
|
audiencePayloads: { online: { headline: "Watching from home" } },
|
||||||
|
id: "greeting",
|
||||||
|
label: "Greeting",
|
||||||
|
payload: { headline: "Good morning" },
|
||||||
|
},
|
||||||
|
{ id: "giving", label: "Giving", payload: { headline: "Giving" } },
|
||||||
|
{ id: "kids", label: "Kids check-in", payload: { headline: "Kids check-in" } },
|
||||||
|
],
|
||||||
|
policy: "cycle",
|
||||||
|
providerId: "fixture.announcements",
|
||||||
|
sourceId: "active-slides",
|
||||||
|
sourceLabel: "Active announcements",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triviaFixture(): FixtureProvider {
|
||||||
|
return createFixtureProvider({
|
||||||
|
items: [
|
||||||
|
{ id: "q1", label: "Question 1", payload: { question: "one" } },
|
||||||
|
{ id: "q2", label: "Question 2", payload: { question: "two" } },
|
||||||
|
{ id: "q3", label: "Question 3", payload: { question: "three" } },
|
||||||
|
],
|
||||||
|
policy: "no-repeat-until-exhausted",
|
||||||
|
providerId: "fixture.trivia",
|
||||||
|
sourceId: "open-questions",
|
||||||
|
sourceLabel: "Open questions",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scriptureFixture(): FixtureProvider {
|
||||||
|
return createFixtureProvider({
|
||||||
|
items: [
|
||||||
|
{ id: "psalm-23", label: "Psalm 23", payload: { reference: "Psalm 23" } },
|
||||||
|
{ id: "john-3", label: "John 3", payload: { reference: "John 3" } },
|
||||||
|
],
|
||||||
|
policy: "no-repeat-until-exhausted",
|
||||||
|
providerId: "fixture.scripture",
|
||||||
|
sourceId: "reading-plan",
|
||||||
|
sourceLabel: "Reading plan",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
/** Real encoded images, generated rather than checked in, so a fixture can never drift from what the
|
||||||
|
* pipeline actually accepts. */
|
||||||
|
export async function makeImage(options: {
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
readonly format: "png" | "jpeg" | "webp";
|
||||||
|
readonly alpha?: boolean;
|
||||||
|
readonly colour?: { r: number; g: number; b: number };
|
||||||
|
}): Promise<Uint8Array> {
|
||||||
|
const colour = options.colour ?? { b: 40, g: 120, r: 200 };
|
||||||
|
const pipeline = sharp({
|
||||||
|
create: {
|
||||||
|
background: { ...colour, alpha: options.alpha === true ? 0.5 : 1 },
|
||||||
|
channels: options.alpha === true ? 4 : 3,
|
||||||
|
height: options.height,
|
||||||
|
width: options.width,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const encoded =
|
||||||
|
options.format === "png"
|
||||||
|
? await pipeline.png().toBuffer()
|
||||||
|
: options.format === "jpeg"
|
||||||
|
? await pipeline.jpeg().toBuffer()
|
||||||
|
: await pipeline.webp().toBuffer();
|
||||||
|
return new Uint8Array(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A genuine two-frame animated WebP. Signage refuses animation: a signage slide is a still frame,
|
||||||
|
* and an animated one would loop out of step with the authoritative slot deadline. */
|
||||||
|
export async function makeAnimatedWebp(): Promise<Uint8Array> {
|
||||||
|
const frame = (red: number) =>
|
||||||
|
sharp({
|
||||||
|
create: { background: { alpha: 1, b: 0, g: 0, r: red }, channels: 4, height: 8, width: 8 },
|
||||||
|
})
|
||||||
|
.raw()
|
||||||
|
.toBuffer();
|
||||||
|
const frames = Buffer.concat([await frame(255), await frame(0)]);
|
||||||
|
const encoded = await sharp(frames, {
|
||||||
|
raw: { channels: 4, height: 16, pageHeight: 8, width: 8 },
|
||||||
|
})
|
||||||
|
.webp({ delay: [100, 100], loop: 0 })
|
||||||
|
.toBuffer();
|
||||||
|
return new Uint8Array(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A genuine two-frame animated GIF. GIF is refused outright, but animation is detected first, so
|
||||||
|
* this proves the *animation* rule rather than the format rule. */
|
||||||
|
export async function makeAnimatedGif(): Promise<Uint8Array> {
|
||||||
|
const frame = (red: number) =>
|
||||||
|
sharp({
|
||||||
|
create: { background: { alpha: 1, b: 0, g: 0, r: red }, channels: 4, height: 8, width: 8 },
|
||||||
|
})
|
||||||
|
.raw()
|
||||||
|
.toBuffer();
|
||||||
|
const frames = Buffer.concat([await frame(255), await frame(0)]);
|
||||||
|
const encoded = await sharp(frames, {
|
||||||
|
raw: { channels: 4, height: 16, pageHeight: 8, width: 8 },
|
||||||
|
})
|
||||||
|
.gif({ delay: [100, 100], loop: 0 })
|
||||||
|
.toBuffer();
|
||||||
|
return new Uint8Array(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesOf(value: string): Uint8Array {
|
||||||
|
return new TextEncoder().encode(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal, genuinely well-formed SVG. Signage refuses it: an SVG is a document with script and
|
||||||
|
* external-reference capability, not a raster image. */
|
||||||
|
export const SVG_DOCUMENT =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>';
|
||||||
|
|
||||||
|
/** The first bytes of a PDF. Refused for the same reason as everything else that is not a still
|
||||||
|
* raster image. */
|
||||||
|
export const PDF_DOCUMENT = "%PDF-1.7\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>";
|
||||||
|
|
||||||
|
/** A GIF89a header with one tiny frame. */
|
||||||
|
export const GIF_DOCUMENT_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import {
|
||||||
|
type NewStoredSelection,
|
||||||
|
type PlaybackChanges,
|
||||||
|
type SelectionChanges,
|
||||||
|
type SignagePlaybackStore,
|
||||||
|
SignageStoreConflict,
|
||||||
|
type StoredChannel,
|
||||||
|
type StoredChannelGroup,
|
||||||
|
type StoredChannelGroupDetail,
|
||||||
|
type StoredPlaybackState,
|
||||||
|
type StoredSelection,
|
||||||
|
} from "../../src/server/store/types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The playback half of the in-memory store.
|
||||||
|
*
|
||||||
|
* It enforces the guarantees the database enforces — one default channel that cannot be removed, a
|
||||||
|
* sequence that is never handed out twice, variants replaced wholesale — so a decision proven here
|
||||||
|
* holds against PostgreSQL too.
|
||||||
|
*/
|
||||||
|
export interface MemoryPlaybackStore extends SignagePlaybackStore {
|
||||||
|
readonly groups: Map<string, StoredChannelGroup>;
|
||||||
|
readonly channels: Map<string, StoredChannel>;
|
||||||
|
readonly playback: Map<string, StoredPlaybackState>;
|
||||||
|
readonly selections: Map<string, StoredSelection>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMemoryPlaybackStore(
|
||||||
|
options: { readonly now?: () => Date } = {},
|
||||||
|
): MemoryPlaybackStore {
|
||||||
|
const now = options.now ?? (() => new Date());
|
||||||
|
const groups = new Map<string, StoredChannelGroup>();
|
||||||
|
const channels = new Map<string, StoredChannel>();
|
||||||
|
const playback = new Map<string, StoredPlaybackState>();
|
||||||
|
const selections = new Map<string, StoredSelection>();
|
||||||
|
|
||||||
|
function detail(groupId: string): StoredChannelGroupDetail | undefined {
|
||||||
|
const group = groups.get(groupId);
|
||||||
|
const state = playback.get(groupId);
|
||||||
|
if (!group || !state) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
channels: [...channels.values()]
|
||||||
|
.filter((channel) => channel.groupId === groupId)
|
||||||
|
.sort((left, right) =>
|
||||||
|
left.isDefault === right.isDefault
|
||||||
|
? left.key.localeCompare(right.key)
|
||||||
|
: left.isDefault
|
||||||
|
? -1
|
||||||
|
: 1,
|
||||||
|
),
|
||||||
|
group,
|
||||||
|
playback: state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
addChannel(channel: StoredChannel): Promise<void> {
|
||||||
|
channels.set(channel.id, channel);
|
||||||
|
const group = groups.get(channel.groupId);
|
||||||
|
if (group) {
|
||||||
|
groups.set(group.id, { ...group, revision: group.revision + 1 });
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
channels,
|
||||||
|
|
||||||
|
createChannelGroup(input: {
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly channelId: string;
|
||||||
|
readonly channelName: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
groups.set(input.groupId, { id: input.groupId, name: input.name, revision: 1 });
|
||||||
|
channels.set(input.channelId, {
|
||||||
|
groupId: input.groupId,
|
||||||
|
id: input.channelId,
|
||||||
|
isDefault: true,
|
||||||
|
key: "default",
|
||||||
|
name: input.channelName,
|
||||||
|
});
|
||||||
|
playback.set(input.groupId, {
|
||||||
|
activeTimelineId: null,
|
||||||
|
blackout: false,
|
||||||
|
currentSelectionId: null,
|
||||||
|
currentSlotId: null,
|
||||||
|
groupId: input.groupId,
|
||||||
|
nextSequence: 1,
|
||||||
|
paused: false,
|
||||||
|
pausedRemainingMs: null,
|
||||||
|
pendingRequestedAt: null,
|
||||||
|
pendingTimelineId: null,
|
||||||
|
revision: 1,
|
||||||
|
scheduledActivationAt: null,
|
||||||
|
scheduledTimelineId: null,
|
||||||
|
slotDeadlineAt: null,
|
||||||
|
slotStartedAt: null,
|
||||||
|
staticAssetId: null,
|
||||||
|
updatedAt: now(),
|
||||||
|
});
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteChannelGroup(groupId: string): Promise<void> {
|
||||||
|
groups.delete(groupId);
|
||||||
|
playback.delete(groupId);
|
||||||
|
for (const [id, channel] of channels) {
|
||||||
|
if (channel.groupId === groupId) {
|
||||||
|
channels.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [id, selection] of selections) {
|
||||||
|
if (selection.groupId === groupId) {
|
||||||
|
selections.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined> {
|
||||||
|
return Promise.resolve(detail(groupId));
|
||||||
|
},
|
||||||
|
|
||||||
|
findLatestSelectionForScope(
|
||||||
|
groupId: string,
|
||||||
|
selectionScope: string,
|
||||||
|
): Promise<StoredSelection | undefined> {
|
||||||
|
const matching = [...selections.values()]
|
||||||
|
.filter(
|
||||||
|
(selection) =>
|
||||||
|
selection.groupId === groupId && selection.selectionScope === selectionScope,
|
||||||
|
)
|
||||||
|
.sort((left, right) => right.sequence - left.sequence);
|
||||||
|
return Promise.resolve(matching[0]);
|
||||||
|
},
|
||||||
|
|
||||||
|
findSelection(selectionId: string): Promise<StoredSelection | undefined> {
|
||||||
|
return Promise.resolve(selections.get(selectionId));
|
||||||
|
},
|
||||||
|
|
||||||
|
groups,
|
||||||
|
|
||||||
|
insertSelection(selection: NewStoredSelection): Promise<void> {
|
||||||
|
selections.set(selection.id, { ...selection, resolvedAt: now(), revision: 1 });
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
listChannelGroups(): Promise<readonly StoredChannelGroupDetail[]> {
|
||||||
|
return Promise.resolve(
|
||||||
|
[...groups.values()]
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name))
|
||||||
|
.map((group) => detail(group.id))
|
||||||
|
.filter((value): value is StoredChannelGroupDetail => value !== undefined),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
listSelections(groupId: string, limit: number): Promise<readonly StoredSelection[]> {
|
||||||
|
return Promise.resolve(
|
||||||
|
[...selections.values()]
|
||||||
|
.filter((selection) => selection.groupId === groupId)
|
||||||
|
.sort((left, right) => right.sequence - left.sequence)
|
||||||
|
.slice(0, limit),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
playback,
|
||||||
|
|
||||||
|
pruneSelections(groupId: string, limit: number): Promise<void> {
|
||||||
|
const ordered = [...selections.values()]
|
||||||
|
.filter((selection) => selection.groupId === groupId)
|
||||||
|
.sort((left, right) => right.sequence - left.sequence);
|
||||||
|
for (const selection of ordered.slice(limit)) {
|
||||||
|
selections.delete(selection.id);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
removeChannel(channelId: string): Promise<void> {
|
||||||
|
const channel = channels.get(channelId);
|
||||||
|
if (!channel) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (channel.isDefault) {
|
||||||
|
return Promise.reject(new SignageStoreConflict("channel.default"));
|
||||||
|
}
|
||||||
|
channels.delete(channelId);
|
||||||
|
const group = groups.get(channel.groupId);
|
||||||
|
if (group) {
|
||||||
|
groups.set(group.id, { ...group, revision: group.revision + 1 });
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
selections,
|
||||||
|
|
||||||
|
takeSelectionSequence(groupId: string): Promise<number> {
|
||||||
|
const state = playback.get(groupId);
|
||||||
|
if (!state) {
|
||||||
|
return Promise.reject(new SignageStoreConflict("playback.sequence"));
|
||||||
|
}
|
||||||
|
playback.set(groupId, { ...state, nextSequence: state.nextSequence + 1 });
|
||||||
|
return Promise.resolve(state.nextSequence);
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePlayback(
|
||||||
|
groupId: string,
|
||||||
|
changes: PlaybackChanges,
|
||||||
|
updateOptions?: { readonly bumpRevision?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const state = playback.get(groupId);
|
||||||
|
if (!state) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
const next: Record<string, unknown> = { ...state };
|
||||||
|
for (const key of Object.keys(changes)) {
|
||||||
|
next[key] = (changes as Record<string, unknown>)[key] ?? null;
|
||||||
|
}
|
||||||
|
playback.set(groupId, {
|
||||||
|
...(next as unknown as StoredPlaybackState),
|
||||||
|
revision: updateOptions?.bumpRevision === false ? state.revision : state.revision + 1,
|
||||||
|
updatedAt: now(),
|
||||||
|
});
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSelection(selectionId: string, changes: SelectionChanges): Promise<void> {
|
||||||
|
const selection = selections.get(selectionId);
|
||||||
|
if (!selection) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
selections.set(selectionId, {
|
||||||
|
...selection,
|
||||||
|
...(changes.availableUntil === undefined ? {} : { availableUntil: changes.availableUntil }),
|
||||||
|
...(changes.deadlineAt === undefined ? {} : { deadlineAt: changes.deadlineAt }),
|
||||||
|
...(changes.durationMs === undefined ? {} : { durationMs: changes.durationMs }),
|
||||||
|
...(changes.itemRevision === undefined ? {} : { itemRevision: changes.itemRevision }),
|
||||||
|
...(changes.presentation === undefined ? {} : { presentation: changes.presentation }),
|
||||||
|
...(changes.stale === undefined ? {} : { stale: changes.stale }),
|
||||||
|
...(changes.startedAt === undefined ? {} : { startedAt: changes.startedAt }),
|
||||||
|
...(changes.withdrawn === undefined ? {} : { withdrawn: changes.withdrawn }),
|
||||||
|
...(changes.variants === undefined ? {} : { variants: changes.variants }),
|
||||||
|
revision: changes.bumpRevision === true ? selection.revision + 1 : selection.revision,
|
||||||
|
});
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
type NewStoredImage,
|
||||||
|
type SignageStore,
|
||||||
|
SignageStoreConflict,
|
||||||
|
type StoredImage,
|
||||||
|
type StoredImageReferences,
|
||||||
|
} from "../../src/server/store/types.js";
|
||||||
|
import { createMemoryPlaybackStore, type MemoryPlaybackStore } from "./memory-playback.js";
|
||||||
|
import { createMemoryTimelineStore, type MemoryTimelineStore } from "./memory-timelines.js";
|
||||||
|
import { createMemoryViewerStore, type MemoryViewerStore } from "./memory-viewers.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An in-memory implementation of Signage's data port.
|
||||||
|
*
|
||||||
|
* This is what makes Signage's *decisions* testable without PostgreSQL: deduplication, no-op
|
||||||
|
* detection, reference-aware deletion, provider resolution, and playback reconciliation are all
|
||||||
|
* behaviour, and behaviour proven against a real database for every case would be slow, flaky, and
|
||||||
|
* therefore rare. The Drizzle implementation is proven separately, against a live disposable
|
||||||
|
* database, where what is being proven is the row mapping rather than the decision.
|
||||||
|
*
|
||||||
|
* It enforces the same uniqueness guarantees the database does, so a test cannot pass here and fail
|
||||||
|
* there.
|
||||||
|
*/
|
||||||
|
export interface MemorySignageStore
|
||||||
|
extends SignageStore,
|
||||||
|
MemoryTimelineStore,
|
||||||
|
MemoryPlaybackStore,
|
||||||
|
MemoryViewerStore {
|
||||||
|
/** Test-only: seed a reference so reference-aware deletion can be exercised. */
|
||||||
|
addImageReference(imageId: string, kind: "slot" | "static-hold"): void;
|
||||||
|
readonly images: Map<string, StoredImage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMemorySignageStore(
|
||||||
|
options: { readonly now?: () => Date } = {},
|
||||||
|
): MemorySignageStore {
|
||||||
|
const now = options.now ?? (() => new Date());
|
||||||
|
const images = new Map<string, StoredImage>();
|
||||||
|
const references = new Map<string, StoredImageReferences>();
|
||||||
|
const timelineStore = createMemoryTimelineStore(options);
|
||||||
|
const playbackStore = createMemoryPlaybackStore(options);
|
||||||
|
const viewerStore = createMemoryViewerStore(options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...timelineStore,
|
||||||
|
...playbackStore,
|
||||||
|
...viewerStore,
|
||||||
|
|
||||||
|
addImageReference(imageId: string, kind: "slot" | "static-hold"): void {
|
||||||
|
const current = references.get(imageId) ?? { slots: 0, staticHolds: 0 };
|
||||||
|
references.set(
|
||||||
|
imageId,
|
||||||
|
kind === "slot"
|
||||||
|
? { slots: current.slots + 1, staticHolds: current.staticHolds }
|
||||||
|
: { slots: current.slots, staticHolds: current.staticHolds + 1 },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
countImageReferences(imageId: string): Promise<StoredImageReferences> {
|
||||||
|
return Promise.resolve(references.get(imageId) ?? { slots: 0, staticHolds: 0 });
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteImage(imageId: string): Promise<void> {
|
||||||
|
images.delete(imageId);
|
||||||
|
references.delete(imageId);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
findImage(imageId: string): Promise<StoredImage | undefined> {
|
||||||
|
return Promise.resolve(images.get(imageId));
|
||||||
|
},
|
||||||
|
|
||||||
|
findReadyImageByContentHash(contentSha256: string): Promise<StoredImage | undefined> {
|
||||||
|
for (const image of images.values()) {
|
||||||
|
if (image.contentSha256 === contentSha256 && image.status === "ready") {
|
||||||
|
return Promise.resolve(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
images,
|
||||||
|
|
||||||
|
insertImage(image: NewStoredImage): Promise<void> {
|
||||||
|
for (const existing of images.values()) {
|
||||||
|
if (existing.contentSha256 === image.contentSha256) {
|
||||||
|
// Exactly what the unique index does, so a race is exercised here and not only in
|
||||||
|
// production.
|
||||||
|
return Promise.reject(new SignageStoreConflict("image.contentSha256"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
images.set(image.id, { ...image, createdAt: now() });
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
listImages(): Promise<readonly StoredImage[]> {
|
||||||
|
return Promise.resolve(
|
||||||
|
[...images.values()].sort(
|
||||||
|
(left, right) => right.createdAt.getTime() - left.createdAt.getTime(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateImage(
|
||||||
|
imageId: string,
|
||||||
|
changes: { readonly displayName?: string; readonly expiresAt?: Date | null },
|
||||||
|
): Promise<void> {
|
||||||
|
const existing = images.get(imageId);
|
||||||
|
if (!existing) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
images.set(imageId, {
|
||||||
|
...existing,
|
||||||
|
...(changes.displayName === undefined ? {} : { displayName: changes.displayName }),
|
||||||
|
...(changes.expiresAt === undefined ? {} : { expiresAt: changes.expiresAt }),
|
||||||
|
});
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import {
|
||||||
|
type NewStoredProviderSource,
|
||||||
|
type NewStoredSlot,
|
||||||
|
type NewStoredTimeline,
|
||||||
|
SignageStoreConflict,
|
||||||
|
type SignageTimelineStore,
|
||||||
|
type SlotChanges,
|
||||||
|
type StoredProviderSource,
|
||||||
|
type StoredSlot,
|
||||||
|
type StoredTimeline,
|
||||||
|
type StoredTimelineDetail,
|
||||||
|
type StoredTimelineUsage,
|
||||||
|
type TimelineChanges,
|
||||||
|
} from "../../src/server/store/types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timeline half of the in-memory store.
|
||||||
|
*
|
||||||
|
* It enforces the same invariants the database does — contiguous positions, an exhaustive reorder,
|
||||||
|
* a revision that increments once per semantic change — so a test cannot pass here and fail against
|
||||||
|
* PostgreSQL. Where the production store uses a transaction, this uses the fact that JavaScript does
|
||||||
|
* not interleave synchronous work; the property being proven is the same.
|
||||||
|
*/
|
||||||
|
export interface MemoryTimelineStore extends SignageTimelineStore {
|
||||||
|
/** Test-only: pretend a channel group has this timeline active, pending, or scheduled. */
|
||||||
|
setTimelineUsage(timelineId: string, usage: StoredTimelineUsage): void;
|
||||||
|
readonly timelines: Map<string, StoredTimeline>;
|
||||||
|
readonly slots: Map<string, StoredSlot>;
|
||||||
|
readonly sources: Map<string, StoredProviderSource>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMemoryTimelineStore(
|
||||||
|
options: { readonly now?: () => Date } = {},
|
||||||
|
): MemoryTimelineStore {
|
||||||
|
const now = options.now ?? (() => new Date());
|
||||||
|
const timelines = new Map<string, StoredTimeline>();
|
||||||
|
const slots = new Map<string, StoredSlot>();
|
||||||
|
const sources = new Map<string, StoredProviderSource>();
|
||||||
|
const usage = new Map<string, StoredTimelineUsage>();
|
||||||
|
|
||||||
|
function slotsOf(timelineId: string): StoredSlot[] {
|
||||||
|
return [...slots.values()]
|
||||||
|
.filter((slot) => slot.timelineId === timelineId)
|
||||||
|
.sort((left, right) => left.position - right.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bump(timelineId: string): void {
|
||||||
|
const timeline = timelines.get(timelineId);
|
||||||
|
if (timeline) {
|
||||||
|
timelines.set(timelineId, {
|
||||||
|
...timeline,
|
||||||
|
revision: timeline.revision + 1,
|
||||||
|
updatedAt: now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renumber(timelineId: string, orderedSlotIds: readonly string[]): void {
|
||||||
|
for (const [index, slotId] of orderedSlotIds.entries()) {
|
||||||
|
const slot = slots.get(slotId);
|
||||||
|
if (slot) {
|
||||||
|
slots.set(slotId, { ...slot, position: index });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bump(timelineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
addSlot(slot: NewStoredSlot, position?: number): Promise<void> {
|
||||||
|
const existing = slotsOf(slot.timelineId).map((entry) => entry.id);
|
||||||
|
const index =
|
||||||
|
position === undefined ? existing.length : Math.max(0, Math.min(position, existing.length));
|
||||||
|
slots.set(slot.id, { ...slot, position: -1 });
|
||||||
|
renumber(slot.timelineId, [...existing.slice(0, index), slot.id, ...existing.slice(index)]);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
addSource(source: NewStoredProviderSource): Promise<void> {
|
||||||
|
sources.set(source.id, { ...source, createdAt: now() });
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
createTimeline(input: {
|
||||||
|
readonly timeline: NewStoredTimeline;
|
||||||
|
readonly sources: readonly NewStoredProviderSource[];
|
||||||
|
readonly slots: readonly NewStoredSlot[];
|
||||||
|
}): Promise<void> {
|
||||||
|
const createdAt = now();
|
||||||
|
timelines.set(input.timeline.id, {
|
||||||
|
...input.timeline,
|
||||||
|
createdAt,
|
||||||
|
revision: 1,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
});
|
||||||
|
for (const source of input.sources) {
|
||||||
|
sources.set(source.id, { ...source, createdAt });
|
||||||
|
}
|
||||||
|
for (const [index, slot] of input.slots.entries()) {
|
||||||
|
slots.set(slot.id, { ...slot, position: index });
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteTimeline(timelineId: string): Promise<void> {
|
||||||
|
timelines.delete(timelineId);
|
||||||
|
for (const [id, slot] of slots) {
|
||||||
|
if (slot.timelineId === timelineId) {
|
||||||
|
slots.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [id, source] of sources) {
|
||||||
|
if (source.timelineId === timelineId) {
|
||||||
|
sources.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
findTimeline(timelineId: string): Promise<StoredTimelineDetail | undefined> {
|
||||||
|
const timeline = timelines.get(timelineId);
|
||||||
|
if (!timeline) {
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
slots: slotsOf(timelineId),
|
||||||
|
sources: [...sources.values()]
|
||||||
|
.filter((source) => source.timelineId === timelineId)
|
||||||
|
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()),
|
||||||
|
timeline,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
listTimelines(): Promise<readonly StoredTimeline[]> {
|
||||||
|
return Promise.resolve(
|
||||||
|
[...timelines.values()].sort((left, right) => left.name.localeCompare(right.name)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeSlot(slotId: string): Promise<void> {
|
||||||
|
const slot = slots.get(slotId);
|
||||||
|
if (!slot) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
slots.delete(slotId);
|
||||||
|
renumber(
|
||||||
|
slot.timelineId,
|
||||||
|
slotsOf(slot.timelineId).map((entry) => entry.id),
|
||||||
|
);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
removeSource(sourceId: string): Promise<void> {
|
||||||
|
const source = sources.get(sourceId);
|
||||||
|
if (!source) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
sources.delete(sourceId);
|
||||||
|
for (const [id, slot] of slots) {
|
||||||
|
if (slot.sourceId === sourceId) {
|
||||||
|
slots.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renumber(
|
||||||
|
source.timelineId,
|
||||||
|
slotsOf(source.timelineId).map((entry) => entry.id),
|
||||||
|
);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderSlots(timelineId: string, orderedSlotIds: readonly string[]): Promise<void> {
|
||||||
|
const current = slotsOf(timelineId).map((slot) => slot.id);
|
||||||
|
const given = new Set(orderedSlotIds);
|
||||||
|
if (given.size !== orderedSlotIds.length || given.size !== current.length) {
|
||||||
|
return Promise.reject(new SignageStoreConflict("timeline.slotOrder"));
|
||||||
|
}
|
||||||
|
for (const slotId of current) {
|
||||||
|
if (!given.has(slotId)) {
|
||||||
|
return Promise.reject(new SignageStoreConflict("timeline.slotOrder"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renumber(timelineId, orderedSlotIds);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
setTimelineUsage(timelineId: string, value: StoredTimelineUsage): void {
|
||||||
|
usage.set(timelineId, value);
|
||||||
|
},
|
||||||
|
|
||||||
|
slots,
|
||||||
|
|
||||||
|
sources,
|
||||||
|
|
||||||
|
timelineUsage(timelineId: string): Promise<StoredTimelineUsage> {
|
||||||
|
return Promise.resolve(
|
||||||
|
usage.get(timelineId) ?? { activeGroups: 0, pendingGroups: 0, scheduledGroups: 0 },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
timelines,
|
||||||
|
|
||||||
|
updateSlot(slotId: string, changes: SlotChanges): Promise<void> {
|
||||||
|
const slot = slots.get(slotId);
|
||||||
|
if (!slot) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
slots.set(slotId, {
|
||||||
|
...slot,
|
||||||
|
...(changes.enabled === undefined ? {} : { enabled: changes.enabled }),
|
||||||
|
...(changes.durationOverrideMs === undefined
|
||||||
|
? {}
|
||||||
|
: { durationOverrideMs: changes.durationOverrideMs }),
|
||||||
|
...(changes.eligibleFrom === undefined ? {} : { eligibleFrom: changes.eligibleFrom }),
|
||||||
|
...(changes.eligibleUntil === undefined ? {} : { eligibleUntil: changes.eligibleUntil }),
|
||||||
|
...(changes.itemId === undefined ? {} : { itemId: changes.itemId }),
|
||||||
|
presentation: { ...slot.presentation, ...(changes.presentation ?? {}) },
|
||||||
|
});
|
||||||
|
bump(slot.timelineId);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTimeline(timelineId: string, changes: TimelineChanges): Promise<void> {
|
||||||
|
const timeline = timelines.get(timelineId);
|
||||||
|
if (!timeline) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
timelines.set(timelineId, {
|
||||||
|
...timeline,
|
||||||
|
...(changes.name === undefined ? {} : { name: changes.name }),
|
||||||
|
...(changes.description === undefined ? {} : { description: changes.description }),
|
||||||
|
...(changes.eligibleFrom === undefined ? {} : { eligibleFrom: changes.eligibleFrom }),
|
||||||
|
...(changes.eligibleUntil === undefined ? {} : { eligibleUntil: changes.eligibleUntil }),
|
||||||
|
revision: timeline.revision + 1,
|
||||||
|
updatedAt: now(),
|
||||||
|
});
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type {
|
||||||
|
NewStoredViewer,
|
||||||
|
SignageViewerStore,
|
||||||
|
StoredViewer,
|
||||||
|
ViewerChanges,
|
||||||
|
} from "../../src/server/store/types.js";
|
||||||
|
|
||||||
|
export interface MemoryViewerStore extends SignageViewerStore {
|
||||||
|
readonly viewers: Map<string, StoredViewer>;
|
||||||
|
/** Test-only: how many times a viewer row was actually written, which is what heartbeat
|
||||||
|
* coalescing exists to keep small. */
|
||||||
|
readonly viewerWrites: { count: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMemoryViewerStore(
|
||||||
|
options: { readonly now?: () => Date } = {},
|
||||||
|
): MemoryViewerStore {
|
||||||
|
const now = options.now ?? (() => new Date());
|
||||||
|
const viewers = new Map<string, StoredViewer>();
|
||||||
|
const viewerWrites = { count: 0 };
|
||||||
|
|
||||||
|
return {
|
||||||
|
deleteViewer(viewerId: string): Promise<void> {
|
||||||
|
viewers.delete(viewerId);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
findViewer(viewerId: string): Promise<StoredViewer | undefined> {
|
||||||
|
return Promise.resolve(viewers.get(viewerId));
|
||||||
|
},
|
||||||
|
|
||||||
|
findViewerByKey(viewerKey: string): Promise<StoredViewer | undefined> {
|
||||||
|
for (const viewer of viewers.values()) {
|
||||||
|
if (viewer.viewerKey === viewerKey) {
|
||||||
|
return Promise.resolve(viewer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
insertViewer(viewer: NewStoredViewer): Promise<void> {
|
||||||
|
viewerWrites.count += 1;
|
||||||
|
viewers.set(viewer.id, { ...viewer, createdAt: now(), lastError: null, lastSeenAt: null });
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
listViewers(): Promise<readonly StoredViewer[]> {
|
||||||
|
return Promise.resolve(
|
||||||
|
[...viewers.values()].sort((left, right) => left.name.localeCompare(right.name)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateViewer(viewerId: string, changes: ViewerChanges): Promise<void> {
|
||||||
|
const viewer = viewers.get(viewerId);
|
||||||
|
if (!viewer) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
viewerWrites.count += 1;
|
||||||
|
const next: Record<string, unknown> = { ...viewer };
|
||||||
|
for (const [key, value] of Object.entries(changes)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
next[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewers.set(viewerId, next as unknown as StoredViewer);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
viewers,
|
||||||
|
viewerWrites,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
type CapabilityId,
|
||||||
|
EMPTY_MODULE_CONFIGURATION,
|
||||||
|
EMPTY_MODULE_STORAGE,
|
||||||
|
type ModuleConfiguration,
|
||||||
|
type ModuleRouteDefinitionInput,
|
||||||
|
type ModuleRouteHandlerContext,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
type RegisteredCapability,
|
||||||
|
} from "@ekklesia/module-sdk";
|
||||||
|
import moduleRuntime from "../../src/server/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The module-layer test harness: everything a Signage unit test needs to exercise the real runtime
|
||||||
|
* definition without a platform.
|
||||||
|
*
|
||||||
|
* Every object here is a `@ekklesia/contracts` interface constructed by hand, which is exactly what
|
||||||
|
* a module-layer test may legitimately do — a module may never import Core. It also demonstrates the
|
||||||
|
* portability claim directly: the host producing these objects could be Ekklesia, or a future
|
||||||
|
* standalone host reading YAML from disk, and Signage cannot tell the difference.
|
||||||
|
*/
|
||||||
|
export interface CapturedRegistration {
|
||||||
|
readonly channels: { channel: string; permission: string }[];
|
||||||
|
readonly provided: Map<CapabilityId, unknown>;
|
||||||
|
readonly routes: ModuleRouteDefinitionInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerModule(
|
||||||
|
config: ModuleConfiguration = EMPTY_MODULE_CONFIGURATION,
|
||||||
|
): Promise<CapturedRegistration> {
|
||||||
|
const channels: { channel: string; permission: string }[] = [];
|
||||||
|
const provided = new Map<CapabilityId, unknown>();
|
||||||
|
const routes: ModuleRouteDefinitionInput[] = [];
|
||||||
|
await moduleRuntime.register?.({
|
||||||
|
capabilities: {
|
||||||
|
provide(capabilityId, implementation) {
|
||||||
|
provided.set(capabilityId, implementation);
|
||||||
|
},
|
||||||
|
resolve(_capabilityId: CapabilityId): RegisteredCapability | undefined {
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
http: {
|
||||||
|
route(definition) {
|
||||||
|
routes.push(definition);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moduleId: "signage",
|
||||||
|
realtime: {
|
||||||
|
channel(definition) {
|
||||||
|
channels.push({ channel: definition.channel, permission: definition.permission });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { channels, provided, routes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRoute(
|
||||||
|
registration: CapturedRegistration,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
): ModuleRouteDefinitionInput {
|
||||||
|
const route = registration.routes.find(
|
||||||
|
(candidate) => candidate.method === method && candidate.path === path,
|
||||||
|
);
|
||||||
|
if (!route) {
|
||||||
|
throw new Error(`Signage did not register ${method} ${path}.`);
|
||||||
|
}
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fakeConfiguration(values: Readonly<Record<string, unknown>>): ModuleConfiguration {
|
||||||
|
const read = <Value>(key: string, kind: string): Value | undefined => {
|
||||||
|
const value = values[key];
|
||||||
|
return typeof value === kind ? (value as Value) : undefined;
|
||||||
|
};
|
||||||
|
return Object.freeze({
|
||||||
|
get: (key: string) => values[key] as never,
|
||||||
|
getBoolean: (key: string) => read<boolean>(key, "boolean"),
|
||||||
|
getEnum: (key: string) => read<string>(key, "string"),
|
||||||
|
getInteger: (key: string) => read<number>(key, "number"),
|
||||||
|
getSecret: (key: string) => read<string>(key, "string"),
|
||||||
|
getString: (key: string) => read<string>(key, "string"),
|
||||||
|
getUrl: (key: string) => read<string>(key, "string"),
|
||||||
|
has: (key: string) => Object.hasOwn(values, key),
|
||||||
|
keys: () => Object.freeze(Object.keys(values)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fakeHandlerContext(
|
||||||
|
overrides: Partial<ModuleRouteHandlerContext> = {},
|
||||||
|
): ModuleRouteHandlerContext {
|
||||||
|
const services = { database: undefined } as unknown as ModuleRuntimeServices;
|
||||||
|
return Object.freeze({
|
||||||
|
config: EMPTY_MODULE_CONFIGURATION,
|
||||||
|
moduleId: "signage",
|
||||||
|
params: Object.freeze({}),
|
||||||
|
principal: ANONYMOUS_PRINCIPAL,
|
||||||
|
realtime: { publish: () => undefined },
|
||||||
|
services,
|
||||||
|
storage: EMPTY_MODULE_STORAGE,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { SignageError } from "../src/server/errors.js";
|
||||||
|
import type { StoredTimelineDetail } from "../src/server/store/types.js";
|
||||||
|
import {
|
||||||
|
addProviderSource,
|
||||||
|
addSlot,
|
||||||
|
createTimeline,
|
||||||
|
deleteTimeline,
|
||||||
|
duplicateTimeline,
|
||||||
|
removeProviderSource,
|
||||||
|
removeSlot,
|
||||||
|
reorderSlots,
|
||||||
|
updateSlot,
|
||||||
|
updateTimeline,
|
||||||
|
} from "../src/server/timelines/service.js";
|
||||||
|
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
|
||||||
|
|
||||||
|
async function expectSignageCode(promise: Promise<unknown>, code: string): Promise<SignageError> {
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(SignageError);
|
||||||
|
const signageError = error as SignageError;
|
||||||
|
expect(signageError.code).toBe(code);
|
||||||
|
return signageError;
|
||||||
|
}
|
||||||
|
throw new Error(`Expected ${code}, but the operation succeeded.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("timeline management", () => {
|
||||||
|
let store: MemorySignageStore;
|
||||||
|
let counter: number;
|
||||||
|
|
||||||
|
const newId = () => {
|
||||||
|
counter += 1;
|
||||||
|
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = createMemorySignageStore();
|
||||||
|
counter = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedTimeline(name = "Sunday morning"): Promise<StoredTimelineDetail> {
|
||||||
|
return createTimeline({ name, newId, store });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pattern from the product definition: three announcement slots, one trivia, two more
|
||||||
|
* announcements, one scripture — with the five announcement occurrences sharing one source. */
|
||||||
|
async function seedPattern(): Promise<StoredTimelineDetail> {
|
||||||
|
let detail = await seedTimeline();
|
||||||
|
const timelineId = detail.timeline.id;
|
||||||
|
detail = await addProviderSource({
|
||||||
|
label: "Active announcements",
|
||||||
|
newId,
|
||||||
|
providerId: "fixture.announcements",
|
||||||
|
sourceId: "active-slides",
|
||||||
|
store,
|
||||||
|
timelineId,
|
||||||
|
});
|
||||||
|
detail = await addProviderSource({
|
||||||
|
label: "Open questions",
|
||||||
|
newId,
|
||||||
|
providerId: "fixture.trivia",
|
||||||
|
sourceId: "open-questions",
|
||||||
|
store,
|
||||||
|
timelineId,
|
||||||
|
});
|
||||||
|
detail = await addProviderSource({
|
||||||
|
label: "Reading plan",
|
||||||
|
newId,
|
||||||
|
providerId: "fixture.scripture",
|
||||||
|
sourceId: "reading-plan",
|
||||||
|
store,
|
||||||
|
timelineId,
|
||||||
|
});
|
||||||
|
const [announcements, trivia, scripture] = detail.sources;
|
||||||
|
if (!announcements || !trivia || !scripture) {
|
||||||
|
throw new Error("Expected three sources.");
|
||||||
|
}
|
||||||
|
for (const source of [
|
||||||
|
announcements,
|
||||||
|
announcements,
|
||||||
|
announcements,
|
||||||
|
trivia,
|
||||||
|
announcements,
|
||||||
|
announcements,
|
||||||
|
scripture,
|
||||||
|
]) {
|
||||||
|
detail = await addSlot({
|
||||||
|
newId,
|
||||||
|
source: { sourceId: source.id },
|
||||||
|
store,
|
||||||
|
timelineId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("creates a timeline on demand, with no preset pattern of any kind", async () => {
|
||||||
|
const detail = await seedTimeline("Christmas Eve");
|
||||||
|
|
||||||
|
expect(detail.timeline.name).toBe("Christmas Eve");
|
||||||
|
expect(detail.timeline.revision).toBe(1);
|
||||||
|
// No slots, no sources, no "Sunday Morning" a church never asked for.
|
||||||
|
expect(detail.slots).toEqual([]);
|
||||||
|
expect(detail.sources).toEqual([]);
|
||||||
|
expect(await store.listTimelines()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets five slots share one source, so they are one rotation rather than five", async () => {
|
||||||
|
const detail = await seedPattern();
|
||||||
|
const announcements = detail.sources[0];
|
||||||
|
|
||||||
|
expect(detail.slots).toHaveLength(7);
|
||||||
|
expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||||
|
// Five occurrences, one selection scope. The provider advances that one scope each time.
|
||||||
|
const announcementSlots = detail.slots.filter((slot) => slot.sourceId === announcements?.id);
|
||||||
|
expect(announcementSlots).toHaveLength(5);
|
||||||
|
expect(new Set(announcementSlots.map((slot) => slot.sourceId)).size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps positions unique and contiguous through every kind of edit", async () => {
|
||||||
|
let detail = await seedPattern();
|
||||||
|
const timelineId = detail.timeline.id;
|
||||||
|
|
||||||
|
// Insert in the middle.
|
||||||
|
const source = detail.sources[1];
|
||||||
|
if (!source) {
|
||||||
|
throw new Error("Expected a source.");
|
||||||
|
}
|
||||||
|
detail = await addSlot({
|
||||||
|
newId,
|
||||||
|
position: 2,
|
||||||
|
source: { sourceId: source.id },
|
||||||
|
store,
|
||||||
|
timelineId,
|
||||||
|
});
|
||||||
|
expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
|
||||||
|
expect(detail.slots[2]?.sourceId).toBe(source.id);
|
||||||
|
|
||||||
|
// Remove from the middle.
|
||||||
|
const removed = detail.slots[3];
|
||||||
|
if (!removed) {
|
||||||
|
throw new Error("Expected a slot.");
|
||||||
|
}
|
||||||
|
detail = await removeSlot({ slotId: removed.id, store, timelineId });
|
||||||
|
expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||||
|
expect(detail.slots.some((slot) => slot.id === removed.id)).toBe(false);
|
||||||
|
|
||||||
|
// Reverse the whole order.
|
||||||
|
const reversed = [...detail.slots].reverse().map((slot) => slot.id);
|
||||||
|
detail = await reorderSlots({ orderedSlotIds: reversed, store, timelineId });
|
||||||
|
expect(detail.slots.map((slot) => slot.id)).toEqual(reversed);
|
||||||
|
expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a slot's identity across a reorder, so history and a display cursor survive it", async () => {
|
||||||
|
let detail = await seedPattern();
|
||||||
|
const before = detail.slots.map((slot) => slot.id);
|
||||||
|
|
||||||
|
detail = await reorderSlots({
|
||||||
|
orderedSlotIds: [...before].reverse(),
|
||||||
|
store,
|
||||||
|
timelineId: detail.timeline.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every id still exists. Only position changed.
|
||||||
|
expect(new Set(detail.slots.map((slot) => slot.id))).toEqual(new Set(before));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a reorder that is not exactly this timeline's slots", async () => {
|
||||||
|
const detail = await seedPattern();
|
||||||
|
const ids = detail.slots.map((slot) => slot.id);
|
||||||
|
const first = ids[0] as string;
|
||||||
|
|
||||||
|
for (const bad of [
|
||||||
|
ids.slice(1), // missing one
|
||||||
|
[...ids, first], // duplicated
|
||||||
|
[...ids.slice(1), "11111111-1111-4111-8111-111111111111"], // a stranger
|
||||||
|
]) {
|
||||||
|
await expectSignageCode(
|
||||||
|
reorderSlots({ orderedSlotIds: bad, store, timelineId: detail.timeline.id }),
|
||||||
|
"SIGNAGE_INVALID_REQUEST",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Nothing moved.
|
||||||
|
const after = await store.findTimeline(detail.timeline.id);
|
||||||
|
expect(after?.slots.map((slot) => slot.id)).toEqual(ids);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes every slot that referenced a deleted source, and closes the gap", async () => {
|
||||||
|
let detail = await seedPattern();
|
||||||
|
const announcements = detail.sources[0];
|
||||||
|
if (!announcements) {
|
||||||
|
throw new Error("Expected a source.");
|
||||||
|
}
|
||||||
|
|
||||||
|
detail = await removeProviderSource({
|
||||||
|
sourceId: announcements.id,
|
||||||
|
store,
|
||||||
|
timelineId: detail.timeline.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The five announcement occurrences went with it; trivia and scripture remain, renumbered.
|
||||||
|
expect(detail.slots).toHaveLength(2);
|
||||||
|
expect(detail.slots.map((slot) => slot.position)).toEqual([0, 1]);
|
||||||
|
expect(detail.sources).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bumps the revision on a semantic change and not on an exact no-op", async () => {
|
||||||
|
let detail = await seedTimeline("Sunday morning");
|
||||||
|
const initial = detail.timeline.revision;
|
||||||
|
|
||||||
|
detail = await updateTimeline({
|
||||||
|
changes: { name: "Sunday morning" },
|
||||||
|
store,
|
||||||
|
timelineId: detail.timeline.id,
|
||||||
|
});
|
||||||
|
expect(detail.timeline.revision).toBe(initial);
|
||||||
|
|
||||||
|
detail = await updateTimeline({
|
||||||
|
changes: { name: "Sunday evening" },
|
||||||
|
store,
|
||||||
|
timelineId: detail.timeline.id,
|
||||||
|
});
|
||||||
|
expect(detail.timeline.revision).toBe(initial + 1);
|
||||||
|
|
||||||
|
// A slot edit is a timeline change too: a client comparing revisions must be woken by it.
|
||||||
|
const withSource = await addProviderSource({
|
||||||
|
label: "Active announcements",
|
||||||
|
newId,
|
||||||
|
providerId: "fixture.announcements",
|
||||||
|
sourceId: "active-slides",
|
||||||
|
store,
|
||||||
|
timelineId: detail.timeline.id,
|
||||||
|
});
|
||||||
|
const source = withSource.sources[0];
|
||||||
|
if (!source) {
|
||||||
|
throw new Error("Expected a source.");
|
||||||
|
}
|
||||||
|
const withSlot = await addSlot({
|
||||||
|
newId,
|
||||||
|
source: { sourceId: source.id },
|
||||||
|
store,
|
||||||
|
timelineId: detail.timeline.id,
|
||||||
|
});
|
||||||
|
const slot = withSlot.slots[0];
|
||||||
|
if (!slot) {
|
||||||
|
throw new Error("Expected a slot.");
|
||||||
|
}
|
||||||
|
const beforeSlotEdit = withSlot.timeline.revision;
|
||||||
|
|
||||||
|
const unchanged = await updateSlot({
|
||||||
|
changes: { enabled: true },
|
||||||
|
slotId: slot.id,
|
||||||
|
store,
|
||||||
|
timelineId: detail.timeline.id,
|
||||||
|
});
|
||||||
|
expect(unchanged.timeline.revision).toBe(beforeSlotEdit);
|
||||||
|
|
||||||
|
const disabled = await updateSlot({
|
||||||
|
changes: { enabled: false },
|
||||||
|
slotId: slot.id,
|
||||||
|
store,
|
||||||
|
timelineId: detail.timeline.id,
|
||||||
|
});
|
||||||
|
expect(disabled.timeline.revision).toBe(beforeSlotEdit + 1);
|
||||||
|
expect(disabled.slots[0]?.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplicates a timeline as a genuinely separate program", async () => {
|
||||||
|
const original = await seedPattern();
|
||||||
|
|
||||||
|
const copy = await duplicateTimeline({
|
||||||
|
name: "Sunday evening",
|
||||||
|
newId,
|
||||||
|
store,
|
||||||
|
timelineId: original.timeline.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(copy.timeline.name).toBe("Sunday evening");
|
||||||
|
expect(copy.slots).toHaveLength(original.slots.length);
|
||||||
|
expect(copy.slots.map((slot) => slot.position)).toEqual(original.slots.map((s) => s.position));
|
||||||
|
// Every identity is new: a copy that shared them would share history and, worse, a rotation.
|
||||||
|
expect(copy.timeline.id).not.toBe(original.timeline.id);
|
||||||
|
for (const slot of copy.slots) {
|
||||||
|
expect(original.slots.some((other) => other.id === slot.id)).toBe(false);
|
||||||
|
}
|
||||||
|
const originalScopes = new Set(
|
||||||
|
[...store.sources.values()]
|
||||||
|
.filter((source) => source.timelineId === original.timeline.id)
|
||||||
|
.map((source) => source.selectionScope),
|
||||||
|
);
|
||||||
|
const copyScopes = [...store.sources.values()]
|
||||||
|
.filter((source) => source.timelineId === copy.timeline.id)
|
||||||
|
.map((source) => source.selectionScope);
|
||||||
|
expect(copyScopes).toHaveLength(3);
|
||||||
|
for (const scope of copyScopes) {
|
||||||
|
// The copy rotates independently: an evening loop does not continue the morning's cursor.
|
||||||
|
expect(originalScopes.has(scope)).toBe(false);
|
||||||
|
}
|
||||||
|
// The copy's slots still share one source the same way the original's did.
|
||||||
|
const copyAnnouncements = copy.slots.filter((slot) => slot.sourceId === copy.sources[0]?.id);
|
||||||
|
expect(copyAnnouncements).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to delete a timeline playback still points at", async () => {
|
||||||
|
const detail = await seedTimeline();
|
||||||
|
store.setTimelineUsage(detail.timeline.id, {
|
||||||
|
activeGroups: 1,
|
||||||
|
pendingGroups: 0,
|
||||||
|
scheduledGroups: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await expectSignageCode(
|
||||||
|
deleteTimeline({ store, timelineId: detail.timeline.id }),
|
||||||
|
"SIGNAGE_CONFLICT",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error.detail).toEqual({ activeGroups: 1, pendingGroups: 0, scheduledGroups: 1 });
|
||||||
|
expect(store.timelines.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes an unused timeline and everything inside it", async () => {
|
||||||
|
const detail = await seedPattern();
|
||||||
|
|
||||||
|
await deleteTimeline({ store, timelineId: detail.timeline.id });
|
||||||
|
|
||||||
|
expect(store.timelines.size).toBe(0);
|
||||||
|
expect(store.slots.size).toBe(0);
|
||||||
|
expect(store.sources.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a slot that is both a source and a pinned item, or neither", async () => {
|
||||||
|
const detail = await seedTimeline();
|
||||||
|
const withSource = await addProviderSource({
|
||||||
|
label: "Active announcements",
|
||||||
|
newId,
|
||||||
|
providerId: "fixture.announcements",
|
||||||
|
sourceId: "active-slides",
|
||||||
|
store,
|
||||||
|
timelineId: detail.timeline.id,
|
||||||
|
});
|
||||||
|
const source = withSource.sources[0];
|
||||||
|
if (!source) {
|
||||||
|
throw new Error("Expected a source.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await expectSignageCode(
|
||||||
|
addSlot({ newId, store, timelineId: detail.timeline.id }),
|
||||||
|
"SIGNAGE_INVALID_REQUEST",
|
||||||
|
);
|
||||||
|
await expectSignageCode(
|
||||||
|
addSlot({
|
||||||
|
newId,
|
||||||
|
pinned: { itemId: "welcome", providerId: "fixture.announcements" },
|
||||||
|
source: { sourceId: source.id },
|
||||||
|
store,
|
||||||
|
timelineId: detail.timeline.id,
|
||||||
|
}),
|
||||||
|
"SIGNAGE_INVALID_REQUEST",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a slot referencing a source configured on a different timeline", async () => {
|
||||||
|
const first = await seedTimeline("First");
|
||||||
|
const second = await seedTimeline("Second");
|
||||||
|
const withSource = await addProviderSource({
|
||||||
|
label: "Active announcements",
|
||||||
|
newId,
|
||||||
|
providerId: "fixture.announcements",
|
||||||
|
sourceId: "active-slides",
|
||||||
|
store,
|
||||||
|
timelineId: first.timeline.id,
|
||||||
|
});
|
||||||
|
const source = withSource.sources[0];
|
||||||
|
if (!source) {
|
||||||
|
throw new Error("Expected a source.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await expectSignageCode(
|
||||||
|
addSlot({ newId, source: { sourceId: source.id }, store, timelineId: second.timeline.id }),
|
||||||
|
"SIGNAGE_INVALID_REQUEST",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a pinned item as an opaque identity from another module", async () => {
|
||||||
|
const detail = await seedTimeline();
|
||||||
|
|
||||||
|
const withPinned = await addSlot({
|
||||||
|
newId,
|
||||||
|
pinned: { itemId: "advent-service", providerId: "fixture.announcements" },
|
||||||
|
store,
|
||||||
|
timelineId: detail.timeline.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(withPinned.slots[0]).toMatchObject({
|
||||||
|
itemId: "advent-service",
|
||||||
|
kind: "pinned-item",
|
||||||
|
providerId: "fixture.announcements",
|
||||||
|
sourceId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an inverted or empty eligibility window rather than silently never showing a slot", async () => {
|
||||||
|
const detail = await seedTimeline();
|
||||||
|
const from = new Date("2026-12-24T18:00:00.000Z");
|
||||||
|
const until = new Date("2026-12-24T17:00:00.000Z");
|
||||||
|
|
||||||
|
await expectSignageCode(
|
||||||
|
createTimeline({ eligibleFrom: from, eligibleUntil: until, name: "Bad", newId, store }),
|
||||||
|
"SIGNAGE_INVALID_REQUEST",
|
||||||
|
);
|
||||||
|
await expectSignageCode(
|
||||||
|
addSlot({
|
||||||
|
eligibleFrom: from,
|
||||||
|
eligibleUntil: from,
|
||||||
|
newId,
|
||||||
|
pinned: { itemId: "x", providerId: "p" },
|
||||||
|
store,
|
||||||
|
timelineId: detail.timeline.id,
|
||||||
|
}),
|
||||||
|
"SIGNAGE_INVALID_REQUEST",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses presentation values it cannot render, and stores the ones it can", async () => {
|
||||||
|
const detail = await seedTimeline();
|
||||||
|
|
||||||
|
await expectSignageCode(
|
||||||
|
addSlot({
|
||||||
|
newId,
|
||||||
|
pinned: { itemId: "x", providerId: "p" },
|
||||||
|
presentation: { backgroundColor: "red; background: url(x)" },
|
||||||
|
store,
|
||||||
|
timelineId: detail.timeline.id,
|
||||||
|
}),
|
||||||
|
"SIGNAGE_INVALID_REQUEST",
|
||||||
|
);
|
||||||
|
|
||||||
|
const stored = await addSlot({
|
||||||
|
newId,
|
||||||
|
pinned: { itemId: "x", providerId: "p" },
|
||||||
|
presentation: { backgroundColor: "#112233", backgroundMode: "solid", blurPixels: 30 },
|
||||||
|
store,
|
||||||
|
timelineId: detail.timeline.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stored.slots[0]?.presentation).toEqual({
|
||||||
|
backgroundColor: "#112233",
|
||||||
|
backgroundDarkened: null,
|
||||||
|
backgroundMode: "solid",
|
||||||
|
blurPixels: 30,
|
||||||
|
foregroundShadow: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a duration outside what a slide can usefully be", async () => {
|
||||||
|
const detail = await seedTimeline();
|
||||||
|
|
||||||
|
for (const durationOverrideMs of [0, 999, 3_600_001, 1.5]) {
|
||||||
|
await expectSignageCode(
|
||||||
|
addSlot({
|
||||||
|
durationOverrideMs,
|
||||||
|
newId,
|
||||||
|
pinned: { itemId: "x", providerId: "p" },
|
||||||
|
store,
|
||||||
|
timelineId: detail.timeline.id,
|
||||||
|
}),
|
||||||
|
"SIGNAGE_INVALID_REQUEST",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an unknown timeline as not found without saying whether it ever existed", async () => {
|
||||||
|
await expectSignageCode(
|
||||||
|
updateTimeline({
|
||||||
|
changes: { name: "x" },
|
||||||
|
store,
|
||||||
|
timelineId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
}),
|
||||||
|
"SIGNAGE_NOT_FOUND",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user