-- 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);