From 29187a90df2a432b8e06384251aa0f7d582f7c92 Mon Sep 17 00:00:00 2001 From: Zac Date: Sat, 30 May 2026 13:17:33 +0000 Subject: [PATCH] =?UTF-8?q?feat(mam-api):=20migration=20029=20=E2=80=94=20?= =?UTF-8?q?playout=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../mam-api/src/db/migrations/029-playout.sql | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 services/mam-api/src/db/migrations/029-playout.sql diff --git a/services/mam-api/src/db/migrations/029-playout.sql b/services/mam-api/src/db/migrations/029-playout.sql new file mode 100644 index 0000000..541f455 --- /dev/null +++ b/services/mam-api/src/db/migrations/029-playout.sql @@ -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);