feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+291
View File
@@ -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 },
);
}