feat(mam-api): migration 029 — playout schema

Six tables: channels, playlists, items, sidecars (sidecar registry for
health-check), schedule (Phase B), as-run log.

- video_format default 1080p5994 (house standard, capture cadence)
- restart_count / last_restart_at / last_heartbeat_at on channels for
  auto-failover bookkeeping
- audio_normalized flag on items so re-stages skip the loudnorm pass
- unique partial index on (channel_id) for running sidecars
This commit is contained in:
Zac 2026-05-30 13:17:33 +00:00
parent 512267159a
commit 29187a90df

View file

@ -0,0 +1,165 @@
-- Migration 029 — Playout / Master Control (MCR).
--
-- Adds a broadcast playout subsystem: take library assets, arrange them on a
-- playlist (Phase A) or a wall-clock timeline (Phase B), and play them out
-- continuously to SDI (DeckLink) / NDI / SRT / RTMP via a CasparCG sidecar.
--
-- This is the mirror of the capture path (input -> ffmpeg -> S3). A channel is
-- placed on a cluster node by capability the same way recorders claim input
-- ports; the engine container is spawned via the same Docker-socket /
-- node-agent orchestration. See docs/superpowers/specs/2026-05-30-playout-mcr-design.md.
--
-- Tables:
-- playout_channels — a logical output (one channel -> one CasparCG instance -> one target)
-- playout_playlists — an ordered list of items bound to a channel (Phase A)
-- playout_items — one clip on a playlist OR one row on the timeline
-- playout_sidecars — running CasparCG sidecar registry (one per channel; health-checked)
-- playout_schedule — wall-clock day-ahead rows (Phase B; unused in A)
-- playout_as_run — append-only log of what actually played (compliance)
-- ── Channels ───────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS playout_channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
node_id UUID REFERENCES cluster_nodes(id) ON DELETE SET NULL,
output_type TEXT NOT NULL DEFAULT 'srt',
-- output_config is consumer-shape-specific:
-- decklink: { "device_index": 1 }
-- ndi: { "ndi_name": "DRAGONFLIGHT CH1" }
-- srt: { "url": "srt://host:9000", "latency": 200 }
-- rtmp: { "url": "rtmp://host/live", "key": "streamkey" }
output_config JSONB NOT NULL DEFAULT '{}'::jsonb,
-- 1080p59.94 is the house standard (matches capture cadence, streaming-friendly,
-- accepted by current SDI gear). Per-channel override allowed.
video_format TEXT NOT NULL DEFAULT '1080p5994',
status TEXT NOT NULL DEFAULT 'stopped',
container_id TEXT,
-- For remote channels the node-agent reports the reachable host:port of the
-- sidecar HTTP shim; stored here so the API can proxy transport calls.
container_meta JSONB NOT NULL DEFAULT '{}'::jsonb,
error_message TEXT,
-- Failover bookkeeping. Scheduler tick health-checks the sidecar; on N missed
-- checks the channel is re-placed on a healthy node (auto for ndi/srt/rtmp,
-- alert-only for decklink — device-index pinning makes re-placement non-trivial).
restart_count INTEGER NOT NULL DEFAULT 0,
last_restart_at TIMESTAMPTZ,
last_heartbeat_at TIMESTAMPTZ,
-- RBAC scoping: a NULL project_id resolves to admin-only (authz.js), the same
-- convention recorders use for unassigned resources.
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (output_type IN ('decklink','ndi','srt','rtmp')),
CHECK (status IN ('stopped','starting','running','error'))
);
CREATE INDEX IF NOT EXISTS idx_playout_channels_node ON playout_channels (node_id);
CREATE INDEX IF NOT EXISTS idx_playout_channels_project ON playout_channels (project_id);
-- ── Playlists ──────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS playout_playlists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
name TEXT NOT NULL,
loop BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_playout_playlists_channel ON playout_playlists (channel_id);
-- ── Items ──────────────────────────────────────────────────────────────────
-- One entry on a playlist (Phase A, ordered by sort_order) OR one entry on the
-- timeline (Phase B, ordered by scheduled_at). in/out points reuse the editor's
-- subclip trim model (seconds). media_status tracks the S3 -> /media staging
-- (see playout-stage worker job); a clip cannot go on air until 'ready'.
CREATE TABLE IF NOT EXISTS playout_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
playlist_id UUID REFERENCES playout_playlists(id) ON DELETE CASCADE,
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
scheduled_at TIMESTAMPTZ,
in_point NUMERIC,
out_point NUMERIC,
transition TEXT NOT NULL DEFAULT 'cut',
transition_ms INTEGER NOT NULL DEFAULT 0,
graphics JSONB,
media_status TEXT NOT NULL DEFAULT 'pending',
media_path TEXT,
-- Set when playout-stage has run loudnorm (EBU R128, -23 LUFS / -1 dBTP) on
-- the staged file. Re-stages skip the loudnorm pass when true.
audio_normalized BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (transition IN ('cut','mix','wipe')),
CHECK (media_status IN ('pending','staging','ready','error'))
);
CREATE INDEX IF NOT EXISTS idx_playout_items_playlist ON playout_items (playlist_id, sort_order);
CREATE INDEX IF NOT EXISTS idx_playout_items_asset ON playout_items (asset_id);
-- ── Sidecars ───────────────────────────────────────────────────────────────
-- Running CasparCG container registry, one row per running channel. The
-- scheduler tick (src/scheduler.js) pings each sidecar's /status endpoint and
-- updates last_heartbeat_at; missed checks trigger the failover path in
-- routes/playout.js.
CREATE TABLE IF NOT EXISTS playout_sidecars (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
node_id UUID REFERENCES cluster_nodes(id) ON DELETE SET NULL,
container_id TEXT NOT NULL,
sidecar_url TEXT, -- http://host:port for the shim
amcp_port INTEGER, -- in-container AMCP port (default 5250)
status TEXT NOT NULL DEFAULT 'running',
last_heartbeat_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (status IN ('starting','running','error','stopped'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_playout_sidecars_channel ON playout_sidecars (channel_id)
WHERE status IN ('starting','running');
CREATE INDEX IF NOT EXISTS idx_playout_sidecars_status ON playout_sidecars (status);
-- ── Schedule (Phase B) ─────────────────────────────────────────────────────
-- Wall-clock day-ahead timeline. The scheduler tick (src/scheduler.js, under
-- the existing PG advisory lock) drives transitions and gap-fill. Unused by the
-- Phase A playlist player but created now so the schema is stable.
CREATE TABLE IF NOT EXISTS playout_schedule (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
asset_id UUID REFERENCES assets(id) ON DELETE SET NULL,
scheduled_at TIMESTAMPTZ NOT NULL,
in_point NUMERIC,
out_point NUMERIC,
transition TEXT NOT NULL DEFAULT 'cut',
transition_ms INTEGER NOT NULL DEFAULT 0,
is_filler BOOLEAN NOT NULL DEFAULT FALSE,
status TEXT NOT NULL DEFAULT 'scheduled',
media_status TEXT NOT NULL DEFAULT 'pending',
media_path TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (transition IN ('cut','mix','wipe')),
CHECK (status IN ('scheduled','playing','played','skipped','error')),
CHECK (media_status IN ('pending','staging','ready','error'))
);
CREATE INDEX IF NOT EXISTS idx_playout_schedule_channel_time ON playout_schedule (channel_id, scheduled_at);
CREATE INDEX IF NOT EXISTS idx_playout_schedule_status ON playout_schedule (status, scheduled_at);
-- ── As-run log ─────────────────────────────────────────────────────────────
-- Append-only record of what actually went to air. Never updated after insert.
CREATE TABLE IF NOT EXISTS playout_as_run (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
asset_id UUID REFERENCES assets(id) ON DELETE SET NULL,
item_id UUID,
clip_name TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
duration_s NUMERIC,
result TEXT NOT NULL DEFAULT 'played',
CHECK (result IN ('played','skipped','error'))
);
CREATE INDEX IF NOT EXISTS idx_playout_as_run_channel ON playout_as_run (channel_id, started_at DESC);