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:
parent
512267159a
commit
29187a90df
1 changed files with 165 additions and 0 deletions
165
services/mam-api/src/db/migrations/029-playout.sql
Normal file
165
services/mam-api/src/db/migrations/029-playout.sql
Normal 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);
|
||||
Loading…
Reference in a new issue