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 { 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(); const group = groups.find((candidate) => candidate.group.id === groupId); if (!group) { throw new SignageError("SIGNAGE_NOT_FOUND"); } if (groups.length <= 1) { // An installation always has somewhere for a viewer to land. throw new SignageError("SIGNAGE_CONFLICT", { reason: "last-group" }); } const fallbackGroup = groups.find((candidate) => candidate.group.id !== groupId); const fallbackChannel = fallbackGroup?.channels.find((channel) => channel.isDefault); if (!fallbackChannel) { throw new SignageError("SIGNAGE_CONFLICT", { reason: "no-fallback-channel" }); } await store.deleteChannelGroup(groupId, fallbackChannel.id); 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, context: ModuleRouteHandlerContext, ) => Promise, 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 }, ); }