Compare commits

..

74 commits

Author SHA1 Message Date
d908c0c056 fix(playout+capture+monitors): preview recovery, SMB UNC, playout monitors
Playout preview: add hls.js ERROR handler (recover media/network errors,
resume on stall) + live-edge tuning — first transient error no longer halts
<video> to black. Purge stale HLS segments before re-mux (re)start so a
prior/duplicate sidecar session can't corrupt the live playlist.

Growing files: normalize growing_smb_mount (smb://, \host\share, host/share)
to CIFS UNC //host/share in capture-manager — mount no longer fails and
falls back to S3.

Monitors: surface playout channels as monitor tiles (live HLS on-air,
idle placeholder otherwise) in a labeled Playout group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:56:45 -04:00
b597ffd58e Merge redesign/panel-icon-rail into main (superseded)
The UXP panel icon-rail redesign (v2.2.2 .ccx + premiere-plugin-uxp src) is
already present in main, identical, via later panel work. The branch's only
remaining delta is a stale screens-admin/editor/data.jsx (~685 lines behind
main's newer admin code, incl. the Add Node wizard). Recorded as merged with
-s ours to avoid reverting newer work; main tree unchanged.
2026-05-31 17:48:44 -04:00
be8fd691a5 Merge fix/srt-rtmp-thumbnail into main (superseded)
All fixes from this May-17 branch (SRT/RTMP ffmpeg spawn, yuv420p thumbnail
pix_fmt, proxy-from-hires enqueue, recorder field aliases) already exist in
main via later work. Recorded as merged with -s ours; main tree unchanged.
2026-05-31 17:47:52 -04:00
dba3435e60 Merge feat/all-intra-hevc-ingest into main
Rebuild capture ffmpeg with nv-codec-headers + NVENC/CUVID for All-Intra HEVC.

# Conflicts:
#	services/capture/Dockerfile
2026-05-31 17:46:59 -04:00
43011bd794 Merge feat/playout-mcr into main
Playout/MCR, as-run log, redesigned dashboard, capture CIFS/growing-files,
SDI settings, cluster Add Node wizard, homepage refresh.

# Conflicts:
#	services/mam-api/src/routes/cluster.js
#	services/mam-api/src/routes/playout.js
#	services/mam-api/src/scheduler.js
#	services/playout/Dockerfile
#	services/playout/entrypoint.sh
#	services/web-ui/public/screens-home.jsx
2026-05-31 17:46:12 -04:00
19f0abeabe feat(cluster+home): Add Node wizard, homepage tagline/logo/settings tweaks
Cluster: AddNodeModal on Admin->Cluster mints a node token via /auth/tokens
and emits a ready-to-paste curl|bash onboarding command. New admin-only
GET /cluster/onboard-info returns apiUrl/scriptUrl/branch. Role->PROFILES
mapping (worker/capture/gpu); gate worker-l4 behind compose profile [gpu].

Home: restore "Let's Create" kicker + one-line "Media Asset Management &
Production Platform" tagline; animated accent pulse behind the dragon logo
(reduced-motion safe); move Settings tile to a centered bottom row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:37:37 -04:00
f21bc490e8 feat(web-ui): redesigned Dashboard + playout as-run log
Dashboard (screens-home.jsx): rebuild to new design, fully live-wired.
Dropped fabricated figures per "real data" rule (object-store %, uptime,
storage breakdown); repurposed ingest cell to real Assets-24h count.
Fixed undefined refs and double-rendered Resources section.

Playout: as-run writer in scheduler.js writeAsRun() off the health-tick
/status poll; AsRunPanel UI + missing CSS in styles-playout.css.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:15:32 -04:00
7451d7c703 fix(playout): lighter 360p/20fps preview encode so the CPU re-mux keeps up
The 720p30 libx264 preview re-encode couldn't sustain real time on the
CPU-only sidecar (running alongside CasparCG's mixer + STREAM consumer):
the UDP input overran and the HLS output stalled, freezing the playlist so
hls.js saw a static live edge (monitor black). Drop the confidence monitor
to 360p / 20fps / ultrafast (-g 40 = 2.0s GOP) — a fraction of the cost,
sustains real time comfortably. NVENC would be ideal but the image's static
ffmpeg has no nvenc encoder; 360p ultrafast is plenty for a preview.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:15:16 -04:00
d3ad2397fb fix(playout): serve preview m3u8 via /api to bypass the proxy's static cache
Root cause of the persistent black preview, fully isolated: ZAMPP1's nginx
serves the live .m3u8 fresh on every request (no-store works there), but
the PUBLIC reverse proxy (159.112.211.103 -> ZAMPP1) caches the static
.m3u8 by path with a multi-second TTL, ignoring both the origin's no-store
and query params. hls.js reloads the playlist ~every second, always landing
inside that TTL, so it sees the live playlist as never advancing
("live playlist MISSED" forever), never establishes the timeline, and never
loads a fragment -> readyState 0 (black). Proven: rapid reads via ZAMPP1
localhost advance (404->405); the same rapid reads via the public URL are
stuck; query-busting doesn't help (proxy caches by path).

Fix: serve the playlist through GET /api/v1/playout/channels/:id/hls/index.m3u8
instead of the static /media/live path. /api/ is not proxy-cached (the live
status poll already updates fine through it), so hls.js always gets the fresh
live edge. Segment (.ts) lines are rewritten to absolute /media/live/<id>/
URLs so they still load from the static path (immutable; caching them is
correct). ProgramMonitor points hls.js at the /api playlist and sends the
session cookie (xhrSetup withCredentials) since /api is auth-gated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:57:41 -04:00
24d10fda5d fix(playout/web-ui): no-store on live HLS m3u8 so hls.js sees the live edge
Definitive root cause of the black preview, proven in-browser: the live
.m3u8 was served Cache-Control: no-cache, so the browser cached the
playlist and served a STALE copy to hls.js's reloads (cache:'default'
stuck at one MEDIA-SEQUENCE while cache:'reload' advanced). hls.js saw the
live playlist as never advancing -> "live playlist MISSED" forever ->
never established the timeline -> never loaded a fragment -> readyState 0
(black), even though the stream itself is clean and advancing server-side.

Fix: serve live HLS (/live and /media/live) with
"no-store, no-cache, must-revalidate" + Pragma no-cache so the browser
never caches the playlist and every reload fetches the fresh live edge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:45:28 -04:00
59551f28a5 fix(playout): re-encode HLS preview to uniform 2s segments so hls.js syncs
The audio-strip fix made the stream decode cleanly server-side, but the
browser monitor was still black: with -c:v copy the re-mux passed through
CasparCG's erratic real-time keyframes (segments 0.6-2.8s) and irregular
PTS. hls.js can't build a live timeline from that — it logs
"sliding 0.00 / prev-sn na / MISSED" and never loads a fragment (verified
live in-browser: readyState stays 0). A standalone ffmpeg honours
-force_key_frames, so re-encode to libx264 with a forced 2s GOP + CFR
fps=30, scaled to 720p for a light confidence monitor. Every HLS segment
is now a clean, keyframe-aligned 2.0s chunk hls.js can sync to.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:22:21 -04:00
e0e0b83810 fix(assets): live-path no longer gates on removed global growing_enabled
Growing-files mode became per-recorder (recorders.growing_enabled); the
global growing_enabled setting was removed. GET /assets/:id/live-path —
which the Premiere plugin calls to mount a still-growing master — still
required growing_enabled==='true', so Mount Live would 409 "Growing-files
mode is disabled" on any deploy where that stale key isn't set. Drop the
global gate: a status='live' asset already proves a growing recorder is
producing the file; only the editor-facing growing_smb_url is required.
Response contract is unchanged, so the plugin needs no update.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:05:06 -04:00
5968d4f681 feat(settings/growing): storage warning, SMB auth + CIFS mount, per-recorder growing
Implements docs/superpowers/specs/2026-05-31-storage-settings-growing-smb-design.md.

1. Storage warning banner at the top of Settings → Storage (set-once /
   path-change-corrupts-data warning).

2. Growing-files SMB credentials + system CIFS mount (Approach A):
   - settings.js: new global keys growing_smb_mount / growing_smb_username /
     growing_smb_vers; growing_smb_password is write-only (GET returns only
     growing_smb_password_exists; growing_smb_password_clear:true removes it).
   - GrowingSettingsCard: SMB mount/username/password (masked, "saved" state) +
     CIFS version fields.
   - capture Dockerfile: add cifs-utils + util-linux.
   - capture-manager: on growing start, mount //host/share at /growing using a
     root-only credentials file (creds never on the command line); unmount on
     stop; mount failure falls back to S3 streaming so a recording is never lost.
   - recorders.js: pass GROWING_SMB_* env; don't host-bind /growing when a CIFS
     mount is configured (an empty mountpoint is required).

3. Per-recorder growing mode (global toggle removed):
   - Removed the global "capture writes to local SMB share first" checkbox; the
     growing card is now SMB-infrastructure-only.
   - recorders.js reads the per-recorder recorders.growing_enabled column
     (already present from migration 014) instead of the global setting;
     RECORDER_FIELDS += growing_enabled.
   - New-recorder modal: "Growing-files mode" toggle.
   - storage.js overview: "enabled" now means the SMB landing zone is configured
     (mount source set), surfaced as smb_mount; health strip labels updated.

No DB migration required (recorders.growing_enabled exists; new settings are
key/value rows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:50:36 -04:00
3122dfd1b9 docs(settings): storage warning + growing SMB auth + per-recorder growing design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:50:36 -04:00
3b2f9fe6a0 fix(playout): strip comments from casparcg.config (parser aborts on them)
CasparCG 2.4.0's config parser aborts at startup (SIGABRT, exit 134) on
nearly every launch when the config contains XML comments / blank lines.
Proven empirically: identical image + comment-free config = 8/8 stable
starts; + commented config = 0/8 (all abort before logging init). Use
the compact comment-free form that the known-good image shipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:32:08 -04:00
869ae1aa83 fix(playout): use static ffmpeg, not apt, to avoid CasparCG SIGABRT
apt-get install ffmpeg pulls in ~80 transitive shared libs (libav*,
libx264, libdrm, libva...) that perturb CasparCG 2.4.0's headless
runtime linking and make it abort with SIGABRT (exit 134) on almost
every launch. Replace it with john van sickle's self-contained static
ffmpeg/ffprobe binaries in /usr/local/bin — the standalone CLI the HLS
re-muxer needs, with zero new shared libraries, keeping CasparCG's
environment identical to the known-good image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:18:56 -04:00
abfbd034ab fix(playout): point CasparCG log/data paths at writable /media volume
The 2.4.x server aborts at startup if its configured log-path isn't
writable/creatable. /opt/casparcg is a read-only-ish symlinked install
dir; the entrypoint already mkdirs /media/casparcg/{log,data}. Point the
config there to match (the working image used these paths).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:11:00 -04:00
739d08d4b5 fix(web-ui): restore home screen icon fixes on feat/playout-mcr
playout tile: monitor -> signal, dashboard tile: home -> layout.
All feature-branch content (Dragon-ISO, tagline, etc.) preserved.
2026-05-31 13:56:19 -04:00
27a868aa5c fix(playout): clean video-only HLS preview via standalone ffmpeg re-mux
CasparCG's bundled FFMPEG/HLS consumer muxes a broken audio track
(aac sample_rate=0, time_base 1/0) into the preview, and silently drops
every arg that would remove it (-an, -codec:a, -g, -r all "Unused
option"). That corrupt audio black-screens the browser preview because
neither ffmpeg nor hls.js can decode the playlist.

Re-architect the preview path: CasparCG now STREAMs plain mpegts to a
UDP loopback port, and a Node-spawned STANDALONE ffmpeg (where -an
actually works) re-muxes it to clean, video-only HLS with -c:v copy.
The child process is tracked, auto-respawned while running, and killed
in stopChannel(). The PRIMARY SRT/RTMP/SDI/NDI output (with program
audio) is untouched.

Also fix the Dockerfile to match the working image: ubuntu:22.04 base +
CasparCG 2.4.0 ubuntu22 zip + NodeSource Node 20, and add a standalone
ffmpeg CLI. The old 2.3.3 tarball URL 404s. entrypoint.sh updated for
the 2.4.x bin/casparcg layout + bundled lib/ LD_LIBRARY_PATH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 13:55:18 -04:00
1db0e81efb fix(web-ui): restore nav icon fixes on feat/playout-mcr
youtube: import, schedule: clock, playout: signal.
Keeps getting wiped by other agents writing full file replacements.
2026-05-31 13:54:16 -04:00
40e987b4a2 fix(web-ui): restore icon audit fixes on feat/playout-mcr
jobs: bulleted list (not hamburger), import: added, grid: rx=1,
hdd: cylinder, proxy: sliders. Keeps getting wiped by other agents.
2026-05-31 13:52:37 -04:00
426273129d fix(playout): video-only HLS preview (broken audio time_base was the black-screen cause)
Definitive root cause of the black preview, found via server-side ffmpeg
decode of the live playlist:

  Error while decoding stream #0:1: Invalid data found (x57)
  [abuffer] Value inf for parameter 'time_base' ... time_base to value 1/0

Stream #0:1 is the AAC audio. CasparCG's real-time channel feeds the HLS
consumer an audio stream whose muxed time_base is 1/0 (infinity). ffmpeg
itself cannot decode the playlist, and hls.js silently fails to append the
fragment after demux, so the <video> stays at readyState 0 (black) even
though the video PTS is perfectly continuous and segments serve 200.

Fix: drop audio from the HLS confidence monitor (-an). The video track is
clean h264 and plays in hls.js. Program audio still rides the primary
SRT/RTMP/SDI/NDI output, which is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 13:44:45 -04:00
87d988810f fix(playout): use CFR rate + frame GOP for uniform HLS segments
CasparCG's FFMPEG consumer ignores -force_key_frames ("Unused option")
because it routes args to the muxer, not the encoder. Revert to the
frame-based GOP (-g 60 -keyint_min 60) but keep the forced CFR rate
(-r 30000/1001): at 29.97fps a 60-frame GOP is exactly 2.0s, so keyframes
and HLS splits land on clean 2s boundaries. CFR is what was missing
originally — with the channel's irregular feed rate, "60 frames" drifted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 13:38:21 -04:00
f28799317d fix(playout): clean CFR HLS preview so hls.js can sync
Root cause of the black preview: CasparCG's real-time channel feeds the
HLS consumer frames with irregular timestamps (the "packet with pts X has
duration 0" warnings). With frame-count GOPs (-g 60) the muxer split
points drift, producing erratic segment durations (0.4s-4.2s) that exceed
the declared TARGETDURATION. hls.js parses the resulting live playlist but
can never establish a fragment timeline — it reloads forever
("sliding 0.00 / prev-sn na / MISSED") and never appends a fragment, so
the video element stays at readyState 0 (black). Verified live via the
browser: manifest + segments serve 200, segment is valid h264/aac with a
keyframe start, yet hls.js logs zero FRAG_LOADED.

Fix: force a constant output frame rate (-r 30000/1001, regenerates
uniform PTS) and time-based keyframes every 2s (-force_key_frames
expr:gte(t,n_forced*2)), so every segment is a clean keyframe-aligned 2.0s
chunk. Yields a spec-compliant playlist (TARGETDURATION 2, stable
8-segment/16s window) identical in shape to the capture/VOD HLS the rest
of the app already plays successfully through the same hls.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 13:35:45 -04:00
d778aa4cdb fix(playout): HLS preview path + live elapsed counter
- nginx.conf: add /media/live/ location serving from the media volume
  mount. CasparCG sidecar writes HLS preview to /media/live/<id>/ but
  nginx only had /live/ (capture volume). Without this, preview
  requests returned the SPA shell instead of the .m3u8 playlist.
- ProgramMonitor: add live elapsed counter (MM:SS, ticks every 500ms)
  driven by engine.currentItemStartedAt. Shows alongside clip index.
  Adds a ⚠ pip when lastError is set (e.g. NDI SDK missing) without
  blocking operation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 13:16:57 -04:00
00b04aa4a8 fix(playout): non-fatal consumer + loadPlaylist guard
- startChannel: make primary consumer ADD non-fatal. CasparCG decodes
  and routes media without an output consumer, so NDI channels (no SDK)
  and misconfigured SRT/RTMP channels still load/play clips and expose
  the HLS preview. state.lastError carries the consumer error for UI
  visibility without blocking operation.
- loadPlaylist: throw early if state.running=false (channel/start was
  never called or failed hard) with a clear error instead of a cryptic
  CasparCG AMCP error propagating to the operator.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 13:01:21 -04:00
e8f91cf4b4 fix(playout): immediate failover on new channels + play 502 vs 409
- spawnChannelSidecar: set last_heartbeat_at = NOW() when flipping
  channel to 'running'. Without this, last_heartbeat_at is NULL so
  the first scheduler tick sees ageMs = (now - epoch) >> TIMEOUT_MS
  and triggers failover before the sidecar has had a single chance
  to respond.
- scheduler playoutHealthTick: when last_heartbeat_at is NULL fall
  back to updated_at as the baseline (belt-and-suspenders with the
  spawnChannelSidecar fix). Also include updated_at in the query.
- POST /channels/:id/play: catch callSidecar errors explicitly and
  return 502 Bad Gateway instead of delegating to next(err) which
  the error middleware maps to 409 Conflict.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:34:41 -04:00
e51cf1aa9c feat(jobs): surface playout-stage queue in Jobs screen
- jobs.js: add playout-stage BullMQ queue to QUEUES; asset_id from
  job data is already resolved to a name by attachAssetNames
- screens-jobs.jsx: map type 'playout-stage' -> kind 'Stage' with
  monitor icon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:06:08 -04:00
f7cf56ae0d fix(playout): silent-audio staging crash, home tiles, channel delete
- playout-stage: skip loudnorm pass 2 when measured_I=-inf (silent or
  no-audio clip); fall back to plain AAC transcode so staging completes
  instead of erroring out
- screens-home: add Playout tile; replace Premiere panel tile with
  Downloads tile opening a combined modal (Premiere panel releases +
  Dragon-ISO link to forge.wilddragon.net/WildDragonLLC/dragon-iso)
- screens-playout: add Delete channel button (visible only when stopped);
  removes channel from list and selects next on confirm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:03:20 -04:00
12115a053a feat(playout): fix 409 drag bug, add HLS preview, advanced playlist
- Fix event bubbling: e.stopPropagation() in onItemDrop prevents
  duplicate POST when dropping on an existing playlist item
- Wrap all drop handlers in try/catch with inline error display
- ProgramMonitor: replace text placeholder with hls.js video player
  loading /media/live/<channel_id>/index.m3u8; falls back to native
  HLS on Safari; destroys Hls instance on channel stop/unmount
- Playlist: per-item duration (MM:SS), staging progress bar with
  animated stripe while staging, now-playing highlight + ▶ indicator
  driven by engine.currentIndex from 4s status poll
- Playlist footer: clip count + total duration sum
- Transport: Play button disabled + shows ' N staging' until all
  items are media_status=ready, eliminating the staging-not-ready 409

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:45:25 -04:00
43656a5e88 shell: youtube nav icon: download → import
download is used by the Downloads section tile on the home screen.
YouTube ingest gets the new import icon (arrow entering box) instead.
2026-05-31 10:52:10 -04:00
68461af990 icons: add import icon (arrow entering box)
Distinct from download (vertical arrow+line). Used for YouTube ingest
to avoid sharing a glyph with the Downloads section tile.
2026-05-31 10:50:43 -04:00
8bc460025d screens-home: fix launcher tile icons
- Dashboard tile: home → layout (matches sidebar nav icon)
- Playout tile: monitor → signal (matches sidebar nav fix)
2026-05-31 00:19:34 -04:00
3578c7b4e9 fix(playout): Privileged only for decklink (SRT/NDI/RTMP/HLS crashed when GPU exposed without driver) 2026-05-30 18:59:27 -04:00
cddcc9a29e fix(mam-api): selfHeartbeat writes last_seen_at so primary node isn't stale-failover-killed 2026-05-30 18:57:20 -04:00
0e844c0fc3 fix(scheduler): use updated_at as grace anchor when last_heartbeat_at NULL
Without this, a freshly-spawned channel with NULL last_heartbeat_at was
instantly failover-killed by the playoutHealthTick because `0` was used as
the lastSeen timestamp, making ageMs huge on the very first tick.
2026-05-30 17:32:15 -04:00
551af09dc7 fix(playout): install libnss3 so CEF can init (NSS -8023 was killing the channel ~30s in) 2026-05-30 17:16:54 -04:00
4d6a999665 fix(playout): pre-create NSS dir + CEF cache so CEF/HTML producer doesn't SIGABRT 2026-05-30 17:14:07 -04:00
f971d57bb9 fix(playout): use unzip not python zipfile (preserves exec bits) 2026-05-30 17:00:25 -04:00
7ab70948a0 fix(playout): entrypoint handles 2.4.x bin/casparcg layout + LD_LIBRARY_PATH for bundled libs 2026-05-30 16:50:04 -04:00
13bbd4216e fix(playout): correct 2.4.0 zip layout — binary is at casparcg_server/bin/casparcg 2026-05-30 16:49:48 -04:00
fcd8e8dd2e fix(playout): entrypoint finds binary in /opt/casparcg for 2.4.x tarball layout 2026-05-30 16:44:23 -04:00
67ac007706 fix(playout): downgrade CasparCG to 2.4.0 ubuntu22 zip (2.5 requires AVX2, ZAMPP has AVX only) 2026-05-30 16:44:07 -04:00
b4f2fb12ff fix(mam-api): heartbeat writes last_seen_at so playout failover sees healthy nodes 2026-05-30 16:32:11 -04:00
aa7f836493 fix(playout): strip XML comments from casparcg.config (2.5 rejects them) 2026-05-30 16:30:54 -04:00
c2409bd037 fix(mam-api): add last_seen_at to cluster_nodes for playout failover
Playout failover queries cluster_nodes.last_seen_at to find healthy nodes
for channel re-placement. Column missing from original cluster schema.

Migration 031 adds column + backfills existing nodes to NOW().

Fixes scheduler error: column "last_seen_at" does not exist

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-30 13:39:06 -04:00
42064acefa shell: fix nav icon conflicts
- schedule: jobs → clock (was sharing hamburger icon with Jobs)
- playout: monitor → signal (was sharing TV icon with Monitors)
2026-05-30 13:30:42 -04:00
2e2b091653 icons: fix 4 icon issues found in audit
- jobs: replace hamburger (nav menu) with bulleted list (task queue)
- grid: add rx="1" to match library icon (consistency)
- hdd: replace circle+dot (vinyl) with cylinder (storage)
- proxy: replace upload-arrow with sliders (transcode/transform)
2026-05-30 13:29:18 -04:00
Zac
c502d4a16f feat(web-ui): update home tagline + add "Let's create" motto
Tagline "Self-hosted broadcast media-asset management" ->
"Media Asset Management & Production Platform"; add italic accent motto
"Let's create" below it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 16:04:28 +00:00
Zac
9d098e9778 feat(auth-ui): interactive permissions matrix, admin 2FA reset, Downloads button
Backend (routes/users.js):
- GET / now returns totp_enabled so the UI can show 2FA status
- GET /:id/access — admin-only effective per-project access (MAX over direct +
  group grants), labels via=direct|group:<name>; admins report all/edit
- POST /:id/totp/disable — admin clears a locked-out user's 2FA without their
  password (self-service disable still requires it); dev user blocked
- role validated against {admin,editor,viewer} on create + PATCH (was unchecked)

Frontend:
- Users>Policies tab: static prose replaced with interactive per-user matrix —
  inline role select, 2FA badge, Reset-2FA action, lazy per-user access expander
- Home "Premiere panel" tile -> "Downloads"; modal renamed, adds Teams ISO row
  (disabled "coming soon" until the .exe is supplied); UXP .ccx link unchanged
- data.jsx: window.TEAMS_ISO placeholder ({available:false})

Not runtime-tested in browser yet. Teams ISO .exe still pending from user.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:59:27 +00:00
Zac
02631f7b96 fix(playout): locate CasparCG 2.5 binary at /usr/bin/casparcg-server-2.5
First 2.5 build got past the deb install but the binary-discovery step
produced an empty $BIN (test -n failed): the 2.5 deb names its executable
casparcg-server-2.5, which the old case pattern (*/casparcg, */CasparCG
Server) didn't match. Broaden the match to /usr/bin/*casparcg*server*, fall
back to the known /usr/bin/casparcg-server-2.5, symlink it to
/usr/local/bin/casparcg, and make /opt/casparcg a real dir for our config
(no longer symlinked onto /usr/bin). Entrypoint launches `casparcg <config>`
from PATH instead of ./casparcg in a cwd.

Still NOT runtime-validated: 2.5 may reject the 2.3-era casparcg.config
schema (a bad config shows up as "Configuration file --version was not
found"); the deb ships a reference config at
/usr/share/casparcg-server-2.5/casparcg.config to diff against at smoke time.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:34:02 +00:00
Zac
9436434599 fix(playout): build CasparCG 2.5.0 from .deb (2.3.3 tarball was a dead URL)
The image never built: CASPAR_URL pointed at a v2.3.3-stable Linux tarball
that CasparCG never published (2.3.x is Windows-only; Linux builds start at
2.4.0, and 2.4.1+ ship only as .deb). Rewrite to install the 2.5.0 noble
server + CEF debs on an ubuntu:24.04 base (Node 20 via nodesource), letting
apt resolve the GL/ffmpeg/openal runtime deps. Binary install dir is
discovered from the deb file list and symlinked to /opt/casparcg so the
entrypoint + config still run from there. Move CasparCG log/data dirs to
/media (writable mount) since the install dir may be read-only.

NOT runtime-validated: the 2.5 casparcg.config schema and the AMCP consumer
syntax (ADD <ch> STREAM/FILE) were authored against 2.3 and must be smoke-
tested against 2.5 before a channel start can be trusted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:25:31 +00:00
Zac
f837e57969 feat(web-ui): add Playout tile to home screen
Fetches /playout/channels separately and degrades silently when the
endpoint or schema is absent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:59:59 +00:00
Zac
ca71e47035 fix(playout): repair failover, authenticate scheduler self-calls, fix playlist walk + CasparCG consumer syntax
Post-review fixes for the 8-commit playout-mcr drop:

- Scheduler self-calls (callSelf -> /recorders, /playout) carried no auth, so
  under AUTH_ENABLED=true requireUiHeader 403'd every mutating POST. This broke
  playout failover AND scheduled recordings. Add a per-boot in-process service
  token (x-internal-token) the scheduler attaches; requireAuth/requireUiHeader
  treat it as the seeded admin. No env/compose config needed.

- Failover deadlocked: restartChannel set status='starting' then the scheduler
  called the guarded /start route, which 409s on 'starting'. Extract the spawn
  body into spawnChannelSidecar() shared by /start and restartChannel; failover
  now spawns directly with no self-call.

- Phase A playlist stalled after 2 clips: _scheduleAdvance cued the next clip
  via LOADBG AUTO but never advanced the pointer. Pass asset_duration_ms in the
  /play payload and arm a duration-based timer that advances currentIndex and
  cues subsequent clips, keeping as-run in sync for arbitrary-length playlists.

- CasparCG consumer syntax was invalid: "ADD <ch> FFMPEG" is the producer name,
  not a consumer keyword, and old -vcodec/-acodec short args are rejected. Use
  STREAM/FILE with -codec:v / -codec:a / -preset:v / -tune:v and a format=yuv420p
  filter ahead of libx264 (channel output is RGBA).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:51:35 +00:00
Zac
34352e3299 docs(playout): work log — commit map, decisions, testing checklist
Replaces the earlier aspirational "complete" log with the actual commit
sequence on feat/playout-mcr, the §7 decisions as built, the media-flow
diagram, port-contention + failover scope, and a runtime testing checklist
(migration → image build → SRT smoke → failover kill test).
2026-05-30 14:05:57 +00:00
Zac
d505a488ac build(playout): compose wiring + .env knobs
- Add /mnt/NVME/MAM/wild-dragon-media:/media to mam-api (rw) and worker-p4
  (rw); web-ui (ro, for serving HLS preview segments).
- worker-p4 WORKER_QUEUES gains 'playout-stage' so master-tier nodes pick up
  the loudnorm stage jobs (they already have ffmpeg + the media mount).
- New build-only 'playout' service with profile ["build-only"] so
  `docker compose --profile build-only build playout` produces the
  wild-dragon-playout:latest image without compose trying to up it as a
  long-running service. mam-api spawns these on demand.
- mam-api env adds PLAYOUT_IMAGE + PLAYOUT_AMCP_BASE_PORT (5250 default).
- .env.example: PLAYOUT_IMAGE, PLAYOUT_AMCP_BASE_PORT.
2026-05-30 14:05:57 +00:00
Zac
793011b78b feat(web-ui): MCR page — channels, playlist, transport, preview
screens-playout.jsx + styles-playout.css: program monitor (HLS preview from
the sidecar), media bin, drag-drop playlist editor, transport controls. Plain
HTML5 drag-drop, no extra library. Talks to /api/v1/playout via
ZAMPP_API.fetch.

Wired into the shell: "Playout" under Operations, breadcrumb mapping, route
case in app.jsx, stylesheet + dist/screens-playout.js script in index.html.
Format dropdown defaults to 1080p5994 (matches the new channel default).
2026-05-30 14:02:25 +00:00
Zac
5538683d78 feat(mam-api): /playout control plane + auto-failover
Routes: channel + playlist CRUD, start/stop/play/pause/skip transport, as-run
log. RBAC via assertProjectAccess on channel.project_id; null project ⇒
admin-only (recorder convention).

Sidecar orchestration mirrors recorders.js: Docker socket for local node,
node-agent /sidecar/start for remote. Channel start passes CHANNEL_ID env so
the sidecar can write HLS preview to /media/live/<id>.

DeckLink port-contention guard: blocks starting a decklink channel when a
recorder or another channel on the same node+device_index is active.

restartChannel(id) helper picks another healthy cluster node and re-places
non-decklink channels; decklink is alert-only. Exposed for the scheduler.

Scheduler tick adds step 6: poll each running channel's sidecar /status,
update last_heartbeat_at, and after ~3 misses trigger restartChannel +
self-call /start. Reuses the existing PG advisory lock so multi-replica
deploys don't double-fire failovers.
2026-05-30 14:02:25 +00:00
Zac
d62af34e98 feat(playout): CasparCG sidecar image + Node AMCP shim
One container per channel. Built like capture/build-with-decklink: NDI +
DeckLink SDKs fetched at build, runs --privileged with Xvfb for the GL
context where no real display is present.

Components:
- entrypoint.sh: Xvfb + CasparCG launch, creates /media/live/<CHANNEL_ID>
- src/amcp.js: TCP AMCP client
- src/playout-manager.js: channel lifecycle, playlist walk via LOADBG AUTO
  for gapless transitions; primary consumer (decklink/ndi/srt/rtmp) plus a
  second FFMPEG HLS consumer (~600 kbps, 2s segments) for the UI preview
- src/index.js: HTTP shim — /channel/start, /playlist/load, transport
- frame-rate helper picks fps from video_format (59.94 → 60000/1001) so
  SEEK / LENGTH frame math is correct
2026-05-30 14:02:25 +00:00
Zac
209f9fda52 feat(worker): playout-stage job — S3 → /media + EBU R128 loudnorm
Stages playlist items from S3 to the shared CasparCG media volume. Pass 1
measures, pass 2 applies linear loudnorm (I=-23 LUFS, TP=-1 dBTP, LRA=11);
output is AAC 192k @ 48 kHz, video stream copied. Atomic rename on success
so CasparCG never sees a partial file. Per-item audio_normalized flag means
re-stages of the same asset skip the loudnorm pass.

Wired into worker/src/index.js behind WORKER_QUEUES=playout-stage so
capability-routed deploys can pin it to nodes that already have ffmpeg +
the media mount.
2026-05-30 14:02:25 +00:00
Zac
29187a90df 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
2026-05-30 14:02:25 +00:00
Zac
512267159a docs(playout): MCR design spec — Phase A playlist + Phase B 24/7
Single-doc design covering the playout subsystem: CasparCG-backed sidecars,
multi-channel placement, S3→/media staging, scheduling phases, the data
model, channel placement vs port contention.

§7 questions are answered inline (2026-05-30): −23 LUFS at stage time,
1080p5994 default, HLS preview v1, auto-restart-on-healthy-node failover
(DeckLink alert-only).
2026-05-30 14:02:25 +00:00
68c8f47c8f feat(capture): rebuild ffmpeg w/ nv-codec-headers + NVENC/CUVID for All-Intra HEVC
The custom FFmpeg 7.1 in this image previously had NO nvenc — only
prores_ks/dnxhd/libx264/libx265. The All-Intra HEVC ingest plan
(docs/design/2026-05-29-all-intra-hevc-ingest.md §4.1) needs
hevc_nvenc / h264_nvenc to offload the per-signal encode from CPU
to the L4, so we rebuild ffmpeg's configure stage with:

  --enable-ffnvcodec --enable-nvenc --enable-cuvid

nv-codec-headers (pinned n12.1.14.0) is cloned + installed before
configure so ffmpeg picks up the ffnvcodec pkg-config. Full CUDA
toolkit is omitted — only needed for GPU filters like yadif_cuda.

Adds a sanity-check RUN that fails the build if hevc_nvenc/h264_nvenc
don't appear in 'ffmpeg -encoders' so a broken NVENC build can't
silently ship. Creates /live and /growing as empty dirs since the
node-agent binds them at sidecar start.
2026-05-29 00:51:56 -04:00
Zac Gaetano
17bf086ef2 feat(web-ui): publish UXP panel v2.2.2 .ccx and repoint download buttons
Package the redesigned UXP panel as dragonflight-mam-2.2.2.ccx (plain zip
of manifest + index.html + styles.css + src/*, the standard UXP format that
double-click-installs on Windows and Mac via Creative Cloud).

- Add the .ccx to web-ui/public/downloads (served by nginx at /downloads).
- data.jsx: new v2.2.2 entry as latest (ccx field); old CEP releases kept.
- Editor screen: single "Download Panel (.ccx)" button.
- Settings -> Capture SDKs: .ccx download + updated UXP install copy;
  per-release buttons fall back to ZXP/Win for the old CEP entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 00:36:38 -04:00
Zac Gaetano
dac5213354 fix(uxp-panel): Export chooser is a full-panel screen, not a clipped popup
The floating popup anchored to the rail button clipped at the panel edge
(the bottom option was cut off). Replace it with a full-panel screen that
takes over the whole panel: a header with close, and two large option
rows (Conform Timeline -> MAM, Local Export) with icon + description.
Conform still routes to the codec-picker panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:57:54 -04:00
Zac Gaetano
3f203f326e feat(uxp-panel): single Export menu, Upload to MAM, Local Export, auto-upload
UI consolidation:
- One Export entry (rail) opens a popup menu: Conform Timeline -> MAM and
  Local Export. Retires the standalone Export & Conform / Fetch & Relink
  dock buttons and the plain "Push Timeline" flow.
- Remove Import All.
- New "Upload to MAM" dock button.

Upload: reads the highlighted project-panel item(s) via the premierepro
API (best-effort, guarded) and falls back to a native file picker. Pushes
via /upload/simple (small) or chunked init/part/complete (large).

Local Export: batch-trim the timeline's hi-res clips server-side (FFMPEG),
poll trim-status, download each temp-segment-url, relink in Premiere.
Relink keys on source media path (last-wins for multi-use sources).

Conform + Local Export auto-upload any timeline sources not yet in the MAM
before proceeding (renders from the hi-res original, available post-upload).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:48:30 -04:00
Zac Gaetano
7e9f1277d4 fix(uxp-panel): rebuild Export & Conform panel - declutter + fix overlap
The preset cards overlapped the codec/quality dropdowns because the
2-column .preset-grid used flex-wrap, and UXP miscomputes wrapped-flex
container height (collapses to ~0), so the fields below rendered on top.

Rethink:
- Presets are now a single-column list (title left, spec right); no
  flex-wrap, so nothing collapses or overlaps.
- Progressive disclosure: the Codec/Quality/Resolution/Audio dropdowns
  are wrapped in #conform-custom and hidden unless the Custom preset is
  chosen. Default view is just Target project + 4 format rows.

The preset->select value contract is unchanged; presets still populate
the (now hidden) selects that Start Conform reads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:13:15 -04:00
Zac Gaetano
9d8adbbbc1 feat(uxp-panel): fix hover tooltips + add compact list view toggle
Tooltips: the floating .tip-bubble used position:fixed, which UXP's
engine does not render reliably, so tooltips never appeared. Switch to
position:absolute (body never scrolls, so viewport coords still map),
add scroll-offset compensation, and guard against UXP returning 0 for
window.innerWidth/innerHeight.

List view: add a toolbar toggle (grid vs compact list). List view shows
a small 50x28 thumbnail with name/meta and an inline status chip per
row, fitting many more clips on screen. Defaults to list, persists via
localStorage when the host allows it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 23:05:52 -04:00
Zac Gaetano
3430ef823e fix(uxp-panel): icon controls as <div role=button> so UXP renders them
ROOT CAUSE: UXP renders native <button> chrome that ignores CSS
`background` and does not draw <svg>-only button content. The original
panel "worked" only because its buttons had TEXT (native buttons render
their text label); the redesign stripped text out, leaving empty grey
pills. Non-<button> elements (the <span> status chips, the <label>
search field + magnifier) render custom backgrounds and SVG children
correctly in this exact UXP -- proof divs are the right vehicle.

FIX: convert the 12 rail/dock/menu icon controls from <button> to
<div role="button" tabindex="0">. Divs have no native `disabled`, so
main.js installs a `disabled` accessor that reflects to a [disabled]
attribute; CSS keys disabled styling off [disabled]. Reverted the
non-working ::before cover back to direct backgrounds (divs honor them).
Text buttons (Connect, slide-panel actions, glyph close) stay <button>.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 22:55:45 -04:00
Zac Gaetano
08a0fb1b60 fix(uxp-panel): use ::before pseudo-element to cover native button chrome
UXP's native button chrome overrides explicit `background` rules on
icon-only <button> elements -- appearance:none and background both lose.
Authored content (::before pseudo-elements) renders above native chrome,
so move all background/hover/active logic there. SVG icons and the
growing-count badge get z-index:1/2 to sit above the cover.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-28 21:09:09 -04:00
Zac Gaetano
dd438b597a fix(uxp-panel): render icons as filled SVGs so UXP draws them
UXP's SVG renderer does not draw Feather/Lucide-style stroke icons
(fill="none" stroke="currentColor"); they showed as blank/grey shapes
in Premiere. Convert all 14 panel icons to filled single-path
(Material-style) SVGs with explicit width/height attributes, which
UXP's simple-icon renderer handles reliably.

Also replace the transparent rail/icon button backgrounds with an
explicit --bg-base fill: appearance:none alone did not suppress UXP's
native grey button chrome, but an explicit background does (same trick
the working .btn rule relies on).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:44:50 -04:00
Zac Gaetano
8746d71af1 fix(premiere-panel): reset native button chrome on icon buttons
UXP renders native <button> appearance (grey rounded fill) unless
appearance:none is set. .btn masked it with an explicit background,
but .rail-btn / .iconbtn use a transparent background, so the native
chrome showed through as grey blobs and suppressed the SVG icons.
Add appearance:none to both, matching the input/select reset.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:32:30 -04:00
Zac Gaetano
6a161c7133 feat(premiere-panel): icon-rail redesign with hover tooltips
Replace the text-heavy panel layout with a minimal icon-first UI:
a VS Code-style vertical activity rail for view switching plus a
contextual icon dock for clip actions. Every control carries a
[data-tip] label surfaced on hover via a JS-positioned tooltip
bubble (UXP's CSS engine can't be trusted with content: attr()).

All existing main.js element IDs and the JS wiring contract are
preserved; the dropped advanced section was already guarded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:57:26 -04:00
Zac
79369c378a fix: SRT/RTMP ingest + thumbnail crashes
Recorder model was creating capture containers but ffmpeg never spawned
inside them, so SRT/RTMP listeners bound the host port without ingesting
anything. Thumbnail extraction was also crashing on yuv444p sources,
leaving uploaded assets stuck at status=processing forever.

* capture/src/index.js: read RECORDER_ID/SOURCE_TYPE/LISTEN/LISTEN_PORT/
  STREAM_KEY/SOURCE_URL from env on startup and call captureManager.start()
  immediately. SIGTERM handler now flushes ffmpeg + S3 upload and POSTs the
  asset to mam-api before exiting.
* worker/ffmpeg/executor.js: force -pix_fmt yuv420p on proxy transcode and
  -pix_fmt yuvj420p on thumbnail extraction so mjpeg encoder accepts the
  input regardless of source pixel format.
* mam-api/routes/assets.js: when capture posts proxyKey=null but hiresKey
  is set (SRT/RTMP case), enqueue a proxy job from the hires so the asset
  ends up with a browser-playable proxy + thumbnail instead of stuck-ready.
* mam-api/routes/recorders.js: accept UI field aliases (codec/resolution/
  proxy_config), clean up unstarted containers on port collision, bump the
  docker stop timeout to 5min so long uploads can flush.
* web-ui/recorders.html: change default ports from 1935/9000 to 41936/49001
  to avoid common collisions with other RTMP/SRT services.
2026-05-17 07:01:54 -04:00
46 changed files with 5298 additions and 807 deletions

View file

@ -63,3 +63,10 @@ GOOGLE_ALLOWED_DOMAIN=
# Note: if a Google-linked account also has TOTP enabled, sign-in still requires # Note: if a Google-linked account also has TOTP enabled, sign-in still requires
# the authenticator code (Google is treated as the first factor). Accounts without # the authenticator code (Google is treated as the first factor). Accounts without
# TOTP complete sign-in in one Google step. # TOTP complete sign-in in one Google step.
# Playout / Master Control (MCR)
# Image tag the mam-api spawns when a channel starts. Build with:
# docker compose --profile build-only build playout
PLAYOUT_IMAGE=wild-dragon-playout:latest
# Base AMCP port — each channel binds to BASE + channel_id (in CasparCG terms).
PLAYOUT_AMCP_BASE_PORT=5250

101
WORK_LOG_PLAYOUT.md Normal file
View file

@ -0,0 +1,101 @@
# Playout / Master Control — Implementation Work Log
**Branch:** `feat/playout-mcr` (off `main`)
**Started:** 2026-05-30
**Status:** Code complete, awaiting runtime validation
Tracks the build of the playout (MCR) subsystem against the design at
`docs/superpowers/specs/2026-05-30-playout-mcr-design.md`.
---
## Commit sequence
| # | Commit | Scope |
|---|--------|-------|
| 1 | `docs(playout)` | Design spec, §7 questions answered |
| 2 | `feat(mam-api): migration 029` | Six tables, failover columns, audio_normalized flag |
| 3 | `feat(worker): playout-stage` | S3 → /media + EBU R128 loudnorm + index.js wiring |
| 4 | `feat(playout): sidecar` | CasparCG image + AMCP shim, HLS preview consumer, fps-aware frame math |
| 5 | `feat(mam-api): /playout control plane + auto-failover` | Routes + scheduler health tick + restartChannel helper |
| 6 | `feat(web-ui): MCR page` | screens-playout, styles, app/shell/index.html wiring |
| 7 | `build(playout): compose wiring + .env knobs` | /media volume, queue addition, build-only service |
| 8 | `docs(playout): work log` | This file |
## Resolved §7 decisions (2026-05-30)
- **Audio loudness:** pre-normalize at stage time. ffmpeg `loudnorm` two-pass
(I=-23 LUFS, TP=-1 dBTP, LRA=11), linear mode preserves dynamics. Output
AAC 192k @ 48 kHz, video stream copied. Per-item `audio_normalized` flag
so re-stages of the same asset skip the pass.
- **Frame rate:** `1080p5994` default (was `1080i5994`). Per-channel
override allowed via `video_format`. `fpsFor(videoFormat)` helper in
the sidecar drives SEEK / LENGTH / transition-frames math.
- **Preview latency:** HLS v1. CasparCG runs a second FFMPEG consumer
alongside the primary output, writing `/media/live/<channel_id>/index.m3u8`
(~600 kbps, 2s segments, 6-window list). Web UI plays via the existing
HLS plumbing.
- **Failover:** auto-restart on healthy node for NDI/SRT/RTMP. Alert-only
for DeckLink (device-index pinning makes blind re-placement risky).
Scheduler tick (PG advisory lock, same lock as recorder schedules) polls
sidecar `/status`; ~3 missed checks → `restartChannel(id)` picks the most
recently-seen-online other node, bumps `restart_count`, calls `/start`.
## Architecture notes
**Sidecar model.** One CasparCG container per channel. Spawned by mam-api
via local Docker socket (primary node) or remote node-agent
`/sidecar/start`. Tracked in `playout_sidecars` plus `playout_channels.container_id`.
Killed on `/stop` or by `restartChannel` during failover.
**Media flow.**
```
S3 master/proxy → playout-stage worker → /media/playout/<assetId>.<ext>
(loudnormed, AAC@-23 LUFS)
CasparCG channel #1
primary consumer HLS consumer
(DeckLink/NDI/ ↓
SRT/RTMP) /media/live/<ch_id>/*.m3u8
```
**Port contention.** `assertDeckLinkFree()` blocks starting a SDI channel
when a recorder or another channel on the same node+device_index is active.
**Failover scope.** NDI/SRT/RTMP have no hardware tie, so any healthy
cluster_node is eligible. DeckLink channels surface an alert in the UI
(`status='error'` + `error_message`) and require operator intervention.
## Testing checklist
- [ ] Apply migration 029 on dev DB
- [ ] Build playout image: `docker compose --profile build-only build playout`
- [ ] Build web-ui (`screens-playout` joins the esbuild list automatically)
- [ ] Create channel via POST /api/v1/playout/channels (SRT first, no HW)
- [ ] Stage 2-3 assets to a playlist, verify loudnorm metadata in stderr
- [ ] Start channel → sidecar container appears in `docker ps`
- [ ] AMCP smoke: `telnet <host> 5250`, `VERSION`, `INFO`
- [ ] Play playlist; verify HLS at /media/live/<id>/index.m3u8
- [ ] Skip / pause / resume / stop
- [ ] As-run log: GET /api/v1/playout/channels/:id/asrun
- [ ] Kill sidecar container → scheduler should restart on another node
within ~3 ticks (~45s), restart_count increments
- [ ] DeckLink channel kill: status flips to 'error', NO restart attempt
- [ ] Try starting a decklink channel on a device_index already held by a
recorder → 409
- [ ] MCR UI smoke: nav entry visible, page renders, drag-drop adds items,
transport buttons hit the API
## Known gaps (deferred)
- No WebRTC preview (HLS-only v1 — 4-6s lag, fine for confidence monitor).
- No graphics/CG overlay layer in Phase A (templates land in Phase B).
- No Phase B scheduler / 24/7 wall-clock channel (schema is in place,
scheduler tick is not).
- No multi-channel grid view (one channel at a time per page).
- No timecode / remaining-duration overlay (would need CasparCG INFO poll).
- No audio level meters on the UI.
- `restartChannel` updates DB state and triggers `/start`; if the new node
also fails repeatedly, there's no exponential backoff yet — bounded only
by the manual stop button.

View file

@ -103,6 +103,7 @@ services:
# worker-l4: HEAVY tier (proxy/conform/trim) on the L4 (NVENC). Talks to # worker-l4: HEAVY tier (proxy/conform/trim) on the L4 (NVENC). Talks to
# zampp1's Redis/Postgres/S3 over the LAN (.200). No promotion scanner here. # zampp1's Redis/Postgres/S3 over the LAN (.200). No promotion scanner here.
worker-l4: worker-l4:
profiles: [gpu]
build: build:
context: ./services/worker context: ./services/worker
dockerfile: Dockerfile.gpu dockerfile: Dockerfile.gpu

View file

@ -40,6 +40,7 @@ services:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- /mnt/NVME/MAM/wild-dragon-live:/live - /mnt/NVME/MAM/wild-dragon-live:/live
- /mnt/NVME/MAM/wild-dragon-growing:/growing - /mnt/NVME/MAM/wild-dragon-growing:/growing
- /mnt/NVME/MAM/wild-dragon-media:/media
- /mnt/NVME/MAM/sdk:/sdk - /mnt/NVME/MAM/sdk:/sdk
- /dev/shm:/dev/shm - /dev/shm:/dev/shm
- /run/dbus:/run/dbus - /run/dbus:/run/dbus
@ -61,6 +62,8 @@ services:
NODE_IP: ${NODE_IP} NODE_IP: ${NODE_IP}
NODE_HOSTNAME: ${NODE_HOSTNAME:-} NODE_HOSTNAME: ${NODE_HOSTNAME:-}
CAPTURE_TOKEN: ${CAPTURE_TOKEN} CAPTURE_TOKEN: ${CAPTURE_TOKEN}
PLAYOUT_IMAGE: ${PLAYOUT_IMAGE:-wild-dragon-playout:latest}
PLAYOUT_AMCP_BASE_PORT: ${PLAYOUT_AMCP_BASE_PORT:-5250}
deploy: deploy:
resources: resources:
reservations: reservations:
@ -120,14 +123,16 @@ services:
# after the capability-routing split, so import jobs sat unprocessed and # after the capability-routing split, so import jobs sat unprocessed and
# assets stayed `ingesting` forever. import is concurrency-1 + network- # assets stayed `ingesting` forever. import is concurrency-1 + network-
# bound, so one consumer (this heavy/primary worker) is sufficient. # bound, so one consumer (this heavy/primary worker) is sufficient.
WORKER_QUEUES: proxy,conform,trim,import WORKER_QUEUES: proxy,conform,trim,import,playout-stage
RUN_PROMOTION: "true" RUN_PROMOTION: "true"
PROXY_CONCURRENCY: "2" PROXY_CONCURRENCY: "2"
PLAYOUT_MEDIA_DIR: /media
NVIDIA_VISIBLE_DEVICES: GPU-79afca3e-2ab2-a6ea-1c44-706c1f0a26d6 NVIDIA_VISIBLE_DEVICES: GPU-79afca3e-2ab2-a6ea-1c44-706c1f0a26d6
WORKER_LABEL: "zampp1 / Tesla P4" WORKER_LABEL: "zampp1 / Tesla P4"
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
volumes: volumes:
- /mnt/NVME/MAM/wild-dragon-growing:/growing - /mnt/NVME/MAM/wild-dragon-growing:/growing
- /mnt/NVME/MAM/wild-dragon-media:/media
networks: networks:
- wild-dragon - wild-dragon
@ -176,12 +181,22 @@ services:
- "${PORT_WEB_UI:-7434}:80" - "${PORT_WEB_UI:-7434}:80"
volumes: volumes:
- /mnt/NVME/MAM/wild-dragon-live:/live - /mnt/NVME/MAM/wild-dragon-live:/live
- /mnt/NVME/MAM/wild-dragon-media:/media:ro
- /dev/shm:/dev/shm - /dev/shm:/dev/shm
- /run/dbus:/run/dbus - /run/dbus:/run/dbus
- /run/systemd:/run/systemd - /run/systemd:/run/systemd
networks: networks:
- wild-dragon - wild-dragon
# Build-only: the CasparCG sidecar image. mam-api spawns these on-demand per
# channel (one container per playout channel), so this service is never up'd —
# it exists so `docker compose build playout` produces the image the API tags
# via PLAYOUT_IMAGE. Profile excludes it from default `up`.
playout:
profiles: ["build-only"]
build: ./services/playout
image: wild-dragon-playout:latest
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:

View file

@ -0,0 +1,235 @@
# Wild Dragon MAM — Playout / Master Control (MCR)
**Date:** 2026-05-30 (revised 2026-05-30 — §7 closed)
**Status:** APPROVED — implementation in progress (code drafted but uncommitted; see WORK_LOG_PLAYOUT.md)
**Author:** Zac + Claude
---
## Resolved Decisions
| Question | Decision |
|----------|----------|
| Playout engine | **CasparCG Server** (orchestrated via AMCP), not ffmpeg-native |
| Channel count | **Multi-channel from start** — N independent channels, placed across cluster nodes by capability (mirrors recorders) |
| Scheduling model | **Phased** — Phase A: on-demand playlist player; Phase B: 24/7 continuous channel |
| Output targets | SDI (DeckLink), NDI, SRT, RTMP — all via CasparCG consumers |
| Media source | Assets live in **S3**; must be staged to a CasparCG-local media volume before play (see §4) |
| CasparCG packaging | **Build our own image** (like `capture/build-with-decklink.sh`) — GL context via GPU passthrough or Xvfb; NDI + DeckLink SDKs fetched at build time (not redistributable) |
| Master codec playability | Zac confirms current masters **play fine in CasparCG** — no transcode-for-playout step; staging is a plain S3→/media copy. Validate on hardware but do not gate on it |
| Management UI | **Single Dragonflight `playout.html` GUI** drives everything via AMCP; operator never touches CasparCG directly |
---
## Overview
Playout adds **master-control-room** capability to Dragonflight: take library assets, arrange them on a timeline / playlist, and play them out continuously to a broadcast output — SDI via DeckLink, or stream via SRT / RTMP / NDI. Drag-and-drop scheduling, a program/preview monitor, and as-run logging.
This is the **mirror image** of the existing capture path. Capture is `input → ffmpeg encode → S3`. Playout is `S3 asset → engine → output`. We reuse three things wholesale:
1. **Cluster node + capability model** — nodes already advertise DeckLink/Deltacast/GPU in `cluster_nodes.capabilities`; channels are placed on nodes that have a free output port, exactly as recorders claim input ports.
2. **Sidecar orchestration** — mam-api spawns containers via the local Docker socket or the remote `node-agent /sidecar/start`. A CasparCG channel is just a different sidecar image.
3. **Scheduler tick + PG advisory lock**`src/scheduler.js` already runs a single-leader tick over a schedule table. Phase B's wall-clock channel reuses this pattern.
### Why CasparCG over ffmpeg-native
The capture stack proves we can drive ffmpeg + DeckLink. But playout's hard part is **gapless, frame-accurate, clean transitions between clips** — every clip boundary in an ffmpeg-per-clip model is a black flash unless we engineer a concat-feeder. CasparCG solves this natively: a channel is a persistent output with a playlist, hard/mix/wipe transitions, layered graphics/logo (CG), and DeckLink/NDI/SRT/RTMP consumers built in. We orchestrate it over **AMCP** (its TCP control protocol) instead of reinventing a feeder. Trade: a new dependency + container image, and media must be on a CasparCG-visible disk (§4).
---
## 1. Data Model
New migration `029-playout.sql`. Five tables.
### 1.1 `playout_channels`
A logical output. One channel → one engine instance → one output target.
```
id uuid pk
name text -- "Channel 1", "Pop-up SDI"
node_id uuid -> cluster_nodes(id) -- where the engine runs (null = primary)
output_type text -- 'decklink' | 'ndi' | 'srt' | 'rtmp'
output_config jsonb -- { device_index } | { ndi_name } | { url, latency } | { url, key }
video_format text -- '1080i5994' | '1080p5994' | '720p5994' ...
status text -- 'stopped' | 'starting' | 'running' | 'error'
container_id text -- running CasparCG sidecar
project_id uuid -> projects(id) -- RBAC scoping (nullable = admin-only)
created_at / updated_at
```
`output_type` + `output_config` map straight to a CasparCG consumer:
- `decklink``ADD <ch> DECKLINK <device> ...`
- `ndi``ADD <ch> NDI ...`
- `srt`/`rtmp` → `ADD <ch> FFMPEG <url> -f mpegts ...` (CasparCG 2.3+ ffmpeg consumer)
### 1.2 `playout_playlists`
An ordered list of items bound to a channel. Phase A's primary object.
```
id, channel_id -> playout_channels(id)
name, loop boolean, created_at / updated_at
```
### 1.3 `playout_items`
One entry on a playlist OR one entry on the 24/7 timeline.
```
id
playlist_id uuid -> playout_playlists(id) -- Phase A
asset_id uuid -> assets(id)
sort_order int -- position in playlist (Phase A)
scheduled_at timestamptz -- wall-clock start (Phase B, null in A)
in_point numeric -- seconds, trim head (reuse subclip in/out from editor)
out_point numeric -- seconds, trim tail
transition text -- 'cut' | 'mix' | 'wipe'
transition_ms int
graphics jsonb -- optional CG/template overlay (Phase B+)
media_status text -- 'pending' | 'staging' | 'ready' | 'error' (see §4)
media_path text -- resolved path inside the CasparCG media volume
```
### 1.4 `playout_schedule` (Phase B)
Day-ahead, wall-clock-bound timeline rows. Same shape as `playout_items` but `scheduled_at` is authoritative and the scheduler tick (§5) drives transitions. Phase A can ignore this table.
### 1.5 `playout_as_run`
Append-only log: what actually played, when, for how long. Compliance / billing.
```
id, channel_id, asset_id, item_id
started_at, ended_at, duration_s, result -- 'played' | 'skipped' | 'error'
```
---
## 2. Services & Components
### 2.1 New sidecar: `services/playout/` (CasparCG wrapper)
A thin container: **CasparCG Server** + a small Node control shim exposing HTTP, the same way `capture` wraps ffmpeg.
- Base image: official/community `casparcg/server` (Linux build with DeckLink + NDI + FFmpeg producers/consumers).
- Node shim (`src/index.js`): opens an AMCP TCP socket to local CasparCG, exposes:
- `POST /channel/start``ADD <ch> <consumer>` for the channel's output target
- `POST /play``PLAY <ch>-<layer> <media> [transition]`
- `POST /loadbg` + `/play` → preview/cue then take (preview monitor)
- `POST /stop`, `GET /status``INFO <ch>` (current clip, position, fps)
- playlist load → translate `playout_items` rows into a sequence of AMCP `LOADBG`/`PLAY` calls, advancing on `OnTransition` / end-of-clip events.
- Mirrors capture's status-polling contract so the UI monitor reuses existing plumbing.
### 2.2 mam-api: `src/routes/playout.js`
CRUD + control, RBAC-scoped via the existing `assertProjectAccess` helper (channels carry `project_id`).
```
GET /playout/channels list (project-filtered)
POST /playout/channels create (edit on project)
POST /playout/channels/:id/start|stop spawn/kill CasparCG sidecar
GET /playout/channels/:id/status proxy engine INFO
POST /playout/channels/:id/play|pause|skip transport control
GET/POST/PUT/DELETE /playout/playlists... playlist + item CRUD, reorder
POST /playout/items/:id/stage kick S3→media-volume staging (§4)
GET /playout/channels/:id/asrun as-run log
```
Channel start/stop reuses `resolveNodeTarget()` + the Docker-socket / `node-agent /sidecar/start` split already in `recorders.js`. **Refactor opportunity:** lift that sidecar-spawn logic out of `recorders.js` into `src/orchestration/sidecar.js` so both recorders and playout share it (keep this small — only what both need).
### 2.3 web-ui: `playout.html` + `public/playout.jsx`
New MCR page. Layout:
```
┌─ PREVIEW ───────────┬─ PROGRAM (on air) ──────┐
│ [cued clip] │ [live output] ● ON AIR │
│ TC / duration │ TC / remaining │
│ [CUE] [TAKE] │ [PLAY][PAUSE][SKIP][STOP]│
├─ MEDIA BIN ─────────┴──────────────────────────┤
│ (draggable asset list, reuse asset browser) │
├─ PLAYLIST / TIMELINE ──────────────────────────┤
│ ▸ clip A ──▸ clip B ──▸ clip C (drag-drop) │ Phase A: ordered list
│ └ 24h time grid w/ now-bar │ Phase B: time-of-day grid
└────────────────────────────────────────────────┘
```
- Drag-drop: reuse whatever the NLE editor timeline uses (check `editor.jsx`); assets drag from the bin into the playlist/grid.
- API via existing `ZAMPP_API.fetch` wrapper.
- Program monitor: HLS preview of the output — CasparCG can emit a second low-bitrate FFmpeg consumer to HLS, reusing the `/live/<id>` HLS plumbing capture already uses.
---
## 3. Channel placement & ports
A DeckLink port is exclusive — same constraint capture already handles. A node's DeckLink port can be an **input (recorder)** or an **output (playout channel)**, never both at once. So:
- Extend the capability/port-claim check: when starting a channel on `output_type=decklink`, verify the target node has that device index free (no active recorder, no active channel).
- NDI / SRT / RTMP outputs have no hardware contention → can stack many per node (GPU/CPU-bound only).
- Surface a unified "device map" (extend the existing cluster DeckLink-status endpoint) showing each port's role: idle / recording / playing-out.
---
## 4. Media staging (the S3 ⇄ CasparCG gap)
**The crux.** Assets live in S3 (`original_s3_key` / `proxy_s3_key`). CasparCG plays from a **local media folder**. Options:
- **A — Pre-stage to a shared media volume (recommended).** Before a clip can go on air, download/symlink it from S3 to a CasparCG-visible volume (`/media`), set `playout_items.media_status='ready'` + `media_path`. A new BullMQ `playout-stage` job (reuses the worker pattern) does the pull. UI shows per-item readiness; TAKE is blocked until `ready`. Mirrors the growing-file SMB share already mounted for capture.
- **B — Stream from S3 via presigned URL.** CasparCG FFmpeg producer plays an HTTPS presigned URL directly. Zero staging, but seeking/trim and gapless transitions over network are fragile for broadcast. Acceptable as a fallback for SRT/RTMP, risky for SDI.
**Decision:** Phase A uses **A** (stage proxies for preview, masters for air) with **B** available as a per-channel "low-latency / no-stage" toggle. Zac confirms the current masters play fine in CasparCG, so staging is a **plain S3→/media copy — no transcode-for-playout step**. (Validate on hardware during implementation, but the model does not assume a transcode stage.)
---
## 5. Scheduling
### Phase A — playlist player
No wall clock. Operator builds a `playout_playlists` row, drags items in, hits PLAY. The playout sidecar walks `playout_items` by `sort_order`, cueing the next clip during the current one (`LOADBG`) and taking it at end-of-clip with the configured transition. `loop` repeats. As-run logged per item.
### Phase B — 24/7 continuous channel
Wall-clock timeline in `playout_schedule`. Reuse `src/scheduler.js`:
- Add a second tick (or extend the existing one) under the **same PG advisory lock pattern** — exactly-one-leader, so a multi-replica deploy doesn't double-fire.
- Tick responsibilities: stage upcoming items (look-ahead window), verify the on-air item matches the schedule, **fill gaps** (loop a filler/slate asset when the timeline has a hole — a channel must never go black), roll the day forward.
- As-run becomes the compliance record.
---
## 6. Phasing / Milestones
**Phase A — Playlist playout MVP**
1. Migration `029-playout.sql` (channels, playlists, items, as-run).
2. `services/playout/` sidecar: CasparCG image + AMCP control shim, single output target (start with **SRT or NDI** — no hardware needed for dev; DeckLink behind hardware check).
3. mam-api `routes/playout.js` — channel + playlist CRUD, start/stop, transport, RBAC.
4. `playout-stage` BullMQ job (S3 → /media).
5. web-ui `playout.html` — bin + drag-drop ordered playlist + program/preview monitors + transport.
6. DeckLink output on real hardware; port-contention check vs recorders.
**Phase B — 24/7 continuous channel**
7. `playout_schedule` + time-of-day grid UI.
8. Scheduler tick (advisory-locked) — look-ahead staging, gap-fill/slate, day-roll.
9. As-run reporting view.
10. Graphics/CG overlay (logo bug, lower-thirds) via CasparCG templates.
---
## 7. Open Questions (for review)
**Resolved (2026-05-30):**
- ~~CasparCG packaging~~**build our own image.** Fetch DeckLink + NDI SDKs at build time (not redistributable — same as capture's DeckLink build). GL context for the mixer comes from GPU passthrough on a real node, or **Xvfb** (virtual framebuffer) where there's no display — community images run `--privileged` + X11 socket. Pin the NDI SDK version to what the server expects (`.so` version mismatch is the common docker failure).
- ~~Master codec playability~~ → Zac confirms masters **play fine**; no transcode-for-playout. Staging = plain S3→/media copy.
- ~~Management GUI~~**single Dragonflight `playout.html`** drives everything via AMCP; operator never touches CasparCG.
- ~~Audio loudness~~**pre-normalize at stage time** (Zac, 2026-05-30). `playout-stage` job runs ffmpeg `loudnorm` (EBU R128, target 23 LUFS, true-peak 1 dBTP) once, on the S3→/media copy. Output is the cached version CasparCG plays. Staging is no longer a pure copy — staging cost ≈ realtime CPU per clip on first stage; results are reusable across channels. Override (`media_status='ready'` + raw copy) available for clips already mastered to spec.
- ~~Frame rate~~**`1080p5994`** default for new channels (Zac, 2026-05-30). Progressive 1080 @ 59.94 fps. Per-channel override allowed (`video_format` column). Streaming-friendly (SRT/RTMP/NDI) and current SDI gear accepts it; matches capture's 59.94 cadence.
- ~~Preview latency~~**HLS v1** (Zac, 2026-05-30). Reuse capture's `/live/<id>` HLS plumbing. CasparCG emits a second low-bitrate FFmpeg consumer to HLS. ~46s lag, fine for confidence monitor. Operator desk gets a real downstream monitor off the SDI/NDI output anyway. Revisit WebRTC if MCR operators complain.
- ~~Failover~~**auto-restart on healthy node** (Zac, 2026-05-30). Scheduler tick (§5) monitors `playout_sidecars` health (AMCP ping + container alive); on N missed checks marks the channel `error`, re-places it on another capability-matching node with a free output port, resumes the playlist from the next item after the as-run-logged on-air item. Gap = black/slate for ~530 s during respawn (operator sees a flag in the UI). **DeckLink channels are not auto-failed-over in v1** — device-index pinning makes the destination port non-trivial; v1 alerts and lets the operator move the channel. NDI/SRT/RTMP channels (no hardware contention) failover automatically. Tracked via `restart_count` + `last_restart_at` on `playout_channels`.
**Still open:**
- (none — all §7 questions resolved 2026-05-30)
---
## 8. Reused building blocks (already in the repo)
| Need | Existing piece |
|------|----------------|
| Spawn engine container local/remote | `recorders.js` Docker-socket + `node-agent /sidecar/start` |
| Node capability / port model | `cluster_nodes.capabilities`, cluster DeckLink-status endpoint |
| Single-leader scheduled transitions | `src/scheduler.js` + PG advisory lock |
| Background media jobs | BullMQ worker (`services/worker`) |
| RBAC scoping | `src/auth/authz.js` `assertProjectAccess` (channel/project_id) |
| HLS preview plumbing | capture's `/live/<id>` HLS output |
| Subclip in/out points | NLE editor in/out marking |
| API wrapper / SPA shell | `ZAMPP_API.fetch`, esbuild JSX pages |

View file

@ -0,0 +1,148 @@
# Storage Settings Warning + Growing-Files SMB Auth + Per-Recorder Growing Mode
**Date:** 2026-05-31
**Branch:** `feat/playout-mcr` (Forgejo: WildDragonLLC/dragonflight)
**Status:** Approved, ready for implementation plan
---
## Scope
Three related refinements to the Settings page and growing-files capture pipeline:
1. **Storage warning header** — a prominent set-once warning at the top of the Storage settings section.
2. **Growing-files SMB credentials + system CIFS mount** — store an SMB username/password and have the capture stack mount the growing share itself (Approach A).
3. **Per-recorder growing mode** — remove the global "capture writes to local SMB share first" toggle; make growing-files mode a per-recorder setting.
All changes live on the `growing-files` / storage-settings path. No playout changes (handled separately).
---
## Background (current state)
- **Settings storage:** single key/value `settings(key TEXT PRIMARY KEY, value TEXT, updated_at)` table (migration 006). Secrets like `s3_secret_key` are stored but `GET /settings/s3` returns only `s3_secret_key_exists` (never the value).
- **Growing settings UI:** `GrowingSettingsCard` in `services/web-ui/public/screens-admin.jsx` (rendered by `StorageSection` alongside `MountHealthStrip` and `S3SettingsCard`). Current keys: `growing_enabled`, `growing_path`, `growing_smb_url`, `growing_promote_after_seconds`.
- **Settings API:** `services/mam-api/src/routes/settings.js``GET/PUT /settings/growing` over `GROWING_KEYS`.
- **Global enable today:** the `growing_enabled` setting (checkbox labelled "Capture writes to the local SMB share first; Premier can edit while it's still growing"). `recorders.js` reads this global key at recorder-start and passes `GROWING_ENABLED=true/false` to the capture container.
- **Capture write path:** `services/capture/src/capture-manager.js` reads `process.env.GROWING_ENABLED` and `GROWING_PATH` (default `/growing`). When on, it writes the master to `/growing/{projectId}/{clipName}.{ext}` instead of streaming to S3; the promotion worker uploads to S3 after stop.
- **Current mount model:** `/growing` is a pre-mounted host bind-mount; the app never authenticates to SMB.
- **Per-recorder column already exists:** migration 014 added `recorders.growing_enabled BOOLEAN DEFAULT NULL` ("NULL = use global"), but recorder-start logic ignores it and the new-recorder modal does not expose it.
---
## Part 1 — Storage warning header
Add a danger-styled banner at the **top of `StorageSection()`**, above `MountHealthStrip`.
- Visual: full-width banner, danger token styling (`--danger` border + subtle danger background), alert icon, bold uppercase text.
- Exact copy:
> **⚠ WARNING — THESE SETTINGS ARE MEANT TO BE SET ONCE AT INITIAL DEPLOYMENT. CHANGING STORAGE PATHS MID-CYCLE CAN CAUSE DATABASE CORRUPTION AND FILE LOSS. PLEASE USE WITH CAUTION.**
- Pure presentational; no backend, no dismiss state (always visible).
**Files:** `services/web-ui/public/screens-admin.jsx` (and a small style rule in the appropriate CSS file if needed).
---
## Part 2 — Growing-files SMB credentials + system CIFS mount (Approach A)
The growing share is **shared infrastructure**, so the SMB connection config is global.
### New settings keys
| Key | Purpose | Notes |
|-----|---------|-------|
| `growing_smb_mount` | CIFS source for the system mount, e.g. `//10.0.0.25/mam-growing` | Distinct from `growing_smb_url` |
| `growing_smb_username` | SMB user | Returned in GET (not secret) |
| `growing_smb_password` | SMB password | **Write-only** — never returned |
| `growing_smb_vers` *(optional)* | CIFS protocol version, default `3.0` | Avoids mount negotiation failures |
`growing_smb_url` (the `smb://…` string) is retained unchanged as the **editor-facing** display value (Premiere connect string).
### Settings API (`settings.js`)
- Extend `GROWING_KEYS` with the new keys (except the password is handled specially).
- `GET /settings/growing`: return `growing_smb_mount`, `growing_smb_username`, `growing_smb_vers`, and `growing_smb_password_exists: boolean`**never** the password value. (Mirror the existing `s3_secret_key_exists` pattern.)
- `PUT /settings/growing`: upsert each provided key. For `growing_smb_password`, only write it when a non-empty value is provided (an empty/omitted field leaves the stored password unchanged). Provide a way to clear it (explicit empty sentinel or a "clear" affordance) — see Resolved decisions below.
### Settings UI (`GrowingSettingsCard`)
Add three fields to the card:
- **SMB mount (CIFS):** text input bound to `growing_smb_mount`, placeholder `//10.0.0.25/mam-growing`.
- **SMB username:** text input bound to `growing_smb_username`.
- **SMB password:** masked password input. Shows a "saved" indicator when `growing_smb_password_exists` is true; typing a new value replaces it; leaving it blank keeps the existing one. `autoComplete="new-password"`.
### Capture image (`services/capture/Dockerfile`)
Add `cifs-utils` to the installed packages so `mount -t cifs` is available inside the capture container.
### Capture-manager (`capture-manager.js`)
On capture start, when growing mode is active **and** `GROWING_SMB_MOUNT` is set:
1. Write a root-only credentials file (e.g. `/run/smb-creds`, mode `0600`) containing:
```
username=<GROWING_SMB_USERNAME>
password=<GROWING_SMB_PASSWORD>
```
(Credentials go in the file, **not** the mount command line, to avoid `ps`/log exposure.)
2. `mkdir -p $GROWING_PATH` then `mount -t cifs $GROWING_SMB_MOUNT $GROWING_PATH -o credentials=/run/smb-creds,uid=0,gid=0,file_mode=0664,dir_mode=0775,vers=$GROWING_SMB_VERS`.
3. If the mount succeeds → proceed writing the master to `$GROWING_PATH/...` (existing behaviour).
4. If the mount **fails** → log the error and fall back to S3 streaming (`growingPath = null`), so a recording is never lost.
5. On capture stop/cleanup, unmount `$GROWING_PATH` (best-effort; ignore "not mounted").
Mount isolation: each recorder runs in its **own** capture container, so each container mounts CIFS at its own private `/growing` — no cross-recorder mount conflicts, no ref-counting needed.
### Recorder start (`recorders.js`)
- Pass new env to the spawned capture container: `GROWING_SMB_MOUNT`, `GROWING_SMB_USERNAME`, `GROWING_SMB_PASSWORD`, `GROWING_SMB_VERS` (read from the `settings` table at start).
- The dynamically-spawned capture container must get `/growing` as an **empty mountpoint** (not a host bind-mount) so the in-container CIFS mount lands cleanly. Confirm/adjust the container spec accordingly. The container is already privileged (required for `mount`).
### Security notes
- The password is stored plaintext in the `settings` table, identical to the existing `s3_secret_key` handling — acceptable within this app's current secret model.
- The password reaches the capture container as an env var (visible via `docker inspect`), same as S3 keys already are. The credentials **file** (not the command line) is used for the actual mount.
---
## Part 3 — Per-recorder growing mode (remove the global toggle)
### Remove global enable
- Delete the global "Capture writes to the local SMB share first…" checkbox (`growing_enabled` key) from `GrowingSettingsCard`. The card no longer carries a global on/off — it is **infrastructure-only**: container mount path, SMB URL (editor), SMB mount + credentials, promote threshold.
- The `growing_enabled` settings *key* is retired from the UI. (It may remain in the table harmlessly; recorder-start no longer reads it.)
### Per-recorder semantics
- Reuse the existing `recorders.growing_enabled BOOLEAN` column. New semantics (no global to defer to): `TRUE` = this recorder uses growing-files mode; `NULL`/`FALSE` = off.
- `recorders.js` recorder-start: read the **recorder's own** `growing_enabled` (defaulting `NULL`→off) and set `GROWING_ENABLED` for the capture container from that, instead of the global setting.
- Add `growing_enabled` to `RECORDER_FIELDS` so create/update accept it.
### UI
- **New-recorder modal** (`modal-new-recorder.jsx`): add a "Growing-files mode" toggle that sets `growing_enabled` on the created recorder (default off).
- **Recorder edit** (wherever recorders are edited): same toggle.
- Helper text on the toggle notes that growing-files requires the SMB share to be configured in Settings → Storage.
### Fallback
If a recorder has `growing_enabled = true` but `growing_smb_mount` is not configured globally, capture logs a warning and falls back to S3 streaming (same fallback path as a failed mount). Recording is never blocked.
---
## Files changed
| File | Change |
|------|--------|
| `services/web-ui/public/screens-admin.jsx` | Storage warning banner; SMB mount/username/password fields in `GrowingSettingsCard`; remove global growing-enable checkbox |
| `services/web-ui/public/modal-new-recorder.jsx` | Per-recorder "Growing-files mode" toggle |
| `services/mam-api/src/routes/settings.js` | New growing SMB keys; write-only password (`growing_smb_password_exists`) |
| `services/mam-api/src/routes/recorders.js` | Read per-recorder `growing_enabled`; pass SMB env to capture; `RECORDER_FIELDS` += `growing_enabled`; empty `/growing` mountpoint |
| `services/capture/Dockerfile` | Add `cifs-utils` |
| `services/capture/src/capture-manager.js` | CIFS mount-on-start (creds file), unmount-on-stop, fallback to S3 on failure |
| CSS (storage warning / fields) | Minor styles if needed |
No DB migration required (the `recorders.growing_enabled` column already exists; new settings are key/value rows).
---
## Resolved decisions
- **Clearing the SMB password:** `PUT /settings/growing` treats a field value of the literal sentinel `""` with an explicit `growing_smb_password_clear: true` flag as "remove the stored password"; a blank field with no clear flag leaves it unchanged. (Keeps the common "don't retype the password on every save" UX while still allowing removal.)
- **CIFS version:** default `growing_smb_vers = 3.0`; overridable via settings to support older NAS targets.
- **Recorders already recording when the toggle changes:** the per-recorder `growing_enabled` is read at **start** only; changing it mid-recording has no effect on the active session (consistent with how all recorder encode settings already behave).
---
## Out of scope (deferred)
- Encrypting secrets at rest (the app's existing model stores `s3_secret_key` in plaintext; SMB password follows the same model).
- A global "growing-files master kill switch" (removed by design — control is now per-recorder).
- Exposing `growing_retention_days` in the UI (seeded in DB, still unsurfaced; unrelated to this work).
- Playout HLS preview fix (handled by a separate parallel effort).

View file

@ -67,10 +67,14 @@ RUN /usr/local/bin/ffmpeg -hide_banner -encoders 2>&1 | grep -E 'nvenc' \
# ── Stage 2: Runtime image ─────────────────────────────────────────────────── # ── Stage 2: Runtime image ───────────────────────────────────────────────────
FROM node:20-bookworm FROM node:20-bookworm
# Runtime deps for compiled ffmpeg libs # Runtime deps for compiled ffmpeg libs.
# cifs-utils provides mount.cifs so growing-files capture can mount the SMB
# landing-zone share inside the (privileged) container at start (Approach A).
# util-linux supplies mount/umount/mountpoint.
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libx264-164 libx265-199 libvpx7 libopus0 libmp3lame0 \ libx264-164 libx265-199 libvpx7 libopus0 libmp3lame0 \
libsrt1.5-openssl libzmq5 libstdc++6 libc++1 libc++abi1 \ libsrt1.5-openssl libzmq5 libstdc++6 libc++1 libc++abi1 \
cifs-utils util-linux \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy compiled ffmpeg/ffprobe # Copy compiled ffmpeg/ffprobe

View file

@ -1,5 +1,5 @@
import { spawn } from 'child_process'; import { spawn, execFileSync } from 'child_process';
import { mkdirSync } from 'node:fs'; import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { createUploadStream } from './s3/client.js'; import { createUploadStream } from './s3/client.js';
@ -9,11 +9,88 @@ const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
// Growing-files mode: writes the master to a local SMB-backed share that the // Growing-files mode: writes the master to a local SMB-backed share that the
// editor can mount, instead of streaming to S3 in real time. The promotion // editor can mount, instead of streaming to S3 in real time. The promotion
// worker uploads the finalized file to S3 after the recording stops. // worker uploads the finalized file to S3 after the recording stops.
// Toggled per-process by `GROWING_ENABLED=true` on the capture container // Toggled per-recorder via `GROWING_ENABLED=true` on the capture container
// (see routes/recorders.js where the env is composed). // (see routes/recorders.js where the env is composed).
const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true'; const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true';
const GROWING_PATH = process.env.GROWING_PATH || '/growing'; const GROWING_PATH = process.env.GROWING_PATH || '/growing';
// Approach A: when a CIFS source is supplied, this (privileged) container mounts
// the SMB landing-zone share at GROWING_PATH itself, using credentials supplied
// by the API from global Settings. Empty GROWING_SMB_MOUNT → no system mount
// (the host-bound /growing volume is used instead, or S3 streaming if growing
// is off).
// mount.cifs needs a UNC source (//host/share). Operators (and Settings) often
// store the share as an `smb://host/share` URL or a Windows `\\host\share`
// path; the kernel rejects those outright ("Mounting cifs URL not implemented
// yet"), which silently drops us back to S3. Normalize any of these forms to
// the `//host/share` UNC the mount helper accepts.
function toUncShare(raw) {
if (!raw) return '';
let s = String(raw).trim().replace(/\\/g, '/'); // \\host\share -> //host/share
s = s.replace(/^smb:\/\//i, '//'); // smb://host/share -> //host/share
if (!s.startsWith('//')) s = '//' + s.replace(/^\/+/, ''); // host/share -> //host/share
return s;
}
const GROWING_SMB_MOUNT = toUncShare(process.env.GROWING_SMB_MOUNT || '');
const GROWING_SMB_USERNAME = process.env.GROWING_SMB_USERNAME || '';
const GROWING_SMB_PASSWORD = process.env.GROWING_SMB_PASSWORD || '';
const GROWING_SMB_VERS = process.env.GROWING_SMB_VERS || '3.0';
const SMB_CREDS_FILE = '/run/smb-creds';
// True when GROWING_PATH is already a mountpoint (e.g. a prior session left it
// mounted, or a host bind-mount is present).
function isMounted(path) {
try { execFileSync('mountpoint', ['-q', path]); return true; }
catch { return false; }
}
// Mount the CIFS growing share at GROWING_PATH. Credentials go in a root-only
// file (NOT the command line) so they never appear in `ps`/process listings.
// Returns true on success (or if already mounted), false on failure — callers
// fall back to S3 streaming so a recording is never lost.
function mountGrowingShare() {
if (!GROWING_SMB_MOUNT) return false;
try {
if (isMounted(GROWING_PATH)) {
console.log('[capture] growing share already mounted at', GROWING_PATH);
return true;
}
try { mkdirSync(GROWING_PATH, { recursive: true }); } catch (_) {}
writeFileSync(
SMB_CREDS_FILE,
`username=${GROWING_SMB_USERNAME}\npassword=${GROWING_SMB_PASSWORD}\n`,
{ mode: 0o600 }
);
const opts = [
`credentials=${SMB_CREDS_FILE}`,
'uid=0', 'gid=0', 'file_mode=0664', 'dir_mode=0775',
`vers=${GROWING_SMB_VERS}`,
].join(',');
execFileSync('mount', ['-t', 'cifs', GROWING_SMB_MOUNT, GROWING_PATH, '-o', opts],
{ stdio: ['ignore', 'ignore', 'pipe'] });
console.log('[capture] mounted CIFS growing share', GROWING_SMB_MOUNT, '->', GROWING_PATH);
return true;
} catch (err) {
const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
console.error('[capture] CIFS mount failed (falling back to S3 streaming):', stderr);
return false;
}
}
// Best-effort unmount on session stop. Ignores "not mounted".
function unmountGrowingShare() {
if (!GROWING_SMB_MOUNT) return;
try {
if (isMounted(GROWING_PATH)) {
execFileSync('umount', [GROWING_PATH], { stdio: ['ignore', 'ignore', 'pipe'] });
console.log('[capture] unmounted growing share at', GROWING_PATH);
}
} catch (err) {
const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
console.warn('[capture] growing share unmount failed (ignored):', stderr);
}
}
// ── Codec catalogue ────────────────────────────────────────────────────── // ── Codec catalogue ──────────────────────────────────────────────────────
// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate // Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate
// / pix_fmt are layered on top from the per-recorder configuration. // / pix_fmt are layered on top from the per-recorder configuration.
@ -283,7 +360,15 @@ class CaptureManager {
// Growing-files: write master to the local SMB share instead of streaming // Growing-files: write master to the local SMB share instead of streaming
// to S3. Path is relative to the container's GROWING_PATH mount. // to S3. Path is relative to the container's GROWING_PATH mount.
const growingPath = GROWING_ENABLED //
// Approach A: if a CIFS source is configured, mount it now. A mount failure
// is non-fatal — we fall back to S3 streaming so the recording is never
// lost.
let growingActive = GROWING_ENABLED;
if (growingActive && GROWING_SMB_MOUNT) {
if (!mountGrowingShare()) growingActive = false; // fall back to S3
}
const growingPath = growingActive
? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}` ? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}`
: null; : null;
if (growingPath) { if (growingPath) {
@ -455,6 +540,11 @@ class CaptureManager {
if (processes.proxy) processes.proxy.kill('SIGINT'); if (processes.proxy) processes.proxy.kill('SIGINT');
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} } if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
// Release the CIFS mount (best-effort) once the ffmpeg writers are done with
// it. The promotion worker reads the staged file from the host/S3 side, not
// through this container's mount, so unmounting here is safe.
unmountGrowingShare();
try { try {
const uploadPromises = [currentSession.uploads.hires]; const uploadPromises = [currentSession.uploads.hires];
if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy); if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);

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

View file

@ -0,0 +1,10 @@
-- Migration 031 — Add last_seen_at to cluster_nodes
--
-- Playout failover (routes/playout.js restartChannel) queries cluster_nodes.last_seen_at
-- to find healthy nodes for channel re-placement. Column was missing from original
-- cluster schema; heartbeat endpoint updates it via /cluster/heartbeat.
ALTER TABLE cluster_nodes ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
-- Backfill existing nodes to NOW() so they're immediately eligible for failover
UPDATE cluster_nodes SET last_seen_at = NOW() WHERE last_seen_at IS NULL;

View file

@ -22,6 +22,7 @@ import jobsRouter from './routes/jobs.js';
import captureRouter from './routes/capture.js'; import captureRouter from './routes/capture.js';
import uploadRouter from './routes/upload.js'; import uploadRouter from './routes/upload.js';
import recordersRouter from './routes/recorders.js'; import recordersRouter from './routes/recorders.js';
import playoutRouter from './routes/playout.js';
import settingsRouter from './routes/settings.js'; import settingsRouter from './routes/settings.js';
import amppRouter from './routes/ampp.js'; import amppRouter from './routes/ampp.js';
import groupsRouter from './routes/groups.js'; import groupsRouter from './routes/groups.js';
@ -40,18 +41,12 @@ import { startCleanupLoop } from './tasks/cleanupTempSegments.js';
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
// ── Middleware ────────────────────────────────────────────────────────────────
// Tightened CORS — once cookies carry authority, `origin: true` would let
// any site forge requests with the cookie. Drive the allowlist from env.
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '') const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
.split(',').map(s => s.trim()).filter(Boolean); .split(',').map(s => s.trim()).filter(Boolean);
app.use(cors({ app.use(cors({
origin: (origin, cb) => { origin: (origin, cb) => {
// No Origin header (same-origin or curl) — allow.
if (!origin) return cb(null, true); if (!origin) return cb(null, true);
if (allowedOrigins.length === 0 || allowedOrigins.includes(origin)) return cb(null, true); if (allowedOrigins.length === 0 || allowedOrigins.includes(origin)) return cb(null, true);
// Reject cleanly: omit the Allow-Origin header so the browser surfaces
// a real CORS error instead of a 500 from a thrown Error in the callback.
console.warn('[cors] rejected origin:', origin); console.warn('[cors] rejected origin:', origin);
return cb(null, false); return cb(null, false);
}, },
@ -59,14 +54,8 @@ app.use(cors({
})); }));
app.use(express.json({ limit: '50mb' })); app.use(express.json({ limit: '50mb' }));
// Trust the reverse proxy only when explicitly told to (production HTTPS).
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1); if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);
// HSTS — once a browser has seen this header over HTTPS for dragonflight.live,
// it auto-upgrades every future http:// request to https:// before hitting the
// wire. Cookies are Secure-only (below) and the CORS allowlist rejects HTTP,
// so without HSTS a user who lands on http:// silently can't log in.
// Only emit on actual HTTPS responses; req.secure honors trust proxy + X-Forwarded-Proto.
if (process.env.AUTH_ENABLED === 'true') { if (process.env.AUTH_ENABLED === 'true') {
app.use((req, res, next) => { app.use((req, res, next) => {
if (req.secure) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); if (req.secure) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
@ -74,17 +63,13 @@ if (process.env.AUTH_ENABLED === 'true') {
}); });
} }
// Hard-fail when production-mode auth has no stable session secret. Without
// this, express-session falls back to an in-memory random secret which
// invalidates every session on restart and breaks multi-node deployments.
if (process.env.AUTH_ENABLED === 'true' && !process.env.SESSION_SECRET) { if (process.env.AUTH_ENABLED === 'true' && !process.env.SESSION_SECRET) {
console.error('[fatal] SESSION_SECRET is required when AUTH_ENABLED=true'); console.error('[fatal] SESSION_SECRET is required when AUTH_ENABLED=true');
process.exit(1); process.exit(1);
} }
// Session — actually wired this time. See specs/2026-05-27-auth-system-design.md.
app.use(session({ app.use(session({
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 /* seconds = 15 min */ }), store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 }),
secret: process.env.SESSION_SECRET, secret: process.env.SESSION_SECRET,
name: 'dragonflight.sid', name: 'dragonflight.sid',
cookie: { cookie: {
@ -94,36 +79,26 @@ app.use(session({
path: '/', path: '/',
maxAge: 8 * 3600 * 1000, maxAge: 8 * 3600 * 1000,
}, },
rolling: false, // sliding renewal handled in requireAuth so idle + absolute can be enforced separately rolling: false,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
})); }));
// ── Health ────────────────────────────────────────────────────────────────────
app.get('/health', (_req, res) => res.json({ status: 'ok' })); app.get('/health', (_req, res) => res.json({ status: 'ok' }));
// ── Auth gate ─────────────────────────────────────────────────────────────────
// req.path is relative to the /api/v1 mount, so /auth/login NOT /api/v1/auth/login.
const UNAUTH_PATHS = new Set([ const UNAUTH_PATHS = new Set([
'/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required', '/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required',
'/auth/google', '/auth/google/callback', '/auth/google/enabled', '/auth/google', '/auth/google/callback', '/auth/google/enabled',
]); ]);
// node-agent now authenticates /cluster/heartbeat with a bound api_token
// (migration 019 + bound_hostname on the token). requireAuth handles the
// bearer lookup and sets req.tokenBoundHostname; the heartbeat handler in
// routes/cluster.js verifies body.hostname matches that binding.
app.use('/api/v1', requireUiHeader); app.use('/api/v1', requireUiHeader);
app.use('/api/v1', (req, res, next) => { app.use('/api/v1', (req, res, next) => {
if (UNAUTH_PATHS.has(req.path)) return next(); if (UNAUTH_PATHS.has(req.path)) return next();
return requireAuth(req, res, next); return requireAuth(req, res, next);
}); });
// ── API Routes ────────────────────────────────────────────────────────────────
app.use('/api/v1/auth', authRouter); app.use('/api/v1/auth', authRouter);
// User and group administration is admin-only (RBAC v2). The auth gate above
// already established req.user; requireAdmin rejects non-admins with 403.
app.use('/api/v1/auth/users', requireAdmin, usersRouter); app.use('/api/v1/auth/users', requireAdmin, usersRouter);
app.use('/api/v1/users', requireAdmin, usersRouter); // alias for the SPA Users page app.use('/api/v1/users', requireAdmin, usersRouter);
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter); app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
app.use('/api/v1/assets', assetsRouter); app.use('/api/v1/assets', assetsRouter);
app.use('/api/v1/projects', projectsRouter); app.use('/api/v1/projects', projectsRouter);
@ -132,6 +107,7 @@ app.use('/api/v1/jobs', jobsRouter);
app.use('/api/v1/capture', captureRouter); app.use('/api/v1/capture', captureRouter);
app.use('/api/v1/upload', uploadRouter); app.use('/api/v1/upload', uploadRouter);
app.use('/api/v1/recorders', recordersRouter); app.use('/api/v1/recorders', recordersRouter);
app.use('/api/v1/playout', playoutRouter);
app.use('/api/v1/settings', settingsRouter); app.use('/api/v1/settings', settingsRouter);
app.use('/api/v1/ampp', amppRouter); app.use('/api/v1/ampp', amppRouter);
app.use('/api/v1/groups', requireAdmin, groupsRouter); app.use('/api/v1/groups', requireAdmin, groupsRouter);
@ -145,21 +121,14 @@ app.use('/api/v1/assets/:assetId/comments', commentsRouter);
app.use('/api/v1/imports', importsRouter); app.use('/api/v1/imports', importsRouter);
app.use('/api/v1/storage', storageRouter); app.use('/api/v1/storage', storageRouter);
// ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler); app.use(errorHandler);
// ── Start ────────────────────────────────────────────────────────────────────
import { readdirSync, readFileSync } from 'node:fs'; import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
const __dirnameMig = dirname(fileURLToPath(import.meta.url)); const __dirnameMig = dirname(fileURLToPath(import.meta.url));
async function runMigrations() { async function runMigrations() {
// Issue #107 — previously the loop swallowed errors and let the server boot
// on a half-migrated schema. Now: track applied migrations in a table, run
// every pending one inside a transaction, and exit non-zero on failure so
// the orchestrator restarts (and so an operator notices) instead of serving
// 500s for the next month.
const dir = join(__dirnameMig, 'db', 'migrations'); const dir = join(__dirnameMig, 'db', 'migrations');
let files = []; let files = [];
try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; } try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
@ -172,7 +141,6 @@ async function runMigrations() {
) )
`); `);
// Allow forcing a re-run via env when iterating locally.
const force = process.env.MIGRATIONS_FORCE === '1'; const force = process.env.MIGRATIONS_FORCE === '1';
const allowFailures = process.env.MIGRATIONS_ALLOW_FAILURES === '1'; const allowFailures = process.env.MIGRATIONS_ALLOW_FAILURES === '1';
@ -198,7 +166,6 @@ async function runMigrations() {
console.error('[migration] FAILED ' + f + ': ' + err.message); console.error('[migration] FAILED ' + f + ': ' + err.message);
client.release(); client.release();
if (allowFailures) continue; if (allowFailures) continue;
// Hard fail — better to crash now than serve traffic on a broken schema.
console.error('[migration] aborting startup. Set MIGRATIONS_ALLOW_FAILURES=1 to override.'); console.error('[migration] aborting startup. Set MIGRATIONS_ALLOW_FAILURES=1 to override.');
process.exit(1); process.exit(1);
} }
@ -207,13 +174,9 @@ async function runMigrations() {
} }
await runMigrations(); await runMigrations();
// Load S3 config from DB so any settings saved via the Settings page override env vars
await loadS3ConfigFromDb(); await loadS3ConfigFromDb();
// ── Cluster self-heartbeat ────────────────────────────────────────────────────
function getLocalIp() { function getLocalIp() {
// Prefer an explicit override — useful when running inside Docker where
// os.networkInterfaces() returns container bridge IPs, not the host LAN IP.
if (process.env.NODE_IP) return process.env.NODE_IP; if (process.env.NODE_IP) return process.env.NODE_IP;
const ifaces = os.networkInterfaces(); const ifaces = os.networkInterfaces();
@ -225,9 +188,6 @@ function getLocalIp() {
return '127.0.0.1'; return '127.0.0.1';
} }
// Detect NVIDIA GPUs available to this container via nvidia-smi.
// Returns an array like [{ index: 0, name: 'Tesla P4', memory_mb: 7680 }, ...]
// or an empty array if nvidia-smi is unavailable or no GPUs found.
function detectGpus() { function detectGpus() {
return new Promise(resolve => { return new Promise(resolve => {
exec( exec(
@ -249,6 +209,10 @@ function detectGpus() {
}); });
} }
// Primary mam-api node self-registers in cluster_nodes every 30s. Must write
// BOTH last_seen (legacy column) and last_seen_at (added by mig 031, used by
// playout failover) — otherwise the primary appears stale to the failover
// query and channels get re-placed off it incorrectly.
async function selfHeartbeat() { async function selfHeartbeat() {
const load = os.loadavg()[0]; const load = os.loadavg()[0];
const total = os.totalmem(); const total = os.totalmem();
@ -260,14 +224,15 @@ async function selfHeartbeat() {
pool.query( pool.query(
`INSERT INTO cluster_nodes `INSERT INTO cluster_nodes
(hostname, ip_address, role, version, api_url, (hostname, ip_address, role, version, api_url,
cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen) cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen, last_seen_at)
VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW()) VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW(),NOW())
ON CONFLICT (hostname) DO UPDATE SET ON CONFLICT (hostname) DO UPDATE SET
ip_address = EXCLUDED.ip_address, ip_address = EXCLUDED.ip_address,
cpu_usage = EXCLUDED.cpu_usage, cpu_usage = EXCLUDED.cpu_usage,
mem_used_mb = EXCLUDED.mem_used_mb, mem_used_mb = EXCLUDED.mem_used_mb,
mem_total_mb = EXCLUDED.mem_total_mb, mem_total_mb = EXCLUDED.mem_total_mb,
capabilities = EXCLUDED.capabilities, capabilities = EXCLUDED.capabilities,
last_seen_at = NOW(),
last_seen = NOW()`, last_seen = NOW()`,
[ [
process.env.NODE_HOSTNAME || os.hostname(), process.env.NODE_HOSTNAME || os.hostname(),
@ -292,39 +257,26 @@ const server = app.listen(PORT, () => {
if (process.env.AUTH_ENABLED === 'true' && process.env.TRUST_PROXY !== 'true') { if (process.env.AUTH_ENABLED === 'true' && process.env.TRUST_PROXY !== 'true') {
console.warn('[auth] WARNING: AUTH_ENABLED=true but TRUST_PROXY=false — req.ip will be the proxy IP, login rate-limit will throttle all clients together. Set TRUST_PROXY=true when behind nginx/HTTPS.'); console.warn('[auth] WARNING: AUTH_ENABLED=true but TRUST_PROXY=false — req.ip will be the proxy IP, login rate-limit will throttle all clients together. Set TRUST_PROXY=true when behind nginx/HTTPS.');
} }
// Boot the recorder scheduler tick loop after the HTTP server is live so
// the loop's self-calls to /recorders/:id/start|stop reach a ready socket.
startSchedulerLoop(); startSchedulerLoop();
// Boot the temp-segment cleanup loop (runs hourly).
startCleanupLoop(); startCleanupLoop();
}); });
// Issue #100 — graceful shutdown. Without this, `docker stop` (SIGTERM) killed
// the process mid-scheduler-tick, leaving Redis connections and Docker
// sockets dangling and producing partial DB writes. Now: stop the scheduler,
// finish in-flight HTTP requests, close PG/Redis pools, and exit cleanly
// (or hard-exit after 25 s if something is stuck).
let _shuttingDown = false; let _shuttingDown = false;
async function gracefulShutdown(signal) { async function gracefulShutdown(signal) {
if (_shuttingDown) return; if (_shuttingDown) return;
_shuttingDown = true; _shuttingDown = true;
console.log(`[shutdown] received ${signal} — closing gracefully…`); console.log(`[shutdown] received ${signal} — closing gracefully…`);
// Stop accepting new requests + wind down the scheduler tick.
try { stopSchedulerLoop(); } catch (_) {} try { stopSchedulerLoop(); } catch (_) {}
// Force-exit watchdog so a hung connection can't keep us alive forever.
const killSwitch = setTimeout(() => { const killSwitch = setTimeout(() => {
console.error('[shutdown] forced exit after 25s timeout'); console.error('[shutdown] forced exit after 25s timeout');
process.exit(1); process.exit(1);
}, 25_000); }, 25_000);
killSwitch.unref(); killSwitch.unref();
// Stop the HTTP server (waits for in-flight requests to finish).
await new Promise(resolve => server.close(resolve)); await new Promise(resolve => server.close(resolve));
// Close DB pool + S3 client + any other resources. Best-effort.
try { await pool.end(); } catch (e) { console.warn('[shutdown] pool.end:', e.message); } try { await pool.end(); } catch (e) { console.warn('[shutdown] pool.end:', e.message); }
console.log('[shutdown] clean exit'); console.log('[shutdown] clean exit');

View file

@ -1,6 +1,23 @@
import crypto from 'crypto';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { parseBearer, hashToken } from '../auth/tokens.js'; import { parseBearer, hashToken } from '../auth/tokens.js';
// In-process service token for the scheduler's loopback self-calls
// (scheduler.js -> /recorders|/playout). The scheduler runs in THIS process, so
// a per-boot random constant needs no env/compose config and is never exposed:
// it only travels over the loopback fetch inside the same process. Multi-replica
// is safe because each replica's scheduler only ever calls 127.0.0.1 (itself),
// matching that replica's token. Requests bearing it are treated as the seeded
// admin (DEV_USER) so RBAC + FK-bearing routes work.
export const INTERNAL_TOKEN = crypto.randomBytes(32).toString('hex');
const INTERNAL_HEADER = 'x-internal-token';
function isInternalCall(req) {
const got = req.headers[INTERNAL_HEADER];
if (typeof got !== 'string' || got.length !== INTERNAL_TOKEN.length) return false;
return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(INTERNAL_TOKEN));
}
// Stable UUID matching migration 023's seeded dev user. // Stable UUID matching migration 023's seeded dev user.
/** UUID of the seeded dev-mode placeholder. NOT a sentinel for "any unauthenticated user". */ /** UUID of the seeded dev-mode placeholder. NOT a sentinel for "any unauthenticated user". */
export const DEV_USER_ID = '00000000-0000-4000-8000-000000000000'; export const DEV_USER_ID = '00000000-0000-4000-8000-000000000000';
@ -25,6 +42,13 @@ async function loadUser(id) {
} }
export async function requireAuth(req, res, next) { export async function requireAuth(req, res, next) {
// Internal loopback self-call (scheduler). Acts as the seeded admin so RBAC
// and FK-bearing routes work, regardless of AUTH_ENABLED.
if (isInternalCall(req)) {
req.user = DEV_USER;
return next();
}
// Dev mode — attach the seeded dev user so FK-bearing routes work. // Dev mode — attach the seeded dev user so FK-bearing routes work.
if (process.env.AUTH_ENABLED !== 'true') { if (process.env.AUTH_ENABLED !== 'true') {
req.user = DEV_USER; req.user = DEV_USER;
@ -98,6 +122,8 @@ const CSRF_EXEMPT_PATHS = new Set(['/cluster/heartbeat']);
export function requireUiHeader(req, res, next) { export function requireUiHeader(req, res, next) {
if (!MUTATING.has(req.method)) return next(); if (!MUTATING.has(req.method)) return next();
// Internal loopback self-call (scheduler) — not a browser, can't be drive-by'd.
if (isInternalCall(req)) return next();
// Bearer-authed requests (Premiere panel, scripts) are exempt — they're not // Bearer-authed requests (Premiere panel, scripts) are exempt — they're not
// browsers and can't be drive-by'd from another origin. // browsers and can't be drive-by'd from another origin.
if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next(); if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next();

View file

@ -734,11 +734,14 @@ router.get('/:id/live-path', async (req, res, next) => {
if (a.rows.length === 0) return res.status(404).json({ error: 'Asset not found' }); if (a.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
const asset = a.rows[0]; const asset = a.rows[0];
if (asset.status !== 'live') return res.status(409).json({ error: 'Asset is not currently growing', status: asset.status }); if (asset.status !== 'live') return res.status(409).json({ error: 'Asset is not currently growing', status: asset.status });
const s = await pool.query(`SELECT key, value FROM settings WHERE key IN ('growing_enabled','growing_smb_url')`); // Growing-files mode is now per-recorder (recorders.growing_enabled), so we
// no longer gate on the removed global `growing_enabled` setting. A
// status='live' asset already proves a growing recorder is producing this
// file; we only need the editor-facing SMB URL to build the UNC path.
const s = await pool.query(`SELECT key, value FROM settings WHERE key = 'growing_smb_url'`);
const cfg = {}; const cfg = {};
for (const { key, value } of s.rows) cfg[key] = value; for (const { key, value } of s.rows) cfg[key] = value;
if (cfg.growing_enabled !== 'true') return res.status(409).json({ error: 'Growing-files mode is disabled' }); if (!cfg.growing_smb_url) return res.status(409).json({ error: 'No SMB URL configured — set the editor SMB URL in Settings → Storage' });
if (!cfg.growing_smb_url) return res.status(409).json({ error: 'No SMB URL configured — set growing_smb_url in Settings' });
const rec = await pool.query( const rec = await pool.query(
`SELECT recording_container FROM recorders WHERE current_session_id = $1 ORDER BY updated_at DESC LIMIT 1`, `SELECT recording_container FROM recorders WHERE current_session_id = $1 ORDER BY updated_at DESC LIMIT 1`,
[asset.id] [asset.id]

View file

@ -1,9 +1,23 @@
import express from 'express'; import express from 'express';
import http from 'http'; import http from 'http';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { requireAdmin } from '../middleware/auth.js';
const router = express.Router(); const router = express.Router();
// GET /onboard-info admin-only. Supplies the Add Node wizard with the bits it
// needs to build a `curl … | bash` onboarding command: the primary API URL the
// remote node-agent should heartbeat to, the raw URL of onboard-node.sh, and
// the deploy branch. apiUrl is a best guess the UI lets the operator edit.
router.get('/onboard-info', requireAdmin, (req, res) => {
const branch = process.env.DEPLOY_BRANCH || 'main';
const apiUrl = process.env.PUBLIC_API_URL
|| `${req.protocol}://${req.hostname}:${process.env.API_PORT || 47432}`;
const scriptUrl =
`https://forge.wilddragon.net/zgaetano/wild-dragon/raw/branch/${branch}/deploy/onboard-node.sh`;
res.json({ apiUrl, scriptUrl, branch });
});
// If the agent reported Docker's default bridge IP (172.17.x) but the request // If the agent reported Docker's default bridge IP (172.17.x) but the request
// itself came from a real LAN address, prefer the request source IP instead. // itself came from a real LAN address, prefer the request source IP instead.
// We only check 172.17.x — the default docker0 bridge — not the full RFC1918 // We only check 172.17.x — the default docker0 bridge — not the full RFC1918
@ -41,7 +55,6 @@ function dockerRequest(path, method = 'GET', body = null) {
}); });
} }
// GET / list all registered cluster nodes with online status
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {
const r = await pool.query( const r = await pool.query(
@ -57,7 +70,6 @@ router.get('/', async (req, res, next) => {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// GET /containers list all containers on the local Docker host
router.get('/containers', async (req, res, next) => { router.get('/containers', async (req, res, next) => {
try { try {
const containers = await dockerRequest('/containers/json?all=true'); const containers = await dockerRequest('/containers/json?all=true');
@ -88,7 +100,6 @@ router.get('/containers', async (req, res, next) => {
} }
}); });
// POST /containers/:nameOrId/restart
router.post('/containers/:nameOrId/restart', async (req, res, next) => { router.post('/containers/:nameOrId/restart', async (req, res, next) => {
try { try {
await dockerRequest(`/containers/${encodeURIComponent(req.params.nameOrId)}/restart`, 'POST'); await dockerRequest(`/containers/${encodeURIComponent(req.params.nameOrId)}/restart`, 'POST');
@ -96,7 +107,6 @@ router.post('/containers/:nameOrId/restart', async (req, res, next) => {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// POST /heartbeat upsert this node's registration (includes hardware capabilities)
router.post('/heartbeat', async (req, res, next) => { router.post('/heartbeat', async (req, res, next) => {
try { try {
const { const {
@ -108,11 +118,6 @@ router.post('/heartbeat', async (req, res, next) => {
if (!hostname) return res.status(400).json({ error: 'hostname is required' }); if (!hostname) return res.status(400).json({ error: 'hostname is required' });
// Issue #106 — any authenticated user used to be able to POST a heartbeat
// for an arbitrary hostname and overwrite the primary node's `api_url`,
// effectively hijacking job dispatch. Now: if the caller's token is bound
// to a hostname (node-agent tokens are bound at issue time), the body
// hostname must match. Admin users with no binding are allowed for ops.
if (process.env.AUTH_ENABLED === 'true') { if (process.env.AUTH_ENABLED === 'true') {
const bound = req.tokenBoundHostname; const bound = req.tokenBoundHostname;
if (bound && bound !== hostname) { if (bound && bound !== hostname) {
@ -132,8 +137,8 @@ router.post('/heartbeat', async (req, res, next) => {
const r = await pool.query( const r = await pool.query(
`INSERT INTO cluster_nodes `INSERT INTO cluster_nodes
(hostname, ip_address, role, version, api_url, (hostname, ip_address, role, version, api_url,
cpu_usage, mem_used_mb, mem_total_mb, last_seen, capabilities, metadata, metrics) cpu_usage, mem_used_mb, mem_total_mb, last_seen, last_seen_at, capabilities, metadata, metrics)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9,$10,$11) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),NOW(),$9,$10,$11)
ON CONFLICT (hostname) DO UPDATE SET ON CONFLICT (hostname) DO UPDATE SET
ip_address = EXCLUDED.ip_address, ip_address = EXCLUDED.ip_address,
role = EXCLUDED.role, role = EXCLUDED.role,
@ -143,6 +148,7 @@ router.post('/heartbeat', async (req, res, next) => {
mem_used_mb = EXCLUDED.mem_used_mb, mem_used_mb = EXCLUDED.mem_used_mb,
mem_total_mb = EXCLUDED.mem_total_mb, mem_total_mb = EXCLUDED.mem_total_mb,
last_seen = NOW(), last_seen = NOW(),
last_seen_at = NOW(),
capabilities = EXCLUDED.capabilities, capabilities = EXCLUDED.capabilities,
metadata = EXCLUDED.metadata, metadata = EXCLUDED.metadata,
metrics = COALESCE(EXCLUDED.metrics, cluster_nodes.metrics) metrics = COALESCE(EXCLUDED.metrics, cluster_nodes.metrics)
@ -165,42 +171,25 @@ router.post('/heartbeat', async (req, res, next) => {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// GET /devices/blackmagic/signal live video-presence state for every
// DeckLink port across the cluster. For each port we check whether there is
// an active SDI recorder assigned to it and, if so, query the capture
// container for its real signal state (receiving / lost / connecting /
// error). Ports without a recorder get signal = 'no-recorder'.
//
// Response shape (array):
// { node_id, hostname, index, device, model,
// signal, framesReceived, currentFps, recorder_id, recorder_status }
router.get('/devices/blackmagic/signal', async (req, res, next) => { router.get('/devices/blackmagic/signal', async (req, res, next) => {
try { try {
// 1. Fetch all cluster nodes with DeckLink capabilities.
const nodesResult = await pool.query( const nodesResult = await pool.query(
`SELECT id, hostname, ip_address, api_url, capabilities, `SELECT id, hostname, ip_address, api_url, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes FROM cluster_nodes
WHERE capabilities IS NOT NULL` WHERE capabilities IS NOT NULL`
); );
// 2. Fetch all SDI recorders that are pinned to a node+device_index.
const recResult = await pool.query( const recResult = await pool.query(
`SELECT id, name, status, container_id, node_id, device_index, `SELECT id, name, status, container_id, node_id, device_index,
source_config source_config
FROM recorders FROM recorders
WHERE source_type = 'sdi' AND node_id IS NOT NULL` WHERE source_type = 'sdi' AND node_id IS NOT NULL`
); );
// Build a fast lookup: "${node_id}:${device_index}" → recorder row.
const recByPort = new Map(); const recByPort = new Map();
for (const r of recResult.rows) { for (const r of recResult.rows) {
const devIdx = r.device_index ?? r.source_config?.device ?? 0; const devIdx = r.device_index ?? r.source_config?.device ?? 0;
recByPort.set(`${r.node_id}:${devIdx}`, r); recByPort.set(`${r.node_id}:${devIdx}`, r);
} }
// 3. For each port, determine signal state. We fire all capture-container
// fetches concurrently so the endpoint stays fast even with many ports.
const tasks = []; const tasks = [];
for (const node of nodesResult.rows) { for (const node of nodesResult.rows) {
const nodeOnline = Number(node.stale_seconds) < 120; const nodeOnline = Number(node.stale_seconds) < 120;
@ -208,79 +197,51 @@ router.get('/devices/blackmagic/signal', async (req, res, next) => {
const model = (node.capabilities && node.capabilities.blackmagic_model) || null; const model = (node.capabilities && node.capabilities.blackmagic_model) || null;
const localHostname = process.env.NODE_HOSTNAME || ''; const localHostname = process.env.NODE_HOSTNAME || '';
const isRemote = node.api_url && node.hostname !== localHostname; const isRemote = node.api_url && node.hostname !== localHostname;
bm.forEach((d, idx) => { bm.forEach((d, idx) => {
const portIndex = d.index !== undefined ? d.index : idx; const portIndex = d.index !== undefined ? d.index : idx;
const rec = recByPort.get(`${node.id}:${portIndex}`); const rec = recByPort.get(`${node.id}:${portIndex}`);
tasks.push((async () => { tasks.push((async () => {
const base = { const base = {
node_id: node.id, node_id: node.id, hostname: node.hostname, index: portIndex,
hostname: node.hostname, device: d.device || null, model, node_online: nodeOnline,
index: portIndex, recorder_id: rec ? rec.id : null, recorder_name: rec ? rec.name : null,
device: d.device || null,
model,
node_online: nodeOnline,
recorder_id: rec ? rec.id : null,
recorder_name: rec ? rec.name : null,
recorder_status: rec ? rec.status : null, recorder_status: rec ? rec.status : null,
signal: 'no-recorder', signal: 'no-recorder', framesReceived: null, currentFps: null,
framesReceived: null,
currentFps: null,
}; };
if (!rec || rec.status !== 'recording' || !rec.container_id) { if (!rec || rec.status !== 'recording' || !rec.container_id) {
// No active capture — if there's a recorder but it's not recording,
// report that; otherwise the port is unassigned.
if (rec && rec.status !== 'recording') base.signal = 'idle'; if (rec && rec.status !== 'recording') base.signal = 'idle';
return base; return base;
} }
// Active recording — query the capture container for real signal.
try { try {
let live = null; let live = null;
if (isRemote) { if (isRemote) {
const r = await fetch( const r = await fetch(`${node.api_url}/sidecar/${rec.container_id}/status`, { signal: AbortSignal.timeout(2500) });
`${node.api_url}/sidecar/${rec.container_id}/status`,
{ signal: AbortSignal.timeout(2500) }
);
if (r.ok) live = (await r.json()).live; if (r.ok) live = (await r.json()).live;
} else { } else {
const r = await fetch( const r = await fetch(`http://recorder-${rec.id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
`http://recorder-${rec.id}:3001/capture/status`,
{ signal: AbortSignal.timeout(2000) }
);
if (r.ok) live = await r.json(); if (r.ok) live = await r.json();
} }
if (live && live.signal) { if (live && live.signal) {
base.signal = live.signal; base.signal = live.signal;
base.framesReceived = live.framesReceived ?? null; base.framesReceived = live.framesReceived ?? null;
base.currentFps = live.currentFps ?? null; base.currentFps = live.currentFps ?? null;
} else { } else { base.signal = 'connecting'; }
base.signal = 'connecting'; } catch (_) { base.signal = 'connecting'; }
}
} catch (_) {
base.signal = 'connecting';
}
return base; return base;
})()); })());
}); });
} }
const results = await Promise.all(tasks); const results = await Promise.all(tasks);
res.json(results); res.json(results);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// GET /devices/blackmagic flatten every node's DeckLink cards for the
// recorder picker. Returns one entry per device with the host node info.
router.get('/devices/blackmagic', async (req, res, next) => { router.get('/devices/blackmagic', async (req, res, next) => {
try { try {
const r = await pool.query( const r = await pool.query(
`SELECT id, hostname, ip_address, role, capabilities, `SELECT id, hostname, ip_address, role, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes FROM cluster_nodes WHERE capabilities IS NOT NULL`
WHERE capabilities IS NOT NULL`
); );
const out = []; const out = [];
for (const row of r.rows) { for (const row of r.rows) {
@ -288,157 +249,98 @@ router.get('/devices/blackmagic', async (req, res, next) => {
const bm = (row.capabilities && row.capabilities.blackmagic) || []; const bm = (row.capabilities && row.capabilities.blackmagic) || [];
const model = (row.capabilities && row.capabilities.blackmagic_model) || null; const model = (row.capabilities && row.capabilities.blackmagic_model) || null;
bm.forEach((d, idx) => { bm.forEach((d, idx) => {
out.push({ out.push({ node_id: row.id, hostname: row.hostname, ip_address: row.ip_address,
node_id: row.id, role: row.role, online, model, index: d.index !== undefined ? d.index : idx, device: d.device });
hostname: row.hostname,
ip_address: row.ip_address,
role: row.role,
online,
model,
index: d.index !== undefined ? d.index : idx,
device: d.device,
});
}); });
} }
res.json(out); res.json(out);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// GET /devices/deltacast flatten every node's Deltacast cards for the
// recorder picker. Mirrors /devices/blackmagic shape so the UI can treat
// both card types uniformly.
router.get('/devices/deltacast', async (req, res, next) => { router.get('/devices/deltacast', async (req, res, next) => {
try { try {
const r = await pool.query( const r = await pool.query(
`SELECT id, hostname, ip_address, role, capabilities, `SELECT id, hostname, ip_address, role, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes FROM cluster_nodes WHERE capabilities IS NOT NULL`
WHERE capabilities IS NOT NULL`
); );
const out = []; const out = [];
for (const row of r.rows) { for (const row of r.rows) {
const online = Number(row.stale_seconds) < 120; const online = Number(row.stale_seconds) < 120;
const dc = (row.capabilities && row.capabilities.deltacast) || []; const dc = (row.capabilities && row.capabilities.deltacast) || [];
const model = (row.capabilities && row.capabilities.deltacast_model) || null; const model = (row.capabilities && row.capabilities.deltacast_model) || null;
// Also synthesise entries from DELTACAST_PORT_COUNT if no entries reported yet —
// useful for nodes that haven't sent a heartbeat since the agent was updated.
dc.forEach((d, idx) => { dc.forEach((d, idx) => {
out.push({ out.push({ node_id: row.id, hostname: row.hostname, ip_address: row.ip_address,
node_id: row.id, role: row.role, online, model: model || 'Deltacast',
hostname: row.hostname, index: d.index !== undefined ? d.index : idx, device: d.device,
ip_address: row.ip_address, present: d.present !== false, port_count: dc.length });
role: row.role,
online,
model: model || 'Deltacast',
index: d.index !== undefined ? d.index : idx,
device: d.device,
present: d.present !== false,
port_count: dc.length,
});
}); });
} }
res.json(out); res.json(out);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// GET /devices/deltacast/signal live signal state for Deltacast ports.
// Same pattern as /devices/blackmagic/signal.
router.get('/devices/deltacast/signal', async (req, res, next) => { router.get('/devices/deltacast/signal', async (req, res, next) => {
try { try {
const [nodesRes, recordersRes] = await Promise.all([ const [nodesRes, recordersRes] = await Promise.all([
pool.query( pool.query(`SELECT id, hostname, ip_address, api_url, capabilities,
`SELECT id, hostname, ip_address, api_url, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes FROM cluster_nodes WHERE capabilities IS NOT NULL`),
WHERE capabilities IS NOT NULL` pool.query(`SELECT id, node_id, device_index, status, source_type, container_id
), FROM recorders WHERE source_type = 'deltacast'`),
pool.query(
`SELECT id, node_id, device_index, status, source_type, container_id
FROM recorders WHERE source_type = 'deltacast'`
),
]); ]);
const recByNodePort = {}; const recByNodePort = {};
for (const rec of recordersRes.rows) { for (const rec of recordersRes.rows) {
recByNodePort[`${rec.node_id}:${rec.device_index}`] = rec; recByNodePort[`${rec.node_id}:${rec.device_index}`] = rec;
} }
const results = []; const results = [];
const fetchPromises = []; const fetchPromises = [];
for (const node of nodesRes.rows) { for (const node of nodesRes.rows) {
const online = Number(node.stale_seconds) < 120; const online = Number(node.stale_seconds) < 120;
const dc = (node.capabilities && node.capabilities.deltacast) || []; const dc = (node.capabilities && node.capabilities.deltacast) || [];
const model = (node.capabilities && node.capabilities.deltacast_model) || 'Deltacast'; const model = (node.capabilities && node.capabilities.deltacast_model) || 'Deltacast';
for (const port of dc) { for (const port of dc) {
const idx = port.index !== undefined ? port.index : dc.indexOf(port); const idx = port.index !== undefined ? port.index : dc.indexOf(port);
const rec = recByNodePort[`${node.id}:${idx}`]; const rec = recByNodePort[`${node.id}:${idx}`];
const base = { const base = { node_id: node.id, hostname: node.hostname, ip_address: node.ip_address,
node_id: node.id, online, model, index: idx, device: port.device, present: port.present !== false,
hostname: node.hostname, recorder_id: rec ? rec.id : null, recorder_status: rec ? rec.status : null,
ip_address: node.ip_address, signal: 'no-recorder', framesReceived: null, currentFps: null };
online,
model,
index: idx,
device: port.device,
present: port.present !== false,
recorder_id: rec ? rec.id : null,
recorder_status: rec ? rec.status : null,
signal: 'no-recorder',
framesReceived: null,
currentFps: null,
};
if (!rec) { results.push(base); continue; } if (!rec) { results.push(base); continue; }
if (rec.status !== 'recording') { base.signal = 'idle'; results.push(base); continue; } if (rec.status !== 'recording') { base.signal = 'idle'; results.push(base); continue; }
// Active recording — query capture container for real signal.
const fetchIdx = results.length; const fetchIdx = results.length;
results.push(base); results.push(base);
fetchPromises.push((async () => { fetchPromises.push((async () => {
try { try {
const url = node.api_url const url = node.api_url ? `${node.api_url}/sidecar/${rec.container_id}/status`
? `${node.api_url}/sidecar/${rec.container_id}/status`
: `http://recorder-${rec.id}:3001/capture/status`; : `http://recorder-${rec.id}:3001/capture/status`;
const r = await fetch(url, { signal: AbortSignal.timeout(2500) }); const r = await fetch(url, { signal: AbortSignal.timeout(2500) });
if (r.ok) { if (r.ok) {
const live = await r.json(); const live = await r.json();
if (live && live.signal) { if (live && live.signal) {
results[fetchIdx].signal = live.signal; results[fetchIdx].signal = live.signal;
results[fetchIdx].framesReceived = live.framesReceived ?? null; results[fetchIdx].framesReceived = live.framesReceived ?? null;
results[fetchIdx].currentFps = live.currentFps ?? null; results[fetchIdx].currentFps = live.currentFps ?? null;
} }
} }
} catch (_) { } catch (_) { results[fetchIdx].signal = 'connecting'; }
results[fetchIdx].signal = 'connecting';
}
})()); })());
} }
} }
await Promise.all(fetchPromises); await Promise.all(fetchPromises);
res.json(results); res.json(results);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// GET /:id/ping probe the node's api_url/health endpoint directly
router.get('/:id/ping', async (req, res, next) => { router.get('/:id/ping', async (req, res, next) => {
try { try {
const r = await pool.query( const r = await pool.query('SELECT id, hostname, api_url FROM cluster_nodes WHERE id = $1', [req.params.id]);
'SELECT id, hostname, api_url FROM cluster_nodes WHERE id = $1',
[req.params.id]
);
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' }); if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
const node = r.rows[0]; const node = r.rows[0];
if (!node.api_url) return res.json({ reachable: false, reason: 'no api_url registered' }); if (!node.api_url) return res.json({ reachable: false, reason: 'no api_url registered' });
const start = Date.now(); const start = Date.now();
try { try {
const upstream = await fetch(`${node.api_url}/health`, { const upstream = await fetch(`${node.api_url}/health`, { signal: AbortSignal.timeout(4000) });
signal: AbortSignal.timeout(4000),
});
const latency_ms = Date.now() - start; const latency_ms = Date.now() - start;
const body = await upstream.json().catch(() => ({})); const body = await upstream.json().catch(() => ({}));
res.json({ reachable: upstream.ok, latency_ms, status: upstream.status, agent: body }); res.json({ reachable: upstream.ok, latency_ms, status: upstream.status, agent: body });
@ -448,8 +350,6 @@ router.get('/:id/ping', async (req, res, next) => {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// GET /metrics - live per-node utilization (CPU, RAM, GPU)
router.get('/metrics', async (req, res, next) => { router.get('/metrics', async (req, res, next) => {
try { try {
const r = await pool.query( const r = await pool.query(
@ -457,59 +357,37 @@ router.get('/metrics', async (req, res, next) => {
cpu_usage, mem_used_mb, mem_total_mb, cpu_usage, mem_used_mb, mem_total_mb,
capabilities, metrics, capabilities, metrics,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes FROM cluster_nodes ORDER BY registered_at ASC`
ORDER BY registered_at ASC`
); );
const nodes = r.rows.map(row => { const nodes = r.rows.map(row => {
const capGpus = (row.capabilities && row.capabilities.gpus) || []; const capGpus = (row.capabilities && row.capabilities.gpus) || [];
const liveGpus = (row.metrics && row.metrics.gpus) || []; const liveGpus = (row.metrics && row.metrics.gpus) || [];
const gpus = capGpus.map((g, idx) => { const gpus = capGpus.map((g, idx) => {
const live = liveGpus.find(l => l.index === g.index) || liveGpus[idx] || {}; const live = liveGpus.find(l => l.index === g.index) || liveGpus[idx] || {};
return { return { name: g.name || null, util_pct: live.util_pct != null ? live.util_pct : null,
name: g.name || null, memory_used_mb: live.memory_used_mb != null ? live.memory_used_mb : null,
util_pct: live.util_pct != null ? live.util_pct : null, memory_total_mb: g.memory_mb != null ? g.memory_mb : (live.memory_total_mb ?? null) };
memory_used_mb: live.memory_used_mb != null ? live.memory_used_mb : null,
memory_total_mb: g.memory_mb != null ? g.memory_mb : (live.memory_total_mb ?? null),
};
}); });
// include any live GPUs not in static capabilities
for (const lg of liveGpus) { for (const lg of liveGpus) {
if (!capGpus.some(g => g.index === lg.index)) { if (!capGpus.some(g => g.index === lg.index)) {
gpus.push({ gpus.push({ name: lg.name || null, util_pct: lg.util_pct != null ? lg.util_pct : null,
name: lg.name || null, memory_used_mb: lg.memory_used_mb != null ? lg.memory_used_mb : null,
util_pct: lg.util_pct != null ? lg.util_pct : null, memory_total_mb: lg.memory_total_mb != null ? lg.memory_total_mb : null });
memory_used_mb: lg.memory_used_mb != null ? lg.memory_used_mb : null,
memory_total_mb: lg.memory_total_mb != null ? lg.memory_total_mb : null,
});
} }
} }
return { id: row.id, hostname: row.hostname, role: row.role,
return { online: Number(row.stale_seconds) < 120, last_seen: row.last_seen,
id: row.id, cpu_util_pct: row.cpu_usage != null ? Number(row.cpu_usage) : null,
hostname: row.hostname, ram_used_mb: row.mem_used_mb != null ? row.mem_used_mb : null,
role: row.role, ram_total_mb: row.mem_total_mb != null ? row.mem_total_mb : null, gpus };
online: Number(row.stale_seconds) < 120,
last_seen: row.last_seen,
cpu_util_pct: row.cpu_usage != null ? Number(row.cpu_usage) : null,
ram_used_mb: row.mem_used_mb != null ? row.mem_used_mb : null,
ram_total_mb: row.mem_total_mb != null ? row.mem_total_mb : null,
gpus,
};
}); });
res.json({ nodes }); res.json({ nodes });
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// DELETE /:id deregister a node
router.delete('/:id', async (req, res, next) => { router.delete('/:id', async (req, res, next) => {
try { try {
const r = await pool.query( const r = await pool.query('DELETE FROM cluster_nodes WHERE id = $1 RETURNING id', [req.params.id]);
'DELETE FROM cluster_nodes WHERE id = $1 RETURNING id',
[req.params.id]
);
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' }); if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { next(err); } } catch (err) { next(err); }

View file

@ -22,20 +22,22 @@ const parseRedisUrl = (url) => {
const redisConn = parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'); const redisConn = parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379');
const proxyQueue = new Queue('proxy', { connection: redisConn }); const proxyQueue = new Queue('proxy', { connection: redisConn });
const thumbnailQueue = new Queue('thumbnail', { connection: redisConn }); const thumbnailQueue = new Queue('thumbnail', { connection: redisConn });
const filmstripQueue = new Queue('filmstrip', { connection: redisConn }); const filmstripQueue = new Queue('filmstrip', { connection: redisConn });
const conformQueue = new Queue('conform', { connection: redisConn }); const conformQueue = new Queue('conform', { connection: redisConn });
const importQueue = new Queue('import', { connection: redisConn }); const importQueue = new Queue('import', { connection: redisConn });
const trimQueue = new Queue('trim', { connection: redisConn }); const trimQueue = new Queue('trim', { connection: redisConn });
const playoutStageQueue = new Queue('playout-stage', { connection: redisConn });
const QUEUES = [ const QUEUES = [
{ queue: proxyQueue, type: 'proxy' }, { queue: proxyQueue, type: 'proxy' },
{ queue: thumbnailQueue, type: 'thumbnail' }, { queue: thumbnailQueue, type: 'thumbnail' },
{ queue: filmstripQueue, type: 'filmstrip' }, { queue: filmstripQueue, type: 'filmstrip' },
{ queue: conformQueue, type: 'conform' }, { queue: conformQueue, type: 'conform' },
{ queue: importQueue, type: 'import' }, { queue: importQueue, type: 'import' },
{ queue: trimQueue, type: 'trim' }, { queue: trimQueue, type: 'trim' },
{ queue: playoutStageQueue, type: 'playout-stage' },
]; ];
// BullMQ state → API status mapping // BullMQ state → API status mapping

View file

@ -0,0 +1,666 @@
// Playout / Master Control routes.
//
// Control plane for the CasparCG-backed playout subsystem. Channels are placed
// on cluster nodes and their engine containers spawned via the same Docker-socket
// / node-agent path recorders use; the channel's transport (play / pause / skip)
// is proxied through to the sidecar's HTTP shim, which drives CasparCG over AMCP.
//
// RBAC: every channel carries a project_id (NULL = admin-only, the recorder
// convention). List routes filter by accessible projects; mutating routes assert
// 'edit'. See docs/superpowers/specs/2026-05-30-playout-mcr-design.md.
import express from 'express';
import http from 'http';
import { readFile } from 'fs/promises';
import { Queue } from 'bullmq';
import pool from '../db/pool.js';
import { validateUuid } from '../middleware/errors.js';
import {
assertProjectAccess, accessibleProjectIds, isAdmin,
} from '../auth/authz.js';
const router = express.Router();
const parseRedisUrl = (url) => {
const parsed = new URL(url);
return { host: parsed.hostname, port: parseInt(parsed.port, 10) };
};
const stageQueue = new Queue('playout-stage', {
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});
const PLAYOUT_SIDECAR_IMAGE = process.env.PLAYOUT_IMAGE || 'wild-dragon-playout:latest';
function dockerApi(method, path, body = null) {
return new Promise((resolve, reject) => {
const options = {
socketPath: '/var/run/docker.sock',
path: `/v1.43${path}`,
method,
headers: { 'Content-Type': 'application/json' },
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try { resolve({ status: res.statusCode, data: data ? JSON.parse(data) : {} }); }
catch { resolve({ status: res.statusCode, data }); }
});
});
req.on('error', reject);
req.setTimeout(10000, () => req.destroy(new Error('Docker API timeout after 10s')));
if (body) req.write(JSON.stringify(body));
req.end();
});
}
async function resolveNodeTarget(nodeId) {
if (!nodeId) return { remote: false };
const r = await pool.query(
'SELECT hostname, ip_address, api_url FROM cluster_nodes WHERE id = $1', [nodeId]
);
if (r.rows.length === 0) return { remote: false };
const node = r.rows[0];
const localHostname = process.env.NODE_HOSTNAME || '';
if (!node.api_url || node.hostname === localHostname) return { remote: false };
return { remote: true, apiUrl: node.api_url, ip: node.ip_address };
}
const SIDECAR_HTTP_PORT = 3002;
function channelAlias(id) { return `playout-${id}`; }
function sidecarBaseUrl(channel) {
if (channel.container_meta && channel.container_meta.sidecar_url) {
return channel.container_meta.sidecar_url;
}
return `http://${channelAlias(channel.id)}:${SIDECAR_HTTP_PORT}`;
}
async function callSidecar(channel, path, method = 'POST', body = null) {
const url = `${sidecarBaseUrl(channel)}${path}`;
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(20000),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`sidecar ${method} ${path} -> HTTP ${res.status}: ${text.slice(0, 200)}`);
}
return res.json().catch(() => ({}));
}
function channelToJson(r) {
return {
id: r.id,
name: r.name,
node_id: r.node_id,
output_type: r.output_type,
output_config: r.output_config,
video_format: r.video_format,
status: r.status,
container_id: r.container_id,
error_message: r.error_message,
project_id: r.project_id,
restart_count: r.restart_count ?? 0,
last_restart_at: r.last_restart_at,
last_heartbeat_at: r.last_heartbeat_at,
created_at: r.created_at,
updated_at: r.updated_at,
};
}
const OUTPUT_TYPES = new Set(['decklink', 'ndi', 'srt', 'rtmp']);
router.param('id', async (req, res, next) => {
validateUuid('id')(req, res, () => {});
if (res.headersSent) return;
try {
const { rows } = await pool.query(
'SELECT * FROM playout_channels WHERE id = $1', [req.params.id]
);
if (rows.length === 0) return res.status(404).json({ error: 'Channel not found' });
req.channel = rows[0];
await assertProjectAccess(req.user, req.channel.project_id, 'view');
next();
} catch (err) { next(err); }
});
async function requireChannelEdit(req, res, next) {
try { await assertProjectAccess(req.user, req.channel.project_id, 'edit'); next(); }
catch (err) { next(err); }
}
router.get('/channels', async (req, res, next) => {
try {
let rows;
if (isAdmin(req.user)) {
({ rows } = await pool.query('SELECT * FROM playout_channels ORDER BY created_at DESC'));
} else {
const ids = await accessibleProjectIds(req.user);
if (ids.length === 0) return res.json([]);
({ rows } = await pool.query(
'SELECT * FROM playout_channels WHERE project_id = ANY($1) ORDER BY created_at DESC', [ids]
));
}
res.json(rows.map(channelToJson));
} catch (err) { next(err); }
});
router.post('/channels', async (req, res, next) => {
try {
const { name, node_id = null, output_type = 'srt', output_config = {},
video_format = '1080p5994', project_id = null } = req.body || {};
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'name is required' });
}
if (!OUTPUT_TYPES.has(output_type)) {
return res.status(400).json({ error: `output_type must be one of: ${[...OUTPUT_TYPES].join(', ')}` });
}
if (project_id) await assertProjectAccess(req.user, project_id, 'edit');
else if (!isAdmin(req.user)) return res.status(403).json({ error: 'admin required for unassigned channel' });
const { rows } = await pool.query(
`INSERT INTO playout_channels (name, node_id, output_type, output_config, video_format, project_id)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[name.trim(), node_id, output_type, JSON.stringify(output_config), video_format, project_id]
);
res.status(201).json(channelToJson(rows[0]));
} catch (err) { next(err); }
});
router.patch('/channels/:id', requireChannelEdit, async (req, res, next) => {
try {
if (req.channel.status === 'running') {
return res.status(409).json({ error: 'Cannot edit a running channel — stop it first' });
}
const allowed = ['name', 'node_id', 'output_type', 'output_config', 'video_format', 'project_id'];
const sets = [];
const vals = [];
let i = 1;
for (const k of allowed) {
if (req.body[k] === undefined) continue;
if (k === 'output_type' && !OUTPUT_TYPES.has(req.body[k])) {
return res.status(400).json({ error: 'invalid output_type' });
}
sets.push(`${k} = $${i++}`);
vals.push(k === 'output_config' ? JSON.stringify(req.body[k]) : req.body[k]);
}
if (sets.length === 0) return res.json(channelToJson(req.channel));
vals.push(req.channel.id);
const { rows } = await pool.query(
`UPDATE playout_channels SET ${sets.join(', ')}, updated_at = NOW() WHERE id = $${i} RETURNING *`, vals
);
res.json(channelToJson(rows[0]));
} catch (err) { next(err); }
});
router.delete('/channels/:id', requireChannelEdit, async (req, res, next) => {
try {
if (req.channel.status === 'running') {
return res.status(409).json({ error: 'Stop the channel before deleting it' });
}
await pool.query('DELETE FROM playout_channels WHERE id = $1', [req.channel.id]);
res.json({ deleted: true });
} catch (err) { next(err); }
});
async function assertDeckLinkFree(channel) {
if (channel.output_type !== 'decklink') return;
const idx = (channel.output_config && channel.output_config.device_index) || 1;
const chan = await pool.query(
`SELECT id FROM playout_channels
WHERE id <> $1 AND node_id IS NOT DISTINCT FROM $2 AND status = 'running'
AND output_type = 'decklink' AND (output_config->>'device_index')::int = $3`,
[channel.id, channel.node_id, idx]
);
if (chan.rows.length > 0) {
throw Object.assign(new Error(`DeckLink device ${idx} already in use by another channel on this node`), { httpStatus: 409 });
}
const rec = await pool.query(
`SELECT id FROM recorders
WHERE node_id IS NOT DISTINCT FROM $1 AND device_index = $2
AND status = 'recording' AND source_type = 'sdi'`,
[channel.node_id, idx]
);
if (rec.rows.length > 0) {
throw Object.assign(new Error(`DeckLink device ${idx} is in use by a recorder on this node`), { httpStatus: 409 });
}
}
async function spawnChannelSidecar(channel) {
await pool.query('UPDATE playout_channels SET status = $1, error_message = NULL WHERE id = $2', ['starting', channel.id]);
const env = [
`OUTPUT_TYPE=${channel.output_type}`,
`OUTPUT_CONFIG=${JSON.stringify(channel.output_config || {})}`,
`VIDEO_FORMAT=${channel.video_format}`,
`PORT=${SIDECAR_HTTP_PORT}`,
`CHANNEL_ID=${channel.id}`,
];
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(channel.node_id);
const dockerNetwork = process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon';
let containerId;
let containerMeta = {};
if (isRemote) {
const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image: PLAYOUT_SIDECAR_IMAGE, env,
capturePort: SIDECAR_HTTP_PORT,
sourceType: channel.output_type,
useGpu: false,
publishHttp: true,
}),
signal: AbortSignal.timeout(20000),
});
if (!sidecarRes.ok) {
const details = await sidecarRes.json().catch(() => ({}));
console.error('[playout] remote sidecar start failed:', JSON.stringify(details));
await pool.query('UPDATE playout_channels SET status = $1, error_message = $2 WHERE id = $3',
['error', 'remote node failed to start sidecar', channel.id]);
throw Object.assign(new Error('Remote node failed to start sidecar'), { httpStatus: 502 });
}
const data = await sidecarRes.json();
containerId = data.containerId;
if (data.sidecarUrl || data.host) {
containerMeta.sidecar_url = data.sidecarUrl || `http://${data.host}:${SIDECAR_HTTP_PORT}`;
}
} else {
const alias = channelAlias(channel.id);
const hostBinds = ['/mnt/NVME/MAM/wild-dragon-media:/media'];
if (channel.output_type === 'decklink') hostBinds.push('/dev/blackmagic:/dev/blackmagic');
const containerConfig = {
Image: PLAYOUT_SIDECAR_IMAGE,
Env: env,
HostConfig: {
// DeckLink SDI needs raw /dev access (privileged). SRT/NDI/RTMP/HLS run
// unprivileged — privileged exposes host GPUs to CasparCG, and the
// missing in-container NVIDIA driver crashes the engine within seconds.
Privileged: channel.output_type === 'decklink',
NetworkMode: dockerNetwork,
Binds: hostBinds,
},
NetworkingConfig: { EndpointsConfig: { [dockerNetwork]: { Aliases: [alias] } } },
Hostname: alias,
};
const createRes = await dockerApi('POST', '/containers/create', containerConfig);
if (createRes.status !== 201) {
console.error('[playout] container create failed:', JSON.stringify(createRes.data));
await pool.query('UPDATE playout_channels SET status = $1, error_message = $2 WHERE id = $3',
['error', 'container create failed', channel.id]);
throw Object.assign(new Error('Failed to create container'), { httpStatus: 500 });
}
containerId = createRes.data.Id;
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
if (startRes.status !== 204) {
console.error('[playout] container start failed:', JSON.stringify(startRes.data));
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
await pool.query('UPDATE playout_channels SET status = $1, error_message = $2 WHERE id = $3',
['error', 'container start failed', channel.id]);
throw Object.assign(new Error('Failed to start container'), { httpStatus: 500 });
}
}
// Set last_heartbeat_at = NOW() so the scheduler health tick treats this
// channel as freshly alive. Without this, last_heartbeat_at starts as NULL
// (epoch=0), and the very first tick sees ageMs >> TIMEOUT_MS and triggers
// failover immediately — before the sidecar has had a chance to respond.
const { rows } = await pool.query(
`UPDATE playout_channels
SET status = 'running', container_id = $1, container_meta = $2,
last_heartbeat_at = NOW(), updated_at = NOW()
WHERE id = $3 RETURNING *`,
[containerId, JSON.stringify(containerMeta), channel.id]
);
return rows[0];
}
router.post('/channels/:id/start', requireChannelEdit, async (req, res, next) => {
try {
const channel = req.channel;
if (channel.status === 'running' || channel.status === 'starting') {
return res.status(409).json({ error: `Channel already ${channel.status}` });
}
await assertDeckLinkFree(channel);
const row = await spawnChannelSidecar(channel);
res.json(channelToJson(row));
} catch (err) {
if (err.httpStatus) return res.status(err.httpStatus).json({ error: err.message });
next(err);
}
});
router.post('/channels/:id/stop', requireChannelEdit, async (req, res, next) => {
try {
const channel = req.channel;
if (channel.container_id) {
const { remote: isRemote, apiUrl } = await resolveNodeTarget(channel.node_id);
if (isRemote) {
await fetch(`${apiUrl}/sidecar/stop`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ containerId: channel.container_id }),
signal: AbortSignal.timeout(20000),
}).catch((e) => console.error('[playout] remote stop failed:', e.message));
} else {
await dockerApi('POST', `/containers/${channel.container_id}/stop?t=10`).catch(() => {});
await dockerApi('DELETE', `/containers/${channel.container_id}?force=true`).catch(() => {});
}
}
const { rows } = await pool.query(
`UPDATE playout_channels SET status = 'stopped', container_id = NULL, updated_at = NOW()
WHERE id = $1 RETURNING *`, [channel.id]
);
res.json(channelToJson(rows[0]));
} catch (err) { next(err); }
});
router.get('/channels/:id/status', async (req, res, next) => {
try {
if (req.channel.status !== 'running') {
return res.json({ running: false, status: req.channel.status });
}
const out = await callSidecar(req.channel, '/status', 'GET');
res.json({ running: true, status: req.channel.status, engine: out });
} catch (err) {
res.json({ running: true, status: req.channel.status, engine: null, engine_error: err.message });
}
});
// GET /playout/channels/:id/hls/index.m3u8 — the live preview playlist, served
// through the API (not the static /media/live path) so it bypasses the public
// reverse proxy's static cache. That proxy caches the .m3u8 by path with a
// multi-second TTL and ignores the origin's no-store, so hls.js's ~1s reloads
// always got a STALE playlist ("MISSED" forever → monitor stayed black). The
// /api/ path is not proxy-cached (the status poll updates fine), so this always
// returns the fresh live edge. Segment (.ts) lines are rewritten to absolute
// /media/live/<id>/ URLs so they still load from the static path (immutable,
// caching them is fine). mam-api shares the same /media volume the sidecars
// write to.
const HLS_MEDIA_DIR = process.env.PLAYOUT_MEDIA_DIR || '/media';
router.get('/channels/:id/hls/index.m3u8', async (req, res, next) => {
try {
const cid = req.channel.id;
let body;
try {
body = await readFile(`${HLS_MEDIA_DIR}/live/${cid}/index.m3u8`, 'utf8');
} catch (e) {
return res.status(404).json({ error: 'No live preview for this channel yet' });
}
// Rewrite bare segment names to absolute static URLs.
const rewritten = body
.split('\n')
.map((line) => (/^[^#].*\.ts\s*$/.test(line) ? `/media/live/${cid}/${line.trim()}` : line))
.join('\n');
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.send(rewritten);
} catch (err) { next(err); }
});
// ── Transport ────────────────────────────────────────────────────────────────
async function transport(req, res, action, body = null) {
if (req.channel.status !== 'running') {
return res.status(409).json({ error: 'Channel is not running' });
}
try { res.json(await callSidecar(req.channel, action, 'POST', body)); }
catch (err) { res.status(502).json({ error: err.message }); }
}
router.post('/channels/:id/play', requireChannelEdit, async (req, res, next) => {
try {
if (req.channel.status !== 'running') {
return res.status(409).json({ error: 'Start the channel before playing' });
}
const { playlist_id } = req.body || {};
if (!playlist_id) return res.status(400).json({ error: 'playlist_id is required' });
const pl = await pool.query('SELECT * FROM playout_playlists WHERE id = $1 AND channel_id = $2',
[playlist_id, req.channel.id]);
if (pl.rows.length === 0) return res.status(404).json({ error: 'Playlist not found for this channel' });
const items = await pool.query(
`SELECT i.*, a.filename AS clip_name, a.duration_ms AS asset_duration_ms
FROM playout_items i JOIN assets a ON a.id = i.asset_id
WHERE i.playlist_id = $1 ORDER BY i.sort_order ASC`, [playlist_id]);
const notReady = items.rows.filter((i) => i.media_status !== 'ready' || !i.media_path);
if (notReady.length > 0) {
return res.status(409).json({
error: 'Some items are not staged yet',
pending: notReady.map((i) => i.id),
});
}
const payload = {
loop: pl.rows[0].loop,
items: items.rows.map((i) => ({
id: i.id, asset_id: i.asset_id, media_path: i.media_path,
in_point: i.in_point ? Number(i.in_point) : null,
out_point: i.out_point ? Number(i.out_point) : null,
transition: i.transition, transition_ms: i.transition_ms,
clip_name: i.clip_name,
asset_duration_ms: i.asset_duration_ms != null ? Number(i.asset_duration_ms) : null,
})),
};
// callSidecar throws on network/timeout errors. Return 502 (not 409) so
// the UI and operators know it's a gateway problem, not a state conflict.
let out;
try {
out = await callSidecar(req.channel, '/playlist/load', 'POST', payload);
} catch (err) {
return res.status(502).json({ error: 'Sidecar unreachable: ' + err.message });
}
res.json(out);
} catch (err) { next(err); }
});
router.post('/channels/:id/pause', requireChannelEdit, (req, res) => transport(req, res, '/transport/pause'));
router.post('/channels/:id/resume', requireChannelEdit, (req, res) => transport(req, res, '/transport/resume'));
router.post('/channels/:id/skip', requireChannelEdit, (req, res) => transport(req, res, '/transport/skip'));
router.post('/channels/:id/stop-playback', requireChannelEdit, (req, res) => transport(req, res, '/channel/stop'));
router.get('/channels/:id/asrun', async (req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT * FROM playout_as_run WHERE channel_id = $1 ORDER BY started_at DESC LIMIT 500`,
[req.channel.id]);
res.json(rows);
} catch (err) { next(err); }
});
async function loadChannelForBody(req, res, next) {
const channelId = req.body.channel_id || req.query.channel_id;
if (!channelId) return res.status(400).json({ error: 'channel_id is required' });
try {
const { rows } = await pool.query('SELECT * FROM playout_channels WHERE id = $1', [channelId]);
if (rows.length === 0) return res.status(404).json({ error: 'Channel not found' });
req.channel = rows[0];
await assertProjectAccess(req.user, req.channel.project_id, 'edit');
next();
} catch (err) { next(err); }
}
router.get('/playlists', async (req, res, next) => {
try {
const channelId = req.query.channel_id;
if (!channelId) return res.status(400).json({ error: 'channel_id is required' });
const ch = await pool.query('SELECT project_id FROM playout_channels WHERE id = $1', [channelId]);
if (ch.rows.length === 0) return res.status(404).json({ error: 'Channel not found' });
await assertProjectAccess(req.user, ch.rows[0].project_id, 'view');
const { rows } = await pool.query(
'SELECT * FROM playout_playlists WHERE channel_id = $1 ORDER BY created_at ASC', [channelId]);
res.json(rows);
} catch (err) { next(err); }
});
router.post('/playlists', loadChannelForBody, async (req, res, next) => {
try {
const { name, loop = false } = req.body || {};
if (!name) return res.status(400).json({ error: 'name is required' });
const { rows } = await pool.query(
'INSERT INTO playout_playlists (channel_id, name, loop) VALUES ($1,$2,$3) RETURNING *',
[req.channel.id, name.trim(), !!loop]);
res.status(201).json(rows[0]);
} catch (err) { next(err); }
});
router.get('/playlists/:plid/items', validateUuid('plid'), async (req, res, next) => {
try {
const pl = await pool.query(
`SELECT p.*, c.project_id FROM playout_playlists p
JOIN playout_channels c ON c.id = p.channel_id WHERE p.id = $1`, [req.params.plid]);
if (pl.rows.length === 0) return res.status(404).json({ error: 'Playlist not found' });
await assertProjectAccess(req.user, pl.rows[0].project_id, 'view');
const { rows } = await pool.query(
`SELECT i.*, a.filename AS clip_name, a.duration_ms AS asset_duration_ms
FROM playout_items i JOIN assets a ON a.id = i.asset_id
WHERE i.playlist_id = $1 ORDER BY i.sort_order ASC`, [req.params.plid]);
res.json(rows);
} catch (err) { next(err); }
});
async function loadPlaylistEdit(plid, user) {
const pl = await pool.query(
`SELECT p.*, c.project_id FROM playout_playlists p
JOIN playout_channels c ON c.id = p.channel_id WHERE p.id = $1`, [plid]);
if (pl.rows.length === 0) { throw Object.assign(new Error('Playlist not found'), { httpStatus: 404 }); }
await assertProjectAccess(user, pl.rows[0].project_id, 'edit');
return pl.rows[0];
}
router.post('/playlists/:plid/items', validateUuid('plid'), async (req, res, next) => {
try {
await loadPlaylistEdit(req.params.plid, req.user);
const { asset_id, in_point = null, out_point = null,
transition = 'cut', transition_ms = 0 } = req.body || {};
if (!asset_id) return res.status(400).json({ error: 'asset_id is required' });
const ord = await pool.query(
'SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM playout_items WHERE playlist_id = $1',
[req.params.plid]);
const { rows } = await pool.query(
`INSERT INTO playout_items (playlist_id, asset_id, sort_order, in_point, out_point, transition, transition_ms)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[req.params.plid, asset_id, ord.rows[0].next, in_point, out_point, transition, transition_ms]);
await stageQueue.add('stage', { itemId: rows[0].id, assetId: asset_id }).catch((e) =>
console.error('[playout] failed to enqueue stage job:', e.message));
res.status(201).json(rows[0]);
} catch (err) {
if (err.httpStatus) return res.status(err.httpStatus).json({ error: err.message });
next(err);
}
});
router.put('/playlists/:plid/reorder', validateUuid('plid'), async (req, res, next) => {
const client = await pool.connect();
try {
await loadPlaylistEdit(req.params.plid, req.user);
const { order } = req.body || {};
if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of item ids' });
await client.query('BEGIN');
for (let i = 0; i < order.length; i++) {
await client.query(
'UPDATE playout_items SET sort_order = $1, updated_at = NOW() WHERE id = $2 AND playlist_id = $3',
[i, order[i], req.params.plid]);
}
await client.query('COMMIT');
res.json({ reordered: order.length });
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
if (err.httpStatus) return res.status(err.httpStatus).json({ error: err.message });
next(err);
} finally { client.release(); }
});
router.delete('/items/:itemId', validateUuid('itemId'), async (req, res, next) => {
try {
const it = await pool.query(
`SELECT i.id, c.project_id FROM playout_items i
JOIN playout_playlists p ON p.id = i.playlist_id
JOIN playout_channels c ON c.id = p.channel_id WHERE i.id = $1`, [req.params.itemId]);
if (it.rows.length === 0) return res.status(404).json({ error: 'Item not found' });
await assertProjectAccess(req.user, it.rows[0].project_id, 'edit');
await pool.query('DELETE FROM playout_items WHERE id = $1', [req.params.itemId]);
res.json({ deleted: true });
} catch (err) { next(err); }
});
router.post('/items/:itemId/stage', validateUuid('itemId'), async (req, res, next) => {
try {
const it = await pool.query(
`SELECT i.id, i.asset_id, c.project_id FROM playout_items i
JOIN playout_playlists p ON p.id = i.playlist_id
JOIN playout_channels c ON c.id = p.channel_id WHERE i.id = $1`, [req.params.itemId]);
if (it.rows.length === 0) return res.status(404).json({ error: 'Item not found' });
await assertProjectAccess(req.user, it.rows[0].project_id, 'edit');
await pool.query("UPDATE playout_items SET media_status = 'pending' WHERE id = $1", [req.params.itemId]);
await stageQueue.add('stage', { itemId: it.rows[0].id, assetId: it.rows[0].asset_id });
res.json({ queued: true });
} catch (err) { next(err); }
});
export async function restartChannel(channelId) {
const { rows } = await pool.query('SELECT * FROM playout_channels WHERE id = $1', [channelId]);
if (rows.length === 0) return { restarted: false, reason: 'channel not found' };
const channel = rows[0];
if (channel.output_type === 'decklink') {
return { restarted: false, reason: 'decklink channels are alert-only' };
}
if (channel.container_id) {
const { remote, apiUrl } = await resolveNodeTarget(channel.node_id);
if (remote && apiUrl) {
await fetch(`${apiUrl}/sidecar/stop`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ containerId: channel.container_id }),
signal: AbortSignal.timeout(10000),
}).catch(() => {});
} else {
await dockerApi('DELETE', `/containers/${channel.container_id}?force=true`).catch(() => {});
}
}
const nodes = await pool.query(
`SELECT id, hostname, api_url, last_seen_at FROM cluster_nodes
WHERE id <> $1 AND last_seen_at > NOW() - INTERVAL '60 seconds'
ORDER BY last_seen_at DESC LIMIT 1`,
[channel.node_id]
);
if (nodes.rows.length === 0) {
await pool.query(
"UPDATE playout_channels SET status = 'error', error_message = $1 WHERE id = $2",
['no healthy node available for failover', channel.id]
);
return { restarted: false, reason: 'no eligible node' };
}
const newNodeId = nodes.rows[0].id;
const { rows: moved } = await pool.query(
`UPDATE playout_channels
SET node_id = $1, status = 'stopped', container_id = NULL, container_meta = '{}'::jsonb,
restart_count = restart_count + 1, last_restart_at = NOW(),
error_message = NULL, updated_at = NOW()
WHERE id = $2 RETURNING *`,
[newNodeId, channel.id]
);
try {
await spawnChannelSidecar(moved[0]);
return { restarted: true, new_node_id: newNodeId };
} catch (err) {
return { restarted: false, reason: `respawn failed: ${err.message}` };
}
}
export default router;

View file

@ -154,6 +154,7 @@ const RECORDER_FIELDS = [
'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels', 'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
'proxy_container', 'proxy_container',
'project_id', 'node_id', 'device_index', 'project_id', 'node_id', 'device_index',
'growing_enabled',
]; ];
function pickRecorderFields(body) { function pickRecorderFields(body) {
@ -363,14 +364,25 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
const externalMamApiUrl = `http://${process.env.NODE_IP || '172.18.91.200'}:${process.env.PORT_MAM_API || 47432}`; const externalMamApiUrl = `http://${process.env.NODE_IP || '172.18.91.200'}:${process.env.PORT_MAM_API || 47432}`;
const dockerNetwork = process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon'; const dockerNetwork = process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon';
// Growing-files mode is a global setting (settings table). When on, the // Growing-files mode is a PER-RECORDER setting (recorders.growing_enabled).
// capture container writes the master to its /growing/ mount instead of // When on, the capture container writes the master to its /growing/ mount
// streaming it to S3 — Premiere can mount the SMB share and edit it live. // instead of streaming it to S3 — editors can mount the SMB share and cut it
const growingRow = await pool.query( // live. The SMB share itself (mount source + credentials) is shared
`SELECT value FROM settings WHERE key = 'growing_enabled'` // infrastructure configured globally in Settings → Storage.
); const growingEnabled = recorder.growing_enabled === true;
const growingEnabled =
growingRow.rows[0]?.value === 'true' || growingRow.rows[0]?.value === true; // Shared growing-files SMB infrastructure (global settings). Used to mount
// the CIFS share inside the capture container (services/capture mounts it
// with these credentials when GROWING_SMB_MOUNT is set).
const growingInfra = {};
{
const r = await pool.query(
`SELECT key, value FROM settings WHERE key = ANY($1)`,
[['growing_smb_mount', 'growing_smb_username', 'growing_smb_password', 'growing_smb_vers']]
);
for (const { key, value } of r.rows) growingInfra[key] = value;
}
const smbMount = growingEnabled ? (growingInfra.growing_smb_mount || '') : '';
// Operator-supplied clip name wins over the auto-timestamped fallback. // Operator-supplied clip name wins over the auto-timestamped fallback.
// The Recorders UI passes this on the start request when the user types // The Recorders UI passes this on the start request when the user types
@ -455,6 +467,13 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`, `MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`, `GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
`GROWING_PATH=/growing`, `GROWING_PATH=/growing`,
// SMB mount details for the in-container CIFS mount (Approach A). Empty
// GROWING_SMB_MOUNT → capture falls back to the host-bound /growing volume
// (or to S3 streaming if growing isn't enabled).
`GROWING_SMB_MOUNT=${smbMount}`,
`GROWING_SMB_USERNAME=${growingInfra.growing_smb_username || ''}`,
`GROWING_SMB_PASSWORD=${growingInfra.growing_smb_password || ''}`,
`GROWING_SMB_VERS=${growingInfra.growing_smb_vers || '3.0'}`,
]; ];
// Deltacast: pass port count so the capture container can enumerate // Deltacast: pass port count so the capture container can enumerate
@ -530,7 +549,15 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
for (const d of dcEntries) hostBinds.push(`/dev/${d}:/dev/${d}`); for (const d of dcEntries) hostBinds.push(`/dev/${d}:/dev/${d}`);
} catch (_) { /* no /dev/deltacast* nodes on this host */ } } catch (_) { /* no /dev/deltacast* nodes on this host */ }
} }
if (growingEnabled) hostBinds.push('/mnt/NVME/MAM/wild-dragon-growing:/growing'); // /growing handling:
// - SMB mount configured → DON'T host-bind; the capture container mounts
// the CIFS share at /growing itself (Approach A). A bind-mount here
// would shadow the in-container mount.
// - growing on but no SMB mount → legacy host bind-mount fallback.
// - growing off → no /growing mount at all.
if (growingEnabled && !smbMount) {
hostBinds.push('/mnt/NVME/MAM/wild-dragon-growing:/growing');
}
const localEnv = [...env]; const localEnv = [...env];
if (useGpu) { if (useGpu) {

View file

@ -258,21 +258,45 @@ router.put('/transcoding', async (req, res, next) => {
// while it's still being written; the promotion worker later moves the // while it's still being written; the promotion worker later moves the
// finalized file to S3 and flips the asset to status='ready'. // finalized file to S3 and flips the asset to status='ready'.
const GROWING_KEYS = ['growing_enabled', 'growing_path', 'growing_smb_url', 'growing_promote_after_seconds']; // Growing-files mode is now a PER-RECORDER setting (recorders.growing_enabled);
// the legacy global `growing_enabled` key is no longer read at recorder start.
// These global keys describe the shared SMB landing-zone infrastructure only:
// - growing_path container mount point (default /growing)
// - growing_smb_url smb://... display string for editors (Premiere)
// - growing_smb_mount //host/share CIFS source the capture container mounts
// - growing_smb_username SMB user for the system-side CIFS mount
// - growing_smb_password SMB password (WRITE-ONLY; never returned)
// - growing_smb_vers CIFS protocol version (default 3.0)
// - growing_promote_after_seconds idle threshold before S3 promotion
const GROWING_KEYS = [
'growing_path', 'growing_smb_url', 'growing_smb_mount',
'growing_smb_username', 'growing_smb_vers', 'growing_promote_after_seconds',
];
// growing_smb_password is handled separately: stored on PUT but NEVER returned
// on GET (only a *_exists flag), mirroring s3_secret_key.
router.get('/growing', async (req, res, next) => { router.get('/growing', async (req, res, next) => {
try { try {
const result = await pool.query( const result = await pool.query(
`SELECT key, value FROM settings WHERE key = ANY($1)`, `SELECT key, value FROM settings WHERE key = ANY($1)`,
[GROWING_KEYS] [[...GROWING_KEYS, 'growing_smb_password']]
); );
const out = { const out = {
growing_enabled: 'false',
growing_path: '/growing', growing_path: '/growing',
growing_smb_url: '', growing_smb_url: '',
growing_smb_mount: '',
growing_smb_username: '',
growing_smb_vers: '3.0',
growing_promote_after_seconds: '8', growing_promote_after_seconds: '8',
growing_smb_password_exists: false,
}; };
for (const { key, value } of result.rows) out[key] = value; for (const { key, value } of result.rows) {
if (key === 'growing_smb_password') {
out.growing_smb_password_exists = !!(value && value.length);
} else {
out[key] = value;
}
}
res.json(out); res.json(out);
} catch (err) { } catch (err) {
next(err); next(err);
@ -290,6 +314,19 @@ router.put('/growing', async (req, res, next) => {
); );
} }
} }
// SMB password is write-only. A non-empty value sets/replaces it. To remove
// it, send growing_smb_password_clear:true. A blank/omitted password field
// leaves the stored value untouched (so operators don't retype it on every
// save).
if (req.body.growing_smb_password_clear === true) {
await pool.query(`DELETE FROM settings WHERE key = 'growing_smb_password'`);
} else if (typeof req.body.growing_smb_password === 'string' && req.body.growing_smb_password.length > 0) {
await pool.query(
`INSERT INTO settings (key, value, updated_at) VALUES ('growing_smb_password', $1, NOW())
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
[req.body.growing_smb_password]
);
}
res.json({ message: 'Growing-files settings saved' }); res.json({ message: 'Growing-files settings saved' });
} catch (err) { } catch (err) {
next(err); next(err);

View file

@ -14,10 +14,12 @@ const exec = promisify(execCb);
const router = express.Router(); const router = express.Router();
// Defaults mirrored from settings.js so the overview never returns nulls. // Defaults mirrored from settings.js so the overview never returns nulls.
// Growing-file mode is now per-recorder; "enabled" here means the shared SMB
// landing zone is CONFIGURED (a mount source is set), not a global on/off.
const GROWING_DEFAULTS = { const GROWING_DEFAULTS = {
growing_enabled: 'false',
growing_path: '/growing', growing_path: '/growing',
growing_smb_url: '', growing_smb_url: '',
growing_smb_mount: '',
growing_promote_after_seconds: '8', growing_promote_after_seconds: '8',
}; };
@ -100,7 +102,9 @@ router.get('/overview', async (req, res, next) => {
try { try {
// Growing files — merge defaults with whatever's in `settings`. // Growing files — merge defaults with whatever's in `settings`.
const growingRaw = { ...GROWING_DEFAULTS, ...(await readSettings(Object.keys(GROWING_DEFAULTS))) }; const growingRaw = { ...GROWING_DEFAULTS, ...(await readSettings(Object.keys(GROWING_DEFAULTS))) };
const growingEnabled = growingRaw.growing_enabled === 'true' || growingRaw.growing_enabled === true; // "enabled" now means the shared SMB landing zone is configured (a mount
// source is set). Per-recorder toggles decide which recorders actually use it.
const growingEnabled = !!(growingRaw.growing_smb_mount && growingRaw.growing_smb_mount.trim());
const containerPath = growingRaw.growing_path || '/growing'; const containerPath = growingRaw.growing_path || '/growing';
const mount = await probeGrowingPath(containerPath); const mount = await probeGrowingPath(containerPath);
@ -117,6 +121,7 @@ router.get('/overview', async (req, res, next) => {
// existing deploy uses this symlink — surface it for operator context. // existing deploy uses this symlink — surface it for operator context.
host_path: '/mnt/NVME/MAM/wild-dragon-growing', host_path: '/mnt/NVME/MAM/wild-dragon-growing',
smb_url: growingRaw.growing_smb_url || '', smb_url: growingRaw.growing_smb_url || '',
smb_mount: growingRaw.growing_smb_mount || '',
promote_after_seconds: parseInt(growingRaw.growing_promote_after_seconds, 10) || 8, promote_after_seconds: parseInt(growingRaw.growing_promote_after_seconds, 10) || 8,
exists: mount.exists, exists: mount.exists,
writable: mount.writable, writable: mount.writable,

View file

@ -3,10 +3,12 @@
import express from 'express'; import express from 'express';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { hashPassword } from '../auth/passwords.js'; import { hashPassword } from '../auth/passwords.js';
import { DEV_USER_ID } from '../middleware/auth.js'; import { DEV_USER_ID, requireAdmin } from '../middleware/auth.js';
import { accessibleProjectIds } from '../auth/authz.js';
const router = express.Router(); const router = express.Router();
const MIN_PASSWORD_LEN = 12; const MIN_PASSWORD_LEN = 12;
const ROLES = ['admin', 'editor', 'viewer'];
function bad(res, msg) { return res.status(400).json({ error: msg }); } function bad(res, msg) { return res.status(400).json({ error: msg }); }
@ -14,7 +16,7 @@ function bad(res, msg) { return res.status(400).json({ error: msg }); }
router.get('/', async (_req, res, next) => { router.get('/', async (_req, res, next) => {
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT id, username, display_name, role, last_login_at, created_at `SELECT id, username, display_name, role, totp_enabled, last_login_at, created_at
FROM users WHERE id <> $1 ORDER BY username`, [DEV_USER_ID]); FROM users WHERE id <> $1 ORDER BY username`, [DEV_USER_ID]);
res.json(rows); res.json(rows);
} catch (err) { next(err); } } catch (err) { next(err); }
@ -26,6 +28,7 @@ router.post('/', async (req, res, next) => {
const { username, password, display_name, role } = req.body || {}; const { username, password, display_name, role } = req.body || {};
if (!username || typeof username !== 'string') return bad(res, 'username required'); if (!username || typeof username !== 'string') return bad(res, 'username required');
if (!password || password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars'); if (!password || password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
if (role !== undefined && !ROLES.includes(role)) return bad(res, "role must be one of: " + ROLES.join(', '));
const hash = await hashPassword(password); const hash = await hashPassword(password);
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO users (username, password_hash, display_name, role) `INSERT INTO users (username, password_hash, display_name, role)
@ -76,7 +79,10 @@ router.patch('/:id', async (req, res, next) => {
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' }); if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' });
const sets = []; const vals = []; const sets = []; const vals = [];
if (typeof req.body?.display_name === 'string') { sets.push('display_name = $' + (sets.length + 1)); vals.push(req.body.display_name); } if (typeof req.body?.display_name === 'string') { sets.push('display_name = $' + (sets.length + 1)); vals.push(req.body.display_name); }
if (typeof req.body?.role === 'string') { sets.push('role = $' + (sets.length + 1)); vals.push(req.body.role); } if (typeof req.body?.role === 'string') {
if (!ROLES.includes(req.body.role)) return bad(res, "role must be one of: " + ROLES.join(', '));
sets.push('role = $' + (sets.length + 1)); vals.push(req.body.role);
}
if (typeof req.body?.password === 'string') { if (typeof req.body?.password === 'string') {
if (req.body.password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars'); if (req.body.password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
sets.push('password_hash = $' + (sets.length + 1) + ', password_updated_at = NOW()'); sets.push('password_hash = $' + (sets.length + 1) + ', password_updated_at = NOW()');
@ -93,4 +99,88 @@ router.patch('/:id', async (req, res, next) => {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// GET /:id/access — effective per-project access for one user (admin only).
// Reuses authz.accessibleProjectIds (MAX over direct user grant + every group the
// user belongs to). `via` is 'direct' for a user grant, 'group:<name>' otherwise.
// When the effective level comes from several sources we report the direct grant
// if present, else the first contributing group.
router.get('/:id/access', requireAdmin, async (req, res, next) => {
try {
const { rows: urows } = await pool.query(
`SELECT id, role FROM users WHERE id = $1`, [req.params.id]);
if (urows.length === 0) return res.status(404).json({ error: 'user not found' });
const target = urows[0];
const { rows: groups } = await pool.query(
`SELECT g.id, g.name
FROM user_groups ug JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = $1 ORDER BY g.name`, [target.id]);
// Admins bypass scoping — every project at 'edit', via their role.
const access = await accessibleProjectIds(target);
if (access.all) {
const { rows: projects } = await pool.query(
`SELECT id, name FROM projects ORDER BY name`);
return res.json({
projects: projects.map(p => ({
project_id: p.id, project_name: p.name, level: 'edit', via: 'direct',
})),
groups,
});
}
const ids = [...access.ids];
if (ids.length === 0) return res.json({ projects: [], groups });
// Resolve names + the source of each grant. groupNameById lets us label a
// group-sourced grant; a direct user grant always wins the `via` label.
const groupNameById = new Map(groups.map(g => [g.id, g.name]));
const { rows: grants } = await pool.query(
`SELECT pa.project_id, pa.subject_type, pa.subject_id, pa.level, p.name AS project_name
FROM project_access pa JOIN projects p ON p.id = pa.project_id
WHERE (pa.subject_type = 'user' AND pa.subject_id = $1)
OR (pa.subject_type = 'group' AND pa.subject_id IN (
SELECT group_id FROM user_groups WHERE user_id = $1
))`,
[target.id]);
const byProject = new Map();
for (const g of grants) {
const eff = access.levelByProject.get(g.project_id); // already the MAX
const via = g.subject_type === 'user'
? 'direct'
: 'group:' + (groupNameById.get(g.subject_id) || g.subject_id);
const prev = byProject.get(g.project_id);
// Keep a row only if it carries the effective level; prefer a direct grant
// when both a direct and a group grant hit the same level.
if (g.level === eff && (!prev || (prev.via !== 'direct' && via === 'direct'))) {
byProject.set(g.project_id, {
project_id: g.project_id, project_name: g.project_name, level: eff, via,
});
}
}
res.json({
projects: [...byProject.values()].sort((a, b) => a.project_name.localeCompare(b.project_name)),
groups,
});
} catch (err) { next(err); }
});
// POST /:id/totp/disable — admin clears a locked-out user's 2FA WITHOUT their
// password (the self-service /auth/totp/disable needs the victim's own). Mirrors
// that handler's SQL but targets :id and skips the password check. Dev user blocked.
router.post('/:id/totp/disable', requireAdmin, async (req, res, next) => {
try {
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' });
const { rowCount } = await pool.query(
`UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_last_counter = 0
WHERE id = $1 AND id <> $2`,
[req.params.id, DEV_USER_ID]);
if (rowCount === 0) return res.status(404).json({ error: 'user not found' });
await pool.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.params.id]);
res.status(204).end();
} catch (err) { next(err); }
});
export default router; export default router;

View file

@ -9,6 +9,8 @@
import pool from './db/pool.js'; import pool from './db/pool.js';
import { syncToAmpp } from './routes/upload.js'; import { syncToAmpp } from './routes/upload.js';
import { restartChannel } from './routes/playout.js';
import { INTERNAL_TOKEN } from './middleware/auth.js';
const TICK_INTERVAL_MS = parseInt(process.env.SCHEDULER_TICK_MS || '15000', 10); const TICK_INTERVAL_MS = parseInt(process.env.SCHEDULER_TICK_MS || '15000', 10);
const SELF_URL = process.env.MAM_API_SELF_URL || `http://127.0.0.1:${process.env.PORT || 3000}`; const SELF_URL = process.env.MAM_API_SELF_URL || `http://127.0.0.1:${process.env.PORT || 3000}`;
@ -19,7 +21,10 @@ let _interval = null;
async function callSelf(path, method = 'POST') { async function callSelf(path, method = 'POST') {
const res = await fetch(`${SELF_URL}${path}`, { const res = await fetch(`${SELF_URL}${path}`, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
'x-internal-token': INTERNAL_TOKEN,
},
signal: AbortSignal.timeout(30000), signal: AbortSignal.timeout(30000),
}); });
if (!res.ok) { if (!res.ok) {
@ -29,11 +34,7 @@ async function callSelf(path, method = 'POST') {
return res.json().catch(() => ({})); return res.json().catch(() => ({}));
} }
// Issue #103 — every mam-api replica runs the same tick on the same interval, const SCHEDULER_LOCK_KEY = 8210301;
// so a multi-node deploy would double-fire recorder starts/stops. We guard
// the whole tick with a PG advisory lock (1 = scheduler) so exactly one
// replica processes a given interval. Pure-Postgres, no extra infra.
const SCHEDULER_LOCK_KEY = 8210301; // arbitrary, must be stable across replicas
async function tryAcquireSchedulerLock(client) { async function tryAcquireSchedulerLock(client) {
const r = await client.query('SELECT pg_try_advisory_lock($1) AS got', [SCHEDULER_LOCK_KEY]); const r = await client.query('SELECT pg_try_advisory_lock($1) AS got', [SCHEDULER_LOCK_KEY]);
@ -52,14 +53,9 @@ async function tick() {
try { try {
haveLock = await tryAcquireSchedulerLock(client); haveLock = await tryAcquireSchedulerLock(client);
if (!haveLock) { if (!haveLock) {
// Another replica is processing this interval — bail silently.
return; return;
} }
// 1) Atomically claim pending schedules whose window has opened. The
// UPDATE...RETURNING flips status to 'running' in the same statement
// so even if another replica got past the lock (it can't, but
// belt-and-braces) each row can only be claimed once.
const dueStart = await client.query( const dueStart = await client.query(
`UPDATE recorder_schedules `UPDATE recorder_schedules
SET status = 'starting', updated_at = NOW() SET status = 'starting', updated_at = NOW()
@ -92,7 +88,6 @@ async function tick() {
} }
} }
// 2) Atomically claim running schedules whose window has closed.
const dueStop = await client.query( const dueStop = await client.query(
`UPDATE recorder_schedules `UPDATE recorder_schedules
SET status = 'stopping', updated_at = NOW() SET status = 'stopping', updated_at = NOW()
@ -115,7 +110,6 @@ async function tick() {
console.log(`[scheduler] stopped schedule "${s.name}" on recorder ${s.recorder_id}`); console.log(`[scheduler] stopped schedule "${s.name}" on recorder ${s.recorder_id}`);
await enqueueNextOccurrence(s, client); await enqueueNextOccurrence(s, client);
} catch (err) { } catch (err) {
// Stop failed — flag as failed but don't keep trying forever.
await client.query( await client.query(
`UPDATE recorder_schedules `UPDATE recorder_schedules
SET status = 'failed', error_message = $2, updated_at = NOW() SET status = 'failed', error_message = $2, updated_at = NOW()
@ -126,7 +120,6 @@ async function tick() {
} }
} }
// 3) If a schedule was cancelled while running, stop the recorder.
const cancelledRunning = await client.query( const cancelledRunning = await client.query(
`SELECT s.* FROM recorder_schedules s `SELECT s.* FROM recorder_schedules s
JOIN recorders r ON r.id = s.recorder_id JOIN recorders r ON r.id = s.recorder_id
@ -142,9 +135,6 @@ async function tick() {
} }
} }
// 4) Mark stale live assets as 'error' (#66).
// If a capture container crashes without calling mark-empty/mark-complete,
// the asset row stays status='live' indefinitely. Timeout after 2 hours.
const LIVE_TIMEOUT_MINUTES = parseInt(process.env.LIVE_ASSET_TIMEOUT_MINUTES || '120', 10); const LIVE_TIMEOUT_MINUTES = parseInt(process.env.LIVE_ASSET_TIMEOUT_MINUTES || '120', 10);
const staleResult = await client.query( const staleResult = await client.query(
`UPDATE assets `UPDATE assets
@ -161,9 +151,6 @@ async function tick() {
} }
} }
// 5) AMPP sync retry (#77). Pick up any pending/failed rows whose
// next-attempt time has arrived and retry them. Cap per tick so we
// don't burn budget on a single rough interval.
const ampps = await client.query( const ampps = await client.query(
`SELECT id, project_id, bin_id FROM assets `SELECT id, project_id, bin_id FROM assets
WHERE ampp_sync_status IN ('pending', 'failed') WHERE ampp_sync_status IN ('pending', 'failed')
@ -175,6 +162,8 @@ async function tick() {
for (const row of ampps.rows) { for (const row of ampps.rows) {
await syncToAmpp(row.id, row.project_id, row.bin_id); await syncToAmpp(row.id, row.project_id, row.bin_id);
} }
await playoutHealthTick(client);
} catch (err) { } catch (err) {
console.error('[scheduler] tick error:', err); console.error('[scheduler] tick error:', err);
} finally { } finally {
@ -201,11 +190,142 @@ async function enqueueNextOccurrence(schedule, client) {
console.log(`[scheduler] queued next "${schedule.name}" → ${start.toISOString()}`); console.log(`[scheduler] queued next "${schedule.name}" → ${start.toISOString()}`);
} }
// ── Playout channel health + failover ────────────────────────────────────────
// Tick step 6. Reuses the same advisory lock so only one replica probes the
// sidecars; multi-replica pings would just waste cycles. A missed probe is
// counted via last_heartbeat_at age: > 3 * TICK_INTERVAL means 3 consecutive
// misses.
// Persist the as-run compliance log for one channel from a sidecar /status
// payload. The sidecar reports the currently on-air item via currentItemId /
// currentClip / currentItemStartedAt (playout-manager.getStatus). We keep at
// most one "open" row (ended_at IS NULL) per channel: when the on-air item
// changes (or playout stops) we close the open row — stamping ended_at and a
// computed duration_s — and, if a new clip is on air, open a fresh row.
//
// playout_as_run columns (migration 029): id, channel_id, asset_id, item_id,
// clip_name, started_at, ended_at, duration_s, result.
async function writeAsRun(client, channelId, engine) {
const currentItemId = engine && engine.currentItemId ? engine.currentItemId : null;
// The currently-open as-run row for this channel, if any.
const { rows: openRows } = await client.query(
`SELECT id, item_id, started_at FROM playout_as_run
WHERE channel_id = $1 AND ended_at IS NULL
ORDER BY started_at DESC LIMIT 1`,
[channelId]
);
const open = openRows[0] || null;
// Same clip still on air → nothing to do.
if (open && currentItemId && open.item_id === currentItemId) return;
// Nothing on air and nothing open → nothing to do.
if (!open && !currentItemId) return;
// Close the previous open row (clip changed, or playout stopped).
if (open) {
await client.query(
`UPDATE playout_as_run
SET ended_at = NOW(),
duration_s = EXTRACT(EPOCH FROM (NOW() - started_at))
WHERE id = $1`,
[open.id]
);
}
// Open a new row for the clip now on air. Resolve the item's asset_id so the
// compliance log links back to the source asset even after the playlist item
// is later deleted.
if (currentItemId) {
let assetId = null;
try {
const { rows } = await client.query(
'SELECT asset_id FROM playout_items WHERE id = $1', [currentItemId]
);
if (rows.length > 0) assetId = rows[0].asset_id;
} catch (_) { /* item may have been deleted; log without asset link */ }
await client.query(
`INSERT INTO playout_as_run
(channel_id, asset_id, item_id, clip_name, started_at, result)
VALUES ($1, $2, $3, $4, COALESCE($5::timestamptz, NOW()), 'played')`,
[channelId, assetId, currentItemId, engine.currentClip || null,
engine.currentItemStartedAt || null]
);
}
}
async function playoutHealthTick(client) {
let channels;
try {
({ rows: channels } = await client.query(
`SELECT id, output_type, container_meta, node_id, last_heartbeat_at, updated_at, restart_count
FROM playout_channels WHERE status = 'running'`
));
} catch (err) {
if (err.code === '42P01') return;
throw err;
}
const TIMEOUT_MS = TICK_INTERVAL_MS * 3 + 5000;
for (const ch of channels) {
const sidecarUrl =
ch.container_meta && ch.container_meta.sidecar_url
? ch.container_meta.sidecar_url
: `http://playout-${ch.id}:3002`;
try {
const r = await fetch(`${sidecarUrl}/status`, { signal: AbortSignal.timeout(5000) });
if (!r.ok) throw new Error(`status HTTP ${r.status}`);
await client.query(
'UPDATE playout_channels SET last_heartbeat_at = NOW() WHERE id = $1', [ch.id]
);
// As-run compliance log: the sidecar only tracks the on-air clip locally
// (playout-manager._reportAsRunStart). On every successful status poll we
// detect a clip change here and persist it to playout_as_run — close the
// previous open row and open a new one. Failures are swallowed so a logging
// hiccup never knocks a healthy channel into failover.
try {
const engine = await r.json().catch(() => null);
if (engine) await writeAsRun(client, ch.id, engine);
} catch (e) {
console.warn(`[scheduler] as-run write failed for ${ch.id}: ${e.message}`);
}
} catch (err) {
// When last_heartbeat_at is NULL (channel just spawned), fall back to
// updated_at (set to NOW() by spawnChannelSidecar). This prevents a
// brand-new channel from being failed over on the very first tick because
// epoch-0 age always exceeds TIMEOUT_MS.
const baseline = ch.last_heartbeat_at || ch.updated_at;
const lastSeen = baseline ? new Date(baseline).getTime() : Date.now();
const ageMs = Date.now() - lastSeen;
if (ageMs < TIMEOUT_MS) continue;
if (ch.output_type === 'decklink') {
await client.query(
"UPDATE playout_channels SET status = 'error', error_message = $1 WHERE id = $2",
[`sidecar unreachable (${err.message}); decklink channels require manual recovery`, ch.id]
);
console.error(`[scheduler] decklink channel ${ch.id} unreachable — alert-only, no auto-failover`);
continue;
}
console.warn(`[scheduler] failover: channel ${ch.id} unreachable (${err.message}), restart #${ch.restart_count + 1}`);
try {
const res = await restartChannel(ch.id);
if (res.restarted) {
console.log(`[scheduler] failover: channel ${ch.id} re-placed on node ${res.new_node_id}`);
} else {
console.error(`[scheduler] failover: channel ${ch.id} restart skipped — ${res.reason}`);
}
} catch (err2) {
console.error(`[scheduler] failover error for ${ch.id}: ${err2.message}`);
}
}
}
}
export function startSchedulerLoop() { export function startSchedulerLoop() {
if (_interval) return; if (_interval) return;
console.log(`[scheduler] tick loop started (interval=${TICK_INTERVAL_MS}ms)`); console.log(`[scheduler] tick loop started (interval=${TICK_INTERVAL_MS}ms)`);
// Fire once on startup so a window that opened while the API was down
// doesn't have to wait a full interval.
setTimeout(() => tick().catch(() => {}), 2000); setTimeout(() => tick().catch(() => {}), 2000);
_interval = setInterval(() => tick().catch(() => {}), TICK_INTERVAL_MS); _interval = setInterval(() => tick().catch(() => {}), TICK_INTERVAL_MS);
} }

102
services/playout/Dockerfile Normal file
View file

@ -0,0 +1,102 @@
# Wild Dragon Playout sidecar — CasparCG Server + Node AMCP control shim.
#
# CasparCG's mixer needs an OpenGL context. On a node with a real GPU we'd pass
# the device + driver through; for the headless / no-GPU case we run a virtual
# framebuffer (Xvfb) so the GL context initialises. The container is launched
# --privileged by mam-api (same as capture) so DeckLink / NDI hardware is
# reachable when present.
#
# CasparCG 2.4.x no longer ships a self-contained Linux tarball — the GitHub
# release provides either Ubuntu .deb packages or an "ubuntu22" zip that bundles
# the binary + its .so files under bin/ and lib/. We use the zip on an
# ubuntu:22.04 base so the bundled libs match the host glibc/abi, then install
# Node 20 from NodeSource on top.
#
# NDI + DeckLink SDKs are NOT redistributable. They are fetched at build time
# from a URL supplied as a build arg (mirror it into your own artifact store);
# the build still succeeds without it (NDI/DeckLink consumers simply won't be
# available — SRT/RTMP/test output still work).
FROM ubuntu:22.04
ARG CASPAR_VERSION=2.4.0-stable
ARG CASPAR_URL=https://github.com/CasparCG/server/releases/download/v2.4.0-stable/casparcg-server-v2.4.0-stable-ubuntu22.zip
ARG NDI_SDK_URL=
ENV DEBIAN_FRONTEND=noninteractive
# CasparCG 2.4 runtime deps + Xvfb for headless GL + CEF (HTML producer) deps +
# Node 20 (NodeSource).
#
# NOTE: we deliberately do NOT `apt-get install ffmpeg`. That package drags in
# ~80 transitive shared libraries (libav*, libx264, libdrm, libva, ...) that
# perturb CasparCG 2.4.0's runtime linking and make its headless startup abort
# with SIGABRT (exit 134) on nearly every launch. A self-contained STATIC
# ffmpeg binary (installed below) gives us the standalone CLI the preview
# re-muxer needs with ZERO new shared libs, keeping CasparCG's environment
# identical to the known-good image.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl unzip tar xz-utils gnupg \
xvfb libgl1-mesa-dri libglu1-mesa fonts-dejavu-core \
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
libgbm1 libpango-1.0-0 libcairo2 libasound2 libatspi2.0-0 \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# ── Standalone STATIC ffmpeg CLI (for the HLS preview re-muxer) ───────────────
# john van sickle's static build is fully self-contained (no shared-lib deps),
# so it can't perturb CasparCG's runtime linking. Override FFMPEG_URL to mirror
# this into your own artifact store if upstream availability is a concern.
ARG FFMPEG_URL=https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
RUN set -eux; \
curl -fsSL "$FFMPEG_URL" -o /tmp/ffmpeg.tar.xz; \
mkdir -p /tmp/ffmpeg; \
tar xJf /tmp/ffmpeg.tar.xz -C /tmp/ffmpeg --strip-components=1; \
cp /tmp/ffmpeg/ffmpeg /tmp/ffmpeg/ffprobe /usr/local/bin/; \
chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe; \
rm -rf /tmp/ffmpeg /tmp/ffmpeg.tar.xz; \
/usr/local/bin/ffmpeg -version | head -1
# ── CasparCG Server (ubuntu22 zip bundle) ────────────────────────────────────
# The zip extracts to /opt/casparcg_server with the binary at bin/casparcg and
# its bundled .so files under lib/ (added to LD_LIBRARY_PATH by entrypoint.sh).
# Symlink to /opt/casparcg so the config/entrypoint paths stay stable.
WORKDIR /tmp/caspar
RUN set -eux; \
curl -fsSL "$CASPAR_URL" -o caspar.zip; \
unzip -q caspar.zip -d /opt; \
chmod +x /opt/casparcg_server/bin/casparcg /opt/casparcg_server/scanner 2>/dev/null || true; \
ls /opt/casparcg_server/; \
test -x /opt/casparcg_server/bin/casparcg; \
ln -sfn /opt/casparcg_server /opt/casparcg; \
echo "caspar binary: /opt/casparcg_server/bin/casparcg"; \
cd /; rm -rf /tmp/caspar
RUN if [ -n "$NDI_SDK_URL" ]; then \
mkdir -p /opt/ndi-lib && \
curl -fsSL "$NDI_SDK_URL" -o /tmp/ndi.tar.gz && \
tar xzf /tmp/ndi.tar.gz -C /tmp && \
find /tmp -name 'libndi*.so*' -exec cp -a {} /opt/ndi-lib/ \; && \
rm -f /tmp/ndi.tar.gz && ldconfig /opt/ndi-lib || true; \
fi
ENV NDI_RUNTIME_DIR_V6=/opt/ndi-lib
RUN mkdir -p /media
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
COPY casparcg.config /opt/casparcg/casparcg.config
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 3002 5250
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<paths>
<media-path>/media/</media-path>
<log-path>/media/casparcg/log/</log-path>
<data-path>/media/casparcg/data/</data-path>
<template-path>/media/templates/</template-path>
</paths>
<channels>
<channel>
<video-mode>1080i5994</video-mode>
<consumers>
</consumers>
</channel>
</channels>
<controllers>
<tcp>
<port>5250</port>
<protocol>AMCP</protocol>
</tcp>
</controllers>
</configuration>

View file

@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
if [ -z "${DISPLAY:-}" ]; then
echo "[entrypoint] starting Xvfb on :99"
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
export DISPLAY=:99
for i in $(seq 1 20); do
[ -e /tmp/.X11-unix/X99 ] && break
sleep 0.25
done
fi
# Ensure the HLS preview directory exists before the re-mux ffmpeg writes to it
# (mam-api serves /live/<channel_id>/* from the shared media volume).
if [ -n "${CHANNEL_ID:-}" ]; then
mkdir -p "/media/live/${CHANNEL_ID}"
fi
mkdir -p /media/casparcg/log /media/casparcg/data /media/templates
# CEF (HTML producer) initialises an NSS database at /root/.pki/nssdb and
# Chrome caches under HOME. Pre-create writable dirs so CEF doesn't SIGABRT
# ~30s into the run when it first lazily inits.
mkdir -p /root/.pki/nssdb /root/.cache /tmp/cef-cache
chmod 700 /root/.pki/nssdb
export HOME=/root
# 2.4.x zip bundles its own .so files under lib/ — add to LD_LIBRARY_PATH.
export LD_LIBRARY_PATH="/opt/casparcg/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
cd /opt/casparcg
CASPAR_CFG=/opt/casparcg/casparcg.config
# 2.4.x: binary at bin/casparcg. 2.5.x: symlinked to casparcg at root.
if [ -x "./bin/casparcg" ]; then CASPAR_BIN="./bin/casparcg";
elif [ -x "./casparcg" ]; then CASPAR_BIN="./casparcg";
elif [ -x "./CasparCG Server" ]; then CASPAR_BIN="./CasparCG Server";
elif command -v casparcg >/dev/null; then CASPAR_BIN="casparcg";
else echo "[entrypoint] ERROR: casparcg binary not found"; exit 1; fi
echo "[entrypoint] launching CasparCG: $CASPAR_BIN $CASPAR_CFG"
"$CASPAR_BIN" "$CASPAR_CFG" &
CASPAR_PID=$!
term() {
echo "[entrypoint] terminating CasparCG ($CASPAR_PID)"
kill -TERM "$CASPAR_PID" 2>/dev/null || true
wait "$CASPAR_PID" 2>/dev/null || true
exit 0
}
trap term SIGTERM SIGINT
cd /app
node src/index.js &
NODE_PID=$!
wait -n "$CASPAR_PID" "$NODE_PID"
term

View file

@ -0,0 +1,18 @@
{
"name": "wild-dragon-playout",
"version": "1.0.0",
"description": "Wild Dragon MAM playout sidecar — wraps a CasparCG Server instance and drives it over AMCP for master-control playout (SDI / NDI / SRT / RTMP).",
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"express": "^4.18.0",
"cors": "^2.8.0",
"dotenv": "^16.4.0"
}
}

View file

@ -0,0 +1,182 @@
import net from 'node:net';
// Minimal AMCP (Advanced Media Control Protocol) client for CasparCG.
//
// AMCP is a line-based TCP protocol: each command is a single CRLF-terminated
// line, and the server replies with a status line ("201 PLAY OK\r\n") optionally
// followed by data lines. We keep one persistent socket per CasparCG instance
// and serialize commands through a FIFO queue — CasparCG processes one command
// at a time per connection, so interleaving replies would otherwise be
// ambiguous.
//
// We only implement the subset the playout sidecar needs (PLAY / LOADBG / STOP /
// CLEAR / INFO / ADD / REMOVE). Responses are returned raw; callers parse the
// status code where they care.
const CRLF = '\r\n';
export class AmcpClient {
constructor({ host = '127.0.0.1', port = 5250 } = {}) {
this.host = host;
this.port = port;
this.socket = null;
this.connected = false;
this._buffer = '';
this._queue = []; // pending { command, resolve, reject, timer }
this._active = null; // command currently awaiting a reply
this._reconnectTimer = null;
}
connect() {
if (this.socket) return;
const socket = net.createConnection({ host: this.host, port: this.port });
socket.setEncoding('utf8');
socket.setKeepAlive(true, 10000);
socket.on('connect', () => {
this.connected = true;
console.log(`[amcp] connected to ${this.host}:${this.port}`);
});
socket.on('data', (chunk) => this._onData(chunk));
socket.on('error', (err) => {
console.error(`[amcp] socket error: ${err.message}`);
});
socket.on('close', () => {
this.connected = false;
this.socket = null;
// Fail any in-flight + queued commands so callers don't hang.
const pending = this._active ? [this._active, ...this._queue] : [...this._queue];
this._active = null;
this._queue = [];
for (const p of pending) {
clearTimeout(p.timer);
p.reject(new Error('AMCP connection closed'));
}
this._scheduleReconnect();
});
this.socket = socket;
}
_scheduleReconnect() {
if (this._reconnectTimer) return;
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
console.log('[amcp] reconnecting...');
this.connect();
}, 2000);
}
// Wait until the socket is usable, up to timeoutMs.
async waitReady(timeoutMs = 30000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (this.connected) return true;
if (!this.socket) this.connect();
await new Promise((r) => setTimeout(r, 250));
}
throw new Error('AMCP not ready within timeout');
}
_onData(chunk) {
this._buffer += chunk;
// A CasparCG reply is a status line, optionally followed by data lines.
// The simplest robust framing: a command's reply is complete when we see a
// status line AND (for 2-line "200" multi-line replies) the terminating
// blank line. For our command subset, single-status-line replies dominate;
// we treat a reply as complete at each newline and let the active command
// decide whether it has enough. To keep this correct for INFO (multi-line),
// we accumulate until the buffer ends with a known terminator.
if (!this._active) {
// Unsolicited data (e.g. connection banner) — discard.
this._buffer = '';
return;
}
// CasparCG ends multi-line replies with CRLF on an empty line. Single-line
// replies (201/202/4xx/5xx) end with a single CRLF. Resolve when we have at
// least one complete line; for "200 ... OK" (list follows) wait for the
// blank-line terminator.
const firstLineEnd = this._buffer.indexOf(CRLF);
if (firstLineEnd === -1) return;
const statusLine = this._buffer.slice(0, firstLineEnd);
const code = parseInt(statusLine, 10);
if (code === 200) {
// Multi-line: data lines until an empty line.
const term = this._buffer.indexOf(CRLF + CRLF);
if (term === -1) return; // wait for more
const full = this._buffer.slice(0, term);
this._buffer = this._buffer.slice(term + 4);
this._finishActive(null, full);
return;
}
if (code === 201 || code === 202) {
// 201: one data line follows the status line. 202: status only.
if (code === 201) {
const secondLineEnd = this._buffer.indexOf(CRLF, firstLineEnd + 2);
if (secondLineEnd === -1) return;
const full = this._buffer.slice(0, secondLineEnd);
this._buffer = this._buffer.slice(secondLineEnd + 2);
this._finishActive(null, full);
} else {
const full = this._buffer.slice(0, firstLineEnd);
this._buffer = this._buffer.slice(firstLineEnd + 2);
this._finishActive(null, full);
}
return;
}
// 4xx / 5xx error, or any other single-line status.
const full = this._buffer.slice(0, firstLineEnd);
this._buffer = this._buffer.slice(firstLineEnd + 2);
if (code >= 400) this._finishActive(new Error(`AMCP error: ${full}`), full);
else this._finishActive(null, full);
}
_finishActive(err, data) {
const active = this._active;
this._active = null;
if (active) {
clearTimeout(active.timer);
if (err) active.reject(err);
else active.resolve(data);
}
this._pump();
}
_pump() {
if (this._active || this._queue.length === 0) return;
const next = this._queue.shift();
this._active = next;
try {
this.socket.write(next.command + CRLF);
} catch (err) {
this._active = null;
clearTimeout(next.timer);
next.reject(err);
}
}
// Send a single AMCP command and resolve with the raw reply string.
send(command, { timeoutMs = 15000 } = {}) {
return new Promise((resolve, reject) => {
const entry = { command, resolve, reject, timer: null };
entry.timer = setTimeout(() => {
// Drop from queue if still pending; if active, detach so the next
// reply doesn't get misrouted.
if (this._active === entry) this._active = null;
else this._queue = this._queue.filter((e) => e !== entry);
reject(new Error(`AMCP command timed out: ${command}`));
}, timeoutMs);
this._queue.push(entry);
this._pump();
});
}
close() {
if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
if (this.socket) { try { this.socket.destroy(); } catch (_) {} this.socket = null; }
this.connected = false;
}
}

View file

@ -0,0 +1,85 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import playoutManager from './playout-manager.js';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3002;
app.use(cors());
app.use(express.json());
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// Start the channel's output consumer. Body: { outputType, outputConfig, videoFormat }
app.post('/channel/start', async (req, res) => {
try {
const out = await playoutManager.startChannel(req.body || {});
res.json(out);
} catch (err) {
console.error('[playout] /channel/start error:', err.message);
res.status(500).json({ error: err.message });
}
});
app.post('/channel/stop', async (req, res) => {
try { res.json(await playoutManager.stopChannel()); }
catch (err) { res.status(500).json({ error: err.message }); }
});
// Load + start a playlist. Body: { items: [...], loop }
app.post('/playlist/load', async (req, res) => {
try {
const { items = [], loop = false } = req.body || {};
res.json(await playoutManager.loadPlaylist({ items, loop }));
} catch (err) {
console.error('[playout] /playlist/load error:', err.message);
res.status(500).json({ error: err.message });
}
});
app.post('/transport/skip', async (req, res) => { try { res.json(await playoutManager.skip()); } catch (e) { res.status(500).json({ error: e.message }); } });
app.post('/transport/pause', async (req, res) => { try { res.json(await playoutManager.pause()); } catch (e) { res.status(500).json({ error: e.message }); } });
app.post('/transport/resume', async (req, res) => { try { res.json(await playoutManager.resume()); } catch (e) { res.status(500).json({ error: e.message }); } });
app.get('/status', (req, res) => res.json(playoutManager.getStatus()));
// Auto-start: when the sidecar is spawned by mam-api with channel env, bring up
// the output consumer immediately so the container is "on air idle" (black/slate)
// the moment it boots, mirroring the capture sidecar's bootstrap pattern.
async function bootstrap() {
const outputType = process.env.OUTPUT_TYPE;
if (!outputType) {
console.log('[bootstrap] no OUTPUT_TYPE — on-demand sidecar, waiting for /channel/start');
return;
}
let outputConfig = {};
try { outputConfig = JSON.parse(process.env.OUTPUT_CONFIG || '{}'); }
catch (err) { console.error('[bootstrap] bad OUTPUT_CONFIG json:', err.message); }
const videoFormat = process.env.VIDEO_FORMAT || '1080i5994';
try {
await playoutManager.startChannel({ outputType, outputConfig, videoFormat });
} catch (err) {
console.error('[bootstrap] channel start failed:', err.message);
}
}
const server = app.listen(PORT, () => {
console.log(`Wild Dragon Playout Service listening on port ${PORT}`);
// Give CasparCG a moment to come up (started by the container entrypoint).
playoutManager.amcp.connect();
bootstrap();
});
function shutdown(sig) {
console.log(`[playout] ${sig} — shutting down`);
playoutManager.stopChannel().catch(() => {}).finally(() => {
playoutManager.amcp.close();
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 5000);
});
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

View file

@ -0,0 +1,458 @@
import { AmcpClient } from './amcp.js';
import { spawn } from 'node:child_process';
import { mkdirSync, readdirSync, unlinkSync } from 'node:fs';
// Playout manager — owns one CasparCG channel's lifecycle inside this sidecar.
//
// One sidecar container == one CasparCG Server == one logical channel (channel
// index 1 in CasparCG terms). We add the output consumer (DeckLink / NDI / SRT
// / RTMP) at start, then walk a playlist by cueing the next clip on a background
// layer (LOADBG ... AUTO) so CasparCG performs a gapless transition at end of
// the current clip.
//
// Media is referenced by a path relative to CasparCG's configured media folder
// (/media inside the container). The mam-api stages assets from S3 to that
// shared volume and passes the resolved relative path on each item.
const CHANNEL = 1; // single CasparCG channel per sidecar
const FG_LAYER = 10; // foreground (on-air) layer
const MEDIA_ROOT = process.env.CASPAR_MEDIA_ROOT || '/media';
// Channel-id-derived HLS preview path. The mam-api proxies /live/<channel_id>/
// to this directory (shared media volume) so the UI's existing HLS player
// (capture's /live/<id> plumbing) works for playout monitors with zero new
// transport.
const CHANNEL_ID = process.env.CHANNEL_ID || '';
const HLS_DIR = CHANNEL_ID ? `${MEDIA_ROOT}/live/${CHANNEL_ID}` : '';
// Loopback UDP port CasparCG's preview STREAM consumer publishes mpegts to, and
// the standalone ffmpeg re-muxer reads from. One CasparCG per sidecar, so a
// fixed port is fine; allow override for parallel local testing.
const PREVIEW_UDP_PORT = parseInt(process.env.PREVIEW_UDP_PORT || '9710', 10);
const PREVIEW_UDP_URL = `udp://127.0.0.1:${PREVIEW_UDP_PORT}`;
// CasparCG SEEK / LENGTH are in frames, not seconds. Capture standard is 59.94;
// SD/film modes need their own values. Default 60000/1001 matches both
// '1080p5994' and '1080i5994'.
function fpsFor(videoFormat) {
const f = String(videoFormat || '').toLowerCase();
if (f.endsWith('5994')) return 60000 / 1001;
if (f.endsWith('p60') || f.endsWith('i60')) return 60;
if (f.endsWith('p50') || f.endsWith('i50')) return 50;
if (f.endsWith('2997')) return 30000 / 1001;
if (f.endsWith('p30')) return 30;
if (f.endsWith('p25')) return 25;
if (f.endsWith('p24') || f.endsWith('2398')) return 24000 / 1001;
return 60000 / 1001; // safe default for the house standard
}
// CasparCG transition syntax fragments keyed by our item.transition value.
function transitionArgs(transition, ms, fps) {
if (!transition || transition === 'cut' || !ms) return '';
const frames = Math.max(1, Math.round((ms / 1000) * fps));
if (transition === 'mix') return ` MIX ${frames}`;
if (transition === 'wipe') return ` WIPE ${frames}`;
return '';
}
// Turn an absolute /media path (or a relative one) into the token CasparCG
// expects: a path relative to MEDIA_ROOT, without extension, forward-slashed.
// CasparCG resolves "subdir/clip" against its media folder + probes extensions.
function toCasparToken(mediaPath) {
let p = String(mediaPath || '');
if (p.startsWith(MEDIA_ROOT)) p = p.slice(MEDIA_ROOT.length);
p = p.replace(/^\/+/, '');
p = p.replace(/\.[^/.]+$/, ''); // strip extension
return p;
}
export class PlayoutManager {
constructor() {
this.amcp = new AmcpClient({
host: process.env.CASPAR_HOST || '127.0.0.1',
port: parseInt(process.env.CASPAR_PORT || '5250', 10),
});
this.state = {
running: false,
outputType: null,
outputConfig: null,
videoFormat: null,
playlist: [], // resolved items in play order
currentIndex: -1,
loop: false,
currentClip: null,
startedAt: null,
lastError: null,
};
this._advanceTimer = null;
this._hlsProc = null; // standalone ffmpeg re-mux child process
this._hlsRestartTimer = null;
}
async _consumerCommand(outputType, cfg) {
// Returns the AMCP ADD argument string for the requested output target.
if (outputType === 'decklink') {
const dev = cfg.device_index || 1;
return `DECKLINK DEVICE ${dev} EMBEDDED_AUDIO`;
}
if (outputType === 'ndi') {
const name = cfg.ndi_name || 'DRAGONFLIGHT';
return `NDI NAME "${name}"`;
}
if (outputType === 'srt' || outputType === 'rtmp') {
// CasparCG 2.3 streams via the FFMPEG consumer, invoked with the STREAM
// keyword (FILE/STREAM are interchangeable aliases for it; the bare word
// "FFMPEG" is the PRODUCER and is NOT a valid consumer keyword). Args must
// use ffmpeg's -param:stream form (-codec:v, not -vcodec) or CasparCG
// rejects them. The channel feeds the consumer as RGBA, so a
// format=yuv420p filter is required before libx264.
const url = cfg.url || '';
if (outputType === 'srt') {
const latency = cfg.latency || 200;
const full = url.includes('latency=') ? url : `${url}${url.includes('?') ? '&' : '?'}latency=${latency}`;
return `STREAM "${full}" -format mpegts -codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 6M -codec:a aac -b:a 192k -filter:v format=yuv420p`;
}
const target = cfg.key ? `${url}/${cfg.key}` : url;
return `STREAM "${target}" -format flv -codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 6M -codec:a aac -b:a 192k -filter:v format=yuv420p`;
}
throw new Error(`Unknown output_type: ${outputType}`);
}
// Start the channel: bring up CasparCG's primary output consumer for the
// target, plus a second FFMPEG consumer writing HLS for the UI preview
// monitor (~4-6s lag, reuses capture's /live/<id> plumbing).
//
// The primary consumer failure is NON-FATAL. CasparCG can decode and route
// media through its pipeline even without an output consumer. This means:
// - NDI channels work (load/play/transport) even if libndi.so is absent.
// - SRT/RTMP channels work even if the destination URL is unreachable.
// - The HLS preview consumer is always attempted independently.
//
// state.consumerError is set when the primary consumer fails so the mam-api
// can surface a warning in the channel status without blocking operation.
async startChannel({ outputType, outputConfig = {}, videoFormat = '1080p5994' }) {
await this.amcp.waitReady(30000);
// Set the channel video mode first.
try { await this.amcp.send(`SET ${CHANNEL} MODE ${videoFormat}`); }
catch (err) { console.warn(`[playout] SET MODE failed (continuing): ${err.message}`); }
// Primary output consumer — non-fatal.
let consumerError = null;
try {
const consumer = await this._consumerCommand(outputType, outputConfig);
await this.amcp.send(`ADD ${CHANNEL} ${consumer}`);
} catch (err) {
consumerError = err.message;
console.warn(`[playout] primary consumer ADD failed (continuing without output): ${err.message}`);
}
// HLS preview consumer — always attempt, independently non-fatal.
if (HLS_DIR) {
try {
await this._addHlsConsumer();
console.log(`[playout] HLS preview at ${HLS_DIR}/index.m3u8`);
} catch (err) {
console.warn(`[playout] HLS preview consumer failed: ${err.message}`);
}
}
this.state.running = true;
this.state.outputType = outputType;
this.state.outputConfig = outputConfig;
this.state.videoFormat = videoFormat;
this.state.fps = fpsFor(videoFormat);
this.state.startedAt = new Date().toISOString();
this.state.lastError = consumerError;
console.log(`[playout] channel started output=${outputType} mode=${videoFormat} fps=${this.state.fps.toFixed(3)}${consumerError ? ' ⚠ consumer: ' + consumerError : ''}`);
return this.getStatus();
}
// HLS preview for the web UI confidence monitor.
//
// ── Why not CasparCG's own HLS (FILE/STREAM ".../index.m3u8") ──────────────
// CasparCG's bundled FFMPEG consumer muxes a BROKEN audio track into the HLS:
// ffprobe reports `aac, sample_rate=0` and ffmpeg decoding the playlist fails
// with "Invalid data ... abuffer: Value inf for parameter 'time_base' ...
// time_base 1/0". That corrupt audio prevents BOTH ffmpeg and hls.js from
// decoding, so the browser <video> sits at readyState 0 and the preview stays
// black. The video track itself is perfectly clean h264. Critically, the
// consumer IGNORES every arg that would fix it — `-an`, `-codec:a`, `-g`,
// `-r`, `-force_key_frames` are all silently dropped ("Unused option"), so we
// CANNOT remove the audio from inside CasparCG.
//
// ── The fix: STREAM mpegts to UDP loopback, re-mux with a STANDALONE ffmpeg ─
// CasparCG outputs a plain mpegts elementary stream to a local UDP port (its
// STREAM/mpegts path is fine — the breakage is specific to its HLS muxer). A
// Node-spawned standalone ffmpeg (where `-an` actually works) reads that UDP
// stream, drops audio, copies the clean h264 video, and writes proper HLS.
// `-c:v copy` avoids re-encoding. The program audio is untouched — it rides
// the PRIMARY SRT/RTMP/SDI/NDI consumer, which we never modify.
async _addHlsConsumer() {
// 1) CasparCG → mpegts over UDP loopback. The channel feeds RGBA, so a
// format=yuv420p filter is required before libx264.
const streamArgs = [
`STREAM "${PREVIEW_UDP_URL}?pkt_size=1316"`,
'-format mpegts',
'-codec:v libx264 -preset:v veryfast -tune:v zerolatency',
'-b:v 2M -maxrate 2M -bufsize 4M',
'-codec:a aac -b:a 96k',
'-filter:v format=yuv420p',
].join(' ');
await this.amcp.send(`ADD ${CHANNEL} ${streamArgs}`);
// 2) Standalone ffmpeg re-mux: UDP mpegts → clean video-only HLS.
this._startHlsRemux();
}
// Spawn (or respawn) the standalone ffmpeg that re-muxes the loopback mpegts
// into video-only HLS. Restarts automatically if it dies while the channel is
// still running (e.g. brief UDP gap before CasparCG's consumer is up).
_startHlsRemux() {
if (!HLS_DIR) return;
try { mkdirSync(HLS_DIR, { recursive: true }); } catch (_) {}
// Purge stale HLS artifacts from any prior session before starting. The
// /media volume is a shared host bind, so a previous (or duplicate/failover)
// sidecar can leave orphaned index*.ts + an old index.m3u8 behind. ffmpeg's
// index%d.ts counter restarts at 0, so those leftovers collide with the new
// segment numbering and can briefly corrupt the live playlist hls.js reads
// (it sees a frozen / non-monotonic edge → monitor goes black). A clean dir
// per session guarantees a coherent live timeline.
try {
for (const f of readdirSync(HLS_DIR)) {
if (/\.ts$/.test(f) || /\.m3u8$/.test(f)) {
try { unlinkSync(`${HLS_DIR}/${f}`); } catch (_) {}
}
}
} catch (_) {}
this._stopHlsRemux();
const out = `${HLS_DIR}/index.m3u8`;
const args = [
'-hide_banner', '-loglevel', 'warning',
// Read the live mpegts loopback. genpts rebuilds timestamps; the analyze/
// probe sizes are kept small so playback starts promptly.
'-fflags', '+genpts',
'-analyzeduration', '2000000', '-probesize', '2000000',
'-i', `${PREVIEW_UDP_URL}?fifo_size=1000000&overrun_nonfatal=1`,
// Drop the (broken) audio entirely.
'-an',
// Re-encode (NOT -c:v copy) to uniform, keyframe-aligned 2s segments with
// regenerated CFR timestamps. -c:v copy passed CasparCG's erratic
// real-time keyframes straight through, producing segments of 0.62.8s
// and irregular PTS; hls.js can't build a live timeline from that — it
// logs "sliding 0.00 / MISSED", never loads a fragment, and the monitor
// stays black even though the stream decodes cleanly server-side. A
// standalone ffmpeg honours -force_key_frames, so every GOP (and thus
// every HLS segment) is exactly 2.0s.
//
// This is a CONFIDENCE MONITOR, kept deliberately tiny: 360p / 20fps /
// ultrafast. The sidecar has no NVENC, so this is a CPU libx264 encode
// running ALONGSIDE CasparCG's mixer + its own STREAM consumer. At 720p30
// the re-encode couldn't sustain real time, the UDP input overran, and the
// HLS output stalled (playlist froze → monitor black). 360p20 ultrafast is
// a fraction of the cost and keeps up comfortably. fps=20 forces CFR;
// -g 40 = 2.0s GOP at 20fps.
'-vf', 'fps=20,scale=-2:360,format=yuv420p',
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
'-b:v', '600k', '-maxrate', '800k', '-bufsize', '1200k',
'-g', '40', '-keyint_min', '40', '-sc_threshold', '0',
'-force_key_frames', 'expr:gte(t,n_forced*2)',
'-f', 'hls',
'-hls_time', '2',
'-hls_list_size', '8',
'-hls_flags', 'delete_segments+append_list+independent_segments',
'-hls_segment_filename', `${HLS_DIR}/index%d.ts`,
out,
];
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
this._hlsProc = proc;
proc.stderr.on('data', (d) => {
const line = d.toString().trim();
if (line) console.warn(`[playout][hls-ffmpeg] ${line}`);
});
proc.on('exit', (code, signal) => {
console.warn(`[playout] HLS re-mux ffmpeg exited code=${code} signal=${signal}`);
if (this._hlsProc === proc) this._hlsProc = null;
// Auto-respawn while the channel is running (and we didn't kill it).
if (this.state.running && signal !== 'SIGTERM' && signal !== 'SIGKILL') {
this._hlsRestartTimer = setTimeout(() => {
this._hlsRestartTimer = null;
if (this.state.running) {
console.log('[playout] respawning HLS re-mux ffmpeg');
this._startHlsRemux();
}
}, 1000);
}
});
proc.on('error', (err) => {
console.warn(`[playout] HLS re-mux ffmpeg spawn error: ${err.message}`);
});
console.log(`[playout] HLS re-mux ffmpeg started: ${PREVIEW_UDP_URL} -> ${out}`);
}
_stopHlsRemux() {
if (this._hlsRestartTimer) {
clearTimeout(this._hlsRestartTimer);
this._hlsRestartTimer = null;
}
if (this._hlsProc) {
const proc = this._hlsProc;
this._hlsProc = null;
try { proc.kill('SIGTERM'); } catch (_) {}
}
}
async stopChannel() {
this._clearAdvance();
this.state.running = false; // set first so the ffmpeg exit handler won't respawn
this._stopHlsRemux();
try { await this.amcp.send(`STOP ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
try { await this.amcp.send(`CLEAR ${CHANNEL}`); } catch (_) {}
this.state.running = false;
this.state.playlist = [];
this.state.currentIndex = -1;
this.state.currentClip = null;
console.log('[playout] channel stopped');
return { stopped: true };
}
// Load a playlist (array of { id, asset_id, media_path, in_point, out_point,
// transition, transition_ms, clip_name }) and start playing from index 0.
async loadPlaylist({ items = [], loop = false }) {
if (!this.state.running) {
throw new Error('Channel not started — call /channel/start first');
}
this.state.playlist = items;
this.state.loop = !!loop;
this.state.currentIndex = -1;
if (items.length === 0) return this.getStatus();
await this._playIndex(0);
return this.getStatus();
}
async _playIndex(index) {
const item = this.state.playlist[index];
if (!item) return;
const fps = this.state.fps || fpsFor(this.state.videoFormat);
const token = toCasparToken(item.media_path);
const seek = item.in_point ? ` SEEK ${Math.round(item.in_point * fps)}` : '';
const length = (item.out_point && item.out_point > (item.in_point || 0))
? ` LENGTH ${Math.round((item.out_point - (item.in_point || 0)) * fps)}`
: '';
const trans = transitionArgs(item.transition, item.transition_ms, fps);
// PLAY puts the clip on the foreground layer immediately (first clip), with
// the configured transition. Subsequent clips are cued via LOADBG ... AUTO
// for a gapless hand-off; see _scheduleAdvance.
await this.amcp.send(`PLAY ${CHANNEL}-${FG_LAYER} "${token}"${seek}${length}${trans}`);
this.state.currentIndex = index;
this.state.currentClip = item.clip_name || token;
console.log(`[playout] PLAY [${index}] ${token}`);
this._reportAsRunStart(item);
this._scheduleAdvance(item);
}
// Effective on-air duration of an item in milliseconds. Prefers an explicit
// in/out trim, else the asset's full duration. Returns null when unknown (no
// duration metadata + no out_point) so the caller can skip the timer.
_itemDurationMs(item) {
const inS = item.in_point || 0;
if (item.out_point && item.out_point > inS) return (item.out_point - inS) * 1000;
if (item.asset_duration_ms != null) return Math.max(0, item.asset_duration_ms - inS * 1000);
return null;
}
// CasparCG's LOADBG ... AUTO swaps the cued background clip to foreground when
// the current clip ends, giving a gapless visual take. But CasparCG won't cue
// clip N+2 on its own and won't move OUR pointer / as-run bookkeeping. So we
// also arm a duration-based timer: when the current clip is due to end we
// advance currentIndex and cue the following clip. This keeps an arbitrary-
// length playlist walking, not just the first two items.
_scheduleAdvance(item) {
this._clearAdvance();
const next = this._nextIndex();
if (next === null) return; // end of a non-looping playlist
const nextItem = this.state.playlist[next];
const nextToken = toCasparToken(nextItem.media_path);
const fps = this.state.fps || fpsFor(this.state.videoFormat);
const trans = transitionArgs(nextItem.transition, nextItem.transition_ms, fps);
// Cue next on background with AUTO so CasparCG performs the gapless take.
this.amcp.send(`LOADBG ${CHANNEL}-${FG_LAYER} "${nextToken}" AUTO${trans}`)
.catch((err) => console.warn(`[playout] LOADBG failed: ${err.message}`));
// Arm the pointer-advance timer. Without duration metadata we can't time the
// hand-off; leave AUTO to take clip N+1 visually but log a warning since the
// pointer (and thus clip N+2 cueing) will stall.
const durMs = this._itemDurationMs(item);
if (durMs == null) {
console.warn(`[playout] no duration for clip [${this.state.currentIndex}] — pointer advance stalled after this clip`);
return;
}
this._advanceTimer = setTimeout(() => {
this._advanceTimer = null;
// The AUTO take already happened in CasparCG; just move our pointer and
// cue the clip after next. _playIndex would re-PLAY and double-take, so we
// advance state directly and re-arm.
this.state.currentIndex = next;
this.state.currentClip = nextItem.clip_name || nextToken;
console.log(`[playout] advance -> [${next}] ${nextToken}`);
this._reportAsRunStart(nextItem);
this._scheduleAdvance(nextItem);
}, Math.max(250, durMs));
}
_nextIndex() {
const n = this.state.currentIndex + 1;
if (n < this.state.playlist.length) return n;
if (this.state.loop && this.state.playlist.length > 0) return 0;
return null;
}
_clearAdvance() {
if (this._advanceTimer) { clearTimeout(this._advanceTimer); this._advanceTimer = null; }
}
async skip() {
const next = this._nextIndex();
if (next === null) { await this.stopChannel(); return this.getStatus(); }
await this._playIndex(next);
return this.getStatus();
}
async pause() {
try { await this.amcp.send(`PAUSE ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
return this.getStatus();
}
async resume() {
try { await this.amcp.send(`RESUME ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
return this.getStatus();
}
_reportAsRunStart(item) {
// The mam-api owns the as-run table; the sidecar just logs locally. The API
// polls /status and writes as-run rows on clip change. Keeping the DB write
// in the API avoids giving the sidecar a DB connection.
this.state.currentItemId = item.id || null;
this.state.currentItemStartedAt = new Date().toISOString();
}
getStatus() {
return {
running: this.state.running,
outputType: this.state.outputType,
videoFormat: this.state.videoFormat,
currentIndex: this.state.currentIndex,
currentClip: this.state.currentClip,
currentItemId: this.state.currentItemId || null,
currentItemStartedAt: this.state.currentItemStartedAt || null,
playlistLength: this.state.playlist.length,
loop: this.state.loop,
startedAt: this.state.startedAt,
lastError: this.state.lastError,
};
}
}
export default new PlayoutManager();

View file

@ -61,12 +61,29 @@ server {
add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Cache-Control "no-cache, no-store, must-revalidate";
} }
# Live HLS served from /live (bind-mounted shared volume), low cache so playlist refreshes # Live HLS served from /live (bind-mounted capture live volume).
# no-store (not just no-cache): with "no-cache" the browser still caches the
# playlist and serves a STALE copy to hls.js's reloads, so hls.js sees the
# live playlist as never advancing ("MISSED" forever) and never plays the
# monitor stays black. no-store forbids caching entirely so every reload
# fetches the fresh live edge. Segments are short-lived; not caching them is
# fine for a live preview.
location /live/ { location /live/ {
alias /live/; alias /live/;
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; } types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
add_header Cache-Control "no-cache"; add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Access-Control-Allow-Origin *; add_header Pragma "no-cache" always;
add_header Access-Control-Allow-Origin * always;
}
# Playout HLS preview CasparCG sidecar writes to the media volume under
# /media/live/<channel_id>/. This is a separate volume from /live/ (capture).
location /media/live/ {
alias /media/live/;
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Access-Control-Allow-Origin * always;
} }
# API proxy - forward to mam-api service # API proxy - forward to mam-api service

View file

@ -67,7 +67,7 @@ function App() {
schedule: ['Ingest', 'Schedule'], schedule: ['Ingest', 'Schedule'],
youtube: ['Ingest', 'YouTube'], youtube: ['Ingest', 'YouTube'],
capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'], capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
jobs: ['Jobs'], editor: ['Editor'], jobs: ['Jobs'], editor: ['Editor'], playout: ['Operations', 'Playout'],
users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'], users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'], containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
settings: ['Admin', 'Settings'], settings: ['Admin', 'Settings'],
@ -120,6 +120,7 @@ function App() {
case 'capture': content = <Capture navigate={navigate} />; break; case 'capture': content = <Capture navigate={navigate} />; break;
case 'monitors': content = <Monitors navigate={navigate} />; break; case 'monitors': content = <Monitors navigate={navigate} />; break;
case 'jobs': content = <Jobs navigate={navigate} />; break; case 'jobs': content = <Jobs navigate={navigate} />; break;
case 'playout': content = <Playout navigate={navigate} />; break;
case 'users': content = <Users />; break; case 'users': content = <Users />; break;
case 'tokens': content = <Tokens />; break; case 'tokens': content = <Tokens />; break;
case 'billing': content = <TokensParody />; break; case 'billing': content = <TokensParody />; break;

View file

@ -38,6 +38,12 @@ window.PREMIERE_RELEASES = [
]; ];
window.PREMIERE_LATEST = window.PREMIERE_RELEASES.find(r => r.latest) || window.PREMIERE_RELEASES[0]; window.PREMIERE_LATEST = window.PREMIERE_RELEASES.find(r => r.latest) || window.PREMIERE_RELEASES[0];
// Teams ISO workstation installer. Placeholder slot: the .exe is not in the
// repo yet, so `available` is false and the Downloads modal renders the row
// disabled with a "coming soon" note. Drop the file into public/downloads/
// and flip `available: true` (set `version`) to finish it.
window.TEAMS_ISO = { version: null, url: '/downloads/TeamsISO.exe', available: false };
window.ZAMPP_DATA = { window.ZAMPP_DATA = {
PROJECTS: [], PROJECTS: [],
ASSETS: [], ASSETS: [],

View file

@ -8,7 +8,7 @@ const ICONS = {
upload: <><path d="M12 16V4" /><path d="M6 10l6-6 6 6" /><path d="M4 20h16" /></>, upload: <><path d="M12 16V4" /><path d="M6 10l6-6 6 6" /><path d="M4 20h16" /></>,
record: <><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M22 8l-6 4 6 4V8z" /></>, record: <><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M22 8l-6 4 6 4V8z" /></>,
capture: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="4" /><circle cx="12" cy="12" r="1" /></>, capture: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="4" /><circle cx="12" cy="12" r="1" /></>,
jobs: <><path d="M3 6h18" /><path d="M3 12h18" /><path d="M3 18h12" /></>, jobs: <><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1" fill="currentColor" /><circle cx="4" cy="12" r="1" fill="currentColor" /><circle cx="4" cy="18" r="1" fill="currentColor" /></>,
editor: <><path d="M14.06 2.94l7 7-11 11H3v-7.06l11.06-10.94z" /><path d="M13 4l7 7" /></>, editor: <><path d="M14.06 2.94l7 7-11 11H3v-7.06l11.06-10.94z" /><path d="M13 4l7 7" /></>,
users: <><circle cx="9" cy="8" r="4" /><path d="M2 21a7 7 0 0 1 14 0" /><circle cx="17" cy="6" r="3" /><path d="M22 18a5 5 0 0 0-7-4.5" /></>, users: <><circle cx="9" cy="8" r="4" /><path d="M2 21a7 7 0 0 1 14 0" /><circle cx="17" cy="6" r="3" /><path d="M22 18a5 5 0 0 0-7-4.5" /></>,
token: <><circle cx="8" cy="15" r="4" /><path d="M10.85 12.15L19 4" /><path d="M18 5l3 3" /><path d="M15 8l3 3" /></>, token: <><circle cx="8" cy="15" r="4" /><path d="M10.85 12.15L19 4" /><path d="M18 5l3 3" /><path d="M15 8l3 3" /></>,
@ -29,6 +29,7 @@ const ICONS = {
audio: <><path d="M3 12v-2a2 2 0 0 1 2-2h2l5-4v16l-5-4H5a2 2 0 0 1-2-2z" /><path d="M16 8a5 5 0 0 1 0 8M19 5a9 9 0 0 1 0 14" /></>, audio: <><path d="M3 12v-2a2 2 0 0 1 2-2h2l5-4v16l-5-4H5a2 2 0 0 1-2-2z" /><path d="M16 8a5 5 0 0 1 0 8M19 5a9 9 0 0 1 0 14" /></>,
image: <><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="2" /><path d="M21 15l-5-5L5 21" /></>, image: <><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="2" /><path d="M21 15l-5-5L5 21" /></>,
download: <><path d="M12 4v12M6 10l6 6 6-6" /><path d="M4 20h16" /></>, download: <><path d="M12 4v12M6 10l6 6 6-6" /><path d="M4 20h16" /></>,
import: <><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><path d="M10 17l5-5-5-5" /><path d="M15 12H3" /></>,
key: <><circle cx="7.5" cy="15.5" r="3.5" /><path d="M10 13l9-9M16 7l3 3M14 9l3 3" /></>, key: <><circle cx="7.5" cy="15.5" r="3.5" /><path d="M10 13l9-9M16 7l3 3M14 9l3 3" /></>,
lock: <><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></>, lock: <><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></>,
edit: <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /></>, edit: <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /></>,
@ -38,14 +39,14 @@ const ICONS = {
x: <path d="M6 6l12 12M6 18L18 6" />, x: <path d="M6 6l12 12M6 18L18 6" />,
filter: <path d="M3 5h18l-7 9v6l-4-2v-4L3 5z" />, filter: <path d="M3 5h18l-7 9v6l-4-2v-4L3 5z" />,
sort: <><path d="M3 6h13M3 12h9M3 18h5" /><path d="M17 14l3 3 3-3M20 9v8" /></>, sort: <><path d="M3 6h13M3 12h9M3 18h5" /><path d="M17 14l3 3 3-3M20 9v8" /></>,
grid: <><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></>, grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
list: <><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1" fill="currentColor" /><circle cx="4" cy="12" r="1" fill="currentColor" /><circle cx="4" cy="18" r="1" fill="currentColor" /></>, list: <><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1" fill="currentColor" /><circle cx="4" cy="12" r="1" fill="currentColor" /><circle cx="4" cy="18" r="1" fill="currentColor" /></>,
comment: <path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z" />, comment: <path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z" />,
clock: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>, clock: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>,
layers: <><path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5M2 12l10 5 10-5" /></>, layers: <><path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5M2 12l10 5 10-5" /></>,
gpu: <><rect x="3" y="7" width="18" height="10" rx="1" /><rect x="6" y="10" width="4" height="4" /><rect x="14" y="10" width="4" height="4" /><path d="M3 11H1M3 13H1M23 11h-2M23 13h-2" /></>, gpu: <><rect x="3" y="7" width="18" height="10" rx="1" /><rect x="6" y="10" width="4" height="4" /><rect x="14" y="10" width="4" height="4" /><path d="M3 11H1M3 13H1M23 11h-2M23 13h-2" /></>,
cpu: <><rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" /><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3" /></>, cpu: <><rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" /><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3" /></>,
hdd: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="1" fill="currentColor" /></>, hdd: <><ellipse cx="12" cy="6" rx="9" ry="3" /><path d="M3 6v12c0 1.66 4.03 3 9 3s9-1.34 9-3V6" /></>,
sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></>, sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></>,
moon: <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />, moon: <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />,
signal: <><path d="M2 20h.01M7 20v-4M12 20v-8M17 20V8M22 20V4" /></>, signal: <><path d="M2 20h.01M7 20v-4M12 20v-8M17 20V8M22 20V4" /></>,
@ -66,7 +67,7 @@ const ICONS = {
power: <><path d="M18.36 6.64a9 9 0 1 1-12.73 0" /><path d="M12 2v10" /></>, power: <><path d="M18.36 6.64a9 9 0 1 1-12.73 0" /><path d="M12 2v10" /></>,
globe: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></>, globe: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></>,
package: <><path d="M3 7l9-4 9 4M3 7v10l9 4 9-4V7M3 7l9 4 9-4M12 11v10" /></>, package: <><path d="M3 7l9-4 9 4M3 7v10l9 4 9-4V7M3 7l9 4 9-4M12 11v10" /></>,
proxy: <><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M9 12l3-3 3 3M12 9v8" /></>, proxy: <><path d="M4 6h11M19 6h1M4 12h2M10 12h10M4 18h7M15 18h5" /><circle cx="17" cy="6" r="2" /><circle cx="8" cy="12" r="2" /><circle cx="13" cy="18" r="2" /></>,
}; };
function Icon({ name, size = 16, className, style }) { function Icon({ name, size = 16, className, style }) {

View file

@ -21,6 +21,7 @@
<link rel="stylesheet" href="styles-rest.css" /> <link rel="stylesheet" href="styles-rest.css" />
<link rel="stylesheet" href="styles-modal.css" /> <link rel="stylesheet" href="styles-modal.css" />
<link rel="stylesheet" href="styles-fixes.css" /> <link rel="stylesheet" href="styles-fixes.css" />
<link rel="stylesheet" href="styles-playout.css" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@ -47,6 +48,7 @@
<script src="js/bmd-card.js"></script> <script src="js/bmd-card.js"></script>
<script src="dist/screens-editor.js"></script> <script src="dist/screens-editor.js"></script>
<script src="dist/screens-admin.js"></script> <script src="dist/screens-admin.js"></script>
<script src="dist/screens-playout.js"></script>
<script src="dist/modal-new-recorder.js"></script> <script src="dist/modal-new-recorder.js"></script>
<script src="dist/app.js"></script> <script src="dist/app.js"></script>
</body> </body>

View file

@ -161,6 +161,7 @@ function NewRecorderModal({ open, onClose }) {
const BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq']); const BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq']);
const codecUsesBitrate = BITRATE_CODECS.has(recCodec); const codecUsesBitrate = BITRATE_CODECS.has(recCodec);
const [proxyOn, setProxyOn] = React.useState(true); const [proxyOn, setProxyOn] = React.useState(true);
const [growingOn, setGrowingOn] = React.useState(false);
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || ''); const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const [submitting, setSubmitting] = React.useState(false); const [submitting, setSubmitting] = React.useState(false);
const [submitErr, setSubmitErr] = React.useState(null); const [submitErr, setSubmitErr] = React.useState(null);
@ -206,6 +207,7 @@ function NewRecorderModal({ open, onClose }) {
source_type: sourceType.toLowerCase(), source_type: sourceType.toLowerCase(),
project_id: projectId || undefined, project_id: projectId || undefined,
generate_proxy: proxyOn, generate_proxy: proxyOn,
growing_enabled: growingOn,
recording_codec: recCodec, recording_codec: recCodec,
recording_container: recContainer, recording_container: recContainer,
// Framerate + resolution are auto-detected from the source signal/stream. // Framerate + resolution are auto-detected from the source signal/stream.
@ -473,6 +475,20 @@ function NewRecorderModal({ open, onClose }) {
</div> </div>
</div> </div>
<div className="modal-toggle-row">
<label className="switch">
<input type="checkbox" checked={growingOn} onChange={e => setGrowingOn(e.target.checked)} />
<span className="switch-track"><span className="switch-knob" /></span>
</label>
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>Growing-files mode</div>
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>
Write the live master to the SMB share so editors can cut while it's still recording.
Requires the SMB share to be configured in Settings Storage.
</div>
</div>
</div>
{proxyOn && ( {proxyOn && (
<div className="modal-section"> <div className="modal-section">
<div className="modal-section-head"><span>Proxy</span></div> <div className="modal-section-head"><span>Proxy</span></div>

View file

@ -258,28 +258,7 @@ function Users() {
{tab === 'groups' && <GroupsPanel groups={groups} users={users} onChange={refreshGroups} />} {tab === 'groups' && <GroupsPanel groups={groups} users={users} onChange={refreshGroups} />}
{tab === 'policies' && ( {tab === 'policies' && (
<div className="panel" style={{ padding: '32px 24px', color: 'var(--text-2)' }}> <PoliciesPanel users={users} onChange={refreshUsers} />
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Icon name="lock" size={16} />
<div style={{ fontWeight: 600, fontSize: 14 }}>Access model</div>
</div>
<div style={{ fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.7, maxWidth: 640 }}>
<div style={{ marginBottom: 8 }}>
<strong style={{ color: 'var(--text-2)' }}>admin</strong> full access to every
project plus user, group, cluster, and system administration.
</div>
<div style={{ marginBottom: 8 }}>
<strong style={{ color: 'var(--text-2)' }}>editor / viewer</strong> see only the
projects they've been granted. A <em>view</em> grant is read-only; an
<em> edit</em> grant allows changes. Grants can target an individual user or a group.
</div>
<div>
Manage a project's grants from the <strong style={{ color: 'var(--text-2)' }}>Projects</strong> page
a project's <em>Manage access</em> menu. Group membership is managed on the
Groups tab above.
</div>
</div>
</div>
)} )}
</div> </div>
{showInvite && <InviteUserModal onCreated={onCreated} onClose={() => setShowInvite(false)} />} {showInvite && <InviteUserModal onCreated={onCreated} onClose={() => setShowInvite(false)} />}
@ -299,6 +278,204 @@ function Users() {
); );
} }
//
// PoliciesPanel - interactive per-user permission matrix for the Policies tab.
// Keeps the access-model explainer as a small header, then renders one row per
// user with: inline role <select> (PATCH /users/:id), a 2FA badge driven by
// totp_enabled, an admin-only "Reset 2FA" action (POST /users/:id/totp/disable,
// 204), and an Access expander backed by GET /users/:id/access.
//
function PoliciesPanel({ users, onChange }) {
const [expandedId, setExpandedId] = React.useState(null);
const [err, setErr] = React.useState(null);
const changeRole = (u, newRole) => {
if (u.role === newRole) return;
setErr(null);
window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify({ role: newRole }) })
.then(() => onChange && onChange())
.catch(e => setErr('Role change failed: ' + (e.message || e)));
};
// Reset 2FA uses a raw fetch because ZAMPP_API.fetch throws on the 204 (no JSON
// body). Mirrors the disable() pattern in TotpSection.
const resetTotp = (u) => {
if (!confirm(`Reset two-factor for "${u.name}" (@${u.username})?\nThey will be able to sign in without a code until they re-enrol.`)) return;
setErr(null);
fetch('/api/v1/users/' + u.id + '/totp/disable', {
method: 'POST',
credentials: 'include',
headers: { 'X-Requested-With': 'dragonflight-ui' },
})
.then(r => {
if (r.status === 204) { onChange && onChange(); return; }
return r.json().catch(() => ({})).then(b => { throw new Error(b.error || ('Failed (' + r.status + ')')); });
})
.catch(e => setErr('Reset 2FA failed: ' + (e.message || e)));
};
return (
<div>
{/* Access-model explainer (kept from the old static tab, condensed) */}
<div className="panel" style={{ padding: '16px 20px', marginBottom: 12, color: 'var(--text-2)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<Icon name="lock" size={15} />
<div style={{ fontWeight: 600, fontSize: 13.5 }}>Access model</div>
</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.6, maxWidth: 720 }}>
<strong style={{ color: 'var(--text-2)' }}>admin</strong> has full access to every project plus
user, group, cluster, and system administration. <strong style={{ color: 'var(--text-2)' }}>editor / viewer</strong> see
only the projects they're granted a <em>view</em> grant is read-only, an <em>edit</em> grant
allows changes, and grants can target a user or a group. Edit per-project grants from the{' '}
<a href="#" onClick={e => { e.preventDefault(); window.dispatchEvent(new CustomEvent('df:nav', { detail: 'projects' })); }}
style={{ color: 'var(--accent-text)' }}>Projects</a> page; manage group membership on the Groups tab above.
</div>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginBottom: 8 }}>{err}</div>}
<div className="panel">
<div className="user-row head">
<div>User</div>
<div>Role</div>
<div>2FA</div>
<div>Access</div>
<div></div>
</div>
{users.length === 0 && (
<div style={{ padding: '32px 0', textAlign: 'center', color: 'var(--text-3)' }}>No users found</div>
)}
{users.map(u => (
<UserPolicyRow key={u.id} user={u}
expanded={expandedId === u.id}
onToggle={() => setExpandedId(expandedId === u.id ? null : u.id)}
onChangeRole={changeRole}
onResetTotp={resetTotp} />
))}
</div>
</div>
);
}
function UserPolicyRow({ user: u, expanded, onToggle, onChangeRole, onResetTotp }) {
const [access, setAccess] = React.useState(null); // null = not loaded, {} once fetched
const [loading, setLoading] = React.useState(false);
const [accessErr, setAccessErr] = React.useState(null);
// Lazily fetch GET /users/:id/access the first time the row is expanded.
React.useEffect(() => {
if (!expanded || access !== null) return;
setLoading(true); setAccessErr(null);
window.ZAMPP_API.fetch('/users/' + u.id + '/access')
.then(d => setAccess(d || {}))
.catch(e => { setAccess({}); setAccessErr(e.message || 'Failed to load access'); })
.finally(() => setLoading(false));
}, [expanded, access, u.id]);
const projects = (access && access.projects) || [];
const memberships = (access && (access.groups || access.memberships)) || [];
return (
<div style={{ borderBottom: '1px solid var(--border)' }}>
<div className="user-row" style={{ borderBottom: 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div className="avatar" style={{ width: 32, height: 32, fontSize: 11, background: avatarColor(u.initials || u.id) }}>{u.initials || '??'}</div>
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>{u.name}</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>@{u.username}</div>
</div>
</div>
<div>
<select value={u.role || 'viewer'}
onChange={e => onChangeRole(u, e.target.value)}
className="field-input"
style={{ width: 90, padding: '3px 6px', fontSize: 11.5, appearance: 'auto' }}>
<option value="admin">admin</option>
<option value="editor">editor</option>
<option value="viewer">viewer</option>
</select>
</div>
<div>
{u.totp_enabled
? <span className="badge success"><Icon name="key" size={10} /> 2FA on</span>
: <span className="badge neutral">2FA off</span>}
</div>
<div>
<button className="btn ghost sm" onClick={onToggle}>
{expanded ? 'Hide' : 'View'}
</button>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
{u.totp_enabled && (
<button className="btn ghost sm danger" onClick={() => onResetTotp(u)} title="Disable this user's two-factor">
<Icon name="key" size={11} />Reset 2FA
</button>
)}
</div>
</div>
{expanded && (
<div style={{ padding: '0 16px 16px 16px', background: 'var(--bg-2)' }}>
{loading && <div style={{ padding: '12px 0', fontSize: 12.5, color: 'var(--text-3)' }}>Loading access</div>}
{accessErr && <div style={{ padding: '8px 0', fontSize: 12, color: 'var(--danger)' }}>{accessErr}</div>}
{!loading && !accessErr && (u.role === 'admin') && (
<div style={{ padding: '12px 0', fontSize: 12.5, color: 'var(--text-3)', display: 'flex', alignItems: 'center', gap: 6 }}>
<Icon name="check" size={12} style={{ color: 'var(--success)' }} />
Admin full access to every project.
</div>
)}
{!loading && !accessErr && u.role !== 'admin' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, paddingTop: 12 }}>
{/* Accessible projects */}
<div>
<div style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, marginBottom: 8 }}>
Projects ({projects.length})
</div>
{projects.length === 0 && (
<div style={{ fontSize: 12, color: 'var(--text-4)' }}>No project access granted.</div>
)}
{projects.map(p => {
// Backend `via` is 'direct' for a user grant, or 'group:<name>'
// when inherited from a group. Split the label off the prefix.
const via = p.via || 'direct';
const isGroup = via.indexOf('group') === 0;
const viaLabel = isGroup ? (via.indexOf(':') >= 0 ? via.slice(via.indexOf(':') + 1) : 'group') : 'direct';
return (
<div key={(p.project_id || p.id) + ':' + via}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0', borderBottom: '1px solid var(--border)' }}>
<span style={{ fontSize: 12.5, flex: 1 }}>{p.project_name || p.name || p.project_id || p.id}</span>
<span className={`badge ${(p.level === 'edit') ? 'accent' : 'neutral'}`}>{p.level || 'view'}</span>
<span className="badge neutral" title={isGroup ? 'Inherited from group ' + viaLabel : 'Granted directly'}>
<Icon name={isGroup ? 'users' : 'user'} size={9} /> {viaLabel}
</span>
</div>
);
})}
</div>
{/* Group memberships */}
<div>
<div style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, marginBottom: 8 }}>
Groups ({memberships.length})
</div>
{memberships.length === 0 && (
<div style={{ fontSize: 12, color: 'var(--text-4)' }}>Not a member of any group.</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{memberships.map(g => (
<span key={g.id || g.group_id || g.name} className="badge neutral" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<Icon name="users" size={9} />{g.name || g.group_name || g.group_id}
</span>
))}
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}
function EditUserModal({ user, onClose, onSaved }) { function EditUserModal({ user, onClose, onSaved }) {
const [name, setName] = React.useState(user.display_name || user.name || ''); const [name, setName] = React.useState(user.display_name || user.name || '');
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
@ -1181,18 +1358,8 @@ function Cluster() {
const [adviceModal, setAdviceModal] = React.useState(null); // {title, lines:[], commands?} const [adviceModal, setAdviceModal] = React.useState(null); // {title, lines:[], commands?}
const addNode = () => setAdviceModal({ const [showAddNode, setShowAddNode] = React.useState(false);
title: 'Add a worker node', const addNode = () => setShowAddNode(true);
lines: [
'Worker nodes auto-register with the cluster on first heartbeat.',
'Run these on the new host (replace 10.0.0.25 with this MAM\'s IP):',
],
commands: [
'git clone https://forge.wilddragon.net/zgaetano/dragonflight.git /opt/dragonflight',
'cd /opt/dragonflight && cp .env.example .env && nano .env # set NODE_ROLE=worker, point at MAM IP',
'docker compose -f docker-compose.worker.yml up -d',
],
});
const drainNode = (node) => setAdviceModal({ const drainNode = (node) => setAdviceModal({
title: `Drain ${node.id}`, title: `Drain ${node.id}`,
@ -1399,6 +1566,7 @@ function Cluster() {
)} )}
</div> </div>
</div> </div>
{showAddNode && <AddNodeModal onClose={() => setShowAddNode(false)} />}
{adviceModal && ( {adviceModal && (
<div className="modal-backdrop" onClick={() => setAdviceModal(null)}> <div className="modal-backdrop" onClick={() => setAdviceModal(null)}>
<div className="modal" style={{ width: 560 }} onClick={e => e.stopPropagation()}> <div className="modal" style={{ width: 560 }} onClick={e => e.stopPropagation()}>
@ -1429,6 +1597,165 @@ function Cluster() {
); );
} }
// AddNodeModal Approach A onboarding wizard. Collects a node name + role,
// mints a one-time auth token via /auth/tokens, and renders a ready-to-paste
// `curl | bash` command that provisions the machine via deploy/onboard-node.sh.
//
// Role compose PROFILES mapping (see docker-compose.worker.yml):
// Worker "worker"
// Capture "worker capture"
// GPU "worker gpu" (worker-l4 service, profiles: [gpu])
const ADD_NODE_ROLES = [
{ id: 'worker', label: 'Worker', profiles: 'worker', desc: 'CPU transcode / general jobs' },
{ id: 'capture', label: 'Capture', profiles: 'worker capture', desc: 'SDI / DeckLink ingest' },
{ id: 'gpu', label: 'GPU', profiles: 'worker gpu', desc: 'NVENC-accelerated transcode' },
];
function AddNodeModal({ onClose }) {
const [nodeName, setNodeName] = React.useState('');
const [role, setRole] = React.useState('worker');
const [apiUrl, setApiUrl] = React.useState('');
const [info, setInfo] = React.useState(null); // { scriptUrl, branch }
const [command, setCommand] = React.useState(null); // generated string
const [error, setError] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [copied, setCopied] = React.useState(false);
// On open, prefill the editable apiUrl + capture scriptUrl/branch.
React.useEffect(() => {
window.ZAMPP_API.fetch('/cluster/onboard-info')
.then(d => {
setInfo({ scriptUrl: d.scriptUrl, branch: d.branch });
if (d.apiUrl) setApiUrl(d.apiUrl);
})
.catch(() => {}); // leave apiUrl empty user must fill it before Generate
}, []);
const roleDef = ADD_NODE_ROLES.find(r => r.id === role) || ADD_NODE_ROLES[0];
const generate = async () => {
setError(null);
if (!nodeName.trim()) { setError('Node name is required.'); return; }
if (!apiUrl.trim()) { setError('Primary API URL is required.'); return; }
setBusy(true);
try {
const r = await fetch('/api/v1/auth/tokens', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ name: 'node: ' + nodeName.trim() }),
});
if (r.status !== 201) {
const body = await r.json().catch(() => ({}));
setError(body.error || ('Failed to mint token (' + r.status + ')'));
return;
}
const { token } = await r.json();
const scriptUrl = (info && info.scriptUrl)
|| 'https://forge.wilddragon.net/zgaetano/wild-dragon/raw/branch/main/deploy/onboard-node.sh';
const cmd =
`curl -sL ${scriptUrl} | NODE_TOKEN=${token} MAM_API_URL=${apiUrl.trim()} ` +
`NODE_ROLE=${role} PROFILES="${roleDef.profiles}" bash`;
setCommand(cmd);
} catch (e) {
setError(e.message || 'Network error');
} finally {
setBusy(false);
}
};
const copy = () => {
if (!command || !navigator.clipboard) return;
navigator.clipboard.writeText(command)
.then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); })
.catch(() => {});
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 620 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Add cluster node</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
{!command && (
<React.Fragment>
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 11.5, color: 'var(--text-3)', marginBottom: 5 }}>Node name</label>
<input className="field-input" style={{ width: '100%' }} autoFocus
placeholder="e.g. zampp3"
value={nodeName} onChange={e => setNodeName(e.target.value)} />
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 11.5, color: 'var(--text-3)', marginBottom: 5 }}>Role</label>
<div style={{ display: 'flex', gap: 6 }}>
{ADD_NODE_ROLES.map(rd => (
<button key={rd.id}
className={'btn sm' + (role === rd.id ? ' primary' : ' ghost')}
style={{ flex: 1, flexDirection: 'column', alignItems: 'flex-start', gap: 2, padding: '8px 10px' }}
onClick={() => setRole(rd.id)}>
<span style={{ fontWeight: 600 }}>{rd.label}</span>
<span style={{ fontSize: 10, opacity: 0.8 }}>{rd.desc}</span>
</button>
))}
</div>
</div>
<div style={{ marginBottom: 4 }}>
<label style={{ display: 'block', fontSize: 11.5, color: 'var(--text-3)', marginBottom: 5 }}>Primary API URL</label>
<input className="field-input mono" style={{ width: '100%', fontSize: 12 }}
placeholder="http://10.0.0.25:47432"
value={apiUrl} onChange={e => setApiUrl(e.target.value)} />
<div style={{ fontSize: 11, color: 'var(--text-4)', marginTop: 4 }}>
The LAN address this new node will heartbeat to. Edit if the guess is wrong.
</div>
</div>
</React.Fragment>
)}
{command && (
<React.Fragment>
<div style={{ marginBottom: 10, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)' }}>
This token is shown only once copy the command now.
</div>
</div>
<code className="mono" style={{ display: 'block', background: 'var(--bg-2)', padding: 12, borderRadius: 6, fontSize: 11.5, lineHeight: 1.5, wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>{command}</code>
<ol style={{ margin: '12px 0 0', paddingLeft: 18, fontSize: 12, color: 'var(--text-2)', lineHeight: 1.6 }}>
<li>SSH into the fresh Ubuntu machine.</li>
<li>Paste and run this command.</li>
<li>The node appears in this Cluster view within ~30s.</li>
</ol>
</React.Fragment>
)}
{error && (
<div style={{ marginTop: 10, fontSize: 11.5, color: 'var(--danger)' }}>{error}</div>
)}
</div>
<div className="modal-foot">
{!command && (
<React.Fragment>
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" disabled={busy} onClick={generate}>
{busy ? 'Generating…' : 'Generate command'}
</button>
</React.Fragment>
)}
{command && (
<React.Fragment>
<button className="btn ghost sm" onClick={copy}>{copied ? 'Copied' : 'Copy'}</button>
<button className="btn primary sm" onClick={onClose}>Done</button>
</React.Fragment>
)}
</div>
</div>
</div>
);
}
function DetailRow({ k, v, mono }) { function DetailRow({ k, v, mono }) {
return ( return (
<div style={{ display: "grid", gridTemplateColumns: "90px 1fr", alignItems: "center", fontSize: 12 }}> <div style={{ display: "grid", gridTemplateColumns: "90px 1fr", alignItems: "center", fontSize: 12 }}>
@ -1749,6 +2076,7 @@ function Settings() {
function StorageSection() { function StorageSection() {
return ( return (
<> <>
<StorageWarningBanner />
<MountHealthStrip /> <MountHealthStrip />
<S3SettingsCard /> <S3SettingsCard />
<GrowingSettingsCard /> <GrowingSettingsCard />
@ -1756,6 +2084,27 @@ function StorageSection() {
); );
} }
// Set-once deployment warning. Storage paths are written into asset rows and
// the S3 layout at ingest time; changing them after assets exist orphans files
// and can corrupt the library's view of where masters/proxies live.
function StorageWarningBanner() {
return (
<div role="alert" style={{
display: 'flex', alignItems: 'flex-start', gap: 12,
padding: '14px 16px', marginBottom: 14, borderRadius: 10,
border: '1px solid var(--danger)',
background: 'color-mix(in srgb, var(--danger) 12%, transparent)',
}}>
<Icon name="alert" size={20} style={{ color: 'var(--danger)', flexShrink: 0, marginTop: 1 }} />
<div style={{ fontSize: 13, fontWeight: 700, letterSpacing: '0.02em', lineHeight: 1.5, color: 'var(--text-1)' }}>
WARNING THESE SETTINGS ARE MEANT TO BE SET ONCE AT INITIAL DEPLOYMENT.
CHANGING STORAGE PATHS MID-CYCLE CAN CAUSE DATABASE CORRUPTION AND FILE LOSS.
PLEASE USE WITH CAUTION.
</div>
</div>
);
}
function formatBytes(n) { function formatBytes(n) {
if (n == null || isNaN(n)) return '·'; if (n == null || isNaN(n)) return '·';
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
@ -1828,8 +2177,8 @@ function MountHealthStrip() {
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<strong style={{ fontSize: 12.5 }}>Growing files</strong> <strong style={{ fontSize: 12.5 }}>Growing files</strong>
{g.enabled {g.enabled
? <HealthPill ok={growingHealthy} label={growingHealthy ? 'mounted' : 'unreachable'} detail={g.error || ''} /> ? <HealthPill ok={growingHealthy} label={growingHealthy ? 'configured' : 'unreachable'} detail={g.error || ''} />
: <span className="badge neutral">disabled</span>} : <span className="badge neutral">not configured</span>}
{g.enabled && g.exists && ( {g.enabled && g.exists && (
<HealthPill ok={g.writable} label={g.writable ? 'writable' : 'read-only'} /> <HealthPill ok={g.writable} label={g.writable ? 'writable' : 'read-only'} />
)} )}
@ -1842,7 +2191,8 @@ function MountHealthStrip() {
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}> <div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
<span>Container</span><span className="mono">{g.container_path || '·'}</span> <span>Container</span><span className="mono">{g.container_path || '·'}</span>
<span>Host</span><span className="mono">{g.host_path || '·'}</span> <span>Host</span><span className="mono">{g.host_path || '·'}</span>
<span>SMB</span><span className="mono">{g.smb_url || '·'}</span> <span>SMB mount</span><span className="mono">{g.smb_mount || '·'}</span>
<span>SMB (editors)</span><span className="mono">{g.smb_url || '·'}</span>
<span>Promote idle</span><span className="mono">{g.promote_after_seconds}s</span> <span>Promote idle</span><span className="mono">{g.promote_after_seconds}s</span>
{g.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{g.error}</span></>} {g.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{g.error}</span></>}
</div> </div>
@ -2036,35 +2386,75 @@ function GpuSettingsCard() {
function GrowingSettingsCard() { function GrowingSettingsCard() {
const [cfg, setCfg] = React.useState(null); const [cfg, setCfg] = React.useState(null);
const [pwd, setPwd] = React.useState(''); // new password to set; '' = leave unchanged
const [pwdExists, setPwdExists] = React.useState(false);
const [clearPwd, setClearPwd] = React.useState(false);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [msg, setMsg] = React.useState(null); const [msg, setMsg] = React.useState(null);
React.useEffect(() => { React.useEffect(() => {
window.ZAMPP_API.fetch('/settings/growing').then(setCfg).catch(() => setCfg({ window.ZAMPP_API.fetch('/settings/growing')
growing_enabled: 'false', growing_path: '/growing', growing_smb_url: '', growing_promote_after_seconds: '8', .then(d => { setCfg(d); setPwdExists(!!d.growing_smb_password_exists); })
})); .catch(() => setCfg({
growing_path: '/growing', growing_smb_url: '', growing_smb_mount: '',
growing_smb_username: '', growing_smb_vers: '3.0', growing_promote_after_seconds: '8',
}));
}, []); }, []);
const save = () => { const save = () => {
setSaving(true); setMsg(null); setSaving(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/growing', { method: 'PUT', body: JSON.stringify(cfg) }) const body = {
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); }) growing_path: cfg.growing_path,
growing_smb_url: cfg.growing_smb_url,
growing_smb_mount: cfg.growing_smb_mount,
growing_smb_username: cfg.growing_smb_username,
growing_smb_vers: cfg.growing_smb_vers,
growing_promote_after_seconds: cfg.growing_promote_after_seconds,
};
if (clearPwd) body.growing_smb_password_clear = true;
else if (pwd) body.growing_smb_password = pwd;
window.ZAMPP_API.fetch('/settings/growing', { method: 'PUT', body: JSON.stringify(body) })
.then(() => {
setSaving(false); setMsg({ ok: true, text: 'Saved.' });
if (clearPwd) { setPwdExists(false); setClearPwd(false); }
else if (pwd) { setPwdExists(true); setPwd(''); }
})
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); }); .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
}; };
if (!cfg) return <SettingsCard icon="hdd" title="Growing files (SMB)" sub="Loading…"><div style={{color:'var(--text-3)'}}></div></SettingsCard>; if (!cfg) return <SettingsCard icon="hdd" title="Growing files (SMB)" sub="Loading…"><div style={{color:'var(--text-3)'}}></div></SettingsCard>;
const set = (k, v) => setCfg(c => ({ ...c, [k]: v })); const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
const enabled = cfg.growing_enabled === 'true' || cfg.growing_enabled === true; const mountConfigured = !!(cfg.growing_smb_mount && cfg.growing_smb_mount.trim());
return ( return (
<SettingsCard icon="hdd" title="Growing files (SMB)" sub="High-speed local landing zone; promote to S3 on stop" <SettingsCard icon="hdd" title="Growing files (SMB)" sub="Shared SMB landing zone. Enable per-recorder under Ingest → Recorders."
tag={enabled ? <span className="badge success">enabled</span> : <span className="badge neutral">disabled</span>}> tag={mountConfigured ? <span className="badge success">configured</span> : <span className="badge neutral">not configured</span>}>
<form onSubmit={e => { e.preventDefault(); save(); }} autoComplete="off"> <form onSubmit={e => { e.preventDefault(); save(); }} autoComplete="off">
<SField label="Enable growing-file capture"> <div style={{ fontSize: 11.5, color: 'var(--text-3)', marginBottom: 10, lineHeight: 1.5 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}> Growing-file mode is enabled <strong style={{ color: 'var(--text-2)' }}>per recorder</strong> (New recorder Growing-files mode).
<input type="checkbox" checked={enabled} onChange={e => set('growing_enabled', String(e.target.checked))} /> These settings describe the SMB share that capture mounts and writes the live master to.
<span style={{ color: 'var(--text-2)' }}>Capture writes to the local SMB share first; Premier can edit while it's still growing.</span> </div>
</label> <SField label="SMB mount source (CIFS)">
<input className="field-input mono" value={cfg.growing_smb_mount || ''} onChange={e => set('growing_smb_mount', e.target.value)} placeholder="//10.0.0.25/mam-growing" />
</SField>
<SField label="SMB username">
<input className="field-input mono" value={cfg.growing_smb_username || ''} onChange={e => set('growing_smb_username', e.target.value)} placeholder="capture" autoComplete="off" />
</SField>
<SField label="SMB password">
<input className="field-input mono" type="password" autoComplete="new-password"
value={pwd}
disabled={clearPwd}
onChange={e => setPwd(e.target.value)}
placeholder={pwdExists ? '•••••••• (saved — leave blank to keep)' : 'Enter SMB password'} />
{pwdExists && (
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11.5, color: 'var(--text-3)', marginTop: 4 }}>
<input type="checkbox" checked={clearPwd} onChange={e => { setClearPwd(e.target.checked); if (e.target.checked) setPwd(''); }} />
Remove saved password
</label>
)}
</SField>
<SField label="CIFS protocol version">
<input className="field-input mono" value={cfg.growing_smb_vers || ''} onChange={e => set('growing_smb_vers', e.target.value)} placeholder="3.0" />
</SField> </SField>
<SField label="Container mount path"> <SField label="Container mount path">
<input className="field-input mono" value={cfg.growing_path || ''} onChange={e => set('growing_path', e.target.value)} placeholder="/growing" /> <input className="field-input mono" value={cfg.growing_path || ''} onChange={e => set('growing_path', e.target.value)} placeholder="/growing" />

File diff suppressed because it is too large Load diff

View file

@ -997,6 +997,7 @@ function Capture({ navigate }) {
/* ===== Monitors ===== */ /* ===== Monitors ===== */
function Monitors({ navigate }) { function Monitors({ navigate }) {
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []); const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA?.RECORDERS || []);
const [channels, setChannels] = React.useState([]);
const [grid, setGrid] = React.useState(4); const [grid, setGrid] = React.useState(4);
React.useEffect(() => { React.useEffect(() => {
@ -1008,6 +1009,11 @@ function Monitors({ navigate }) {
setRecorders(norm); setRecorders(norm);
}) })
.catch(() => {}); .catch(() => {});
// Playout channels surface here too so an operator can watch on-air
// output alongside ingest. Degrade silently if the endpoint is absent.
window.ZAMPP_API.fetch('/playout/channels')
.then(raw => setChannels(Array.isArray(raw) ? raw : []))
.catch(() => setChannels([]));
}; };
refresh(); refresh();
const id = setInterval(refresh, 5000); const id = setInterval(refresh, 5000);
@ -1032,18 +1038,87 @@ function Monitors({ navigate }) {
</div> </div>
</div> </div>
<div className="page-body"> <div className="page-body">
{feeds.length === 0 ? ( {feeds.length === 0 && channels.length === 0 ? (
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No active feeds. Start a recorder to see live video here.</div> <div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No active feeds. Start a recorder or playout channel to see live video here.</div>
) : ( ) : (
<div className="monitors-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(' + grid + ', 1fr)', gap: 8 }}> <React.Fragment>
{feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)} {feeds.length > 0 && (
</div> <React.Fragment>
<div className="monitor-section-head">Ingest</div>
<div className="monitors-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(' + grid + ', 1fr)', gap: 8 }}>
{feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)}
</div>
</React.Fragment>
)}
{channels.length > 0 && (
<React.Fragment>
<div className="monitor-section-head">Playout</div>
<div className="monitors-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(' + grid + ', 1fr)', gap: 8 }}>
{channels.slice(0, grid * grid).map(c => <PlayoutMonitorTile key={c.id} channel={c} />)}
</div>
</React.Fragment>
)}
</React.Fragment>
)} )}
</div> </div>
</div> </div>
); );
} }
function PlayoutMonitorTile({ channel }) {
const videoRef = React.useRef(null);
const hlsRef = React.useRef(null);
const onAir = channel.status === 'running';
const previewUrl = '/api/v1/playout/channels/' + channel.id + '/hls/index.m3u8';
React.useEffect(() => {
const vid = videoRef.current;
if (!vid) return;
if (hlsRef.current) { try { hlsRef.current.destroy(); } catch (_) {} hlsRef.current = null; }
if (!onAir) { vid.src = ''; return; }
if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls({
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
xhrSetup: (xhr) => { xhr.withCredentials = true; },
});
hlsRef.current = hls;
hls.loadSource(previewUrl);
hls.attachMedia(vid);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => vid.play().catch(() => {}));
} else if (vid.canPlayType('application/vnd.apple.mpegurl')) {
vid.src = previewUrl;
vid.play().catch(() => {});
}
return () => {
if (hlsRef.current) { try { hlsRef.current.destroy(); } catch (_) {} hlsRef.current = null; }
};
}, [onAir, channel.id]);
return (
<div className="monitor-tile">
{onAir ? (
<video ref={videoRef} muted playsInline autoPlay
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', background: '#000' }} />
) : (
<FauxFrame />
)}
{onAir && <div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />}
{!onAir && (
<div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', color: 'var(--text-3)', fontSize: 11 }}>channel idle</div>
)}
<div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 6 }}>
{onAir ? <span className="badge live">ON AIR</span> : <span className="badge neutral">IDLE</span>}
</div>
<div className="monitor-tile-label">
<span className="name">{channel.name}</span>
{channel.output_type && <span className="time mono">{String(channel.output_type).toUpperCase()}</span>}
</div>
</div>
);
}
function MonitorTile({ feed, seed }) { function MonitorTile({ feed, seed }) {
const [levels, setLevels] = React.useState([0.65, 0.78]); const [levels, setLevels] = React.useState([0.65, 0.78]);
const isLive = feed.status === 'recording'; const isLive = feed.status === 'recording';

View file

@ -45,7 +45,7 @@ function Jobs({ navigate }) {
const normalizeJob = (j) => { const normalizeJob = (j) => {
const statusMap = { waiting: 'queued', active: 'running', completed: 'done', failed: 'failed' }; const statusMap = { waiting: 'queued', active: 'running', completed: 'done', failed: 'failed' };
const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', import: 'YouTube' }; const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', import: 'YouTube', 'playout-stage': 'Stage' };
const meta = j.metadata || {}; const meta = j.metadata || {};
return { return {
...j, ...j,
@ -207,7 +207,7 @@ function Jobs({ navigate }) {
} }
function JobRow({ job, onRetry, onDelete }) { function JobRow({ job, onRetry, onDelete }) {
const iconMap = { Proxy: 'proxy', Transcode: 'film', Thumbnail: 'image', Conform: 'layers' }; const iconMap = { Proxy: 'proxy', Transcode: 'film', Thumbnail: 'image', Conform: 'layers', Stage: 'monitor' };
return ( return (
<div className="job-row"> <div className="job-row">
<div><StatusDot status={job.status} /></div> <div><StatusDot status={job.status} /></div>

View file

@ -0,0 +1,729 @@
// screens-playout.jsx Master Control (MCR) playout page.
//
// Operator workflow (Phase A playlist player):
// 1. Create / pick a channel (output target: SRT / RTMP / NDI / DeckLink).
// 2. Start the channel spawns the CasparCG sidecar, brings up the output.
// 3. Drag assets from the media bin into the playlist; reorder by dragging.
// Each item stages from S3 to the CasparCG /media volume in the background.
// 4. Hit PLAY the engine walks the playlist gaplessly. PAUSE / SKIP / STOP
// transport. As-run log records what aired.
//
// Talks to /api/v1/playout via window.ZAMPP_API.fetch. Native HTML5 drag-drop,
// no extra library. Components are plain globals (esbuild bundle:false).
const PO_OUTPUTS = [
{ value: 'srt', label: 'SRT' },
{ value: 'rtmp', label: 'RTMP' },
{ value: 'ndi', label: 'NDI' },
{ value: 'decklink', label: 'SDI (DeckLink)' },
];
const PO_FORMATS = ['1080p5994', '1080i5994', '1080p2997', '720p5994', '1080i50', '1080p25'];
async function poFetch(path, opts) {
return window.ZAMPP_API.fetch('/playout' + path, opts);
}
// Helpers
function fmtDuration(secs) {
if (!secs || secs < 0) return '—';
const s = Math.floor(secs);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const ss = s % 60;
const mm = String(m).padStart(2, '0');
const ssStr = String(ss).padStart(2, '0');
return h > 0 ? `${h}:${mm}:${ssStr}` : `${m}:${ssStr}`;
}
function itemEffectiveDuration(it) {
const total = (it.asset_duration_ms || 0) / 1000;
const inPt = it.in_point != null ? Number(it.in_point) : 0;
const outPt = it.out_point != null ? Number(it.out_point) : total;
return Math.max(0, outPt - inPt);
}
// Output-config sub-form (varies by output type)
function OutputConfigFields({ type, config, onChange }) {
const set = (k, v) => onChange({ ...config, [k]: v });
if (type === 'decklink') {
return (
<div className="field">
<label className="field-label">DeckLink device index</label>
<input className="field-input" type="number" min="1" value={config.device_index || 1}
onChange={e => set('device_index', parseInt(e.target.value, 10) || 1)} />
</div>
);
}
if (type === 'ndi') {
return (
<div className="field">
<label className="field-label">NDI source name</label>
<input className="field-input" value={config.ndi_name || ''} placeholder="DRAGONFLIGHT CH1"
onChange={e => set('ndi_name', e.target.value)} />
</div>
);
}
// srt / rtmp
return (
<React.Fragment>
<div className="field">
<label className="field-label">{type.toUpperCase()} URL</label>
<input className="field-input mono" value={config.url || ''}
placeholder={type === 'srt' ? 'srt://host:9000' : 'rtmp://host/live'}
onChange={e => set('url', e.target.value)} />
</div>
{type === 'rtmp' && (
<div className="field">
<label className="field-label">Stream key</label>
<input className="field-input mono" value={config.key || ''}
onChange={e => set('key', e.target.value)} />
</div>
)}
{type === 'srt' && (
<div className="field">
<label className="field-label">Latency (ms)</label>
<input className="field-input" type="number" value={config.latency || 200}
onChange={e => set('latency', parseInt(e.target.value, 10) || 200)} />
</div>
)}
</React.Fragment>
);
}
// Channel create modal
function ChannelCreate({ onClose, onCreated }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const [name, setName] = React.useState('');
const [outputType, setOutputType] = React.useState('srt');
const [config, setConfig] = React.useState({});
const [videoFormat, setVideoFormat] = React.useState('1080i5994');
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
const submit = async () => {
setBusy(true); setErr(null);
try {
const ch = await poFetch('/channels', {
method: 'POST',
body: JSON.stringify({
name, output_type: outputType, output_config: config,
video_format: videoFormat, project_id: projectId || null,
}),
});
onCreated(ch);
} catch (e) { setErr(e.message || 'Failed to create channel'); }
finally { setBusy(false); }
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 460 }}>
<div className="modal-header"><h3>New Playout Channel</h3></div>
<div className="modal-body">
<div className="field">
<label className="field-label">Name</label>
<input className="field-input" value={name} autoFocus
onChange={e => setName(e.target.value)} placeholder="Channel 1" />
</div>
<div className="field">
<label className="field-label">Output</label>
<select className="field-input" value={outputType}
onChange={e => { setOutputType(e.target.value); setConfig({}); }}>
{PO_OUTPUTS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<OutputConfigFields type={outputType} config={config} onChange={setConfig} />
<div className="field">
<label className="field-label">Video format</label>
<select className="field-input" value={videoFormat} onChange={e => setVideoFormat(e.target.value)}>
{PO_FORMATS.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
<div className="field">
<label className="field-label">Project (RBAC scope)</label>
<select className="field-input" value={projectId} onChange={e => setProjectId(e.target.value)}>
<option value=""> admin only </option>
{PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
{err && <div className="alert error">{err}</div>}
</div>
<div className="modal-footer">
<button className="btn ghost" onClick={onClose}>Cancel</button>
<button className="btn primary" disabled={busy || !name} onClick={submit}>
{busy ? 'Creating…' : 'Create'}
</button>
</div>
</div>
</div>
);
}
// Media bin: assets draggable into the playlist
function MediaBin({ projectId }) {
const ASSETS = (window.ZAMPP_DATA?.ASSETS || []).filter(a =>
!projectId || a.project_id === projectId);
const [q, setQ] = React.useState('');
const filtered = ASSETS.filter(a => !q || (a.name || '').toLowerCase().includes(q.toLowerCase()));
const onDragStart = (e, asset) => {
e.dataTransfer.setData('application/x-df-asset', JSON.stringify({ id: asset.id, name: asset.name }));
e.dataTransfer.effectAllowed = 'copy';
};
return (
<div className="panel po-bin">
<div className="po-bin-head">
<span className="po-section-label">Media Bin</span>
<input className="field-input sm" placeholder="Filter…" value={q}
onChange={e => setQ(e.target.value)} style={{ maxWidth: 160 }} />
</div>
<div className="po-bin-list">
{filtered.length === 0 && <div className="muted" style={{ padding: 12 }}>No assets.</div>}
{filtered.map(a => (
<div key={a.id} className="po-bin-item" draggable
onDragStart={e => onDragStart(e, a)} title="Drag into the playlist">
<span className="po-bin-name">{a.name}</span>
<span className="mono muted" style={{ fontSize: 11 }}>{a.duration || ''}</span>
</div>
))}
</div>
</div>
);
}
// Staging progress bar
function StagingBar({ status }) {
return (
<div className={'po-staging-bar po-staging-bar--' + (status || 'pending')} aria-hidden="true" />
);
}
// Playlist: ordered, drag-drop reorder, drop-target for bin assets
function Playlist({ channel, playlistId, items, activeIndex, onReload }) {
const [dragIndex, setDragIndex] = React.useState(null);
const [dropErr, setDropErr] = React.useState(null);
const onItemDragStart = (e, index) => {
setDragIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const onItemDragOver = (e) => { e.preventDefault(); };
const onItemDrop = async (e, index) => {
e.preventDefault();
e.stopPropagation(); // prevent bubble to onContainerDrop
setDropErr(null);
const assetRaw = e.dataTransfer.getData('application/x-df-asset');
if (assetRaw) {
try {
const asset = JSON.parse(assetRaw);
await poFetch('/playlists/' + playlistId + '/items', {
method: 'POST', body: JSON.stringify({ asset_id: asset.id }),
});
onReload();
} catch (err) { setDropErr(err.message || 'Failed to add clip'); }
return;
}
// Reorder within the playlist.
if (dragIndex === null || dragIndex === index) return;
const order = items.map(i => i.id);
const [moved] = order.splice(dragIndex, 1);
order.splice(index, 0, moved);
setDragIndex(null);
try {
await poFetch('/playlists/' + playlistId + '/reorder', {
method: 'PUT', body: JSON.stringify({ order }),
});
onReload();
} catch (err) { setDropErr(err.message || 'Failed to reorder'); }
};
const onContainerDrop = async (e) => {
const assetRaw = e.dataTransfer.getData('application/x-df-asset');
if (!assetRaw) return;
e.preventDefault();
setDropErr(null);
try {
const asset = JSON.parse(assetRaw);
await poFetch('/playlists/' + playlistId + '/items', {
method: 'POST', body: JSON.stringify({ asset_id: asset.id }),
});
onReload();
} catch (err) { setDropErr(err.message || 'Failed to add clip'); }
};
const removeItem = async (id) => {
try { await poFetch('/items/' + id, { method: 'DELETE' }); onReload(); }
catch (err) { setDropErr(err.message || 'Failed to remove'); }
};
const restage = async (id) => {
try { await poFetch('/items/' + id + '/stage', { method: 'POST' }); onReload(); }
catch (err) { setDropErr(err.message || 'Failed to restage'); }
};
const totalSecs = items.reduce((sum, it) => sum + itemEffectiveDuration(it), 0);
return (
<div className="panel po-playlist" onDragOver={e => e.preventDefault()} onDrop={onContainerDrop}>
<div className="po-playlist-head">
<span className="po-section-label">Playlist</span>
{dropErr && <span className="po-drop-err">{dropErr}</span>}
</div>
{items.length === 0 && (
<div className="muted po-playlist-empty">Drag clips here to build the playlist.</div>
)}
{items.map((it, index) => {
const isActive = index === activeIndex;
const dur = itemEffectiveDuration(it);
return (
<div key={it.id}
className={'po-pl-item' + (isActive ? ' po-pl-item--active' : '')}
draggable
onDragStart={e => onItemDragStart(e, index)}
onDragOver={onItemDragOver}
onDrop={e => onItemDrop(e, index)}>
<span className="po-pl-index">
{isActive ? <span className="po-pl-onair"></span> : index + 1}
</span>
<span className="po-pl-name">{it.clip_name || it.asset_id}</span>
<span className="mono po-pl-dur">{fmtDuration(dur)}</span>
<span className={'badge po-pl-badge ' + (it.media_status === 'ready' ? 'success' : it.media_status === 'staging' ? 'warn' : it.media_status === 'error' ? 'error' : 'neutral')}>
{it.media_status}
</span>
{it.media_status === 'error' && (
<button className="btn ghost xs" onClick={() => restage(it.id)}>Retry</button>
)}
<button className="btn ghost xs" onClick={() => removeItem(it.id)}></button>
<StagingBar status={it.media_status} />
</div>
);
})}
{items.length > 0 && (
<div className="po-playlist-footer">
<span className="mono muted">{items.length} clip{items.length !== 1 ? 's' : ''}</span>
<span className="mono po-pl-total">{fmtDuration(totalSecs)} total</span>
</div>
)}
</div>
);
}
// Transport bar
function Transport({ channel, playlistId, items, onStatus }) {
const [busy, setBusy] = React.useState(false);
const act = async (fn) => { setBusy(true); try { await fn(); } catch (e) { alert(e.message); } finally { setBusy(false); } };
const notReady = items.filter(i => i.media_status !== 'ready').length;
const canPlay = channel.status === 'running' && !busy && !!playlistId && notReady === 0;
const play = () => act(async () => {
const r = await poFetch('/channels/' + channel.id + '/play', {
method: 'POST', body: JSON.stringify({ playlist_id: playlistId }),
});
onStatus && onStatus(r);
});
const pause = () => act(() => poFetch('/channels/' + channel.id + '/pause', { method: 'POST' }));
const resume = () => act(() => poFetch('/channels/' + channel.id + '/resume', { method: 'POST' }));
const skip = () => act(() => poFetch('/channels/' + channel.id + '/skip', { method: 'POST' }));
const stopPb = () => act(() => poFetch('/channels/' + channel.id + '/stop-playback', { method: 'POST' }));
const live = channel.status === 'running';
return (
<div className="po-transport">
<button className="btn primary" disabled={!canPlay} onClick={play} title={notReady > 0 ? notReady + ' clip(s) still staging' : ''}>
{notReady > 0 && live ? '⏳ ' + notReady + ' staging' : '▶ Play'}
</button>
<button className="btn ghost" disabled={!live || busy} onClick={pause}> Pause</button>
<button className="btn ghost" disabled={!live || busy} onClick={resume}> Resume</button>
<button className="btn ghost" disabled={!live || busy} onClick={skip}> Skip</button>
<button className="btn danger ghost" disabled={!live || busy} onClick={stopPb}> Stop</button>
</div>
);
}
// Elapsed timer
function useElapsed(startedAt) {
const [elapsed, setElapsed] = React.useState(0);
React.useEffect(() => {
if (!startedAt) { setElapsed(0); return; }
const base = new Date(startedAt).getTime();
const tick = () => setElapsed(Math.max(0, Math.floor((Date.now() - base) / 1000)));
tick();
const id = setInterval(tick, 500);
return () => clearInterval(id);
}, [startedAt]);
return elapsed;
}
function fmtElapsed(secs) {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
return (h > 0 ? String(h).padStart(2,'0') + ':' : '') +
String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0');
}
// Program monitor
function ProgramMonitor({ channel, engine }) {
const videoRef = React.useRef(null);
const hlsRef = React.useRef(null);
const onAir = channel.status === 'running';
// Load the playlist through the API (not the static /media/live path): the
// public reverse proxy caches the static .m3u8 with a multi-second TTL and
// ignores no-store, which starved hls.js's reloads of the live edge and kept
// the monitor black. /api/ isn't proxy-cached, so this always returns fresh.
const previewUrl = `/api/v1/playout/channels/${channel.id}/hls/index.m3u8`;
const elapsed = useElapsed(engine && engine.currentItemStartedAt);
React.useEffect(() => {
const vid = videoRef.current;
if (!vid) return;
// Tear down any previous HLS instance before re-evaluating.
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
if (!onAir) { vid.src = ''; return; }
if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls({
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
// Keep hls.js pinned to the live edge. The preview is a CPU-encoded
// confidence monitor whose live-edge segment may still be mid-write
// when first fetched; a small back-buffer + tolerant stall handling
// lets the player skip transient gaps instead of freezing.
backBufferLength: 8,
maxBufferLength: 10,
liveDurationInfinity: true,
highBufferWatchdogPeriod: 1,
nudgeMaxRetry: 10,
// The playlist is served from /api/ (auth-gated); send the session
// cookie so the request authenticates. Segments are static + public.
xhrSetup: (xhr) => { xhr.withCredentials = true; },
});
hlsRef.current = hls;
hls.loadSource(previewUrl);
hls.attachMedia(vid);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => vid.play().catch(() => {}));
// Resilient recovery. Without this, the FIRST fatal hls.js error (a
// buffer stall on the live edge, a media/decode error, or a transient
// fragment/playlist load error against the rewinding live playlist)
// permanently halts playback and the monitor goes black which is
// exactly the "flashes a frame then stays black" symptom: hls.js renders
// a fragment or two, hits an unrecovered error, and never resumes. We
// distinguish error types and recover in place rather than tearing down.
let recoverCount = 0;
hls.on(window.Hls.Events.ERROR, (_evt, data) => {
if (!data.fatal) {
// Non-fatal buffer stalls: nudge hls.js back to the live edge.
if (data.details === window.Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
try { hls.startLoad(); } catch (_) {}
}
return;
}
switch (data.type) {
case window.Hls.ErrorTypes.NETWORK_ERROR:
// Playlist/fragment load errors against the live edge are usually
// transient (segment rotated or mid-write). Re-arm the loader.
try { hls.startLoad(); } catch (_) {}
break;
case window.Hls.ErrorTypes.MEDIA_ERROR:
// Decode/buffer-append failures: flush + rebuild the buffer.
recoverCount += 1;
if (recoverCount <= 3) {
try { hls.recoverMediaError(); } catch (_) {}
} else {
// Repeated media errors: full reload of the source from scratch.
recoverCount = 0;
try { hls.destroy(); } catch (_) {}
if (hlsRef.current === hls) hlsRef.current = null;
}
break;
default:
// Unrecoverable: drop the instance so a re-render can re-init.
try { hls.destroy(); } catch (_) {}
if (hlsRef.current === hls) hlsRef.current = null;
}
});
// A stalled <video> (readyState frozen) gets a gentle kick back to live.
hls.on(window.Hls.Events.FRAG_BUFFERED, () => {
if (vid.paused) vid.play().catch(() => {});
});
} else if (vid.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS (Safari).
vid.src = previewUrl;
vid.play().catch(() => {});
}
return () => {
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
};
}, [onAir, channel.id]);
return (
<div className="po-monitor">
<div className="po-monitor-head">
<span className={'po-onair ' + (onAir ? 'live' : '')}>{onAir ? '● ON AIR' : '○ OFF'}</span>
<span className="mono muted">{channel.output_type?.toUpperCase()} · {channel.video_format}</span>
</div>
<div className="po-monitor-screen">
<video ref={videoRef} className="po-monitor-video" muted playsInline autoPlay />
{!onAir && (
<div className="po-monitor-overlay muted">Channel stopped</div>
)}
</div>
<div className="po-monitor-foot mono muted">
{engine && engine.currentClip
? <span className="po-monitor-clip-name">{engine.currentClip}</span>
: <span>{onAir ? 'Idle' : 'Stopped'}</span>}
{engine && engine.currentIndex >= 0 && (
<span style={{ marginLeft: 'auto', display: 'flex', gap: 10 }}>
<span style={{ color: 'var(--success)', fontVariantNumeric: 'tabular-nums' }}>
{fmtElapsed(elapsed)}
</span>
<span>clip {engine.currentIndex + 1}/{engine.playlistLength || 0}</span>
{engine.loop && <span></span>}
</span>
)}
{engine && engine.lastError && (
<span style={{ color: 'var(--warning)', fontSize: 10, marginLeft: 6 }} title={engine.lastError}></span>
)}
</div>
</div>
);
}
// Channel detail (monitors + bin + playlist + transport)
// As-run compliance log. Polls the existing GET /channels/:id/asrun endpoint
// (rows written by the scheduler health tick on every clip change) and shows the
// most recent plays: start time, clip, on-air duration, result.
function AsRunPanel({ channel, refreshKey }) {
const [rows, setRows] = React.useState([]);
React.useEffect(() => {
let alive = true;
let t;
const poll = async () => {
try {
const r = await poFetch('/channels/' + channel.id + '/asrun');
if (alive) setRows(Array.isArray(r) ? r : []);
} catch (_) {}
t = setTimeout(poll, 5000);
};
poll();
return () => { alive = false; clearTimeout(t); };
}, [channel.id, refreshKey]);
const fmtTime = (ts) => {
if (!ts) return '—';
const d = new Date(ts);
return isNaN(d) ? '—' : d.toLocaleTimeString();
};
return (
<div className="po-asrun">
<div className="po-section-label">As-Run Log</div>
{rows.length === 0
? <div className="mono muted" style={{ padding: '8px 0' }}>No as-run entries yet.</div>
: (
<table className="po-asrun-table">
<thead>
<tr><th>Time</th><th>Clip</th><th>Duration</th><th>Result</th></tr>
</thead>
<tbody>
{rows.slice(0, 50).map((r) => (
<tr key={r.id}>
<td className="mono">{fmtTime(r.started_at)}</td>
<td>{r.clip_name || r.item_id || '—'}</td>
<td className="mono">{r.duration_s != null ? fmtDuration(Number(r.duration_s)) : (r.ended_at ? '—' : 'on air')}</td>
<td className={'po-asrun-result po-asrun-' + (r.result || 'played')}>{r.result || 'played'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
function ChannelDetail({ channel, onChannelChange }) {
const [playlists, setPlaylists] = React.useState([]);
const [playlistId, setPlaylistId] = React.useState(null);
const [items, setItems] = React.useState([]);
const [engine, setEngine] = React.useState(null);
const [ch, setCh] = React.useState(channel);
React.useEffect(() => { setCh(channel); }, [channel.id]);
const loadPlaylists = React.useCallback(async () => {
const pls = await poFetch('/playlists?channel_id=' + channel.id);
setPlaylists(pls);
if (pls.length && !playlistId) setPlaylistId(pls[0].id);
if (!pls.length) {
// Auto-create a default playlist so the operator can start dragging.
const created = await poFetch('/playlists', {
method: 'POST', body: JSON.stringify({ channel_id: channel.id, name: 'Main' }),
});
setPlaylists([created]); setPlaylistId(created.id);
}
}, [channel.id]);
const loadItems = React.useCallback(async () => {
if (!playlistId) return;
const its = await poFetch('/playlists/' + playlistId + '/items');
setItems(its);
}, [playlistId]);
React.useEffect(() => { loadPlaylists(); }, [channel.id]);
React.useEffect(() => { loadItems(); }, [playlistId]);
// Poll engine status + item staging while live.
React.useEffect(() => {
let t;
const poll = async () => {
try {
const s = await poFetch('/channels/' + channel.id + '/status');
setEngine(s.engine || null);
} catch (_) {}
try { await loadItems(); } catch (_) {}
t = setTimeout(poll, 4000);
};
poll();
return () => clearTimeout(t);
}, [channel.id, playlistId]);
const startChannel = async () => {
const updated = await poFetch('/channels/' + channel.id + '/start', { method: 'POST' });
setCh(updated); onChannelChange(updated);
};
const stopChannel = async () => {
const updated = await poFetch('/channels/' + channel.id + '/stop', { method: 'POST' });
setCh(updated); onChannelChange(updated);
};
const deleteChannel = async () => {
if (!window.confirm('Delete channel "' + ch.name + '"? This cannot be undone.')) return;
try {
await poFetch('/channels/' + ch.id, { method: 'DELETE' });
onChannelChange({ ...ch, _deleted: true });
} catch (e) { alert(e.message); }
};
// engine.currentIndex maps directly to the sorted item position.
const activeIndex = (engine && engine.currentIndex >= 0) ? engine.currentIndex : -1;
return (
<div className="po-detail">
<div className="po-detail-head">
<div>
<h3 style={{ margin: 0 }}>{ch.name}</h3>
<span className="mono muted">{ch.output_type?.toUpperCase()} · {ch.video_format} · {ch.status}</span>
</div>
<div className="po-detail-actions">
{ch.status === 'running'
? <button className="btn danger" onClick={stopChannel}>Stop channel</button>
: <button className="btn primary" onClick={startChannel}>Start channel</button>}
{ch.status !== 'running' && (
<button className="btn ghost danger sm" onClick={deleteChannel} title="Delete this channel">Delete</button>
)}
</div>
</div>
{ch.error_message && <div className="alert error">{ch.error_message}</div>}
<div className="po-grid">
<ProgramMonitor channel={ch} engine={engine} />
<MediaBin projectId={ch.project_id} />
</div>
<Transport channel={ch} playlistId={playlistId} items={items} onStatus={() => loadItems()} />
{playlistId && (
<Playlist
channel={ch}
playlistId={playlistId}
items={items}
activeIndex={activeIndex}
onReload={loadItems}
/>
)}
<AsRunPanel channel={ch} refreshKey={engine && engine.currentItemId} />
</div>
);
}
// Top-level page
function Playout() {
const [channels, setChannels] = React.useState(null);
const [selectedId, setSelectedId] = React.useState(null);
const [showCreate, setShowCreate] = React.useState(false);
const [err, setErr] = React.useState(null);
const load = React.useCallback(async () => {
try {
const list = await poFetch('/channels');
setChannels(list);
if (list.length && !selectedId) setSelectedId(list[0].id);
} catch (e) { setErr(e.message); setChannels([]); }
}, [selectedId]);
React.useEffect(() => { load(); }, []);
const selected = (channels || []).find(c => c.id === selectedId) || null;
const onChannelChange = (updated) => {
if (updated._deleted) {
setChannels(cs => {
const next = (cs || []).filter(c => c.id !== updated.id);
setSelectedId(next.length ? next[0].id : null);
return next;
});
return;
}
setChannels(cs => (cs || []).map(c => c.id === updated.id ? updated : c));
};
return (
<div className="page">
<div className="page-header">
<span className="title">Playout Master Control</span>
<span className="subtitle">Schedule and play assets to SDI, NDI, SRT or RTMP.</span>
</div>
<div className="page-body po-page">
{err && <div className="alert error">{err}</div>}
<div className="po-channels-bar">
{(channels || []).map(c => (
<button key={c.id}
className={'po-chan-tab ' + (c.id === selectedId ? 'active' : '')}
onClick={() => setSelectedId(c.id)}>
<span className={'po-chan-dot ' + (c.status === 'running' ? 'live' : '')} />
{c.name}
</button>
))}
<button className="btn ghost sm" onClick={() => setShowCreate(true)}>+ Channel</button>
</div>
{channels === null && <div className="muted">Loading channels</div>}
{channels !== null && channels.length === 0 && (
<div className="po-empty">
<p className="muted">No playout channels yet.</p>
<button className="btn primary" onClick={() => setShowCreate(true)}>Create your first channel</button>
</div>
)}
{selected && <ChannelDetail key={selected.id} channel={selected} onChannelChange={onChannelChange} />}
</div>
{showCreate && (
<ChannelCreate
onClose={() => setShowCreate(false)}
onCreated={(ch) => { setShowCreate(false); setChannels(cs => [...(cs || []), ch]); setSelectedId(ch.id); }}
/>
)}
</div>
);
}
window.Playout = Playout;

View file

@ -18,9 +18,9 @@ const NAV_SECTIONS = [
label: "Ingest", label: "Ingest",
items: [ items: [
{ id: "upload", label: "Upload", icon: "upload" }, { id: "upload", label: "Upload", icon: "upload" },
{ id: "youtube", label: "YouTube", icon: "download" }, { id: "youtube", label: "YouTube", icon: "import" },
{ id: "recorders", label: "Recorders", icon: "record" }, { id: "recorders", label: "Recorders", icon: "record" },
{ id: "schedule", label: "Schedule", icon: "jobs" }, { id: "schedule", label: "Schedule", icon: "clock" },
{ id: "monitors", label: "Monitors", icon: "monitor" }, { id: "monitors", label: "Monitors", icon: "monitor" },
], ],
}, },
@ -28,6 +28,7 @@ const NAV_SECTIONS = [
label: "Operations", label: "Operations",
items: [ items: [
{ id: "capture", label: "Capture", icon: "capture" }, { id: "capture", label: "Capture", icon: "capture" },
{ id: "playout", label: "Playout", icon: "signal" },
{ id: "jobs", label: "Jobs", icon: "jobs" }, { id: "jobs", label: "Jobs", icon: "jobs" },
], ],
}, },

View file

@ -293,7 +293,40 @@
text-align: center; text-align: center;
margin-top: 8px; margin-top: 8px;
} }
/* Logo wrapper holds the animated pulse halo behind the image. */
.launcher-logo-wrap {
position: relative;
display: inline-grid;
place-items: center;
width: 180px;
height: 180px;
}
.launcher-logo-pulse {
position: absolute;
left: 50%;
top: 50%;
width: 200px;
height: 200px;
transform: translate(-50%, -50%) scale(0.85);
border-radius: 50%;
pointer-events: none;
z-index: 0;
background: radial-gradient(
circle at center,
color-mix(in srgb, var(--accent) 55%, transparent) 0%,
color-mix(in srgb, var(--accent) 22%, transparent) 38%,
transparent 68%
);
filter: blur(2px);
animation: launcherLogoPulse 3.4s ease-in-out infinite;
}
@keyframes launcherLogoPulse {
0%, 100% { transform: translate(-50%, -50%) scale(0.82); opacity: 0.45; }
50% { transform: translate(-50%, -50%) scale(1.08); opacity: 0.9; }
}
.launcher-logo { .launcher-logo {
position: relative;
z-index: 1;
width: 180px; width: 180px;
height: 180px; height: 180px;
object-fit: contain; object-fit: contain;
@ -308,6 +341,9 @@
from { opacity: 0; transform: translateY(8px) scale(0.96); } from { opacity: 0; transform: translateY(8px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 1; transform: translateY(0) scale(1); }
} }
@media (prefers-reduced-motion: reduce) {
.launcher-logo-pulse { animation: none; opacity: 0.5; }
}
.launcher-wordmark { .launcher-wordmark {
margin: 0; margin: 0;
font-size: 44px; font-size: 44px;
@ -317,11 +353,31 @@
color: var(--text-1); color: var(--text-1);
text-shadow: 0 0 32px rgba(91, 124, 250, 0.15); text-shadow: 0 0 32px rgba(91, 124, 250, 0.15);
} }
.launcher-kicker {
margin: 2px 0 0;
color: var(--accent);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.22em;
text-transform: uppercase;
}
.launcher-tagline { .launcher-tagline {
margin: 0; margin: 0;
color: var(--text-3); color: var(--text-3);
font-size: 13.5px; font-size: 13.5px;
letter-spacing: 0.02em; letter-spacing: 0.02em;
white-space: nowrap;
}
@media (max-width: 480px) {
.launcher-tagline { font-size: 11.5px; letter-spacing: 0; }
}
.launcher-tagline-motto {
margin-top: 6px;
color: var(--accent);
font-style: italic;
font-size: 15px;
letter-spacing: 0.04em;
} }
.launcher-grid { .launcher-grid {
@ -333,6 +389,19 @@
@media (max-width: 960px) { .launcher-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 960px) { .launcher-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 620px) { .launcher-grid { grid-template-columns: 1fr; } } @media (max-width: 620px) { .launcher-grid { grid-template-columns: 1fr; } }
/* Settings sits on its own centered row beneath the main grid. */
.launcher-settings-row {
width: 100%;
display: flex;
justify-content: center;
}
.launcher-tile-settings {
width: 100%;
max-width: calc((100% - 28px) / 3);
}
@media (max-width: 960px) { .launcher-tile-settings { max-width: calc((100% - 14px) / 2); } }
@media (max-width: 620px) { .launcher-tile-settings { max-width: 100%; } }
.launcher-tile { .launcher-tile {
position: relative; position: relative;
display: grid; display: grid;

View file

@ -0,0 +1,195 @@
/* Playout / Master Control (MCR) page styles. */
.po-page { display: flex; flex-direction: column; gap: 14px; }
/* Channel tab bar */
.po-channels-bar {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding-bottom: 10px; border-bottom: 1px solid var(--border);
}
.po-chan-tab {
display: inline-flex; align-items: center; gap: 7px;
padding: 6px 12px; border-radius: 8px;
background: var(--bg-2); border: 1px solid var(--border);
color: var(--text-2); font-size: 13px; cursor: pointer;
}
.po-chan-tab:hover { background: var(--bg-3); color: var(--text-1); }
.po-chan-tab.active { background: var(--accent-soft); color: var(--accent-text); border-color: var(--accent-soft-2); }
.po-chan-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--text-3);
}
.po-chan-dot.live { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); }
.po-empty { text-align: center; padding: 48px 0; display: flex; flex-direction: column; gap: 12px; align-items: center; }
/* Channel detail */
.po-detail { display: flex; flex-direction: column; gap: 14px; }
.po-detail-head { display: flex; justify-content: space-between; align-items: flex-start; }
.po-detail-actions { display: flex; gap: 8px; }
.po-grid {
display: grid; grid-template-columns: 1.4fr 1fr; gap: 14px;
}
@media (max-width: 900px) { .po-grid { grid-template-columns: 1fr; } }
.po-section-label {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--text-3); font-weight: 600;
}
/* Program monitor */
.po-monitor {
background: var(--bg-1); border: 1px solid var(--border); border-radius: 12px;
display: flex; flex-direction: column; overflow: hidden;
}
.po-monitor-head {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 12px; border-bottom: 1px solid var(--border);
}
.po-onair { font-size: 12px; font-weight: 700; color: var(--text-3); letter-spacing: 0.04em; }
.po-onair.live { color: var(--danger); }
.po-monitor-screen {
position: relative; flex: 1; min-height: 220px; background: #000;
display: flex; align-items: center; justify-content: center;
}
.po-monitor-video {
width: 100%; height: 100%; object-fit: contain; display: block;
}
.po-monitor-overlay {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.6); color: var(--text-2);
pointer-events: none;
}
.po-monitor-foot {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px; border-top: 1px solid var(--border); font-size: 11px;
}
.po-monitor-clip-name {
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--text-1);
}
/* Media bin */
.po-bin {
display: flex; flex-direction: column; min-height: 260px; max-height: 360px;
border-radius: 12px; overflow: hidden;
}
.po-bin-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); }
.po-bin-list { overflow-y: auto; flex: 1; }
.po-bin-item {
display: flex; justify-content: space-between; align-items: center; gap: 8px;
padding: 8px 12px; border-bottom: 1px solid var(--border);
cursor: grab; user-select: none;
}
.po-bin-item:hover { background: var(--bg-3); }
.po-bin-item:active { cursor: grabbing; }
.po-bin-name { font-size: 13px; color: var(--text-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Transport */
.po-transport {
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
padding: 12px; background: var(--bg-1); border: 1px solid var(--border); border-radius: 12px;
}
/* Playlist */
.po-playlist {
border-radius: 12px; overflow: hidden;
min-height: 120px;
}
.po-playlist-head {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px; border-bottom: 1px solid var(--border);
}
.po-drop-err { font-size: 11px; color: var(--danger); }
.po-playlist-empty { padding: 28px 12px; text-align: center; }
.po-pl-item {
position: relative;
display: flex; align-items: center; gap: 10px;
padding: 9px 12px 13px; /* extra bottom padding for the staging bar */
border-bottom: 1px solid var(--border);
cursor: grab; user-select: none;
}
.po-pl-item:hover { background: var(--bg-3); }
.po-pl-item:active { cursor: grabbing; }
.po-pl-item--active {
background: color-mix(in srgb, var(--danger) 8%, transparent);
border-left: 3px solid var(--danger);
}
.po-pl-item--active:hover { background: color-mix(in srgb, var(--danger) 12%, transparent); }
.po-pl-index {
width: 22px; text-align: center; font-family: var(--font-mono);
font-size: 12px; color: var(--text-3); flex-shrink: 0;
}
.po-pl-onair { color: var(--danger); font-size: 11px; }
.po-pl-name { flex: 1; font-size: 13px; color: var(--text-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.po-pl-dur { font-size: 11px; color: var(--text-3); flex-shrink: 0; min-width: 40px; text-align: right; }
.po-pl-badge { flex-shrink: 0; }
/* Staging progress bar — sits flush at the bottom of each playlist item */
.po-staging-bar {
position: absolute; bottom: 0; left: 0; right: 0; height: 3px;
}
.po-staging-bar--pending { background: var(--text-3); opacity: 0.3; }
.po-staging-bar--staging {
background: linear-gradient(90deg, transparent 0%, var(--warning) 50%, transparent 100%);
background-size: 200% 100%;
animation: po-staging-sweep 1.4s linear infinite;
}
.po-staging-bar--ready { background: var(--success); opacity: 0.8; }
.po-staging-bar--error { background: var(--danger); }
@keyframes po-staging-sweep {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Playlist footer */
.po-playlist-footer {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px; border-top: 1px solid var(--border);
font-size: 11px; color: var(--text-3);
background: var(--bg-2);
}
.po-pl-total { color: var(--text-2); }
/* As-run log */
.po-asrun {
display: flex; flex-direction: column; gap: 8px;
padding: 12px; background: var(--bg-1); border: 1px solid var(--border); border-radius: 12px;
}
.po-asrun-table {
width: 100%; border-collapse: collapse; font-size: 12px;
}
.po-asrun-table th {
text-align: left; font-weight: 600; color: var(--text-3);
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
padding: 4px 8px; border-bottom: 1px solid var(--border);
}
.po-asrun-table td {
padding: 5px 8px; border-bottom: 1px solid var(--border);
color: var(--text-1); overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; max-width: 220px;
}
.po-asrun-table tr:last-child td { border-bottom: none; }
.po-asrun-result { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
.po-asrun-played { color: var(--success); }
.po-asrun-skipped { color: var(--warning); }
.po-asrun-error { color: var(--danger); }
/* Downloads modal section header */
.downloads-section-head {
display: flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--text-3);
padding-bottom: 8px; border-bottom: 1px solid var(--border);
margin-bottom: 10px;
}
/* Small button variants reused */
.btn.xs { padding: 2px 8px; font-size: 11px; }
.btn.sm { padding: 5px 10px; font-size: 12px; }
.field-input.sm { padding: 5px 8px; font-size: 12px; }

View file

@ -344,6 +344,15 @@
.capture-stat-value { font-size: 13px; margin-top: 2px; } .capture-stat-value { font-size: 13px; margin-top: 2px; }
/* ========== Monitors ========== */ /* ========== Monitors ========== */
.monitor-section-head {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-3);
margin: 4px 2px 6px;
}
.monitor-section-head + .monitors-grid { margin-bottom: 14px; }
.monitors-grid { .monitors-grid {
display: grid; display: grid;
gap: 10px; gap: 10px;

View file

@ -1376,3 +1376,231 @@
/* Tint Cancel-all-failed button to signal destructive action without /* Tint Cancel-all-failed button to signal destructive action without
making it loud same pattern as the per-row Cancel. */ making it loud same pattern as the per-row Cancel. */
.jobs-cancel-all { color: var(--danger); } .jobs-cancel-all { color: var(--danger); }
/* ========================================================================
Dashboard (operations overview) - design rebuild.
Appended last so the design's .dash-grid / .dash-statusbar override the
earlier (pre-redesign) definitions of those two container classes.
======================================================================== */
.page.dashboard { padding: 0; }
.ops-header {
display: flex; align-items: flex-end; gap: 16px;
padding: 24px 28px 18px;
}
.ops-header h1 { margin: 0; font-size: 22px; font-weight: 600; letter-spacing: -0.02em; }
.ops-sub { margin-top: 5px; color: var(--text-3); font-size: 12.5px; }
.ops-header-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.ops-clock {
display: flex; align-items: center; gap: 9px;
height: 32px; padding: 0 12px;
border: 1px solid var(--border); border-radius: var(--r-sm);
background: var(--bg-1);
}
.ops-clock-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--live); box-shadow: 0 0 0 3px var(--live-soft);
animation: dotpulse 1.6s ease-in-out infinite;
}
.ops-clock-time { font-size: 13px; font-weight: 500; letter-spacing: 0.03em; color: var(--text-1); font-variant-numeric: tabular-nums; }
.ops-clock-day { font-size: 10px; color: var(--text-3); letter-spacing: 0.08em; }
.ops-nodes-pill {
display: flex; align-items: center; gap: 7px;
height: 32px; padding: 0 12px;
border: 1px solid var(--border); border-radius: var(--r-sm);
background: var(--bg-1);
font-size: 12px; color: var(--text-2); font-family: var(--font-mono);
}
/* ---- status strip ---- */
.ops-stats {
display: grid; grid-template-columns: repeat(4, 1fr);
margin: 0 28px;
background: var(--bg-1); border: 1px solid var(--border);
border-radius: var(--r-lg); overflow: hidden;
}
.ops-stats.six { grid-template-columns: repeat(6, 1fr); }
.stat-cell { padding: 15px 16px 14px; border-left: 1px solid var(--border); min-width: 0; }
.stat-cell:first-child { border-left: 0; }
.stat-cell-label {
font-size: 10.5px; color: var(--text-3); font-weight: 600;
text-transform: uppercase; letter-spacing: 0.06em; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
.stat-cell-value {
margin-top: 9px; font-size: 26px; font-weight: 600; line-height: 1;
letter-spacing: -0.02em; font-variant-numeric: tabular-nums;
display: flex; align-items: baseline; gap: 6px;
}
.stat-cell-unit { font-size: 12px; font-weight: 500; color: var(--text-3); letter-spacing: 0; }
.stat-cell-foot {
margin-top: 10px; font-size: 11.5px; color: var(--text-3);
display: flex; align-items: center; gap: 8px; white-space: nowrap;
overflow: hidden;
}
.stat-cell-foot .foot-danger { color: var(--danger); }
.stat-cell-foot .foot-warn { color: var(--warning); }
.stat-pips { display: flex; align-items: center; gap: 11px; }
.stat-pip { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text-2); font-variant-numeric: tabular-nums; }
.stat-pip i { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.stat-pip.armed i { background: var(--accent); }
.stat-pip.idle i { background: var(--text-4); }
.stat-pip.zero { color: var(--text-4); }
.stat-pip.zero i { opacity: 0.4; }
/* ---- section heads ---- */
.section-head { display: flex; align-items: center; gap: 10px; padding: 22px 0 11px; }
.section-head:first-child { padding-top: 6px; }
.section-head-title { font-size: 13px; font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; }
.section-head-sub { font-size: 11.5px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.section-head-count { font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; color: var(--danger); background: var(--danger-soft); padding: 1px 7px; border-radius: 99px; }
.section-head .btn { margin-left: auto; flex-shrink: 0; }
.section-head-live {
width: 7px; height: 7px; border-radius: 50%;
background: var(--live); box-shadow: 0 0 0 3px var(--live-soft);
animation: dotpulse 1.6s ease-in-out infinite; flex-shrink: 0;
}
/* ---- dashboard grid (overrides earlier .dash-grid) ---- */
.page.dashboard .dash-grid {
display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(300px, 1fr);
gap: 22px; padding: 6px 28px 8px; align-items: start;
}
.dash-main, .dash-side { min-width: 0; }
/* ---- live ingest tiles ---- */
.live-now-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(188px, 1fr));
gap: 12px;
}
.ingest-tile {
background: var(--bg-1); border: 1px solid var(--border);
border-radius: var(--r-md); overflow: hidden; cursor: pointer;
transition: border-color 120ms, transform 120ms;
}
.ingest-tile:hover { border-color: var(--border-strong); transform: translateY(-1px); }
.ingest-tile.recording { border-color: rgba(255,59,48,0.28); }
.ingest-tile-screen { position: relative; aspect-ratio: 16 / 9; background: var(--bg-2); overflow: hidden; }
.ingest-tile-audio {
position: absolute; inset: 0; display: grid; place-items: center; padding: 16px;
background: linear-gradient(160deg, var(--bg-2), var(--bg-1));
}
.ingest-tile-audio .waveform { width: 100%; height: 58%; opacity: 0.85; }
.ingest-tile-veil { position: absolute; inset: 0; background: rgba(11,13,17,0.5); z-index: 1; }
.ingest-tile-top { position: absolute; top: 8px; left: 8px; right: 8px; display: flex; align-items: center; gap: 6px; z-index: 2; }
.ingest-tile-top .badge.outline { margin-left: auto; background: rgba(0,0,0,0.45); border-color: rgba(255,255,255,0.18); color: #fff; backdrop-filter: blur(4px); }
.ingest-tile-bottom { position: absolute; left: 8px; right: 8px; bottom: 8px; display: flex; align-items: center; gap: 6px; z-index: 2; }
.ingest-tile-name {
color: #fff; font-size: 12px; font-weight: 500;
background: rgba(0,0,0,0.6); padding: 3px 8px; border-radius: 4px; backdrop-filter: blur(4px);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0;
}
.ingest-tile-tc { margin-left: auto; color: #fff; font-size: 11px; background: rgba(0,0,0,0.6); padding: 3px 6px; border-radius: 4px; backdrop-filter: blur(4px); flex-shrink: 0; }
.ingest-tile-foot { display: flex; align-items: center; gap: 8px; padding: 8px 11px; font-size: 11px; color: var(--text-3); }
.ingest-tile-foot .dot-sep { color: var(--text-4); }
.ingest-tile-node { margin-left: auto; color: var(--text-4); }
/* ---- on-air empty / standby ---- */
.onair-empty { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; }
.onair-empty-head { display: flex; align-items: center; gap: 14px; padding: 18px; }
.onair-empty-icon {
width: 38px; height: 38px; flex-shrink: 0; border-radius: 50%;
background: var(--bg-3); border: 1px solid var(--border);
display: grid; place-items: center; color: var(--text-3);
}
.onair-empty-copy { flex: 1; min-width: 0; }
.onair-empty-title { font-size: 13.5px; font-weight: 600; }
.onair-empty-sub { font-size: 12px; color: var(--text-3); margin-top: 2px; }
.onair-empty-head .btn { flex-shrink: 0; }
.onair-sources {
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 8px; padding: 14px; border-top: 1px solid var(--border); background: var(--bg-0);
}
.onair-source {
display: flex; align-items: center; gap: 9px; padding: 9px 11px;
background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--r-md);
text-align: left; cursor: pointer; transition: background 80ms, border-color 80ms;
}
.onair-source:hover { background: var(--bg-3); border-color: var(--border-strong); }
.onair-source-name { font-size: 12.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.onair-source-src { font-size: 10px; font-family: var(--font-mono); color: var(--text-3); padding: 1px 6px; border: 1px solid var(--border-strong); border-radius: 4px; }
.onair-source-go { margin-left: auto; display: flex; align-items: center; gap: 3px; font-size: 11px; color: var(--accent-text); white-space: nowrap; }
/* ---- job queue table ---- */
.job-table { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; }
.job-table-head, .job-table-row {
display: grid; grid-template-columns: 148px minmax(0, 1fr) 84px 170px 52px;
gap: 14px; align-items: center; padding: 0 14px;
}
.job-table-head {
height: 34px; border-bottom: 1px solid var(--border);
font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-4);
}
.job-table-row { height: 42px; border-bottom: 1px solid var(--border); }
.job-table-row:last-child { border-bottom: 0; }
.jt-job { display: flex; align-items: center; gap: 8px; color: var(--text-2); font-size: 11.5px; }
.jt-asset { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-1); font-size: 12.5px; }
.jt-node { color: var(--text-3); font-size: 11px; }
.jt-progress { display: flex; align-items: center; }
.jt-bar { display: block; width: 100%; height: 5px; background: var(--bg-3); border-radius: 99px; overflow: hidden; }
.jt-bar > span { display: block; height: 100%; background: var(--accent); border-radius: 99px; transition: width 300ms; }
.jt-eta { color: var(--text-3); font-size: 11px; text-align: right; }
/* ---- needs attention ---- */
.attention-panel { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; }
.attn-row { display: flex; align-items: center; gap: 11px; padding: 11px 13px; border-bottom: 1px solid var(--border); }
.attn-row:last-child { border-bottom: 0; }
.attn-sev { width: 26px; height: 26px; flex-shrink: 0; border-radius: var(--r-sm); display: grid; place-items: center; }
.attn-sev.danger { background: var(--danger-soft); color: var(--danger); }
.attn-sev.warning { background: var(--warning-soft); color: var(--warning); }
.attn-body { flex: 1; min-width: 0; }
.attn-title { font-size: 12.5px; font-weight: 500; color: var(--text-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.attn-meta { font-size: 10.5px; color: var(--text-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.attn-row .btn { flex-shrink: 0; }
/* ---- cluster node list ---- */
.node-list { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; }
.node-row { display: flex; align-items: center; gap: 14px; padding: 11px 14px; border-bottom: 1px solid var(--border); }
.node-row:last-child { border-bottom: 0; }
.node-row.offline { opacity: 0.55; }
.node-row-id { display: flex; align-items: center; gap: 8px; width: 158px; flex-shrink: 0; }
.node-name { font-size: 12px; color: var(--text-1); font-weight: 500; }
.badge.node-role { height: 17px; padding: 0 5px; font-size: 9px; }
.node-row-metrics { flex: 1; display: grid; grid-template-columns: 1fr 1fr auto; gap: 16px; align-items: center; min-width: 0; }
.node-metric { display: flex; align-items: center; gap: 8px; min-width: 0; }
.node-metric-label { font-size: 10px; color: var(--text-4); width: 24px; flex-shrink: 0; letter-spacing: 0.04em; }
.node-metric-bar { flex: 1; height: 5px; background: var(--bg-3); border-radius: 99px; overflow: hidden; min-width: 26px; }
.node-metric-bar > span { display: block; height: 100%; border-radius: 99px; transition: width 300ms; }
.node-metric-text { font-size: 10.5px; color: var(--text-3); white-space: nowrap; flex-shrink: 0; }
.node-gpu { font-size: 10.5px; color: var(--text-3); white-space: nowrap; justify-self: end; }
.node-row-off { flex: 1; color: var(--text-4); font-size: 11.5px; font-family: var(--font-mono); }
/* ---- footer status bar (overrides earlier .dash-statusbar) ---- */
.page.dashboard .dash-statusbar {
display: flex; align-items: center; gap: 14px;
margin: 14px 28px 30px; padding-top: 13px;
border-top: 1px solid var(--border);
font-size: 11.5px; color: var(--text-3); font-family: var(--font-mono);
}
.dash-statusbar .sb-item { display: flex; align-items: center; gap: 6px; }
.dash-statusbar .sb-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-4); }
.dash-statusbar .sb-dot.live { background: var(--live); }
.dash-statusbar .sb-dot.run { background: var(--accent); }
.dash-statusbar .sb-dot.fail { background: var(--danger); }
.dash-statusbar .sb-spacer { flex: 1; }
.dash-statusbar .sb-sep { color: var(--text-4); }
@media (max-width: 1340px) {
.ops-stats.six { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 1180px) {
.ops-stats { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 1080px) {
.page.dashboard .dash-grid { grid-template-columns: 1fr; }
.job-table-head, .job-table-row { grid-template-columns: 130px minmax(0, 1fr) 140px 48px; }
.job-table-head span:nth-child(3), .job-table-row .jt-node { display: none; }
}

View file

@ -7,6 +7,7 @@ import { conformWorker } from './workers/conform.js';
import { youtubeImportWorker, proxyQueue as youtubeProxyQueue } from './workers/youtube-import.js'; import { youtubeImportWorker, proxyQueue as youtubeProxyQueue } from './workers/youtube-import.js';
import { trimWorker } from './workers/trimWorker.js'; import { trimWorker } from './workers/trimWorker.js';
import { hlsWorker } from './workers/hls.js'; import { hlsWorker } from './workers/hls.js';
import { playoutStageWorker } from './workers/playout-stage.js';
import { startPromotionWorker } from './workers/promotion.js'; import { startPromotionWorker } from './workers/promotion.js';
const parseRedisUrl = (url) => { const parseRedisUrl = (url) => {
@ -94,6 +95,9 @@ const workers = [
lockDuration: 10 * 60 * 1000, lockDuration: 10 * 60 * 1000,
lockRenewTime: 60000, lockRenewTime: 60000,
}), }),
// playout-stage = S3 → /media volume + EBU R128 loudnorm. CPU/IO-bound;
// colocate with workers that already have ffmpeg + the media mount.
want('playout-stage') && createWorker('playout-stage', playoutStageWorker, { concurrency: 1 }),
].filter(Boolean); ].filter(Boolean);
console.log(`WORKER_QUEUES=${_wq || '(all)'}`); console.log(`WORKER_QUEUES=${_wq || '(all)'}`);

View file

@ -0,0 +1,157 @@
import { join, extname } from 'path';
import { mkdir, stat, rename, unlink } from 'fs/promises';
import { spawn } from 'child_process';
import { query } from '../db/client.js';
import { downloadFromS3 } from '../s3/client.js';
// Playout media staging — copy an asset from S3 into the shared CasparCG media
// volume so a playout channel can play it. CasparCG plays from a local folder
// (/media), not from S3, so every playlist item must be staged to 'ready'
// before it can go on air. See docs/superpowers/specs/2026-05-30-playout-mcr-design.md §4.
//
// Two passes:
// 1. download from S3 to /media/playout/<assetId><ext>.raw
// 2. ffmpeg loudnorm (EBU R128, target I=-23 LUFS, TP=-1 dBTP, LRA=11) to the
// final path, then atomic rename. Skipped when items.audio_normalized=true.
//
// The media volume is mounted into BOTH this worker and the playout sidecars at
// the same path (PLAYOUT_MEDIA_DIR, default /media). We stage under a per-asset
// filename so re-staging is idempotent and multiple items referencing the same
// asset share one file.
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
const MEDIA_DIR = process.env.PLAYOUT_MEDIA_DIR || '/media';
async function fileExists(p) {
try { const s = await stat(p); return s.size > 0; } catch { return false; }
}
// Two-pass loudnorm: pass 1 measures, pass 2 applies linear normalization with
// the measured values. Linear mode preserves dynamics at the cost of accuracy
// vs the target — fine for broadcast playout where transparent levels matter
// more than hitting -23 LUFS to the decibel.
function runFfmpeg(args) {
return new Promise((resolve, reject) => {
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stderr = '';
proc.stderr.on('data', (d) => { stderr += d.toString(); });
proc.on('error', reject);
proc.on('close', (code) => {
if (code === 0) resolve(stderr);
else reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(-500)}`));
});
});
}
async function measureLoudness(inputPath) {
// -23 / -1 / 11 are the EBU R128 broadcast targets; loudnorm prints a JSON
// block to stderr after the analysis pass which feeds pass 2's measured_*
// params.
const stderr = await runFfmpeg([
'-hide_banner', '-nostats', '-i', inputPath,
'-af', 'loudnorm=I=-23:TP=-1:LRA=11:print_format=json',
'-f', 'null', '-',
]);
const match = stderr.match(/\{[\s\S]*?"input_i"[\s\S]*?\}/);
if (!match) throw new Error('loudnorm pass 1 produced no JSON');
return JSON.parse(match[0]);
}
function isFiniteLoudness(val) {
const n = parseFloat(val);
return isFinite(n);
}
async function applyLoudnorm(inputPath, outputPath, m) {
// Pass 2: linear normalization using pass 1's measurements. -c:v copy keeps
// the video stream intact so we only re-encode audio (target AAC stereo, the
// common-denominator CasparCG ffmpeg producer accepts).
//
// Silent / no-audio clips measure I=-inf which ffmpeg rejects in pass 2.
// When any loudnorm measurement is non-finite, fall back to a plain audio
// transcode (AAC 192k) with no loudness adjustment — the clip has no
// meaningful audio to normalize.
const silentOrNoAudio = !isFiniteLoudness(m.input_i) || !isFiniteLoudness(m.input_tp);
if (silentOrNoAudio) {
console.log(`[playout-stage] loudnorm skip — silent/no audio (I=${m.input_i}), transcoding audio only`);
await runFfmpeg([
'-hide_banner', '-nostats', '-y', '-i', inputPath,
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000',
outputPath,
]);
return;
}
await runFfmpeg([
'-hide_banner', '-nostats', '-y', '-i', inputPath,
'-af', `loudnorm=I=-23:TP=-1:LRA=11:measured_I=${m.input_i}:measured_TP=${m.input_tp}:measured_LRA=${m.input_lra}:measured_thresh=${m.input_thresh}:offset=${m.target_offset}:linear=true:print_format=summary`,
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000',
outputPath,
]);
}
export async function playoutStageWorker(job) {
const { itemId, assetId } = job.data;
if (!itemId || !assetId) throw new Error('playout-stage requires itemId + assetId');
await query("UPDATE playout_items SET media_status = 'staging', updated_at = NOW() WHERE id = $1", [itemId]);
try {
const a = await query(
'SELECT id, filename, original_s3_key, proxy_s3_key FROM assets WHERE id = $1', [assetId]);
if (a.rows.length === 0) throw new Error(`asset ${assetId} not found`);
const asset = a.rows[0];
// Prefer the master for air quality; fall back to proxy if no master key.
const s3Key = asset.original_s3_key || asset.proxy_s3_key;
if (!s3Key) throw new Error(`asset ${assetId} has no S3 media key to stage`);
const ext = extname(s3Key) || extname(asset.filename || '') || '.mp4';
// Stable per-asset path under the media volume; CasparCG resolves the token
// "playout/<assetId>" against MEDIA_DIR.
const relDir = 'playout';
const fileName = `${assetId}${ext}`;
const absDir = join(MEDIA_DIR, relDir);
const absPath = join(absDir, fileName);
const mediaPath = join(MEDIA_DIR, relDir, fileName);
await mkdir(absDir, { recursive: true });
// Skip the whole pipeline when the final file already exists from a prior
// stage of the same asset. The audio_normalized flag is per-item so a
// second item referencing the same staged file gets flipped to true below.
const itemRow = await query('SELECT audio_normalized FROM playout_items WHERE id = $1', [itemId]);
const alreadyNormalized = itemRow.rows[0]?.audio_normalized === true;
if (!(await fileExists(absPath))) {
const rawPath = `${absPath}.raw${ext}`;
console.log(`[playout-stage] downloading ${s3Key} -> ${rawPath}`);
await downloadFromS3(S3_BUCKET, s3Key, rawPath);
if (alreadyNormalized) {
// Asset was previously normalized for another item — keep the bytes
// as-is. Atomic rename so CasparCG never sees a partial file.
await rename(rawPath, absPath);
} else {
console.log(`[playout-stage] loudnorm pass 1: ${rawPath}`);
const measured = await measureLoudness(rawPath);
const tmpOut = `${absPath}.tmp${ext}`;
console.log(`[playout-stage] loudnorm pass 2: I=${measured.input_i} TP=${measured.input_tp} -> ${tmpOut}`);
await applyLoudnorm(rawPath, tmpOut, measured);
await rename(tmpOut, absPath);
await unlink(rawPath).catch(() => {});
}
} else {
console.log(`[playout-stage] already staged: ${absPath}`);
}
await query(
"UPDATE playout_items SET media_status = 'ready', media_path = $1, audio_normalized = TRUE, updated_at = NOW() WHERE id = $2",
[mediaPath, itemId]);
console.log(`[playout-stage] item ${itemId} ready at ${mediaPath}`);
return { itemId, mediaPath };
} catch (err) {
await query("UPDATE playout_items SET media_status = 'error', updated_at = NOW() WHERE id = $1", [itemId])
.catch(() => {});
throw err;
}
}