Compare commits

..

1 commit

Author SHA1 Message Date
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
283 changed files with 6593 additions and 58209 deletions

View file

@ -22,51 +22,5 @@ SESSION_SECRET=changeme
# MAM API Configuration # MAM API Configuration
MAM_API_URL=http://mam-api:3000 MAM_API_URL=http://mam-api:3000
# Auth — default to ON in production. Setting to 'false' is a dev-only escape # Auth (set to 'true' to require login; false for open/dev mode)
# hatch that disables all auth checks and attaches a synthetic 'dev' user to AUTH_ENABLED=false
# every request. Never run with AUTH_ENABLED=false on a network you don't control.
#
# RBAC v2 note: with AUTH_ENABLED=true, per-project access is enforced. Service
# API tokens (capture sidecar, Premiere panel, integrations) must belong to a
# user with the access they need — an 'admin' user (full access), or a user with
# the right project grants. A non-admin service token with no grants will get
# 403 on asset registration (ingest) and streaming. In dev mode the synthetic
# user is admin, so this only matters once auth is on.
AUTH_ENABLED=true
# CORS allowlist — comma-separated origins that may carry credentials to the API.
# Same-origin requests via the nginx reverse proxy do not need to be listed here.
# Leave empty to allow any origin (DEV ONLY).
ALLOWED_ORIGINS=
# Reverse-proxy trust — set 'true' when the API sits behind nginx terminating HTTPS,
# so secure-cookie + X-Forwarded-Proto behave correctly. ALSO required for accurate
# per-IP login rate-limiting (otherwise req.ip is always the nginx IP).
TRUST_PROXY=false
# Google OAuth (OIDC) sign-in — OPTIONAL. Leave the client id/secret blank to
# disable; the "Sign in with Google" button and the /auth/google routes only
# activate when all three of CLIENT_ID, CLIENT_SECRET, and REDIRECT_URL are set.
# Create an OAuth 2.0 Client (type: Web application) in Google Cloud Console and
# add OAUTH_REDIRECT_URL to its authorized redirect URIs.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Must exactly match a redirect URI on the OAuth client, e.g.
# https://dragonflight.live/api/v1/auth/google/callback
OAUTH_REDIRECT_URL=
# Restrict sign-in to one Google Workspace domain (recommended). First login from
# an allowed-domain account auto-provisions a NEW 'viewer' account (matched only
# by Google's stable subject id, never by email — so a Google login can never
# seize a pre-existing local account). An admin then grants project access.
# Leave blank to allow any verified Google account to self-provision (NOT advised).
GOOGLE_ALLOWED_DOMAIN=
# 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
# 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

16
.gitignore vendored
View file

@ -14,24 +14,8 @@ yarn-error.log*
# Build output # Build output
dist/ dist/
build/ build/
# ...but the Premiere panel's packaging pipeline lives at build/ — keep it tracked.
!services/premiere-plugin/build/
!services/premiere-plugin/build/**
# OS # OS
.DS_Store .DS_Store
.env.swp .env.swp
.env.swo .env.swo
services/editor/node_modules
services/editor/**/node_modules
services/editor/**/dist
services/editor/.pnpm-store
# Blackmagic DeckLink SDK + runtime libs (operator-supplied; see services/capture/build-with-decklink.sh)
services/capture/sdk/
services/capture/lib/
# Editor backups
*.bak
*.bak2
.env.bak.*

153
DESIGN.md
View file

@ -1,153 +0,0 @@
# Design system
Documented from the live tokens in `services/web-ui/public/styles.css` and the patterns used across `screens-*.jsx`. Treat this as the source of truth for shared primitives; pages may override locally but should not invent new color or type scales.
## Color
Dark theme only. Tokens live in `:root` in `styles.css`. Tinted neutrals — no `#000`, no `#fff`.
### Surfaces
| Token | Hex | Use |
|---|---|---|
| `--bg-0` | `#0B0D11` | Page background, deepest surface |
| `--bg-1` | `#14171E` | Panels, sidebars, primary chrome |
| `--bg-2` | `#1B1F27` | Panel headers, hover state |
| `--bg-3` | `#232833` | Inputs, raised buttons, badges |
| `--bg-4` | `#2D3340` | Strongly raised elements (rare) |
### Borders + overlays
| Token | Use |
|---|---|
| `--border` (rgba white 6%) | Default 1px separator |
| `--border-strong` (rgba white 10%) | Emphasized separator |
| `--border-stronger` (rgba white 14%) | Hover/active border |
| `--hover` (rgba white 4%) | Subtle hover fill |
| `--hover-strong` (rgba white 7%) | Stronger hover fill |
### Text
| Token | Hex | Use |
|---|---|---|
| `--text-1` | `#F2F3F6` | Primary content |
| `--text-2` | `#A8AEBC` | Secondary content |
| `--text-3` | `#6B7280` | Labels, metadata |
| `--text-4` | `#4B5260` | Section labels, off-month, disabled |
### Accent
`--accent: #5B7CFA` (Frame.io-ish blue). Soft variants `--accent-soft` (14%) and `--accent-soft-2` (22%) for fills. `--accent-text: #B4C3FF` for high-contrast accent text.
**Restrained strategy.** Accent is the only saturated color in chrome. Status colors below appear only where they reflect actual state. Project colors appear only on project chips.
### Status
| Token | Hex | Use |
|---|---|---|
| `--success` | `#2DD4A8` | Done, healthy, ready |
| `--warning` | `#F5A623` | Processing, attention-needed |
| `--danger` | `#FF5B5B` | Failed, error |
| `--live` | `#FF3B30` | Currently recording (broadcast red, intentionally hotter than `--danger`) |
| `--purple` | `#B57CFA` | Editor / dev tags |
Each has a `*-soft` variant at ~14% for background fills.
### Project palette
Six fixed colors cycled by index in `data.jsx` (`PROJECT_COLORS`):
`#5B7CFA · #2DD4A8 · #FF5B5B · #F5A623 · #B57CFA · #6B7280`
Used only on the project chip / rail dot. Do not reuse for status meaning.
## Typography
- **Sans:** Geist (with `cv11`, `ss01` features enabled). All UI text by default.
- **Mono:** Geist Mono. URLs, IDs, timestamps, durations, technical metadata.
Body scale runs small for density:
| Size | Use |
|---|---|
| 10.5px, 600, uppercase, 0.060.08em letterspacing | Column heads, section labels |
| 1111.5px | Metadata, secondary rows |
| 1212.5px | Body in lists/tables |
| 13px, 500600 | Row labels, button text |
| 14px | Default body |
| 15px, 600 | Modal titles, panel heads |
| 2228px, 600+ | Page H1 (`.page-header h1`) |
Never use `gradient text` (impeccable absolute ban). Emphasis via weight and size only.
## Layout
- Sidebar: 232px fixed (`--sidebar-w`).
- Topbar: 56px (`--topbar-h`).
- Row height: 44px default, 36px compact (`--row-h`, `data-density="compact"`).
- Gap unit: 16px default, 12px compact (`--gap`).
- Border radius scale: 4 / 6 / 8 / 12 / 16 px (`--r-xs` → `--r-xl`).
- Panels (`.panel`): `--bg-1` + 1px `--border` + `--r-lg`. Use for grouped lists, not for every section.
- Do NOT nest panels.
### Page header
Standard screens use `.page > .page-header > h1`. Three screens are documented exceptions because they need full-bleed layouts and their own top-chrome:
- **Home** uses `.launcher` (lobby pattern: hero logo + tile grid + status pip).
- **Library** uses `.library-layout` (dual-pane rail + main). The h1 sits inside `.library-toolbar` as `.toolbar-title`.
- **Editor** uses `.editor-shell` (NLE with timeline + monitors). The beta banner doubles as its top chrome.
All other screens should render `<div className="page"><div className="page-header"><h1>…</h1>…</div>…</div>` for consistent IA and screen-reader hierarchy.
## Shadow
Two tokens, used sparingly:
- `--shadow-card`: subtle inset highlight + soft outer. Default for raised inputs.
- `--shadow-pop`: modal / popover / context menu.
No drop shadows on flat surfaces. No glow effects (except: the EPG now-line uses a tight `box-shadow` for the broadcast-red glow, and live status dots have a pulse halo; these are state cues, not decoration).
## Motion
- Default transition: 80120ms on background/border (`transition: background 80ms, border 80ms`).
- Heavier reveals: 200ms.
- Easing: prefer ease-out (no bounce, no elastic).
- Don't animate layout (width/height/top); animate transforms and opacity.
## Patterns
### Status badges (`.badge`)
Variants: `live` (red, animated dot), `success`, `danger`, `warning`, `accent`, `neutral`, `outline`. Tiny — 910px font, ~2px vertical padding. Reserved for state, not labels.
### Row tables (`.user-row`, `.token-row`, `.job-row`, `.schedule-row`, etc.)
CSS-grid with explicit columns. Header row uses `.head` (uppercase 10.5px). No card stacking — these are dense data lists.
### Schedule EPG (`.epg-*`)
Broadcast timeline pattern. Recorder rows × time-of-day axis. Single scrolling container with sticky left gutter (220px) and sticky top ruler (32px). Hour rhythm via `repeating-linear-gradient`. Now-line is a 1px hot-red vertical bar with `--live` glow, animated by re-rendering the line component every second (transform-only positioning, no layout thrash). Event blocks are absolute-positioned within each row, colored via `--epg-block-color` set per recorder's project color. Live events get a red gradient + pulse; failures get a glyph + full red border; past events fade to 0.55 opacity.
### Inputs
`.field-input``--bg-3` fill, 1px `--border`, `--r-sm`, 12.5px font. Focus: `--border-strong`.
### Status dot (`.signal-dot`, `.rec-dot`, etc.)
Small (~68px) circle, used inline with text. Recording dots pulse with a keyframe animation.
## Impeccable absolute bans (apply project-wide)
- No `border-left` / `border-right` greater than 1px as a colored accent (rewrite with full borders, leading icons, or background tint).
- No `background-clip: text` gradient text.
- No glassmorphism (blur + translucent) decoratively.
- No hero-metric template (big number, small label, gradient accent, supporting stats).
- No identical card grids.
- Modal as last resort — exhaust inline alternatives first.
- No em dashes in code or copy. Use commas, colons, parentheses, periods.
## To extend
When a new design need arises, prefer adding a variant to an existing primitive over inventing a new token. New tokens land in `styles.css`. New components land in the relevant `screens-*.jsx` only if reused; otherwise keep them local.

View file

@ -1,61 +0,0 @@
# Product
## What it is
Dragonflight (codebase name; UI brand: "Dragonflight · Wild Dragon Broadcast") is an on-prem broadcast media asset manager and live ingest controller. Operators capture from SRT, RTMP, and SDI sources, schedule windowed recordings against named recorders, transcode/proxy in the background, browse and clip in a library, import from YouTube, and hand off to Premiere or an in-house editor.
The deployable surface is a React (Babel-in-browser) single-page app served by nginx, talking to a Node/Express API backed by Postgres + Redis + BullMQ, with capture and worker containers handling the actual media.
Stack lives at `services/web-ui` (UI), `services/mam-api` (API), `services/capture`, `services/worker`, `services/editor`.
## Register
**Product.** This is operator UI for a working broadcast tool, not a brand site or marketing surface. Design serves the operator's job, not the brand's identity.
## Users
Primary: broadcast operators and engineers running live productions. They schedule and supervise back-to-back recordings across multiple recorders in a single shift. They care about: what's recording right now, what's about to start, what failed, and which recorder is bound to what source.
Secondary: editors and producers who consume the resulting library, comment on assets, request proxy regeneration. They mostly live in the Library and Asset detail screens, not the scheduler.
Tertiary: admins managing recorders, users, cluster nodes, and storage. Live in the Admin screens.
## Product purpose
Replace a stack of one-trick tools (NewBlue scheduler, vMix capture, ad-hoc Premiere ingest, manual S3 syncs) with a single operator surface that supervises recorders, owns the asset catalog through proxy generation, and stays out of the editor's way once footage lands.
## Tone
Function-first. Dense. Operations-room. Mono fonts where data lives. Small type. Restrained chrome. The operator should be able to glance at any screen and read the state of the system in under a second; they should never wonder "is this still happening" or "did that finish."
Not: marketing-warm, conversational, gamified, congratulatory.
## Strategic principles
1. **Glance-readable status.** Every list, every cell, every badge must answer "what is the state of this thing right now" without a hover.
2. **Trust the operator.** No confirmation modals for reversible actions. No nag, no toasts for routine success. Errors stay visible until acknowledged.
3. **Time is the spine.** This product is about time-based events (recordings, schedules, jobs). UIs should privilege time as a primary axis, not bury it under categorical filters.
4. **Density over whitespace.** Operators run multi-monitor setups and want maximum signal per pixel. Generous whitespace is a brand-site reflex; reject it here.
5. **No half-states.** Pending UIs disable controls; live UIs show live data; failed UIs show the failure inline, not in a separate notification feed.
## Anti-references
Steer away from:
- **Linear-pastel SaaS aesthetics.** Purples, mint accents, friendly empty states with cartoon line illustrations.
- **Google-Calendar generic.** A neutral month grid with rounded event chips, no operational signal, optimized for "is Friday free" rather than "is recorder A in conflict at 14:00."
- **Gantt-chart project-management feel.** Implies long-horizon planning of tasks with dependencies; this product is hour-scale broadcast operations.
- **Cards-for-everything.** Identical card grids of icon+label+value. Particularly the SaaS hero-metric template.
- **Decorative blur / glassmorphism / gradient text.** Read as decorative AI slop in a broadcast-ops context.
- **NewBlue / Wirecast skinning.** Heavy bevels, gradient buttons, drop shadows. Read as outdated broadcast software.
## Decision context (Schedule v2)
The Schedule screen was rebuilt as an EPG (electronic program guide) timeline. Operator confirmed:
- Density: **heavy / back-to-back, many recorders all day** — month grid was the wrong primary.
- At-a-glance signals: now/next, recorder bookings (conflicts), project context, failure history.
- Aesthetic: **studio / cinematic — dark, type-led, accent moments.** DaVinci-Resolve-panel territory.
- Scope: full rethink — replace the primary view.
Implementation: recorder rows × time-of-day horizontal axis, sticky gutter + ruler, vertical hot-red now-line, event blocks colored by project, status pills in a top strip. Today / Week / List views.

264
README.md
View file

@ -1,258 +1,60 @@
# Dragonflight # Wild Dragon
Self-hosted broadcast media-asset management system that replaces legacy tools like Grass Valley AMPP and FramelightX. Handles live ingest, growing-file editing, scheduling, transcoding, and asset management in a single operator-focused interface. Self-hosted Media Asset Management platform built to replace Grass Valley AMPP FramelightX.
> Repo renamed from `wild-dragon``dragonflight` (2026-05-23). The old URL still redirects. ## Services
## Home Dashboard | Service | Port | Description |
|---------|------|-------------|
<img src="docs/screenshots/01-home.png" alt="Home Dashboard" width="800" /> | **web-ui** | 8080 | Browser-based MAM interface + capture controls |
| **mam-api** | 3000 | REST API — assets, projects, bins, jobs |
The home screen provides quick access to all major features and displays system status at a glance: | **capture** | 3001 | SDI capture daemon (Blackmagic DeckLink + FFmpeg) |
- **Library** — Browse projects, bins, and assets with hover-scrub previews | **worker** | — | Async job processor (proxy gen, thumbnails, conform) |
- **Recorders** — View configured capture devices and their status | **db** | 5432 | PostgreSQL 16 metadata store |
- **Editor** — Timeline editor with cross-clip preview and render queue | **queue** | 6379 | Redis 7 job queue (BullMQ) |
- **Jobs** — Proxy and thumbnail queue with retry controls
- **Settings** — Configure storage, encoder, growing files, and capture SDK
- **Dashboard** — Operations view showing recent activity, job queue, and cluster health
---
## Core Features
### 1. Live Ingest & Capture
**Multi-protocol source capture with per-recorder codec settings**
Dragonflight ingests from multiple sources simultaneously:
- **SRT** (Secure Reliable Transport) — caller and listener modes
- **RTMP** — standard streaming protocol
- **SDI** — via Blackmagic DeckLink cards with FFmpeg SDK 16.x patches
Each recorder can be configured with independent codec settings:
- ProRes (hi-res masters)
- H.264 / H.265 (proxies)
- DNxHR (Avid compatibility)
Audio routing and per-source configuration ensure flexibility for multi-camera productions.
### 2. Growing-File Editing
**Live editing in Premiere Pro while capture is still writing**
Editors mount the SMB landing zone directly in Premiere Pro and edit the live master file as it's being written. The included CEP (Custom Extension Panel) provides:
- Real-time clip detection and frame-accurate trimming
- One-click relink to final S3 master after promotion
- No waiting for capture to finish before editorial begins
### 3. Recorder Scheduler
**Time-windowed recording automation**
Schedule recordings with:
- One-shot, daily, or weekly recurrence
- Automatic start/stop via 15-second tick loop
- Conflict detection across recorders
- Project and bin assignment at schedule time
### 4. Library & Asset Management
**Browse, search, and organize captured footage**
The Library screen provides:
- Project and bin hierarchy
- Asset detail view with frame-anchored persistent comments
- Right-click context menu (move-to-bin, rename, delete)
- Global cmd/ctrl-K search across assets, projects, recorders, jobs, and users
- Hover-scrub preview with HLS playback
### 5. Jobs Queue
**BullMQ-backed proxy and thumbnail generation**
Automated background processing:
- Per-job retry logic with exponential backoff
- Bulk "retry all failed" for batch recovery
- Inline error messages with actionable diagnostics
- Status tracking: ingesting → processing → ready
Proxy encoder options:
- CPU-based: libx264 (H.264)
- GPU-accelerated: NVENC (NVIDIA) or VAAPI (AMD/Intel)
### 6. Timeline Conform & Export
**FCP XML export with server-side FFmpeg rendering**
The Premiere Pro panel exports FCP XML with:
- Server-side conform via FFmpeg
- Multiple output formats: H.264, H.265, ProRes
- Resolution presets: Broadcast, Web, Archive
- Batch processing with job queue integration
### 7. Hi-Res Auto-Relink
**One-click batch relink of proxy clips to frame-accurate server-trimmed masters**
After editing on proxies:
- Select clips in Premiere
- Trigger relink from the CEP panel
- Server trims hi-res segments to exact in/out points
- Concurrent trim worker pool for speed
- 24-hour TTL with automatic cleanup
### 8. Settings & Configuration
**Centralized control for storage, encoding, and capture**
Configure:
- **S3 Storage** — endpoint, bucket, credentials (with env-var fallback)
- **Proxy Encoder** — CPU vs GPU, bitrate, resolution
- **Growing Files** — SMB path, retention, auto-promotion
- **Capture SDK** — Blackmagic, AJA, or Deltacast uploader selection
### 9. Cluster & Distributed Capture
**Primary + worker topology with remote DeckLink nodes**
- Primary node runs API, scheduler, and web UI
- Worker nodes handle proxy/thumbnail jobs
- Remote capture nodes run DeckLink cards off-host
- Heartbeat health monitoring
- Automatic failover and recovery
### 10. Admin & User Management
**Role-based access, token auth, and cluster monitoring**
- User creation and role assignment
- API token generation for integrations
- Container and cluster node status
- System health dashboard
---
## Quick Start ## Quick Start
```bash ```bash
# Clone (repo renamed; old URL still redirects) # Clone
git clone https://forge.wilddragon.net/zgaetano/dragonflight.git git clone https://forge.wilddragon.net/zgaetano/wild-dragon.git
cd dragonflight cd wild-dragon
# Configure # Configure
cp .env.example .env cp .env.example .env
# Edit .env — S3 credentials + SESSION_SECRET at minimum # Edit .env with your S3 credentials and secrets
# Launch # Launch
docker compose up -d docker compose up -d
# Open # Open
open http://localhost:47434 open http://localhost:8080
``` ```
## Architecture ## Architecture
``` ```
SDI / SRT / RTMP ──► capture (FFmpeg) SDI Input (DeckLink) → capture service → dual FFmpeg streams
├─ HLS preview tee ──► /live/<assetId>/index.m3u8 ├─ HiRes (ProRes) → S3
└─ master output └─ Proxy (H.264) → S3
├─ growing_enabled=true:
│ /growing/<projectId>/<clip>.mov web-ui ← mam-api ← PostgreSQL ← worker (BullMQ)
│ (Premiere mounts SMB, edits live) ├─ proxy_gen
│ └─► promotion worker uploads to S3 ├─ thumbnail
└─ conform (EDL → FFmpeg → export)
└─ growing_enabled=false:
multipart stream → S3
assets POST ──► proxy job ──► worker
├─ libx264 (CPU) or NVENC/VAAPI (GPU)
├─ thumbnail job
└─ status: ingesting → processing → ready
``` ```
## Tech Stack ## Tech Stack
- **Runtime:** Node.js 22, Docker Compose - **Backend:** Node.js / Express
- **Backend:** Express, PostgreSQL 16, Redis 7 + BullMQ - **Frontend:** Vanilla HTML/CSS/JS
- **Frontend:** Vanilla React via in-browser Babel (no bundler), hls.js - **Database:** PostgreSQL 16
- **Media:** FFmpeg 7.1 with SDK 16 DeckLink patches - **Queue:** Redis 7 + BullMQ
- **Codecs:** ProRes, H.264, H.265, DNxHR, MOV/MP4/MXF containers - **Storage:** S3-compatible (RustFS)
- **Storage:** S3-compatible (RustFS) for masters, proxies, thumbnails - **Media Processing:** FFmpeg
- **Capture:** Blackmagic DeckLink SDK
## Services - **Deployment:** Docker Compose
| Service | Port | Purpose |
|---------|------|---------|
| **web-ui** | 47434 | Browser SPA + capture controls |
| **mam-api** | 47432 | REST API + recorder orchestration + scheduler |
| **capture** | 47433 / 9000 / 1935 | DeckLink/SRT/RTMP ingest sidecar |
| **worker** | — | BullMQ proxy + thumbnail workers |
| **db** | 5432 | PostgreSQL 16 |
| **queue** | 6379 | Redis 7 |
---
## Workflow Example: Live-to-Edit
1. **Operator** schedules a recording on Recorder A for 14:0015:30, assigns to "News/Segment-A" project
2. **Capture** starts at 14:00, writes ProRes master to SMB landing zone
3. **Editor** mounts SMB in Premiere, opens the live .mov file via the CEP panel
4. **Editor** trims and marks in/out points while capture is still writing
5. **Capture** finishes at 15:30, promotion worker uploads master to S3
6. **Editor** clicks "Relink to Master" in CEP panel
7. **Server** trims hi-res segment to exact in/out, stores for 24 hours
8. **Premiere** relinks proxy clips to trimmed master
9. **Editor** exports final timeline via FCP XML conform
Total time from end of capture to relinked master: ~2 minutes.
---
## Operations
- `deploy/api-smoke.sh` — verify every API endpoint after deploy
- `deploy/onboard-node.sh` — provision a remote worker host
- `deploy/test-cluster.sh` — primary↔worker connectivity smoke test
- `docs/GROWING_FILES_QUICKSTART.md` — Premiere CEP panel install + growing-file flow
## Authentication
Dragonflight uses local username/password authentication with two transports:
- **Browser:** session cookie (`dragonflight.sid`), 8 hour absolute + 1 hour idle timeout.
- **Premiere panel / scripts:** SHA-256-hashed bearer tokens issued from `Settings → API Tokens`.
### First-run setup
On a fresh install with `AUTH_ENABLED=true`, navigate to the web UI in a browser.
With no users in the database, the login screen renders a "First-run setup" form
instead — fill it in to create the first admin and you are logged in immediately.
Subsequent users are created from `Settings → Users` (any signed-in user can
create others — flat access).
### Dev mode
Setting `AUTH_ENABLED=false` disables all auth checks; a synthetic `dev` user
is attached to every request. **Never deploy this way.** The dev user row is
seeded with a hash that no real password can match, so flipping
`AUTH_ENABLED=true` later does not expose the dev account.
### Recovering a forgotten admin password
Any signed-in user can reset another user's password from `Settings → Users`.
If no one can sign in (all admins forgot their passwords), reset directly in
Postgres:
```sql
-- generate a fresh bcrypt hash with:
-- node -e "import('bcrypt').then(b => b.default.hash(process.argv[1], 12).then(h => console.log(h)))" 'new-passphrase-here'
UPDATE users SET password_hash = '<bcrypt-hash>', password_updated_at = NOW()
WHERE username = 'admin';
```
### `AUTH_ENABLED` transition
When flipping `AUTH_ENABLED=false``true` on an existing install:
1. Ensure `SESSION_SECRET` is set to a stable value (rotating it logs everyone out).
2. Set `ALLOWED_ORIGINS` to the public origin(s) of the web UI.
3. Set `TRUST_PROXY=true` when behind nginx (required for rate-limit accuracy).
4. Restart `mam-api`.
5. Visit the UI — first-run setup will appear if no real users exist yet.
---
## License ## License
Proprietary — Wild Dragon LLC, all rights reserved. MIT

View file

@ -1,101 +0,0 @@
# 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

@ -1,104 +0,0 @@
#!/usr/bin/env bash
# Dragonflight MAM API smoke test
#
# Hits every read-only endpoint and a handful of safe write endpoints
# against a running mam-api. Reports per-endpoint HTTP code + a one-line
# pass/fail. Exits non-zero on any failure.
#
# Usage:
# deploy/api-smoke.sh # against http://localhost:47432
# API=http://10.0.0.25:47432 deploy/api-smoke.sh
set -u
API="${API:-http://localhost:47432}"
PASS=0
FAIL=0
# Per-endpoint check. Args: METHOD PATH EXPECTED_HTTP_CODE [BODY]
# Treats anything < 500 as OK by default; auth-gated endpoints typically
# return 401 with AUTH_ENABLED, also acceptable.
hit() {
local method="$1" path="$2" expect="${3:-2..}" body="${4:-}"
local args=(-s -o /dev/null -w '%{http_code}' -X "$method" "${API}${path}")
if [ -n "$body" ]; then args+=(-H 'Content-Type: application/json' -d "$body"); fi
local code
code=$(curl "${args[@]}" 2>/dev/null || echo "000")
if [[ "$code" =~ ^(2|3|401|400)[0-9][0-9]$ ]]; then
printf " %s %-40s %s OK\n" "$method" "$path" "$code"
PASS=$((PASS + 1))
else
printf " %s %-40s %s FAIL\n" "$method" "$path" "$code"
FAIL=$((FAIL + 1))
fi
}
echo "Dragonflight API smoke test — target ${API}"
echo ""
echo "── auth ──────────────────────────────────────────"
hit GET /api/v1/auth/me
echo ""
echo "── core lists ────────────────────────────────────"
hit GET /api/v1/projects
hit GET /api/v1/assets
hit GET /api/v1/assets?limit=5
hit GET /api/v1/recorders
hit GET /api/v1/jobs
hit GET /api/v1/bins
hit GET /api/v1/users
hit GET /api/v1/groups
hit GET /api/v1/cluster
hit GET /api/v1/cluster/containers
hit GET /api/v1/cluster/devices/blackmagic
echo ""
echo "── settings ──────────────────────────────────────"
hit GET /api/v1/settings/s3
hit GET /api/v1/settings/transcoding
hit GET /api/v1/settings/growing
hit GET /api/v1/settings/ampp
hit GET /api/v1/settings/hardware
hit GET /api/v1/settings/capture-service
echo ""
echo "── feature endpoints ─────────────────────────────"
hit GET /api/v1/metrics/home
hit GET /api/v1/metrics/home?hours=1
hit GET /api/v1/schedules
hit GET /api/v1/schedules?status=upcoming
hit GET /api/v1/sdk
echo ""
echo "── deep-link sanity (one asset) ──────────────────"
ASSET_ID=$(curl -s "${API}/api/v1/assets?limit=1" 2>/dev/null \
| sed -n 's/.*"id":"\([0-9a-f-]\{36\}\)".*/\1/p' | head -1)
if [ -n "$ASSET_ID" ]; then
echo " using asset_id=$ASSET_ID"
hit GET "/api/v1/assets/$ASSET_ID"
hit GET "/api/v1/assets/$ASSET_ID/comments"
hit GET "/api/v1/assets/$ASSET_ID/stream"
hit GET "/api/v1/assets/$ASSET_ID/thumbnail"
else
echo " (no assets to deep-link; skipping per-asset endpoints)"
fi
echo ""
echo "── deep-link sanity (one recorder) ───────────────"
REC_ID=$(curl -s "${API}/api/v1/recorders" 2>/dev/null \
| sed -n 's/.*"id":"\([0-9a-f-]\{36\}\)".*/\1/p' | head -1)
if [ -n "$REC_ID" ]; then
echo " using recorder_id=$REC_ID"
hit GET "/api/v1/recorders/$REC_ID"
hit GET "/api/v1/recorders/$REC_ID/status"
else
echo " (no recorders to deep-link)"
fi
echo ""
echo "── summary ───────────────────────────────────────"
echo " PASS: $PASS"
echo " FAIL: $FAIL"
[ "$FAIL" -eq 0 ]

View file

@ -1,197 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# Wild Dragon MAM — Cluster Node Onboarding
# =============================================================================
#
# Provisions a Linux machine as a cluster worker node in one command.
#
# Quick-start (pipe to bash):
# export MAM_API_URL=http://10.0.0.25:47432
# export NODE_TOKEN=wd_xxxx # create via Z-AMPP → Admin → Tokens
# curl -sL https://forge.wilddragon.net/zgaetano/wild-dragon/raw/branch/main/deploy/onboard-node.sh | bash
#
# Or run from a cloned repo:
# MAM_API_URL=http://10.0.0.25:47432 NODE_TOKEN=wd_xxxx ./deploy/onboard-node.sh
#
# Environment variables:
# MAM_API_URL REQUIRED Primary MAM API base URL
# NODE_TOKEN API bearer token (required if AUTH_ENABLED=true)
# NODE_ROLE Role tag reported to the cluster (default: worker)
# NODE_IP Override the LAN IP reported back (default: auto-detect)
# AGENT_PORT Host port for the node agent (default: 7436)
# INSTALL_DIR Where to clone/find the repo (default: /opt/wild-dragon)
# PROFILES Extra compose profiles, space-sep e.g. "worker capture"
# BMD_MODEL DeckLink card model name (e.g. "DeckLink Duo 2")
# REPO_URL Override the Forgejo clone URL
# =============================================================================
set -euo pipefail
# ── Config ───────────────────────────────────────────────────────────────────
REPO_URL="${REPO_URL:-https://forge.wilddragon.net/zgaetano/wild-dragon.git}"
INSTALL_DIR="${INSTALL_DIR:-/opt/wild-dragon}"
MAM_API_URL="${MAM_API_URL:-}"
NODE_TOKEN="${NODE_TOKEN:-}"
NODE_ROLE="${NODE_ROLE:-worker}"
NODE_IP="${NODE_IP:-}"
AGENT_PORT="${AGENT_PORT:-7436}"
PROFILES="${PROFILES:-}"
BMD_MODEL="${BMD_MODEL:-}"
PROJECT_NAME="wild-dragon-worker"
# ── Colours ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'; YEL='\033[1;33m'; GRN='\033[0;32m'; CYN='\033[0;36m'
BLD='\033[1m'; NC='\033[0m'
log() { echo -e "${GRN}${NC} $*"; }
info() { echo -e "${CYN}${NC} $*"; }
warn() { echo -e "${YEL}${NC} $*"; }
header() { echo -e "\n${BLD}${CYN}── $* ──────────────────────────────────────${NC}"; }
die() { echo -e "${RED} ✗ ERROR:${NC} $*" >&2; exit 1; }
# ── Auto-detect LAN IP ───────────────────────────────────────────────────────
# Node-agent runs in a container; os.networkInterfaces() inside the container
# returns the docker-bridge IP unless we pass NODE_IP through. We resolve the
# host's primary LAN IP here so the cluster page shows the right address.
detect_lan_ip() {
local ip=""
if command -v ip &>/dev/null; then
ip=$(ip -4 route get 1.1.1.1 2>/dev/null \
| awk '/src/ {for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}' \
|| true)
fi
if [[ -z "$ip" ]] && command -v hostname &>/dev/null; then
ip=$(hostname -I 2>/dev/null | awk '{print $1}' || true)
fi
echo "$ip"
}
# ── Preflight ────────────────────────────────────────────────────────────────
echo -e "\n${BLD}${CYN}Wild Dragon MAM — Cluster Node Onboarding${NC}\n"
[[ -z "$MAM_API_URL" ]] && die "MAM_API_URL is required.\n\n Example:\n export MAM_API_URL=http://10.0.0.25:47432\n export NODE_TOKEN=wd_xxxx\n ./deploy/onboard-node.sh"
if [[ -z "$NODE_IP" ]]; then
NODE_IP="$(detect_lan_ip)"
if [[ -n "$NODE_IP" ]]; then
info "Auto-detected LAN IP: $NODE_IP"
else
warn "Could not auto-detect LAN IP — agent will fall back to interface heuristics."
fi
fi
info "Primary API : $MAM_API_URL"
info "Role : $NODE_ROLE"
info "Agent port : $AGENT_PORT"
info "Install dir : $INSTALL_DIR"
[[ -n "$NODE_IP" ]] && info "Node IP : $NODE_IP"
[[ -n "$BMD_MODEL" ]] && info "DeckLink : $BMD_MODEL"
[[ -n "$PROFILES" ]] && info "Profiles : $PROFILES"
if [[ -z "$NODE_TOKEN" ]]; then
warn "NODE_TOKEN is not set."
warn "If AUTH_ENABLED=true on the primary, heartbeats will be rejected."
warn "Create a token: Z-AMPP web UI → Admin → Tokens → New Token"
fi
# ── Step 1: Docker ───────────────────────────────────────────────────────────
header "1/4 Docker"
if ! command -v docker &>/dev/null; then
warn "Docker not found — installing via get.docker.com"
curl -fsSL https://get.docker.com | bash
systemctl enable --now docker 2>/dev/null || true
usermod -aG docker "${SUDO_USER:-$USER}" 2>/dev/null || true
log "Docker installed"
else
log "Docker $(docker --version | grep -oP '\d+\.\d+\.\d+' | head -1) already installed"
fi
if ! docker info &>/dev/null; then
die "Docker daemon not accessible.\n Try: sudo systemctl start docker\n Or add your user to the docker group and re-login."
fi
# ── Step 2: Repository ───────────────────────────────────────────────────────
header "2/4 Repository"
if [[ -d "$INSTALL_DIR/.git" ]]; then
info "Updating $INSTALL_DIR"
git -C "$INSTALL_DIR" pull --ff-only
log "Repository up to date ($(git -C "$INSTALL_DIR" rev-parse --short HEAD))"
else
info "Cloning $REPO_URL$INSTALL_DIR"
mkdir -p "$(dirname "$INSTALL_DIR")"
git clone "$REPO_URL" "$INSTALL_DIR"
log "Repository cloned"
fi
# ── Step 3: Environment ──────────────────────────────────────────────────────
header "3/4 Configuration"
ENV_FILE="$INSTALL_DIR/.env.worker"
info "Writing $ENV_FILE"
{
echo "# Wild Dragon worker node — generated $(date -u +%Y-%m-%dT%H:%M:%SZ) by onboard-node.sh"
echo "MAM_API_URL=$MAM_API_URL"
echo "NODE_TOKEN=$NODE_TOKEN"
echo "NODE_ROLE=$NODE_ROLE"
echo "NODE_IP=$NODE_IP"
echo "AGENT_PORT=$AGENT_PORT"
echo "HEARTBEAT_MS=30000"
[[ -n "$BMD_MODEL" ]] && echo "BMD_MODEL=$BMD_MODEL"
for v in REDIS_URL DATABASE_URL S3_ENDPOINT S3_BUCKET S3_ACCESS_KEY S3_SECRET_KEY S3_REGION; do
val="${!v:-}"
[[ -n "$val" ]] && echo "$v=$val"
done
} > "$ENV_FILE"
log "Env file written"
# ── Step 4: Start services ───────────────────────────────────────────────────
header "4/4 Starting services"
COMPOSE="docker compose -f $INSTALL_DIR/docker-compose.worker.yml --env-file $ENV_FILE --project-name $PROJECT_NAME"
PROFILE_FLAGS=""
for p in $PROFILES; do
PROFILE_FLAGS="$PROFILE_FLAGS --profile $p"
done
info "Building images (this may take a minute on first run)…"
$COMPOSE build
info "Starting containers…"
# shellcheck disable=SC2086
$COMPOSE $PROFILE_FLAGS up -d
# ── Verify ───────────────────────────────────────────────────────────────────
echo ""
info "Waiting 6 seconds for agent to initialise…"
sleep 6
HEALTH_URL="http://localhost:$AGENT_PORT/health"
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
log "Node agent healthy at $HEALTH_URL"
REPORTED_IP=$(curl -sf "$HEALTH_URL" | sed -nE 's/.*"ip":"([^"]+)".*/\1/p')
[[ -n "$REPORTED_IP" ]] && log "Reporting IP: $REPORTED_IP"
else
warn "Could not reach $HEALTH_URL — check logs:"
warn " $COMPOSE logs node-agent"
fi
# ── Done ─────────────────────────────────────────────────────────────────────
echo ""
echo -e "${BLD}${GRN}Onboarding complete!${NC}"
echo ""
echo -e " Node agent ${BLD}:$AGENT_PORT${NC} (heartbeating every 30s)"
echo -e " Primary API ${BLD}$MAM_API_URL${NC}"
echo -e " Role ${BLD}$NODE_ROLE${NC}"
[[ -n "$NODE_IP" ]] && echo -e " Node IP ${BLD}$NODE_IP${NC}"
echo ""
echo -e " ${CYN}Useful commands:${NC}"
echo -e " Status : $COMPOSE ps"
echo -e " Logs : $COMPOSE logs -f"
echo -e " Stop : $COMPOSE down"
echo -e " Update : git -C $INSTALL_DIR pull && $COMPOSE build && $COMPOSE up -d"
echo ""
echo -e " Open the Z-AMPP web UI → ${BLD}Admin → Cluster${NC} to see this node."

View file

@ -1,186 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# Wild Dragon MAM — API Smoke Test
# =============================================================================
# Hits every major endpoint and reports pass/fail.
#
# Usage:
# MAM_API_URL=http://10.0.0.25:47432 ./deploy/test-api.sh
# MAM_API_URL=http://10.0.0.25:47432 NODE_TOKEN=wd_xxxx ./deploy/test-api.sh
# =============================================================================
set -euo pipefail
BASE="${MAM_API_URL:-http://localhost:47432}"
TOKEN="${NODE_TOKEN:-}"
PASS=0; FAIL=0; SKIP=0
GRN='\033[0;32m'; RED='\033[0;31m'; YEL='\033[1;33m'; CYN='\033[0;36m'; BLD='\033[1m'; NC='\033[0m'
pass() { PASS=$((PASS+1)); echo -e " ${GRN}PASS${NC} $1"; }
fail() { FAIL=$((FAIL+1)); echo -e " ${RED}FAIL${NC} $1 ${RED}$2${NC}"; }
skip() { SKIP=$((SKIP+1)); echo -e " ${YEL}SKIP${NC} $1 ${YEL}($2)${NC}"; }
header() { echo -e "\n${BLD}$1${NC}"; }
AUTH_ARGS=()
[[ -n "$TOKEN" ]] && AUTH_ARGS+=(-H "Authorization: Bearer $TOKEN")
# GET — check HTTP status code (no -f so 4xx/5xx are visible as their real code)
check_status() {
local label="$1" path="$2" want="$3"
local got
got=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH_ARGS[@]}" "$BASE$path" 2>/dev/null)
[[ -z "$got" ]] && got="000"
if [[ "$got" == "$want" ]]; then
pass "$label [HTTP $got]"
else
fail "$label [HTTP $got]" "expected $want"
fi
}
# GET — check response body contains literal string (fgrep avoids regex interpretation)
check_body() {
local label="$1" path="$2" needle="$3"
local body
body=$(curl -s "${AUTH_ARGS[@]}" "$BASE$path" 2>/dev/null) || { fail "$label" "request failed"; return; }
if echo "$body" | grep -qF "$needle"; then
pass "$label"
else
fail "$label" "'$needle' not in response"
fi
}
# POST — check HTTP status code
check_post() {
local label="$1" path="$2" data="$3" want="$4"
local got
got=$(curl -s -o /dev/null -w "%{http_code}" \
"${AUTH_ARGS[@]}" \
-H "Content-Type: application/json" \
-X POST -d "$data" \
"$BASE$path" 2>/dev/null)
[[ -z "$got" ]] && got="000"
if [[ "$got" == "$want" ]]; then
pass "$label [HTTP $got]"
else
fail "$label [HTTP $got]" "expected $want"
fi
}
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo -e "${BLD}${CYN}Wild Dragon MAM — API Smoke Test${NC}"
echo -e " Base URL : ${BLD}$BASE${NC}"
[[ -n "$TOKEN" ]] && echo -e " Auth : Bearer token" || echo -e " Auth : none"
echo ""
# ── Connectivity ─────────────────────────────────────────────────────────────
header "Connectivity"
CONNECT=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/health" 2>/dev/null)
if [[ "$CONNECT" == "200" ]]; then
pass "API server reachable [/health → 200]"
else
fail "API server reachable [HTTP $CONNECT]" "cannot reach $BASE"
echo -e "\n ${RED}Cannot reach the server — aborting.${NC}"
exit 1
fi
# ── Auth ─────────────────────────────────────────────────────────────────────
header "Auth"
check_status "GET /auth/me" "/api/v1/auth/me" 200
check_body "GET /auth/me returns username" "/api/v1/auth/me" '"username"'
check_post "POST /auth/login (missing body → 400)" "/api/v1/auth/login" '{}' 400
# ── Assets ───────────────────────────────────────────────────────────────────
header "Assets"
check_status "GET /assets" "/api/v1/assets" 200
check_body "GET /assets returns assets key" "/api/v1/assets" '"assets"'
check_status "GET /assets bogus id → 404" "/api/v1/assets/00000000-0000-0000-0000-000000000000" 404
# ── Projects ─────────────────────────────────────────────────────────────────
header "Projects"
check_status "GET /projects" "/api/v1/projects" 200
check_body "GET /projects returns array" "/api/v1/projects" '['
# ── Jobs ─────────────────────────────────────────────────────────────────────
header "Jobs"
check_status "GET /jobs" "/api/v1/jobs" 200
check_body "GET /jobs returns array" "/api/v1/jobs" '['
# ── Recorders ────────────────────────────────────────────────────────────────
header "Recorders"
check_status "GET /recorders" "/api/v1/recorders" 200
# ── Sequences (requires project_id param) ────────────────────────────────────
header "Sequences"
check_status "GET /sequences (no project_id → 400)" "/api/v1/sequences" 400
check_status "GET /sequences bogus project_id → 200" "/api/v1/sequences?project_id=00000000-0000-0000-0000-000000000000" 200
# ── Settings ─────────────────────────────────────────────────────────────────
header "Settings"
check_status "GET /settings/ampp" "/api/v1/settings/ampp" 200
# ── Cluster ──────────────────────────────────────────────────────────────────
header "Cluster"
check_status "GET /cluster" "/api/v1/cluster" 200
check_body "GET /cluster returns array" "/api/v1/cluster" '['
# Heartbeat: register a temporary smoke-test node, verify it appears, remove it
TEST_HOST="smoke-test-$(date +%s)"
check_post "POST /cluster/heartbeat" "/api/v1/cluster/heartbeat" \
"{\"hostname\":\"$TEST_HOST\",\"role\":\"smoketest\",\"cpu_usage\":0,\"mem_used_mb\":512,\"mem_total_mb\":4096}" \
200
NODE_ID=$(curl -s "${AUTH_ARGS[@]}" "$BASE/api/v1/cluster" 2>/dev/null \
| grep -o '"id":"[^"]*"' | head -1 | grep -o '[0-9a-f-]\{36\}' || true)
if [[ -n "$NODE_ID" ]]; then
pass "Cluster node visible in registry"
DEL=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH_ARGS[@]}" \
-X DELETE "$BASE/api/v1/cluster/$NODE_ID" 2>/dev/null)
[[ "$DEL" == "200" ]] && pass "DELETE /cluster/:id (cleanup) [HTTP $DEL]" \
|| fail "DELETE /cluster/:id (cleanup)" "HTTP $DEL"
else
skip "Cluster node visible in registry" "could not parse node id from response"
fi
# ── System / Containers ───────────────────────────────────────────────────────
header "System"
check_status "GET /system/containers" "/api/v1/system/containers" 200
check_body "Containers returns array" "/api/v1/system/containers" '['
# ── Capture (proxies to capture service) ─────────────────────────────────────
header "Capture"
# 200 = capture active and responding
# 404 = capture in sidecar/idle mode (no active recorder — expected in dev)
# 5xx = capture container unreachable
CAPTURE_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH_ARGS[@]}" \
"$BASE/api/v1/capture/status" 2>/dev/null)
if [[ "$CAPTURE_CODE" == "200" ]]; then
pass "GET /capture/status [HTTP 200 — capture active]"
elif [[ "$CAPTURE_CODE" == "404" ]]; then
skip "GET /capture/status [HTTP 404]" "capture in idle/sidecar mode (normal when not recording)"
elif [[ "$CAPTURE_CODE" =~ ^5 ]]; then
skip "GET /capture/status [HTTP $CAPTURE_CODE]" "capture container unreachable"
else
fail "GET /capture/status [HTTP $CAPTURE_CODE]" "unexpected status"
fi
# ── Users / Tokens ───────────────────────────────────────────────────────────
header "Users / Tokens"
check_status "GET /users" "/api/v1/users" 200
check_status "GET /tokens" "/api/v1/tokens" 200
# ── Summary ───────────────────────────────────────────────────────────────────
TOTAL=$((PASS + FAIL + SKIP))
echo ""
echo -e "${BLD}Results:${NC} ${GRN}${PASS} passed${NC} / ${RED}${FAIL} failed${NC} / ${YEL}${SKIP} skipped${NC} / $TOTAL total"
echo ""
if [[ $FAIL -gt 0 ]]; then
echo -e "${RED}Some tests failed — check output above.${NC}"
exit 1
else
echo -e "${GRN}All tests passed.${NC}"
fi

View file

@ -1,183 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# Wild Dragon MAM — Cluster Smoke Test
# =============================================================================
#
# Validates the cluster end-to-end from any node that can reach the primary.
# Designed to be run after `onboard-node.sh` finishes on every worker.
#
# MAM_API_URL=http://10.0.0.25:47432 ./deploy/test-cluster.sh
# MAM_API_URL=... AUTH_TOKEN=wd_xxxx ./deploy/test-cluster.sh
#
# Checks:
# 1. Primary API health
# 2. Cluster registry (no duplicate hostnames, IPs are real LAN addresses)
# 3. Each worker's /health endpoint
# 4. GPU detection (nvidia-smi exits clean on nodes that report GPUs)
# 5. NVENC encode probe (5s of synthetic h264_nvenc → /tmp)
# 6. Blackmagic device enumeration
#
# Exit 0 = all pass, 1 = any failure. Failures are logged inline.
# =============================================================================
set -uo pipefail
MAM_API_URL="${MAM_API_URL:-}"
AUTH_TOKEN="${AUTH_TOKEN:-}"
if [[ -z "$MAM_API_URL" ]]; then
echo "✗ MAM_API_URL is required" >&2
exit 1
fi
RED='\033[0;31m'; YEL='\033[1;33m'; GRN='\033[0;32m'; CYN='\033[0;36m'; BLD='\033[1m'; NC='\033[0m'
PASS=0; FAIL=0
pass() { echo -e "${GRN}${NC} $*"; PASS=$((PASS+1)); }
fail() { echo -e "${RED}${NC} $*"; FAIL=$((FAIL+1)); }
note() { echo -e "${CYN}${NC} $*"; }
warn() { echo -e "${YEL} !${NC} $*"; }
api() {
local method="${1:-GET}"; shift
local path="$1"; shift
local args=(-sS -X "$method" -H 'Content-Type: application/json')
[[ -n "$AUTH_TOKEN" ]] && args+=(-H "Authorization: Bearer $AUTH_TOKEN")
curl "${args[@]}" "$@" "${MAM_API_URL}${path}"
}
echo -e "${BLD}${CYN}Wild Dragon — Cluster Smoke Test${NC}"
echo -e "Primary: $MAM_API_URL"
echo ""
# ── 1. Primary API health ───────────────────────────────────────────────
echo -e "${BLD}1. Primary API health${NC}"
if api GET /health | grep -q '"status":"ok"'; then
pass "primary /health responds"
else
fail "primary /health did not return ok"
fi
echo ""
# ── 2. Cluster registry ─────────────────────────────────────────────────
echo -e "${BLD}2. Cluster registry${NC}"
NODES_JSON=$(api GET /api/v1/cluster || echo '[]')
TOTAL=$(echo "$NODES_JSON" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0)
note "$TOTAL nodes registered"
if [[ "$TOTAL" -gt 0 ]]; then
# No duplicate hostnames
DUP=$(echo "$NODES_JSON" | python3 -c '
import sys, json
nodes = json.load(sys.stdin)
seen = {}
dups = []
for n in nodes:
h = n.get('hostname')
if h in seen: dups.append(h)
seen[h] = True
print(",".join(sorted(set(dups))))' 2>/dev/null)
if [[ -z "$DUP" ]]; then
pass "no duplicate hostnames"
else
fail "duplicate hostnames: $DUP — run migration 007"
fi
# No private docker IPs (172.16.0.0/12 reserved for docker bridges)
BAD_IPS=""
while IFS=$'\t' read -r host ip; do
[[ -z "$ip" ]] && continue
first="${ip%%.*}"; rest="${ip#*.}"; second="${rest%%.*}"
if [[ "$first" == "172" && "$second" == "17" ]]; then
BAD_IPS+="${host}=${ip},"
fi
done < <(echo "$NODES_JSON" | jq -r '.[] | [.hostname, (.ip_address // "")] | @tsv')
BAD_IPS="${BAD_IPS%,}"
if [[ -z "$BAD_IPS" ]]; then
pass "all node IPs are real LAN addresses"
else
fail "nodes still reporting docker bridge IPs: $BAD_IPS"
warn " → set NODE_IP in .env.worker and restart the node-agent"
fi
# All nodes recently seen
STALE=$(echo "$NODES_JSON" | python3 -c '
import sys, json
nodes = json.load(sys.stdin)
stale = [n["hostname"] for n in nodes if float(n.get("stale_seconds") or 9999) > 120]
print(",".join(stale))' 2>/dev/null)
if [[ -z "$STALE" ]]; then
pass "all nodes heartbeated within 2 min"
else
warn "stale nodes (>2 min since heartbeat): $STALE"
fi
fi
echo ""
# ── 3. Per-node /health probes ──────────────────────────────────────────
echo -e "${BLD}3. Worker agent /health endpoints${NC}"
echo "$NODES_JSON" | python3 -c '
import sys, json
for n in json.load(sys.stdin):
if n.get("role") == "primary": continue
print(n["id"], n["hostname"], n.get("api_url") or "")
' 2>/dev/null | while read -r ID HOST URL; do
[[ -z "$URL" ]] && { warn "$HOST: no api_url registered"; continue; }
if curl -sf --max-time 4 "$URL/health" >/dev/null 2>&1; then
pass "$HOST ($URL/health)"
else
fail "$HOST agent unreachable at $URL/health"
fi
done
echo ""
# ── 4. Local GPU + NVENC probe (when run on a GPU node) ─────────────────
echo -e "${BLD}4. Local GPU + NVENC${NC}"
if command -v nvidia-smi >/dev/null 2>&1; then
GPU_COUNT=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | wc -l)
if [[ "$GPU_COUNT" -gt 0 ]]; then
pass "$GPU_COUNT NVIDIA GPU(s) visible to host"
if command -v ffmpeg >/dev/null 2>&1; then
if ffmpeg -hide_banner -loglevel error \
-f lavfi -i testsrc=duration=5:size=1280x720:rate=30 \
-c:v h264_nvenc -preset p1 -b:v 4M \
-t 5 -f null - 2>/tmp/wd-nvenc.log; then
pass "NVENC encode test succeeded"
else
fail "NVENC encode failed — see /tmp/wd-nvenc.log"
fi
else
warn "ffmpeg not installed locally — skipping NVENC encode test"
fi
else
warn "nvidia-smi found but reports 0 GPUs"
fi
else
warn "nvidia-smi not present (not a GPU node)"
fi
echo ""
# ── 5. Blackmagic device enumeration ────────────────────────────────────
echo -e "${BLD}5. Blackmagic devices (cluster-wide)${NC}"
BMD_JSON=$(api GET /api/v1/cluster/devices/blackmagic || echo '[]')
BMD_COUNT=$(echo "$BMD_JSON" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0)
if [[ "$BMD_COUNT" -gt 0 ]]; then
pass "$BMD_COUNT DeckLink port(s) registered"
echo "$BMD_JSON" | jq -r '.[] | " \(.hostname) port=\(.index) model=\(.model // "unknown") online=\(.online)"'
else
warn "no DeckLink devices reported by any node"
fi
echo ""
# ── 6. Local Blackmagic device files ────────────────────────────────────
echo -e "${BLD}6. Local /dev/blackmagic${NC}"
if [[ -d /dev/blackmagic ]]; then
ls /dev/blackmagic/ | sed 's/^/ /'
pass "$(ls /dev/blackmagic/ | wc -l) device node(s) under /dev/blackmagic"
else
warn "no /dev/blackmagic on this machine"
fi
echo ""
# ── Summary ─────────────────────────────────────────────────────────────
echo -e "${BLD}Summary:${NC} ${GRN}$PASS pass${NC} ${RED}$FAIL fail${NC}"
[[ "$FAIL" -gt 0 ]] && exit 1 || exit 0

View file

@ -1,33 +0,0 @@
# Wild Dragon MAM — GPU overlay
# Apply on top of docker-compose.yml on nodes with NVIDIA GPUs.
#
# Prerequisites: NVIDIA Container Toolkit installed on the host.
# Install: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/
#
# Usage (core MAM node with GPUs):
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
#
# Usage (worker node with GPUs):
# docker compose -f docker-compose.worker.yml -f docker-compose.gpu.yml --profile worker up -d
#
# This overlay:
# - Rebuilds worker from Dockerfile.gpu (CUDA base + ffmpeg NVENC)
# - Passes all NVIDIA GPUs into the worker container
# - Sets NVENC_ENABLED=true so the worker prioritises h264_nvenc/hevc_nvenc
services:
worker:
build:
context: ./services/worker
dockerfile: Dockerfile.gpu
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
environment:
NVENC_ENABLED: "true"
NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility

View file

@ -1,130 +0,0 @@
# Wild Dragon MAM — Worker Node Stack
# ─────────────────────────────────────
# Deploy on any machine you want to join the cluster as a worker.
# The primary stack (mam-api, db, redis) continues running on TrueNAS.
#
# Required env vars (set in .env.worker or export before running):
# MAM_API_URL URL of the primary MAM API e.g. http://10.0.0.25:47432
# NODE_TOKEN Bearer token from the primary's Tokens page
# NODE_IP Host LAN IP to report (set by onboard-node.sh)
#
# Optional hardware overrides (if Docker can't see /dev directly):
# GPU_COUNT Number of NVIDIA GPUs on this node (default: auto-detect from /dev/nvidia*)
# BMD_COUNT Number of Blackmagic DeckLink cards (default: auto-detect from /dev/blackmagic/)
# BMD_MODEL Marketed card name (e.g. "DeckLink Duo 2") — drives the port-diagram UI
#
# Optional env vars (needed only if starting the worker or capture profiles):
# REDIS_URL, DATABASE_URL, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY
# BMD_DEVICE_0 DeckLink device path (default: /dev/blackmagic/dv0)
# (DeckLink IO / Quad cards expose /dev/blackmagic/io* instead — set BMD_DEVICE_PREFIX=io)
# BMD_DEVICE_1 DeckLink device path (default: /dev/blackmagic/dv1)
# BMD_DEVICE_PREFIX Naming prefix for synthesized BMD_COUNT-based devices (default: dv). Use 'io' for IO/Quad.
# LIVE_DIR Host path for HLS live segments (default: /mnt/NVME/MAM/wild-dragon-live)
#
# Profiles:
# (default) node-agent only — cluster visibility + hardware heartbeat
# --profile worker + CPU/GPU job worker (proxy generation, transcoding)
# --profile capture + SDI capture service (requires Blackmagic DeckLink card)
#
# To enable GPU transcoding, also apply docker-compose.gpu.yml:
# docker compose -f docker-compose.worker.yml -f docker-compose.gpu.yml --profile worker up -d
#
# NOTE: The node-agent mounts /var/run/docker.sock to spawn on-demand SDI
# capture sidecars when the primary mam-api routes a recorder to this node.
# Build the capture image before first use:
# docker compose -f docker-compose.worker.yml build capture
services:
# node-agent runs in host network mode so it can see the real host
# interfaces, GPU devices and DeckLink cards without bridging tricks.
# The reported IP / hostname will be the host's, not the container's.
node-agent:
build: ./services/node-agent
restart: unless-stopped
network_mode: host
environment:
MAM_API_URL: ${MAM_API_URL}
NODE_TOKEN: ${NODE_TOKEN:-}
NODE_ROLE: ${NODE_ROLE:-worker}
NODE_IP: ${NODE_IP:-}
AGENT_PORT: ${AGENT_PORT:-7436}
HEARTBEAT_MS: ${HEARTBEAT_MS:-30000}
GPU_COUNT: ${GPU_COUNT:--1}
BMD_COUNT: ${BMD_COUNT:--1}
BMD_MODEL: ${BMD_MODEL:-}
BMD_DEVICE_PREFIX: ${BMD_DEVICE_PREFIX:-dv}
LIVE_DIR: ${LIVE_DIR:-/mnt/NVME/MAM/wild-dragon-live}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /dev:/dev:ro
- /mnt/NVME/MAM/wild-dragon-live:/mnt/NVME/MAM/wild-dragon-live:ro
devices:
- /dev/blackmagic:/dev/blackmagic
worker:
build: ./services/worker
profiles: [worker]
restart: unless-stopped
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DATABASE_URL}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_BUCKET: ${S3_BUCKET}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
NVENC_ENABLED: ${NVENC_ENABLED:-false}
networks:
- wild-dragon-worker
# SDI capture service — only start on nodes with Blackmagic DeckLink cards
# Set BMD_DEVICE_0 in .env.worker to the actual device path, e.g. /dev/blackmagic/dv0
capture:
build: ./services/capture
profiles: [capture]
restart: unless-stopped
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DATABASE_URL}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_BUCKET: ${S3_BUCKET}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
CAPTURE_PORT: 3001
devices:
- ${BMD_DEVICE_0:-/dev/blackmagic/dv0}:/dev/blackmagic/dv0
- ${BMD_DEVICE_1:-/dev/blackmagic/dv1}:/dev/blackmagic/dv1
ports:
- "${CAPTURE_PORT:-7437}:3001"
networks:
- wild-dragon-worker
# 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.
worker-l4:
build:
context: ./services/worker
dockerfile: Dockerfile.gpu
image: wild-dragon-worker-gpu:latest
runtime: nvidia
restart: unless-stopped
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DATABASE_URL}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_BUCKET: ${S3_BUCKET}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
WORKER_QUEUES: proxy,conform,trim
PROXY_CONCURRENCY: "3"
NVIDIA_VISIBLE_DEVICES: GPU-13acf439-8bf4-a5e0-7804-c1071bca547a
WORKER_LABEL: "zampp2 / L4"
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
networks:
- wild-dragon-worker
networks:
wild-dragon-worker:
driver: bridge

View file

@ -5,8 +5,6 @@ services:
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${PORT_DB:-5432}:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./services/mam-api/src/db/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro - ./services/mam-api/src/db/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
@ -20,8 +18,6 @@ services:
queue: queue:
image: redis:7-alpine image: redis:7-alpine
ports:
- "${PORT_REDIS:-6379}:6379"
volumes: volumes:
- redis_data:/data - redis_data:/data
networks: networks:
@ -38,14 +34,6 @@ services:
- "${PORT_MAM_API:-7432}:3000" - "${PORT_MAM_API:-7432}:3000"
volumes: volumes:
- /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-growing:/growing
- /mnt/NVME/MAM/wild-dragon-media:/media
- /mnt/NVME/MAM/sdk:/sdk
- /dev/shm:/dev/shm
- /run/dbus:/run/dbus
- /run/systemd:/run/systemd
- /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
environment: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL} REDIS_URL: ${REDIS_URL}
@ -56,21 +44,7 @@ services:
S3_REGION: ${S3_REGION:-us-east-1} S3_REGION: ${S3_REGION:-us-east-1}
SESSION_SECRET: ${SESSION_SECRET} SESSION_SECRET: ${SESSION_SECRET}
AUTH_ENABLED: ${AUTH_ENABLED:-false} AUTH_ENABLED: ${AUTH_ENABLED:-false}
TRUST_PROXY: ${TRUST_PROXY:-false}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
DOCKER_NETWORK: wild-dragon_wild-dragon DOCKER_NETWORK: wild-dragon_wild-dragon
NODE_IP: ${NODE_IP}
NODE_HOSTNAME: ${NODE_HOSTNAME:-}
CAPTURE_TOKEN: ${CAPTURE_TOKEN}
PLAYOUT_IMAGE: ${PLAYOUT_IMAGE:-wild-dragon-playout:latest}
PLAYOUT_AMCP_BASE_PORT: ${PLAYOUT_AMCP_BASE_PORT:-5250}
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
networks: networks:
- wild-dragon - wild-dragon
@ -90,23 +64,11 @@ services:
S3_SECRET_KEY: ${S3_SECRET_KEY} S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1} S3_REGION: ${S3_REGION:-us-east-1}
MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000} MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000}
volumes:
- /mnt/NVME/MAM/wild-dragon-live:/live
- /dev/shm:/dev/shm
- /run/dbus:/run/dbus
- /run/systemd:/run/systemd
networks: networks:
- wild-dragon - wild-dragon
# ── GPU worker pool (capability-routed) ────────────────────────────── worker:
# worker-p4: HEAVY tier (proxy/conform/trim) on the Tesla P4 (NVENC). build: ./services/worker
# Also runs the promotion scanner (RUN_PROMOTION) — exactly one worker must.
worker-p4:
build:
context: ./services/worker
dockerfile: Dockerfile.gpu
image: wild-dragon-worker-gpu:latest
runtime: nvidia
depends_on: depends_on:
- queue - queue
- db - db
@ -118,60 +80,6 @@ services:
S3_ACCESS_KEY: ${S3_ACCESS_KEY} S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY} S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1} S3_REGION: ${S3_REGION:-us-east-1}
GROWING_PATH: /growing
# Includes `import` (YouTube importer): the import queue had no consumer
# after the capability-routing split, so import jobs sat unprocessed and
# assets stayed `ingesting` forever. import is concurrency-1 + network-
# bound, so one consumer (this heavy/primary worker) is sufficient.
WORKER_QUEUES: proxy,conform,trim,import,playout-stage
RUN_PROMOTION: "true"
PROXY_CONCURRENCY: "2"
PLAYOUT_MEDIA_DIR: /media
NVIDIA_VISIBLE_DEVICES: GPU-79afca3e-2ab2-a6ea-1c44-706c1f0a26d6
WORKER_LABEL: "zampp1 / Tesla P4"
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
volumes:
- /mnt/NVME/MAM/wild-dragon-growing:/growing
- /mnt/NVME/MAM/wild-dragon-media:/media
networks:
- wild-dragon
# worker-p400a/b: LIGHT tier (thumbnail/filmstrip) on the two Quadro P400s.
worker-p400a:
image: wild-dragon-worker-gpu:latest
runtime: nvidia
depends_on: [queue, db, worker-p4]
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DATABASE_URL}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_BUCKET: ${S3_BUCKET}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
WORKER_QUEUES: thumbnail,filmstrip
NVIDIA_VISIBLE_DEVICES: GPU-331c53ea-2ed9-0007-e364-c1451775948f
WORKER_LABEL: "zampp1 / P400 #1"
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
networks:
- wild-dragon
worker-p400b:
image: wild-dragon-worker-gpu:latest
runtime: nvidia
depends_on: [queue, db, worker-p4]
environment:
REDIS_URL: ${REDIS_URL}
DATABASE_URL: ${DATABASE_URL}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_BUCKET: ${S3_BUCKET}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
WORKER_QUEUES: thumbnail,filmstrip
NVIDIA_VISIBLE_DEVICES: GPU-b514a592-9077-44bd-d9e8-9efa0591ef88
WORKER_LABEL: "zampp1 / P400 #2"
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
networks: networks:
- wild-dragon - wild-dragon
@ -179,24 +87,9 @@ services:
build: ./services/web-ui build: ./services/web-ui
ports: ports:
- "${PORT_WEB_UI:-7434}:80" - "${PORT_WEB_UI:-7434}:80"
volumes:
- /mnt/NVME/MAM/wild-dragon-live:/live
- /mnt/NVME/MAM/wild-dragon-media:/media:ro
- /dev/shm:/dev/shm
- /run/dbus:/run/dbus
- /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

@ -1,186 +0,0 @@
# Dragonflight · Feature Overview
Dragonflight is a self-hosted broadcast media-asset management system that replaces legacy tools like Grass Valley AMPP and FramelightX. It handles live ingest, growing-file editing, scheduling, transcoding, and asset management in a single operator-focused interface.
## Home Dashboard
![Home Dashboard](./screenshots/01-home.png)
The home screen provides quick access to all major features and displays system status at a glance:
- **Library** — Browse projects, bins, and assets with hover-scrub previews
- **Recorders** — View configured capture devices and their status
- **Editor** — Timeline editor with cross-clip preview and render queue
- **Jobs** — Proxy and thumbnail queue with retry controls
- **Settings** — Configure storage, encoder, growing files, and capture SDK
- **Dashboard** — Operations view showing recent activity, job queue, and cluster health
---
## Core Features
### 1. Live Ingest & Capture
**Multi-protocol source capture with per-recorder codec settings**
Dragonflight ingests from multiple sources simultaneously:
- **SRT** (Secure Reliable Transport) — caller and listener modes
- **RTMP** — standard streaming protocol
- **SDI** — via Blackmagic DeckLink cards with FFmpeg SDK 16.x patches
Each recorder can be configured with independent codec settings:
- ProRes (hi-res masters)
- H.264 / H.265 (proxies)
- DNxHR (Avid compatibility)
Audio routing and per-source configuration ensure flexibility for multi-camera productions.
### 2. Growing-File Editing
**Live editing in Premiere Pro while capture is still writing**
Editors mount the SMB landing zone directly in Premiere Pro and edit the live master file as it's being written. The included CEP (Custom Extension Panel) provides:
- Real-time clip detection and frame-accurate trimming
- One-click relink to final S3 master after promotion
- No waiting for capture to finish before editorial begins
### 3. Recorder Scheduler
**Time-windowed recording automation**
Schedule recordings with:
- One-shot, daily, or weekly recurrence
- Automatic start/stop via 15-second tick loop
- Conflict detection across recorders
- Project and bin assignment at schedule time
### 4. Library & Asset Management
**Browse, search, and organize captured footage**
The Library screen provides:
- Project and bin hierarchy
- Asset detail view with frame-anchored persistent comments
- Right-click context menu (move-to-bin, rename, delete)
- Global cmd/ctrl-K search across assets, projects, recorders, jobs, and users
- Hover-scrub preview with HLS playback
### 5. Jobs Queue
**BullMQ-backed proxy and thumbnail generation**
Automated background processing:
- Per-job retry logic with exponential backoff
- Bulk "retry all failed" for batch recovery
- Inline error messages with actionable diagnostics
- Status tracking: ingesting → processing → ready
Proxy encoder options:
- CPU-based: libx264 (H.264)
- GPU-accelerated: NVENC (NVIDIA) or VAAPI (AMD/Intel)
### 6. Timeline Conform & Export
**FCP XML export with server-side FFmpeg rendering**
The Premiere Pro panel exports FCP XML with:
- Server-side conform via FFmpeg
- Multiple output formats: H.264, H.265, ProRes
- Resolution presets: Broadcast, Web, Archive
- Batch processing with job queue integration
### 7. Hi-Res Auto-Relink
**One-click batch relink of proxy clips to frame-accurate server-trimmed masters**
After editing on proxies:
- Select clips in Premiere
- Trigger relink from the CEP panel
- Server trims hi-res segments to exact in/out points
- Concurrent trim worker pool for speed
- 24-hour TTL with automatic cleanup
### 8. Settings & Configuration
**Centralized control for storage, encoding, and capture**
Configure:
- **S3 Storage** — endpoint, bucket, credentials (with env-var fallback)
- **Proxy Encoder** — CPU vs GPU, bitrate, resolution
- **Growing Files** — SMB path, retention, auto-promotion
- **Capture SDK** — Blackmagic, AJA, or Deltacast uploader selection
### 9. Cluster & Distributed Capture
**Primary + worker topology with remote DeckLink nodes**
- Primary node runs API, scheduler, and web UI
- Worker nodes handle proxy/thumbnail jobs
- Remote capture nodes run DeckLink cards off-host
- Heartbeat health monitoring
- Automatic failover and recovery
### 10. Admin & User Management
**Role-based access, token auth, and cluster monitoring**
- User creation and role assignment
- API token generation for integrations
- Container and cluster node status
- System health dashboard
---
## Architecture
```
SDI / SRT / RTMP ──► capture (FFmpeg)
├─ HLS preview tee ──► /live/<assetId>/index.m3u8
└─ master output
├─ growing_enabled=true:
│ /growing/<projectId>/<clip>.mov
│ (Premiere mounts SMB, edits live)
│ └─► promotion worker uploads to S3
└─ growing_enabled=false:
multipart stream → S3
assets POST ──► proxy job ──► worker
├─ libx264 (CPU) or NVENC/VAAPI (GPU)
├─ thumbnail job
└─ status: ingesting → processing → ready
```
## Tech Stack
- **Runtime:** Node.js 22, Docker Compose
- **Backend:** Express, PostgreSQL 16, Redis 7 + BullMQ
- **Frontend:** Vanilla React via in-browser Babel (no bundler), hls.js
- **Media:** FFmpeg 7.1 with SDK 16 DeckLink patches
- **Codecs:** ProRes, H.264, H.265, DNxHR, MOV/MP4/MXF containers
- **Storage:** S3-compatible (RustFS) for masters, proxies, thumbnails
## Services
| Service | Port | Purpose |
|---------|------|---------|
| **web-ui** | 47434 | Browser SPA + capture controls |
| **mam-api** | 47432 | REST API + recorder orchestration + scheduler |
| **capture** | 47433 / 9000 / 1935 | DeckLink/SRT/RTMP ingest sidecar |
| **worker** | — | BullMQ proxy + thumbnail workers |
| **db** | 5432 | PostgreSQL 16 |
| **queue** | 6379 | Redis 7 |
---
## Workflow Example: Live-to-Edit
1. **Operator** schedules a recording on Recorder A for 14:0015:30, assigns to "News/Segment-A" project
2. **Capture** starts at 14:00, writes ProRes master to SMB landing zone
3. **Editor** mounts SMB in Premiere, opens the live .mov file via the CEP panel
4. **Editor** trims and marks in/out points while capture is still writing
5. **Capture** finishes at 15:30, promotion worker uploads master to S3
6. **Editor** clicks "Relink to Master" in CEP panel
7. **Server** trims hi-res segment to exact in/out, stores for 24 hours
8. **Premiere** relinks proxy clips to trimmed master
9. **Editor** exports final timeline via FCP XML conform
Total time from end of capture to relinked master: ~2 minutes.
---
## Operations
- `deploy/api-smoke.sh` — verify every API endpoint after deploy
- `deploy/onboard-node.sh` — provision a remote worker host
- `deploy/test-cluster.sh` — primary↔worker connectivity smoke test
- `docs/GROWING_FILES_QUICKSTART.md` — Premiere CEP panel install + growing-file flow

View file

@ -1,99 +0,0 @@
# Growing Files + Premiere Panel — Test Plan
A local SMB landing zone for capture so Premiere can edit the master while
it is still recording. The promotion worker uploads the finalized file to S3
and the panel relinks Premiere to the hi-res original.
## Cluster state (deployed 2026-05-22)
- TrueNAS dataset: `NVME/MAM-growing` (LZ4, 0777)
- TrueNAS SMB share: `mam-growing``/mnt/NVME/MAM-growing`
- Host symlink for docker compose: `/mnt/NVME/MAM/wild-dragon-growing` → the dataset
- mam-api + worker containers mount it at `/growing`
- Settings (live): `growing_enabled=true`, `growing_smb_url=smb://10.0.0.25/mam-growing`
## Capture flow (when growing_enabled=true)
1. Recorder starts. mam-api spawns a capture sidecar with `GROWING_ENABLED=true`
and binds `/mnt/NVME/MAM/wild-dragon-growing:/growing`.
2. FFmpeg writes the hi-res master directly to
`/growing/<projectId>/<clipName>.<ext>` (no S3 stream).
3. The HLS tee continues to publish `/live/<assetId>/index.m3u8`, so the
Recorders + Monitors pages get a real video preview.
4. On stop — or when the file's mtime is idle for
`growing_promote_after_seconds` — the promotion worker:
- uploads the local file to S3 at `projects/<projectId>/masters/<clipName>.<ext>`
- queues a proxy job
- flips the asset to `status=ready`
- deletes the local copy.
## Premiere panel install
Grab the latest release artifact and run it — the installer handles the file
copy, registry/plist debug-mode flip, and removes any legacy
`com.wilddragon.mam.panel` install:
- **Windows:** `dragonflight-premiere-panel-<version>-windows-setup.exe`
- **macOS / Win:** `dragonflight-premiere-panel-<version>.zxp` — install via
[Anastasiy's ZXP Installer](https://install.anastasiy.com/) (free GUI)
Releases live at
<https://forge.wilddragon.net/zgaetano/dragonflight/releases>.
Building locally (requires Windows for the `.exe`, any OS for the `.zxp`):
```
cd services/premiere-plugin/build
npm install
powershell -File build-all.ps1 # or: node build-zxp.mjs
```
The Windows installer takes care of `PlayerDebugMode`. If you installed the
ZXP and the panel does not appear in **Window → Extensions**, enable debug
mode manually:
```
# macOS
defaults write com.adobe.CSXS.11 PlayerDebugMode 1
# Windows
reg add "HKCU\Software\Adobe\CSXS.11" /v PlayerDebugMode /t REG_SZ /d 1 /f
```
Mount the SMB share once at OS level: `smb://10.0.0.25/mam-growing`.
In Premiere: Window → Extensions → Wild Dragon MAM.
## Test the live → finalized swap
1. Start a recorder (Ingest → Recorders → Record).
2. The Recorder row's preview becomes a real HLS `<video>` element.
3. In Premiere, with the growing asset selected (status=live), click
**Mount Live**. The panel calls `GET /api/v1/assets/:id/live-path`,
resolves the SMB UNC path, and `app.project.importFiles()` it. Premiere
imports the still-growing MOV.
4. Stop the recorder. After `growing_promote_after_seconds` of mtime
inactivity, the promotion worker uploads to S3 and flips status.
5. The panel polls every 5 s. When it sees `status=ready` it surfaces
**Relink to Hi-Res** — clicking that downloads the finalized hi-res
and calls `ProjectItem.changeMediaPath()` to relink in place. Timeline
cuts are preserved.
## Knobs (Settings → Growing files (SMB))
- `growing_enabled` — master switch
- `growing_path` — container mount path (default `/growing`)
- `growing_smb_url` — what the Premiere panel hands to the editor
- `growing_promote_after_seconds` — idle threshold for promotion
## What's NOT yet here
- Auth on the SMB share — currently passwordless. Add Samba auth via
`midclt call sharing.smb.update` and put creds in the editor's keychain
before exposing this beyond the LAN.
- Concurrent S3 backup of the growing file. Today's MVP writes to SMB only;
S3 happens at promotion. If you need belt-and-suspenders, add `-f tee` in
capture-manager to fan out to both destinations.
- Cleanup for stranded files (e.g. recorder crashes mid-capture). The idle
threshold will eventually promote them, but a stale-file sweeper would be
more graceful.

View file

@ -1,116 +0,0 @@
# Work Log — May 2026
## Summary
Session focused on auth system architecture, dashboard redesign, and audio track inspector. Multiple iterations on auth approach; settled on simplified local-account model with RBAC. Dashboard rebuilt as control-room status board. Audio tab completed with full metering and fader controls.
## Auth System Work
### Commits
- `002e5ac` — auth: top-to-bottom rework — local accounts, RBAC + client tag, audit log, env-bootstrap
- `d1f9557` — auth: park login flow — circle back
- `9726dbb` — Revert "auth: top-to-bottom rework..."
- `4172b0d` — rip out entire auth/login flow
### What Happened
Attempted comprehensive auth rewrite including:
- Local user account system with bcrypt hashing
- Role-based access control (admin/editor/viewer)
- Client tagging for audit trails
- Environment-based bootstrap (AUTH_ENABLED flag)
- Session management with PostgreSQL backing
**Decision**: Reverted entire auth work. Reason: complexity vs. current product stage. System was over-engineered for self-hosted use case where auth is optional.
**Current State**: Auth disabled by default (AUTH_ENABLED=false). When enabled, system returns synthetic /auth/me endpoint. No persistent user management yet.
### Related Fixes
- `cfcbec0` — fix(auth): make AUTH_ENABLED=true workable end-to-end
- `e71c330` — fix(auth): remove manual session.save() — was suppressing Set-Cookie header
- `65684aa` — fix(auth): ensure sessions table exists + log session.save errors
## Dashboard Redesign
### Commits
- `a48e1d9` — dashboard: rebuild as control-room status board (on air / up next / attention / work)
- `e5e0656` — dashboard: redesign stat cards, compress header, improve density
- `5de1e3d` — dashboard: add dense stat cards, cluster bars, job rows, sparkline fixes
- `48d54a3` — dashboard: add missing dash-* CSS classes; cluster: add stat-row/stat-card CSS
### What Changed
Transformed dashboard from generic metrics view to broadcast control-room interface:
- **On Air** section: live stream status, bitrate, duration
- **Up Next** section: queued clips/segments
- **Attention** section: warnings, errors, resource alerts
- **Work** section: active jobs, encoding progress
Added visual components:
- Dense stat cards with icon + value + trend
- Cluster health bars (CPU, memory, disk per node)
- Job progress rows with ETA
- Sparkline charts for trend visualization
CSS infrastructure added for consistent spacing/sizing across dashboard components.
## Audio Tab Implementation
### Commit
- `c48c7e6` — feat(audio-tab): full audio track inspector with meters, mute/solo, faders
### Features
- Per-track audio meters (VU-style, real-time)
- Mute/solo buttons per track
- Fader controls (0-100 dB range)
- Master output meter
- Track naming/labeling
- Visual feedback for clipping/peaks
## Other Work
### Storage & Admin
- `64d739b` — feat(admin): unified Storage settings page with mount/bucket health diagnostics
- `a44d8bd` — feat(admin): live video-presence indicators on cluster DeckLink ports
### Player & Streaming
- `a86c1c7` — fix(player): stitch S3 ranges around RustFS empty-body bug (#143)
- `d257a19` — fix(player): buffer indicator + 416 instead of 500 on out-of-range S3
- `37247fd` — fix(video): direct S3 signed URL for streaming + proxy bitrate 1.5Mbps
- `e4d4c00` — feat(proxy): VBR 500k-1M encoding for proxy generation
### Cluster & Hardware
- `55ff2e7` — feat(cluster): full hardware breakdown per node
- `5ff507b` — fix(node-agent): use nsenter to run nvidia-smi in host mount namespace
- `558c18e` — fix(node-agent): detect GPUs via docker run --gpus all ubuntu:22.04
- `a6f045b` — fix(node-agent): probe GPU via Docker API async at startup, cache result
### Release & Cleanup
- `04ce096` — chore: 1.2 ship-prep sweep — close 38 issues
- `f0f6156` — release: add v1.1.0 ZXP artifact (Growing tab + visual system alignment)
## Blockers / Open Questions
### Auth System
- **Decision needed**: Should auth be mandatory for production? Current design assumes optional.
- **API endpoints missing**: `/users`, `/auth/me`, `/groups` routes not yet implemented in mam-api
- **Frontend expects**: Users list, groups management, role-based UI filtering
### Dashboard
- Real data integration needed (currently mock data)
- Cluster stats endpoint integration
- Job queue polling/WebSocket updates
### Audio
- Backend audio processing pipeline not yet connected
- Metering data source undefined
- Fader changes need routing to encoder
## Next Steps (Recommended)
1. **Clarify auth requirements**: Is user management needed for v1.2? If yes, implement `/users` and `/groups` endpoints.
2. **Connect dashboard to live data**: Wire cluster stats, job queue, stream status to real endpoints.
3. **Audio backend integration**: Define audio processing pipeline and metering data flow.
4. **Testing**: Add integration tests for auth flow, dashboard data binding, audio control.
---
**Session ended**: 2026-05-27 06:31 CDT
**Status**: Work logged, auth decision documented, next steps identified

View file

@ -1,197 +0,0 @@
# All-Intra HEVC (NVENC) Growing-File Ingest
Date: 2026-05-29 | Status: design, pending validation gate (see §8)
Authors: Zac + Claude
## 1. Purpose
Replace the CPU-bound ProRes capture encode with **All-Intra HEVC on NVENC**
as the growing-file master codec, so we can:
- **Offload ingest encode from CPU to GPU** (the current scaling wall), and
- **Keep edit-while-record** (all-intra => growing file stays editable), and
- **Scale to up to 8 simultaneous signals per machine**, across Blackmagic
today and Deltacast + AJA later.
This doc captures the target design AND the current working system it builds on,
so it is self-contained for whoever implements it.
## 2. Why this codec
Growing-file editing (Premiere/Avid mounting a still-recording file over SMB)
requires two things: **intra-frame** (every frame a keyframe, so a partial file
is decodable to the last whole frame) and a **container whose index is not
deferred to EOF**. ProRes/DNxHR satisfy this but are CPU-only (NVIDIA has no
ProRes encoder). Long-GOP H.264/HEVC/AV1 do NOT work for edit-while-record.
**All-Intra HEVC (`-g 1 -bf 0`) via `hevc_nvenc`** is the one path that is both
GPU-accelerated AND all-intra: it breaks the "ProRes must be CPU" constraint
without losing edit-while-record. Trade-off: All-Intra bitrate approaches
ProRes, so the win is **CPU offload, not storage**. AV1 is rejected (no NLE
edit support; av1_nvenc absent from our ffmpeg builds).
## 3. Current working system (what we build on)
### Topology
- **zampp1** (172.18.91.200): primary. Runs db (postgres), queue (redis),
mam-api (:47432), web-ui (:47434), and the GPU worker pool. GPUs: Tesla P4 +
2x Quadro P400. Repo at /opt/wild-dragon (its own clone).
- **zampp2** (172.18.91.216): worker/capture node. 12-vCPU QEMU VM, NVIDIA L4,
4x Blackmagic DeckLink (exposed as /dev/blackmagic/io0..io3). Runs node-agent
(:7436). Repo at /opt/wild-dragon (separate clone).
- The repo is checked out independently on BOTH nodes; node-specific files
(node-agent, capture, worker overlay) are edited on the node that runs them.
### Capture (current)
mam-api `POST /recorders/:id/start` pre-creates a `live` asset and dispatches
`POST /sidecar/start` to the recorder's node-agent, which spawns a
`wild-dragon-capture:latest` container (host network, privileged,
/dev/blackmagic bound). The capture ffmpeg:
- input: `-f decklink -i "DeckLink Duo (N)"`
- filter: `yadif` (CPU deinterlace)
- output 0 (master): `prores_ks` (CPU) -> S3 (pipe) or growing SMB file
- output 1 (preview): `libx264` veryfast HLS -> /live/{assetId} (CPU)
DeckLink does capture (cheap); BOTH encodes are CPU. ~5 vCPU per 1080i signal
=> ~2 signals saturate the 12-vCPU VM. GPUs are idle during capture.
### Stop / finalize (working)
node-agent stops the sidecar with a **180s grace** (was 10s -> SIGKILL bug).
Capture's SIGTERM handler finalises the session and calls
`POST /assets/:id/finalize` (the live asset id passed as ASSET_ID), which flips
the asset out of `live`, records duration + S3 keys, and kicks the
proxy -> thumbnail -> filmstrip chain. (Earlier 409 bug: it used to POST a new
asset and collide with the live row.)
### Live monitor (working)
SDI HLS preview is a 2nd output of the capture ffmpeg (one DeckLink read ->
split -> ProRes + H.264 HLS), written to /live/{assetId} on the capture node.
node-agent serves GET /live/* over HTTP; mam-api proxies
GET /api/v1/recorders/:id/live/* to the recorder's node-agent; the web-ui
HlsPreview loads the proxied URL. Browser auth is the session cookie
(same-origin).
### GPU worker pool (working, post-capture)
BullMQ on shared Redis; queues are type-named (proxy/thumbnail/filmstrip/
conform/trim). Workers are capability-routed by `WORKER_QUEUES`, one GPU-pinned
container per card (`NVIDIA_VISIBLE_DEVICES` by UUID):
- HEAVY (proxy/conform/trim): Tesla P4 (zampp1) + L4 (zampp2), `h264_nvenc`.
- LIGHT (thumbnail/filmstrip): 2x Quadro P400 (zampp1).
DB setting `gpu_transcode_enabled=true` + `gpu_codec=h264_nvenc` enable NVENC.
Each worker stamps `WORKER_LABEL` onto job data -> Jobs UI "Node" column.
`RUN_PROMOTION=true` on exactly one worker runs the growing-files->S3 scan.
The worker GPU image is built from services/worker/Dockerfile.gpu (CUDA base +
Ubuntu ffmpeg with h264/hevc_nvenc; NO av1_nvenc).
### Deploy gotchas (learned)
- Service source is BAKED into images; edits need rebuild + recreate (or the
GPU image rebuild reuses cached layers so only final COPY changes -> fast).
- The capture image can only build on zampp2 (DeckLink SDK present there).
- Per-node `.env`: zampp2's REDIS_URL/DATABASE_URL/S3_* now point at zampp1
(.200); secrets live only in .env, never in committed compose.
- Clear all containers on both nodes before a full redeploy (user preference).
## 4. Target design
### 4.1 Capture ffmpeg gains NVENC
The capture image's custom FFmpeg 7.1 is currently built WITHOUT nvenc (only
prores_ks/dnxhd/libx264). Rebuild `services/capture/Dockerfile` ffmpeg with:
`--enable-cuda-nvcc --enable-libnpp --enable-nvenc --enable-cuvid` plus
nv-codec-headers (ffnvcodec) installed before configure. Keep `--enable-decklink`
and the existing codecs (ProRes stays available as a selectable fallback).
Verify `ffmpeg -encoders | grep nvenc` shows hevc_nvenc/h264_nvenc afterwards.
### 4.2 Capture sidecar gets a GPU
node-agent `handleSidecarStart` currently spawns the capture container with no
GPU. Add NVIDIA runtime + device pinning to the sidecar create spec:
`HostConfig.Runtime='nvidia'` (or DeviceRequests with the node's GPU) and env
`NVIDIA_VISIBLE_DEVICES=<uuid>` + `NVIDIA_DRIVER_CAPABILITIES=video,compute,utility`.
The capture node's GPU is shared with its worker-l4 (see capacity, §5).
### 4.3 Encode parameters (master)
All-Intra HEVC on NVENC:
`-c:v hevc_nvenc -preset p4 -rc vbr -g 1 -bf 0 -profile:v main10 -pix_fmt p010le` (10-bit 4:2:2 is not NVENC-native; NVENC HEVC is 4:2:0 8/10-bit.
If 4:2:2 mezzanine is required, that is a HARD blocker for NVENC and we stay on
ProRes for those feeds — see §8). Bitrate target tuned per format (1080i59.94
~100-160 Mbps to rival ProRes HQ). `-g 1 -bf 0` => every frame IDR (all-intra).
### 4.4 Container (growing-file)
Write the master to a growing file on the SMB share (GROWING_PATH), same path
the promotion worker already uploads on EOF. Container candidates, in order of
preference for Premiere growing-file mounts:
1. **MXF OP1a** (`-f mxf`) — broadcast standard, designed for growing/edit-while-
ingest; best Avid/Premiere support. HEVC-in-MXF support in Premiere is the
key unknown to validate (§8).
2. **Fragmented MOV/MP4** (`-movflags +frag_keyframe+empty_moov+default_base_moof`)
— no moov-at-EOF, readable while growing; fallback if MXF+HEVC is unsupported.
The HLS preview path is unchanged except it can also move to h264_nvenc now that
capture has NVENC (frees the last libx264 CPU cost).
## 5. Capacity & scaling (8 signals/machine)
After the move, per-signal CPU is just: DeckLink capture + yadif + mux + frame
upload to the GPU. The heavy HEVC encode is on NVENC. The constraint shifts from
CPU to **NVENC throughput + GPU memory + PCIe/host bandwidth**:
- The **L4 is a datacenter card => unlimited NVENC sessions** (no consumer
3-session cap). 8x 1080i HEVC-I encode sessions are well within an L4.
- GPU memory: ~8 concurrent 1080 NVENC sessions + frame buffers fit in 24 GB.
- The capture node's L4 is shared between capture (per-signal HEVC-I) and the
worker-l4 proxy jobs. Under 8-signal load, give capture priority; consider
moving worker-l4 (post-record proxies) to zampp1's P4 only, or gate worker-l4
intake while signals are live.
- yadif on CPU is still ~0.5-1 vCPU/signal; consider `yadif_cuda`/`bwdif_cuda`
(GPU deinterlace) once frames are uploaded to the GPU, keeping CPU near-idle.
**Node sizing:** a 12-vCPU VM was the ProRes wall; with GPU encode the same VM
should carry many more signals, but for 8x SDI + GPU + card passthrough prefer a
larger VM or bare metal with proper PCIe passthrough. Or spread signals across
multiple capture nodes (the node-agent model already supports N nodes; mam-api
routes each recorder to its node).
## 6. Multi-vendor capture (Blackmagic / Deltacast / AJA)
Today capture is hard-wired to `-f decklink`. Before three vendors accrue
special-cases, introduce a **source-backend abstraction** in capture-manager:
each backend returns ffmpeg input args + device discovery.
- **Blackmagic**: `-f decklink -i "<name>"` (current). Devices via
`ffmpeg -sources decklink`.
- **Deltacast**: VideoMaster SDK. No native ffmpeg demuxer upstream — needs an
SDK-backed capture (their SDK -> pipe to ffmpeg, or a small grabber). Plan a
`deltacast` backend that shells their tool into ffmpeg stdin (rawvideo).
- **AJA**: libajantv2. Also no upstream ffmpeg input; AJA ships `ntv2` capture
tools. Plan an `aja` backend feeding rawvideo into ffmpeg.
All backends converge on the SAME encode/output stage (HEVC-I NVENC + HLS), so
only the input differs. node-agent already binds the right /dev nodes per
sourceType (decklink/deltacast); extend for AJA.
## 7. Risks
- **4:2:2 / 10-bit chroma:** NVENC HEVC is 4:2:0 (8/10-bit). ProRes HQ is 4:2:2
10-bit. If a workflow REQUIRES 4:2:2 mezzanine, NVENC HEVC cannot match it and
those feeds stay on ProRes (CPU). Decide per-workflow.
- **Premiere growing HEVC support:** edit-while-record for HEVC-in-MXF (or frag
MOV) is unproven in our stack — this is the make-or-break validation (§8).
- **GPU contention** between live capture and post-record proxies on the same
L4; mitigate by prioritising capture / relocating proxy load.
- **Storage:** All-Intra HEVC bitrate ~ ProRes; expect similar disk usage.
- **Editor performance:** HEVC-I decode in Premiere is heavier than ProRes on
the edit workstation (decode cost moves to the editor). Validate scrubbing.
- **NVENC quality at all-intra** vs ProRes for archival; tune bitrate/preset.
## 8. Validation gate (do FIRST, before building the pipeline)
Prove the editor story on ONE channel before wiring 8:
1. Rebuild capture ffmpeg with NVENC; give the sidecar the L4.
2. Capture one DeckLink feed to All-Intra HEVC, writing a GROWING file to the
SMB share in (a) MXF OP1a, then (b) fragmented MOV.
3. While still recording, mount it in Premiere over SMB and confirm:
edit-while-record works, scrubbing is acceptable, audio in sync, file remains
valid after stop. Pick the container that works; if neither does, HEVC-I is
capture-only (no growing edit) and we keep ProRes for growing workflows.
## 9. Rollout
1. Validation gate (§8) on one channel.
2. Make capture codec/container a recorder setting; default growing feeds to
HEVC-I NVENC, keep ProRes selectable.
3. Move HLS preview to h264_nvenc.
4. Source-backend abstraction (§6) — land before Deltacast/AJA hardware.
5. GPU deinterlace + capacity test to 8 signals; finalise node sizing.

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -1,57 +0,0 @@
# Cluster Hardening + Codec Settings Revamp
> Status: **mostly shipped 2026-05-21**. One follow-up remains: the recorders.html UI rewrite. See "Pending" below.
## Goal
Four user-driven asks from 2026-05-20:
1. Fix cluster page: workers were registering with docker bridge IPs, and three duplicate "zampp2" rows kept appearing.
2. Expand recorder codec settings: per-recorder control over bitrate, framerate, audio channels, container format.
3. Better DeckLink port picker: "BM1/BM2" dropdown was unusable -- diagram the card so operators pick a port visually.
4. Validate the cluster end-to-end now that GPUs are in place.
## What shipped (commit list)
| Commit | Area | Summary |
|---|---|---|
| `a39c983` | mam-api | Migration 007 -- dedupe `cluster_nodes` rows + unique index on `hostname`. |
| `049beb8` | mam-api | Migration 008 -- expanded codec columns on `recorders` (video/audio bitrate, framerate, audio channels, container, plus `node_id` / `device_index` pinning). |
| `3b4af6e` | node-agent | Prefer `NODE_IP` env override; skip docker bridge / veth / cni interfaces when auto-detecting. Version bumped to 1.1.0. |
| `0efef0d` | mam-api | `routes/cluster.js`: `pickIp()` fallback to request source IP. New `GET /api/v1/cluster/devices/blackmagic` flattens every node's DeckLink capabilities. |
| `40a66ba` | compose | `docker-compose.worker.yml`: `network_mode: host` for node-agent so it inherits host hostname + LAN IP. |
| `0ebb3cf` | deploy | `onboard-node.sh`: auto-detect host LAN IP and write `NODE_IP` + `BMD_MODEL` to `.env.worker`. |
| `f4a83ee` | capture | `capture-manager.js`: dynamic ffmpeg args. Exports `VIDEO_CODECS`, `AUDIO_CODECS`, `CONTAINER_FMT`, `CONTAINER_EXT`. |
| `485af25` | capture | `index.js` bootstrap forwards every codec env var to `captureManager.start()`. |
| `4c65753` | mam-api | `routes/recorders.js`: full codec field whitelist; `/start` passes settings to the capture sidecar. |
| `d39f86d` | web-ui | `services/web-ui/public/js/bmd-card.js` -- SVG renderer for DeckLink port selection. Models: Duo 2, Quad 2, Mini Recorder 4K, Mini Monitor 4K, UltraStudio 4K Mini. |
| `8aa3783` | deploy | `deploy/test-cluster.sh` cluster smoke test. |
| `4a3a672` | cluster | `mam-api` self-heartbeat reads `NODE_HOSTNAME` (otherwise every restart spawns a new primary row). Smoke test rewritten with `jq` after Python f-strings were found to silently false-pass the docker-bridge check. Bridge alarm narrowed to 172.17.x since this LAN occupies 172.18.0.0/16. |
## Verified cluster state (post-deploy, 2026-05-21)
```
$ MAM_API_URL=http://localhost:47432 bash deploy/test-cluster.sh
6 pass 0 fail
```
Two nodes registered, no duplicate hostnames, real LAN IPs (zampp1=172.18.91.216 primary, zampp2=172.18.91.217 worker), fresh heartbeats, 3 NVIDIA GPUs visible on zampp1, DeckLink Duo 2 reporting all 4 ports on zampp2.
## Deploy state
- **zampp1**: at `4a3a672`, rebuilt `mam-api`/`web-ui`/`worker`/`capture`, migrations 007+008 applied at startup. `.env` has `NODE_HOSTNAME=zampp1`, `NODE_IP=172.18.91.216`.
- **zampp2**: at `4a3a672`, rebuilt `node-agent` + `worker`. `.env` has `NODE_IP=172.18.91.217`, `BMD_COUNT=4`, `BMD_MODEL="DeckLink Duo 2"`, `BMD_DEVICE_0..3` populated.
- **Forgejo PAT** is at `/root/.git-credentials` on zampp1 (mode 600). Pushes from zampp1 need `HOME=/root`.
## LAN topology gotcha
The user's LAN is **172.18.91.0/24** -- inside Docker's reserved 172.16.0.0/12 range. Any heuristic that flags all of 172.16-172.31 as "docker bridge" will produce false positives. The smoke test now alarms only on 172.17.x (default docker0). The server-side `pickIp()` in `routes/cluster.js` has the same vulnerability but the node-agent's `NODE_IP` env-var override masks it in practice.
## Pending
- [ ] **`services/web-ui/public/recorders.html` rewrite.** The supporting pieces are in `main` but the HTML wiring was lost to a context-compaction event mid-session. Required UI:
- Tabbed codec settings (Video / Audio / Container) for both master and proxy.
- SDI source picker: node dropdown + inline `BMDCards.render(...)` SVG with click-to-select.
- Load BMD card data from `GET /api/v1/cluster/devices/blackmagic`.
- `<script src="js/bmd-card.js?v=1"></script>` in the head.
- SVG styles (`.bmd-card-svg`, `.bmd-port-ring`, `.bmd-port-group.is-selected`, ...) inlined or split into a CSS file.
- [ ] **Visual polish pass** with flyonui MCP -- the user noted the current UI "still looks AI-designed". Should happen AFTER the recorders.html rewrite.
## How to pick this up
1. `cd /opt/wild-dragon && git pull` on zampp1 (or zampp2).
2. Read this file end-to-end. Then `services/web-ui/public/js/bmd-card.js` (top JSDoc explains the API) and `services/capture/src/capture-manager.js` (codec catalogs).
3. Inspect `recorders.html` -- it still has the pre-revamp "BM1/BM2" dropdown and flat codec fields. Compare against the `recorders` table columns in `008-codec-settings.sql` for the full field set the UI should drive.
4. Iterate against a live deployment: `bash deploy/test-cluster.sh` for regression check, plus the actual `/recorders.html` page in a browser (web-ui on port 8080, mam-api on 47432).
5. Commit through Forgejo MCP if the diff is small; otherwise push from zampp1 (see Deploy state above for creds location). **Cloudflare WAF blocks large MCP uploads** (the blocked domain is `anthropic.com`, not Forgejo) -- pushing from a host with creds is faster for anything over ~3 KB.

View file

@ -1,502 +0,0 @@
# UI Shell Rework — Wave 2 (Low-risk page migrations) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or executing-plans. Steps use `- [ ]` checkboxes.
**Goal:** Migrate the 6 lowest-risk shell pages (login, home, settings, tokens, users, containers) from `css/common.css` + bespoke per-page CSS to the new `/dist/app.css` primitive bundle from wave 1. Each page goes from "old look" to "new look" with the same functionality. Also fold in the deferred token cleanups from the wave-1 code review.
**Architecture:** Each page migration is a self-contained markup rewrite. Pattern: swap the `<link>` to `/dist/app.css`, replace the sidebar + topbar markup with `wd-*` primitives, restyle page-specific content with `wd-card-asset` / `wd-card-op` / `wd-list-row` / `wd-form-*` / `wd-btn` / etc. Preserve every existing `<script>` and `id` so JS keeps working. Deploy after each page; check.
**Tech Stack:** Tailwind+flyon-ui bundle from wave 1 (already live), nginx static, no JS changes expected.
**Reference spec:** `docs/superpowers/specs/2026-05-21-ui-shell-rework-design.md`
**Wave 1 plan:** `docs/superpowers/plans/2026-05-21-ui-shell-rework-wave-1-plan.md`
---
## File structure
**Files this wave modifies:**
```
services/web-ui/
├── public/
│ ├── login.html (REWRITE: small, no sidebar, just form)
│ ├── home.html (REWRITE: hero + stat tiles, has sidebar)
│ ├── settings.html (REWRITE: tabbed settings forms, has sidebar)
│ ├── tokens.html (REWRITE: list of tokens + create panel, has sidebar)
│ ├── users.html (REWRITE: user list + edit slide-panel, has sidebar)
│ └── containers.html (REWRITE: docker container list + logs, has sidebar)
└── src/css/components/
└── tokens.css (MODIFY: add deferred token cleanups)
```
**Files this wave does NOT touch:** the other 9 pages (index, projects, upload, jobs, api-tokens, recorders, cluster, capture, edit, editor, player). They're wave 3 / 4 / excluded.
---
## Tasks
### Task 0: Fold deferred token cleanups into tokens.css
Address items 1, 2, 4, 6 from the wave-1 code review BEFORE the page migrations multiply duplication of raw oklch values.
**Files:**
- Modify: `services/web-ui/src/css/components/tokens.css`
- Modify: `services/web-ui/src/css/components/button.css` (use new tokens)
- Modify: `services/web-ui/src/css/components/card-operational.css` (use new tokens)
- Modify: `services/web-ui/src/css/components/sidebar.css`, `topbar.css`, `slide-panel.css`, `card-asset.css`, `form-controls.css`, `field-group.css`, `list-row.css`, `toast.css` (use shared --ease / --dur tokens)
- [ ] **Step 1: Extend tokens.css with the missing tokens**
Append to `services/web-ui/src/css/components/tokens.css` inside `:root`:
```css
/* Hover-darker variants of accent + signals — promoted from
* inline oklch() arithmetic that was duplicated across button.css
* and card-operational.css */
--accent-hover: oklch(52% 0.20 266);
--accent-bright: oklch(70% 0.18 266);
--signal-bad-hover: oklch(68% 0.22 25);
--signal-good-hover: oklch(74% 0.18 148);
--signal-warn-hover: oklch(84% 0.16 90);
/* Pure-black-ish tinted toward brand hue for thumbnails & overlays.
* Numerically still ~black but the hue channel is set so future
* derivations stay on-brand. */
--thumb-black: oklch(0% 0 266);
--overlay: oklch(8% 0.010 266 / 0.65);
--shadow: oklch(0% 0 266 / 0.5);
/* Motion + ease tokens — promoted from raw cubic-bezier strings
* that appeared in 8 of 12 primitive files */
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--dur-fast: 120ms;
--dur-normal: 180ms;
--dur-slide: 240ms;
/* Z layers — promoted from sidebar/topbar where 30 was hard-coded */
--z-topbar: 30;
```
- [ ] **Step 2: Find-and-replace raw oklch hover values across primitives**
For each of these files, replace the literal oklch string with the new token. Use `sed -i` for the substitutions, but verify each file afterward.
```bash
cd /opt/wild-dragon/services/web-ui/src/css/components
# button.css
sed -i 's|background: oklch(52% 0.20 266);|background: var(--accent-hover);|' button.css
sed -i 's|background: oklch(68% 0.22 25);|background: var(--signal-bad-hover);|' button.css
# card-operational.css — gradient stop in signal-strip-fill
sed -i 's|oklch(70% 0.18 266)|var(--accent-bright)|' card-operational.css
# card-asset.css — pure-black thumb background
sed -i 's|background: oklch(0% 0 0);|background: var(--thumb-black);|' card-asset.css
# slide-panel.css — overlay color
sed -i 's|oklch(8% 0.010 266 / 0.65)|var(--overlay)|' slide-panel.css
# toast.css — shadow
sed -i 's|oklch(0% 0 0 / 0.7)|var(--shadow)|' toast.css
```
- [ ] **Step 3: Replace raw cubic-bezier strings with --ease + --dur tokens**
```bash
cd /opt/wild-dragon/services/web-ui/src/css/components
# Replace exact "120ms cubic-bezier(0.25, 1, 0.5, 1)" with the tokens
for f in sidebar.css topbar.css slide-panel.css card-asset.css card-operational.css form-controls.css field-group.css list-row.css button.css; do
sed -i 's|120ms cubic-bezier(0\.25, 1, 0\.5, 1)|var(--dur-fast) var(--ease-out-quart)|g' "$f"
done
# slide-panel slide-in (240ms ease-out-expo)
sed -i 's|240ms cubic-bezier(0\.16, 1, 0\.3, 1)|var(--dur-slide) var(--ease-out-expo)|' slide-panel.css
# Tab indicator
sed -i 's|240ms cubic-bezier(0\.25, 1, 0\.5, 1)|var(--dur-slide) var(--ease-out-quart)|' slide-panel.css
sed -i 's|200ms cubic-bezier(0\.25, 1, 0\.5, 1)|200ms var(--ease-out-quart)|g' form-controls.css
sed -i 's|240ms cubic-bezier(0\.16, 1, 0\.3, 1)|var(--dur-slide) var(--ease-out-expo)|' card-operational.css
```
- [ ] **Step 4: Replace hard-coded z-index 30 with --z-topbar**
```bash
cd /opt/wild-dragon/services/web-ui/src/css/components
sed -i 's|z-index: 30;|z-index: var(--z-topbar);|' topbar.css
```
- [ ] **Step 5: Rebuild + verify primitives still ship correctly**
```bash
cd /opt/wild-dragon
docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
docker exec wild-dragon-web-ui-1 grep -c '.wd-' /usr/share/nginx/html/dist/app.css
# Expect: same large number (~116+) — no rules dropped
docker exec wild-dragon-web-ui-1 grep -c '\-\-accent-hover\|\-\-ease-out-quart\|\-\-z-topbar' /usr/share/nginx/html/dist/app.css
# Expect: at least 3 hits (tokens now defined + referenced)
```
- [ ] **Step 6: Visual regression check on smoke page**
```bash
curl -sk -o /dev/null -w 'smoke=%{http_code}/%{size_download}\n' http://localhost:47434/_primitives-smoke.html
# Expect: HTTP 200, ~12 KB (unchanged from wave 1)
```
Manually load the smoke page in a browser; everything should look identical to wave 1. If anything changed visually, the sed substitutions introduced a regression.
- [ ] **Step 7: Commit + push**
```bash
cd /opt/wild-dragon
HOME=/root git add services/web-ui/src/css/components/
HOME=/root git diff --cached --stat
HOME=/root git -c user.email=zgaetano@wilddragon.net -c user.name='Zac Gaetano' commit -m 'web-ui: token cleanups from wave-1 code review
- Promote --accent-hover, --signal-bad-hover, --signal-good-hover,
--signal-warn-hover, --accent-bright tokens (were duplicated raw
oklch arithmetic in button.css / card-operational.css)
- Promote --thumb-black, --overlay, --shadow tokens (tinted toward
brand hue 266 so future derivations stay on-brand)
- Promote --ease-out-quart, --ease-out-expo, --dur-fast/normal/slide
tokens (cubic-bezier strings appeared in 8 of 12 primitive files)
- Promote --z-topbar (was hard-coded 30 in topbar.css while every
other layer was tokenized)
- Replace all usages across the 12 primitive files via sed.
Bundle byte count unchanged (~138 KB); visual regression on smoke
page = zero. Code-review concerns from wave 1 now resolved before
wave 2 page migrations begin.'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 1: Migrate login.html
Smallest page. No sidebar, no topbar — just a centered card with email/password. Migrating first because if it breaks nothing else does.
**Files:**
- Modify: `services/web-ui/public/login.html`
- [ ] **Step 1: Read the current page to see what's there**
```bash
cd /opt/wild-dragon/services/web-ui/public
cat login.html | head -80
```
Note: form ids, input names, any inline JS handlers. Preserve all of them.
- [ ] **Step 2: Write the new login.html**
The new structure:
- `<link rel="stylesheet" href="/dist/app.css">` instead of the old `common.css`
- Centered `<main>` with a single `.wd-card-op`-shaped panel (operational card primitive, sized small)
- Inside: brand logo + "Z-AMPP" wordmark at top, then `<form>` with two `.wd-form-group` (email + password), then `.wd-btn.wd-btn--primary.wd-btn--md` submit
- Keep every existing `id`, `name`, `type`, and `<script>` tag from the old file
- If there's an "error message" div, replace its class with `.wd-toast.wd-toast--error` (inline, not floating)
Replace the entire `<head>` and `<body>` with the new shell. JS at the bottom stays as-is.
- [ ] **Step 3: Deploy on zampp1 (no Docker rebuild needed — HTML is static)**
```bash
# Actually nginx serves from the image's filesystem, not the host's
# /opt/wild-dragon/services/web-ui/public/. So we DO need a rebuild.
cd /opt/wild-dragon
docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'login=%{http_code}/%{size_download}\n' http://localhost:47434/login.html
# Confirm new bundle is referenced
curl -sk http://localhost:47434/login.html | grep -E 'dist/app.css|common.css'
# Expect: dist/app.css present, common.css absent
```
- [ ] **Step 4: Visual + functional check**
Open `http://172.18.91.216:47434/login.html` in a browser. Verify:
- Page renders with new brand styling
- Email + password fields look like the wd-input primitive
- Submit button looks like wd-btn--primary
- Logging in still actually works (POST to /api/v1/auth/login)
- [ ] **Step 5: Commit + push**
```bash
HOME=/root git add services/web-ui/public/login.html
HOME=/root git commit -m 'web-ui(wave 2): migrate login.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 2: Migrate home.html
Has sidebar + topbar + dashboard stat tiles. The first page that exercises the full shell.
**Files:**
- Modify: `services/web-ui/public/home.html`
- [ ] **Step 1: Read the current page**
```bash
cd /opt/wild-dragon/services/web-ui/public
wc -l home.html
head -80 home.html
```
Identify: page title, what's in the topbar right side, the stat tile structure, any chart libraries, and the bottom `<script>` blocks. Preserve all script references and JS state.
- [ ] **Step 2: Migrate the markup**
The migration recipe for every shell page:
1. **`<head>`**: replace `<link rel=stylesheet href=css/common.css>` with `<link rel=stylesheet href=/dist/app.css>`. Keep favicon, viewport meta.
2. **`<body>` root**: wrap in `<div class="wd-shell">` (style inline: `display:flex;min-height:100vh`).
3. **Sidebar**: copy verbatim from the smoke page's `<nav class="wd-sidebar">` block. Mark the active nav item with `is-active` on Home.
4. **Right column**: `<div style="flex:1;display:flex;flex-direction:column;">`
5. **Topbar**: `<header class="wd-topbar">` with breadcrumb in `.wd-topbar-left` containing just "Home", any existing right-side button as `.wd-btn.wd-btn--primary.wd-btn--sm`.
6. **Main content**: `<main style="padding:20px 20px 32px;">`
7. **Stat tiles**: replace with `.wd-card-op-grid` containing `.wd-card-op` (small, content-only — no footer needed if there's no action).
8. **Auth-guard script** at the bottom — stays exactly as-is.
- [ ] **Step 3: Deploy + check**
```bash
cd /opt/wild-dragon
docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'home=%{http_code}/%{size_download}\n' http://localhost:47434/home.html
curl -sk http://localhost:47434/home.html | grep -c 'wd-sidebar\|wd-topbar\|wd-card'
# Expect: 8+ matches
```
Load `http://172.18.91.216:47434/home.html` in a browser. Sidebar should be the new one, breadcrumb shows "Home", stat tiles render as operational cards.
- [ ] **Step 4: Commit + push**
```bash
HOME=/root git add services/web-ui/public/home.html
HOME=/root git commit -m 'web-ui(wave 2): migrate home.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 3: Migrate settings.html
System settings form. Lots of form-groups, possibly tabbed.
**Files:**
- Modify: `services/web-ui/public/settings.html`
- [ ] **Step 1: Read current page + identify all form sections**
```bash
cd /opt/wild-dragon/services/web-ui/public
grep -E '<h[12]|form-group|form-section-label' settings.html | head -30
```
- [ ] **Step 2: Migrate using the standard recipe**
Same recipe as task 2, except for the form content:
- Replace each form section with a `.wd-field-group` (header + body, no tabs unless the section is genuinely tabbed)
- Replace every `<input>` with `class="wd-input"`, every `<select>` with `class="wd-select"`, every `<label>` with `class="wd-label"`
- Replace every `<button>` with `class="wd-btn wd-btn--primary wd-btn--md"` (or `--secondary` / `--ghost` / `--danger` as appropriate)
- Wrap rows of inputs in `.wd-form-row`
- Preserve every `id`, `name`, `type`, and JS handler
Set `.wd-nav-item.is-active` on Settings.
- [ ] **Step 3: Deploy + check**
```bash
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'settings=%{http_code}/%{size_download}\n' http://localhost:47434/settings.html
```
Load in browser. Verify the form actually saves (test by changing one value and clicking save).
- [ ] **Step 4: Commit + push**
```bash
HOME=/root git add services/web-ui/public/settings.html
HOME=/root git commit -m 'web-ui(wave 2): migrate settings.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 4: Migrate tokens.html
Lists API tokens, allows creation of new ones with a slide-panel. First page that exercises the slide-panel primitive in the migrated context.
**Files:**
- Modify: `services/web-ui/public/tokens.html`
- [ ] **Step 1: Read + identify the slide-panel structure**
```bash
cd /opt/wild-dragon/services/web-ui/public
grep -E 'slide-panel|slide-overlay|wd-list-row' tokens.html | head -20
```
- [ ] **Step 2: Migrate**
- Standard shell recipe (sidebar with `is-active` on Tokens, topbar with "Tokens" breadcrumb and "New token" primary button)
- Token list → `.wd-list` containing `.wd-list-row` for each token: name (cell--name), created date (cell--meta), badge for scope, action buttons in cell--actions
- Create-token form moves into `.wd-slide-panel` (with overlay, header, body, footer pattern exactly as in the smoke page's field-group)
- Preserve every JS handler — especially the copy-to-clipboard one for the newly-generated token
- [ ] **Step 3: Deploy + check**
```bash
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'tokens=%{http_code}/%{size_download}\n' http://localhost:47434/tokens.html
```
Load + verify: clicking "New token" opens the slide-panel (codec-clipping bug fix from wave 1 applies — body should scroll if it overflows), creating a token shows the copy-once display.
- [ ] **Step 4: Commit + push**
```bash
HOME=/root git add services/web-ui/public/tokens.html
HOME=/root git commit -m 'web-ui(wave 2): migrate tokens.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 5: Migrate users.html
User management. List + edit slide-panel. Pattern matches tokens.html closely.
**Files:**
- Modify: `services/web-ui/public/users.html`
- [ ] **Step 1: Read + identify**
```bash
cd /opt/wild-dragon/services/web-ui/public
grep -E '<h[12]|wd-list|slide-panel' users.html | head
```
- [ ] **Step 2: Migrate using the tokens.html recipe**
Identical pattern to task 4: shell + list + slide-panel for create/edit. Mark Users active. Preserve every JS handler (role dropdown, password reset, etc.).
- [ ] **Step 3: Deploy + check**
```bash
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'users=%{http_code}/%{size_download}\n' http://localhost:47434/users.html
```
Verify: list renders, edit panel opens, save works.
- [ ] **Step 4: Commit + push**
```bash
HOME=/root git add services/web-ui/public/users.html
HOME=/root git commit -m 'web-ui(wave 2): migrate users.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 6: Migrate containers.html
Docker container list. List rows with status badges + action buttons (logs / restart). No slide-panel (logs typically opens in a separate tab or inline).
**Files:**
- Modify: `services/web-ui/public/containers.html`
- [ ] **Step 1: Read + identify**
```bash
cd /opt/wild-dragon/services/web-ui/public
head -80 containers.html
```
- [ ] **Step 2: Migrate**
- Standard shell recipe (Containers active)
- Container list → `.wd-list` with `.wd-list-row` per container:
- cell--name: container name
- cell with image: cell--meta
- cell with status: `.wd-badge.wd-badge--good` (Up) / `.wd-badge--bad` (Down) / `.wd-badge--warn` (Restarting)
- cell--actions: ghost buttons for Logs / Restart / Stop
- Auto-refresh polling JS stays unchanged
- [ ] **Step 3: Deploy + check**
```bash
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
sleep 4
curl -sk -o /dev/null -w 'containers=%{http_code}/%{size_download}\n' http://localhost:47434/containers.html
```
Load + verify: all containers visible, status badges color-coded, Logs button still opens logs.
- [ ] **Step 4: Commit + push**
```bash
HOME=/root git add services/web-ui/public/containers.html
HOME=/root git commit -m 'web-ui(wave 2): migrate containers.html to new primitives'
HOME=/root git push 2>&1 | tail -3
```
---
### Task 7: Wave-2 user QA gate
- [ ] **Step 1: Verify all 6 migrated pages serve correctly**
```bash
for p in login home settings tokens users containers; do
printf ' %s.html: HTTP=%s\n' "$p" "$(curl -sk -o /dev/null -w '%{http_code}' http://localhost:47434/$p.html)"
done
```
Expected: all 200.
- [ ] **Step 2: Verify all 6 pages reference /dist/app.css and NOT common.css**
```bash
for p in login home settings tokens users containers; do
CNT_NEW=$(curl -sk http://localhost:47434/$p.html | grep -c dist/app.css)
CNT_OLD=$(curl -sk http://localhost:47434/$p.html | grep -c common.css)
printf ' %s.html: new=%s old=%s\n' "$p" "$CNT_NEW" "$CNT_OLD"
done
```
Expected: new=1 old=0 for every page.
- [ ] **Step 3: Verify wave-3 / wave-4 pages are STILL on the old CSS (no accidental change)**
```bash
for p in index projects upload jobs api-tokens recorders cluster capture editor; do
CNT_OLD=$(curl -sk http://localhost:47434/$p.html | grep -c common.css)
printf ' %s.html: still-on-old=%s\n' "$p" "$CNT_OLD"
done
```
Expected: still-on-old=1 for every page (none of them migrated yet).
- [ ] **Step 4: User visual QA**
Stop. Ask user to load each of the 6 migrated pages and confirm the new look is correct, navigation still works, forms still save, lists still poll. If anything looks wrong, fix it before wave 3 starts.
---
## Self-review notes
- **Spec coverage**: Every page in the wave-2 list from the design spec is in the plan. Token cleanups from wave-1 review are folded in as task 0.
- **Placeholders**: none. Every step has the actual command / file change.
- **Type consistency**: every migrated page uses `.wd-shell` / `.wd-sidebar` / `.wd-topbar` / `.wd-nav-item.is-active` / `.wd-card-op` / `.wd-list` / `.wd-list-row` / `.wd-btn` / `.wd-input` / `.wd-select` / `.wd-label` / `.wd-form-row` / `.wd-field-group` — exact class names from the wave-1 bundle.
- **Open risk**: each page migration is a manual markup rewrite. The implementer subagent needs to actually read each existing page before rewriting, not work from the description alone, because each page has page-specific JS handlers that must be preserved verbatim.

View file

@ -1,73 +0,0 @@
# NLE Editor: React SPA Polish — Phases 13 Implementation Plan
> **Date:** 2026-05-24
> **Status:** Phase 1 IN PROGRESS
> **Progress:** Tasks 1.11.6 code-complete, pending test/deploy
---
## Phase 1 — Core Editor (IN PROGRESS)
### Task 1.1: Sequence API helpers in data.jsx ✅
- Added `getSequences`, `createSequence`, `getSequence`, `updateSequence`, `deleteSequence`, `syncSequenceClips`, `exportSequenceEDL` to `data.jsx`
- All exported on `window.ZAMPP_API`
### Task 1.2: Timecode.js wired into SPA ✅
- Added `<script src="js/timecode.js">` and `<script src="js/timeline.js">` to `index.html` before `screens-editor.jsx`
- `window.TC` available globally for 59.94 DF timecode math
### Task 1.3: TimelinePanel React component ✅
- `tlRef` container div in editor layout
- `useEffect` mounts `window.Timeline.init()` on first render
- `useEffect` pushes scale changes via `window.Timeline.setScale()`
- `onClipsChanged` / `onPlayheadMoved` callbacks connect timeline engine to React state
### Task 1.4: screens-editor.jsx rewrite ✅ (455 lines, was 162)
Full rewrite with:
- **App state**: `projectId`, `sequences`, `currentSeq`, `assets`, `sourceAsset`, `srcIn`/`srcOut`, `playheadFrames`, `history` (undo stack), `scale`, `tool`, `saveStatus`, `isDirty`
- **Source monitor**: `<video>` + `apiFetch('/assets/:id/stream')` + Mark In/Out + Insert button
- **Program monitor**: Virtual clip-by-clip playback — loads V1 clips sorted by `timeline_in_frames`, advances on `timeUpdate`/`ended` events
- **Media panel**: Asset list from `ZAMPP_DATA.ASSETS`, filter by bin, `AssetThumb` thumbnails, double-click loads source
- **Sequence management**: Picker `<select>`, "New sequence" modal, `openSequence()` loads via API
- **Auto-save**: `markDirty()` → debounce 2s → `syncSequenceClips()` → status updates
- **Undo/redo**: 50-step history stack, Ctrl+Z / Ctrl+Shift+Z
- **EDL export**: Button triggers `window.ZAMPP_API.exportSequenceEDL()`
- **Tool toolbar**: V/C/H buttons synced with `Timeline.setTool()`
- **Zoom slider**: Range input driving `window.Timeline.setScale()`
- **Keyboard handler**: I/O, V/C/H, Ctrl+Z/Shift+Z, Ctrl+S
### Task 1.5: "In Development" overlay removed ✅
- Deleted the `position: absolute; inset: 0; backdropFilter: blur(6px)` overlay div (was lines 98-117)
- Removed `FauxFrame` component reference
- All buttons are now functional
### Task 1.6: Editor nav badge removed ✅
- `shell.jsx`: `{ id: "editor", label: "Editor", icon: "editor" }` — no more `badge: { kind: "dev", text: "DEV" }`
### NEXT: Task 1.7 — Test & Deploy
1. `docker compose up -d --build web-ui`
2. Navigate to Editor route in browser
3. Verify: source monitor loads video, timeline renders with 4 track rows, Insert places clip, auto-save fires, EDL export downloads
---
## Phase 2 — UX Polish & Growing File (PENDING)
- [ ] 2.1 Multi-track refinements (ripple delete, snap, locking, overlap prevention)
- [ ] 2.2 Zoom slider + adaptive ruler
- [ ] 2.3 JKL transport + frame stepping
- [ ] 2.4 Waveform display on audio tracks
- [ ] 2.5 Inspector panel wiring
- [ ] 2.6 Style migration to Tailwind primitives
- [ ] 2.7 HLS live preview during capture
## Phase 3 — Export, Conform & Features (PENDING)
- [ ] 3.1 FCP XML export + conform queue
- [ ] 3.2 Hi-Res Auto-Relink
- [ ] 3.3 Timecoded Comments
- [ ] 3.4 Player Rebuild (P1)
- [ ] 3.5 Subclips (P2)
- [ ] 3.6 Multi-select & Bulk Ops (P3)
- [ ] 3.7 Smart Bins (P6)
- [ ] 3.8 Metadata Templates (P7)

File diff suppressed because it is too large Load diff

View file

@ -1,356 +0,0 @@
# Wild Dragon MAM — Editor, Growing File Preview & Feature Expansion
**Date:** 2026-05-18
**Status:** APPROVED — ready for implementation planning
---
## Resolved Decisions
| Question | Decision |
|----------|----------|
| Save strategy | **Auto-save** (debounced 2s after any change) |
| Sequences per project | **Multiple named sequences** (like Premiere's project panel) |
| HLS temp disk | **3 TB available** — no constraint |
| Primary frame rate | **59.94 fps** |
| Program monitor fidelity | **Brief gap at clip boundaries is acceptable for v1** |
---
## Overview
Three sequenced phases:
1. **NLE Editor** (`editor.html`) — Premiere-style timeline editor with razor blade, in/out marking, and EDL export
2. **Growing File Workflow** — HLS live preview during SDI/SRT/RTMP capture, with rewind support
3. **Feature Additions** — Subclips, improved player, bulk ops, waveform, timecoded comments, smart bins, metadata templates
---
## 1. NLE Editor
### 1.1 Layout
A new page `editor.html`. `player.html` is kept as the lightweight browse/metadata view; the editor is the full-screen creative environment.
```
┌─ SOURCE MONITOR ──────┬─ PROGRAM MONITOR ──────┐ ← 40vh
│ [video preview] │ [video preview] │
│ TC: 00:00:00;00 │ TC: 00:00:00;00 │
│ [────scrub bar────] │ [────scrub bar────] │
│ [IN] [OUT] [Insert] │ [J] [K] [L] [⏩] │
├─ MEDIA PANEL ─────────┴─ TIMELINE ─────────────┤ ← 60vh
│ [sequence picker] │ [V] [C] [H] [zoom] │
│ [bin tree] │ ruler: 00:00 00:05 … │
│ [asset list] │ V1 ░░░░[clip A]░░░░ │
│ │ V2 │
│ │ A1 ░░░░[clip A]░░░░ │
│ │ A2 │
└───────────────────────┴─────────────────────────┘
```
Accessed from the library via an "Open in Editor" action on each asset card. Opens `editor.html?project=<id>&asset=<id>` — loads the asset into the source monitor and opens the project's most-recent sequence (or creates one named "Sequence 1" if none exists).
The sidebar nav gains an **Editor** link (between Library and Ingest).
### 1.2 Source Monitor
- Displays the currently loaded clip (double-click in media panel, or via `?asset=` param)
- Native `<video>` element with a **custom** transport bar: scrub slider, current-time / duration label, play/pause button
- **Mark In (`I`):** stores `sourceIn` as seconds; shown as left handle on the scrub bar
- **Mark Out (`O`):** stores `sourceOut` as seconds; shown as right handle on the scrub bar
- In/out range highlighted on scrub bar (accent color tint)
- **Insert:** drops marked range at timeline playhead, shifts downstream clips right
- **Overwrite:** drops marked range at timeline playhead, overwrites what's there
- No marks set → uses full clip duration
### 1.3 Program Monitor
- Plays the timeline from the current playhead position
- **Virtual playback:** single `<video>` element. On each clip start, set `src` = that clip's signed proxy URL and `currentTime` = `sourceIn`. When `currentTime` reaches `sourceOut`, load the next clip. Brief load gap at clip boundaries is acceptable for v1.
- Timecode display: `HH:MM:SS;FF` at **59.94 fps** (drop-frame notation, semicolon separator)
- Transport shortcuts: `Space` (play/pause), `J` (reverse), `K` (stop), `L` (forward), `←`/`→` (± 1 frame at 59.94), `Home` (jump to start)
### 1.4 Timeline
**Ruler:** Timecode-marked horizontal ruler. Default scale: 100px/s. Zoom: `Ctrl + scroll wheel` or zoom slider (range: 20px/s → 500px/s).
**Tracks:** V1, V2 (video), A1, A2 (audio). Each track row: 48px tall. Track header (40px wide, left): track label, lock toggle.
**Clips:** Absolutely positioned `<div>` elements within track rows.
- `left` = `timelineIn_seconds × scale`
- `width` = `(timelineOut - timelineIn)_seconds × scale`
- Shows: clip name (truncated), source TC range
- If width > 120px: asset thumbnail centered in clip body
**Playhead:** Red vertical line spanning all tracks. Draggable. Clicking the ruler jumps playhead.
**Tools:**
| Tool | Key | Behavior |
|------|-----|----------|
| Select | `V` | Click to select (accent border). Drag body to move horizontally. Drag left/right edge to trim source in/out. |
| Razor | `C` | Click on a clip → splits into two clips at that exact frame. |
| Hand | `H` | Click-drag to pan timeline horizontally. |
**Editing:**
- `Delete` / `Backspace`: remove selected clip(s)
- `Ctrl+Z` / `Ctrl+Shift+Z`: undo / redo (local history stack, max 50 steps)
- Clips cannot overlap within the same track (enforced on move/razor)
**Auto-save:** Debounced 2s after any timeline change. Visual indicator: subtle "Saving…" / "Saved" text in the timeline toolbar.
### 1.5 Data Model
**New migration: `schema_patch_editor.sql`**
```sql
CREATE TABLE sequences (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Sequence 1',
frame_rate NUMERIC(6,3) NOT NULL DEFAULT 59.94,
width INTEGER NOT NULL DEFAULT 1920,
height INTEGER NOT NULL DEFAULT 1080,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_sequences_project_id ON sequences(project_id);
CREATE TABLE sequence_clips (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
sequence_id UUID NOT NULL REFERENCES sequences ON DELETE CASCADE,
asset_id UUID NOT NULL REFERENCES assets ON DELETE CASCADE,
track INTEGER NOT NULL DEFAULT 0,
-- track encoding: 0=V1, 1=V2, 100=A1, 101=A2
timeline_in_frames BIGINT NOT NULL,
timeline_out_frames BIGINT NOT NULL,
source_in_frames BIGINT NOT NULL DEFAULT 0,
source_out_frames BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_sequence_clips_sequence_id ON sequence_clips(sequence_id);
```
**Frame math:** All frame counts use **59.94 fps** (= 60000/1001). Timecode display uses SMPTE drop-frame (`;` separator). Conversion helpers:
- `framesToSeconds(f)` = `f / 59.94`
- `secondsToFrames(s)` = `Math.round(s * 59.94)`
- Drop-frame TC calculation uses standard SMPTE DF algorithm
### 1.6 API Routes
New file: `services/mam-api/src/routes/sequences.js`
| Method | Path | Body / Params | Notes |
|--------|------|--------------|-------|
| GET | `/api/v1/sequences` | `?project_id=` | List sequences, ordered by `updated_at DESC` |
| POST | `/api/v1/sequences` | `{ project_id, name, frame_rate?, width?, height? }` | Create |
| GET | `/api/v1/sequences/:id` | — | Sequence + all clips joined with asset (`display_name`, `fps`, `duration_ms`, `proxy_s3_key`, `thumbnail_s3_key`) |
| PUT | `/api/v1/sequences/:id` | `{ name?, frame_rate?, width?, height? }` | Update metadata |
| DELETE | `/api/v1/sequences/:id` | — | Cascade deletes clips |
| PUT | `/api/v1/sequences/:id/clips` | `[ { asset_id, track, timeline_in_frames, timeline_out_frames, source_in_frames, source_out_frames } ]` | **Full replace** — deletes existing clips, inserts new array in one transaction |
| POST | `/api/v1/sequences/:id/export/edl` | — | Returns CMX3600 EDL as `text/plain; charset=utf-8`, `Content-Disposition: attachment` |
### 1.7 EDL Export
`generateEDL(sequenceName, clips, fps)` function produces CMX3600. Timecode math reuses the logic from `worker/src/edl/parser.js` (copied into a shared util or duplicated in the API route).
```
TITLE: My Sequence
001 clip_filename V C 00:00:00;00 00:00:05;00 00:00:00;00 00:00:05;00
002 other_clip V C 00:00:02;00 00:00:08;00 00:00:05;00 00:00:11;00
```
Conforms via the existing `conform.js` BullMQ worker unchanged.
### 1.8 `api.js` Additions
```js
getSequences(projectId)
createSequence(data)
getSequence(sequenceId)
updateSequence(sequenceId, data)
deleteSequence(sequenceId)
syncSequenceClips(sequenceId, clipsArray)
exportSequenceEDL(sequenceId) // triggers file download
```
---
## 2. Growing File Workflow
### 2.1 Problem
During SDI capture, proxy is piped to S3 via multipart upload — invisible until `CompleteMultipartUpload`. No live preview is possible. For SRT/RTMP, no proxy exists at all until the BullMQ worker runs post-stop.
### 2.2 Solution: HLS Segments During Capture
Write proxy as HLS segments to local disk during recording. Serve them live from the capture service. On stop: stitch segments → single MP4 → upload to S3 as proxy.
**Why HLS:** Growing playlists are the browser-native live video mechanism. Accumulated segments give free rewind. `hls.js` is a small CDN-loadable polyfill for Chrome/Firefox. FFmpeg's `hls` muxer is proven in production.
### 2.3 FFmpeg Args Change
**SDI (second FFmpeg process — proxy process):**
```bash
# Before: fragmented MP4 → pipe → S3
ffmpeg [decklink] -c:v libx264 -preset fast -b:v 10M -c:a aac -b:a 192k \
-movflags +frag_keyframe+empty_moov -f mp4 pipe:1
# After: HLS segments → local dir
ffmpeg [decklink] -c:v libx264 -preset fast -b:v 10M -c:a aac -b:a 192k \
-hls_time 2 -hls_list_size 0 -hls_flags append_list \
-hls_segment_filename '$HLS_DIR/<sessionId>/seg%05d.ts' \
$HLS_DIR/<sessionId>/index.m3u8
```
**SRT/RTMP (single FFmpeg process — two output pads):**
```bash
ffmpeg [srt/rtmp input] \
-c:v prores_ks -profile:v 3 -c:a pcm_s24le \
-movflags +frag_keyframe+empty_moov -f mov pipe:1 \
-c:v libx264 -preset fast -b:v 10M -c:a aac -b:a 192k \
-hls_time 2 -hls_list_size 0 -hls_flags append_list \
-hls_segment_filename '$HLS_DIR/<sessionId>/seg%05d.ts' \
$HLS_DIR/<sessionId>/index.m3u8
```
### 2.4 Capture Service Changes
**`capture-manager.js`:**
- New env var: `HLS_SESSION_DIR` (default: `/tmp/wd-hls`). 3 TB disk — no constraint.
- `start()`: `mkdir -p $HLS_SESSION_DIR/<sessionId>/`, spawn FFmpeg with HLS output. Add `liveUrl: /capture/live/<sessionId>/index.m3u8` to session state.
- `stop()`:
1. `SIGINT` FFmpeg (existing)
2. Wait for process exit (FFmpeg writes final segment + closes playlist before exiting)
3. `ffmpeg -i $HLS_DIR/<sessionId>/index.m3u8 -c copy <tmp>.mp4` (stitch)
4. Upload stitched MP4 to S3 as proxy key (existing `uploadToS3` helper)
5. `rm -rf $HLS_DIR/<sessionId>/`
- Startup: scan `$HLS_SESSION_DIR/` and delete dirs older than 24h
**New capture routes:**
```
GET /capture/live/:sessionId/index.m3u8 → serve HLS playlist file
GET /capture/live/:sessionId/:file → serve .ts segment files
```
Both routes: check session exists (active or recently stopped with dir still present), stream file from disk with correct `Content-Type` (`application/vnd.apple.mpegurl` / `video/mp2t`).
**Updated `/capture/status` response:**
```json
{
"recording": true,
"sessionId": "abc123",
"liveUrl": "/capture/live/abc123/index.m3u8",
"startedAt": "...",
"duration": 42,
...
}
```
**nginx:** Add location block for `/capture/live/` → proxy_pass to capture service (same pattern as existing `/capture/` block).
### 2.5 Live Preview in `capture.html`
When status poll returns `recording: true` with `liveUrl`:
1. Show collapsible **Live Preview** panel beneath capture controls
2. Load hls.js from CDN: `cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.15/hls.min.js`
3. `new Hls()``hls.loadSource(status.liveUrl)``hls.attachMedia(videoEl)`
4. `videoEl.play()` on `MANIFEST_PARSED` (plays at live edge)
5. **⏮ Rewind** button: `videoEl.currentTime = 0`
6. Elapsed time counter from `status.startedAt`
On recording stop: `hls.destroy()`, hide preview panel. Asset appears in library after proxy upload + thumbnail job complete.
---
## 3. Feature Additions (Prioritized)
### P1 — Improved Player (`player.html` rebuild)
- Migrate from old CSS variables (`--color-bg-tertiary`) to current design tokens (`--bg-panel`, etc.)
- Replace browser default controls with custom transport bar: scrub slider, timecode display `HH:MM:SS;FF` at 59.94 fps, frame-step buttons, J/K/L shortcuts
- Inline rename: click `display_name` to edit in place → auto-save
- "Open in Editor" button → `editor.html?asset=<id>`
### P2 — Subclips
- Player shows In/Out markers → "Create Subclip" button
- `POST /api/v1/assets` with `{ parent_asset_id, subclip_in_ms, subclip_out_ms, display_name }`
- New columns: `parent_asset_id UUID REFERENCES assets`, `subclip_in_ms BIGINT`, `subclip_out_ms BIGINT`
- Library shows subclip cards with ✂ badge; player pre-seeks to `subclip_in_ms`
- Subclips use parent's proxy S3 key — no re-transcode
### P3 — Multi-select & Bulk Ops
- Shift-click or checkbox (visible on hover) to select multiple asset cards
- Floating action bar: **Move to bin | Add tags | Delete**
- Move to bin: slide panel with bin tree, `PATCH /assets/:id { bin_id }` for each
- Add tags: appends to all selected assets
- Bulk delete: confirm modal → soft-delete
### P4 — Waveform Display in Editor
- In proxy worker (`proxy.js`): after transcode, run FFmpeg `astats` filter to generate per-second peak arrays → store as `waveforms/<assetId>.json` in S3
- New `waveform_s3_key TEXT` column on `assets`
- Editor timeline: fetch waveform JSON for audio track clips, render as SVG polyline within clip block
### P5 — Timecoded Comments
```sql
CREATE TABLE asset_comments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
asset_id UUID NOT NULL REFERENCES assets ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users,
timecode_seconds NUMERIC(10,3),
body TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
Player sidebar: "Comments" tab with "Post at current TC" button. Clickable entries seek video to that timecode.
### P6 — Smart Bins
```sql
ALTER TABLE bins ADD COLUMN is_smart BOOLEAN DEFAULT false;
ALTER TABLE bins ADD COLUMN smart_query JSONB;
-- smart_query shape: { "tags": ["interview"], "media_type": "video", "created_after": "2026-01-01" }
```
Smart bin assets: dynamically queried from `assets` table. Shows ✦ icon in bin tree.
### P7 — Metadata Templates
```sql
CREATE TABLE project_metadata_fields (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
field_key TEXT NOT NULL,
label TEXT NOT NULL,
field_type TEXT NOT NULL DEFAULT 'text',
options JSONB,
required BOOLEAN DEFAULT false,
sort_order INTEGER DEFAULT 0
);
ALTER TABLE assets ADD COLUMN metadata JSONB DEFAULT '{}';
```
---
## Implementation Sequencing
### Phase 1 — Editor
1. DB migration (`schema_patch_editor.sql`)
2. API (`sequences.js` route — CRUD + clip sync + EDL export)
3. `api.js` — sequence helpers
4. `editor.html` — 4-panel shell + CSS (sidebar nav update)
5. Timeline engine — ruler, playhead, track rows, clip rendering
6. Select tool — click-select, drag-move, drag-edge trim
7. Razor tool — click-to-split
8. Source monitor — video + transport + in/out marking + Insert/Overwrite
9. Program monitor — virtual playback + 59.94 drop-frame timecode
10. Auto-save (debounced 2s) + sequence picker in media panel
11. Library "Open in Editor" action + sidebar link
### Phase 2 — Growing File
1. Capture service: HLS output, `$HLS_SESSION_DIR` env var
2. Capture service: `/capture/live/:sessionId/` routes + nginx config
3. Capture service: on-stop stitch + S3 upload + cleanup
4. `capture.html`: live preview panel (hls.js), rewind button
### Phase 3 — Feature Additions
P1 → P2 → P3 → P4 → P5 → P6 → P7

View file

@ -1,198 +0,0 @@
# UI Shell Rework — Design Spec
> Status: **design approved 2026-05-21**, awaiting user review of the spec before the implementation plan is written.
## Context
The Wild Dragon MAM web-ui currently ships 15 static HTML pages served by nginx, sharing a single hand-written `common.css`. The token system is already strong (oklch palette, brand hue 266, 5-step depth surfaces, semantic signal tokens, 4pt spacing). What does not work is the gap between tokens and execution: cards across pages have undifferentiated spacing, generic chrome, weak hierarchy, and identical shape regardless of role. The user feedback that prompted this work was "the UI still looks AI-designed."
The rework adopts flyon-ui (Tailwind plugin) as the component primitive layer, ports the oklch palette into a custom flyon-ui theme so brand identity is preserved, and rebuilds every page that uses the standard shell against the new primitives. The personality target is *quiet pro tool* — closer to Sony Media Cloud and DaVinci Resolve than to a consumer SaaS dashboard.
## Goals & non-goals
**Goals**
- A coherent visual system across every shell page (15 pages minus 3 excluded).
- Higher information density at every screen — closer to Sony / DaVinci than to today's spacing.
- Four distinct card families so the eye reads role from shape.
- A theme port that preserves brand hue 266 and the existing oklch palette under flyon-ui.
- Accessibility floor: WCAG AA contrast, full keyboard nav, reduced-motion honored.
**Non-goals**
- Mobile UX. Phones get an explicit "desktop only" splash. Tablet gets a collapsed icon-rail sidebar but no further accommodation.
- Replacing the brand color, the font stack, or the dark theme.
- Animations beyond functional state transitions (no celebrations, no page-fade, no sound design).
- Adding new pages or features. This is purely visual / structural.
- Rebuilding `edit.html`, `editor.html`, or `player.html` (deliberately excluded — see Rollout).
## Personality, scene & color strategy
- **Register:** product (app UI, design serves the product), not brand.
- **Theme scene sentence:** "MAM operator at a 27-inch monitor in a dim control room, scanning a grid of 100+ video assets at 2am while a live recording timer runs." Forces dark, low-chroma, tabular-numeric trust signals.
- **Color strategy:** restrained. Tinted neutrals (chroma 0.0100.015, hue 266) plus a single amber accent used in ≤10% of surfaces — for active states, recording indicators, primary CTAs, focus rings.
- **Anti-patterns explicitly banned:** glassmorphism as default, gradient text, side-stripe borders (>1px on the side of cards/rows/callouts), hero-metric SaaS template, identical card grids across roles, em dashes in copy. Cliché category-reflex check passes: this design lands as DAW / NLE / NLE-adjacent operational tool, not "dark blue observability dashboard."
## Architecture
### 1. Build system & theme port
The `services/web-ui` Docker image gains a Node build stage. During `docker build`, `tailwindcss --minify` runs once, scans `public/**/*.html` for class usage, and emits `public/dist/app.[hash].css`. The runtime stage stays nginx-static — no runtime Node, longer startup, or extra moving parts.
A `tailwind.config.js` at `services/web-ui/` defines a custom flyon-ui theme that maps the existing oklch palette into flyon-ui's color slots. Brand hue 266 is preserved; the 5-step depth surfaces become Tailwind's `bg-deep` / `bg-base` / `bg-panel` / `bg-surface` / `bg-raised` / `bg-hover` utility chain. Signal tokens (`signal-good` / `signal-bad` / `signal-warn` / `signal-idle`) map directly. Spacing scale uses Tailwind's default 4pt scale, which already matches the existing `--sp-*` tokens — utilities like `p-3` and `gap-4` replace `var(--sp-3)` and `gap: var(--sp-4)`.
Fonts (Inter + JetBrains Mono) move from Google CDN to self-hosted woff2 in `public/fonts/`. The four "legacy alias" entries in the current `:root` (`--status-amber`, `--status-amber-bg`, etc.) get cleaned up during the port.
The custom theme also disables flyon-ui utility classes for the banned patterns: no `glass-*`, no `gradient-text`, no card-shadow defaults.
### 2. Sidebar
- **Dimensions:** 200px wide (down from 220px). Items 28px tall (down from ~36px), 8px horizontal padding, 4px vertical.
- **Type:** Inter 13px / 500 for items. Section labels 10px / 600 / 0.14em tracked / uppercase / `text-tertiary`.
- **Header:** 18px dragon logo + Inter 13px / 600 / -0.01em "Z-AMPP" wordmark. Total header height 48px to align with topbar.
- **Active state:** `bg-surface` background + `text-primary` text + 4px leading accent dot (8px tall, vertically centered). No side-stripe border (banned). No accent background fill.
- **Hover:** `bg-hover` fade-in over 120ms ease-out. No transform.
- **IN DEV badge** (injected by `auth-guard.js`): retained, restyled as 9px / 700 / 0.12em tracked amber pill.
- **Footer user widget:** 28px round avatar, name + role stacked, logout button reveals on row hover.
### 3. Topbar
- **Dimensions:** 48px tall. Padding 16px left / 12px right. Bottom border `border-faint`.
- **Left:** breadcrumb pattern, not flat title. Inter 13px / 500 / `text-secondary` for ancestor crumbs, 13px / 600 / `text-primary` for current. 10px chevron separator in `text-tertiary` with 8px gutters.
- **Center:** page-scoped search input on pages that have searchable content (Library, Recorders, Projects, Jobs, Cluster). 360px wide, 28px tall, leading magnifier, monospace placeholder.
- **Right:** primary CTA rightmost (28px button with 12px leading icon), 1px vertical divider, then 28px-square icon-only ghost buttons for secondary actions (filter, sort, view-toggle).
- **Sticky:** `position: sticky; top: 0; z-index: 30` inside `.main`. Sidebar does not scroll separately; topbar stays visible while content scrolls.
### 4. Card families
Four distinct card shapes, each with one clear job. Same-shape repetition is banned.
**Asset card** — Library, Projects asset grid, Recorders recording cards
- 16:9 thumbnail, full-bleed. Duration chip bottom-right (JetBrains Mono 10px, `bg-deep` 70% opacity). Comment-count chip bottom-left (when >0). Selection checkbox top-left (only on hover or when any are selected). Version badge top-right when applicable.
- Metadata: filename (Inter 13px / 500), then `{author} · {date}` row (11px / `text-tertiary`, mono numerics).
- Role pill at bottom: full-width, light tint of role color, 10px / 600 / 0.08em tracked / uppercase. Dotted-border placeholder when unset.
- 1px `border-faint`, 6px radius, `bg-panel`. Hover: thumbnail +4% brightness, border lifts to `border`. No scale, no shadow.
**Operational card** — Recorders cards, Cluster nodes, Jobs queue
- Header / content / footer rows. Wider than tall (min 8:3 ratio). 14px padding, 10px row gap.
- Header: 16px name + status pill (semantic signal tokens).
- Content: role-specific. Recorder gets live preview + signal strip + timer; cluster node gets CPU/mem mini-bars; job gets progress strip.
- Footer: 1px top hairline, metadata-left + actions-right.
- Border 1px `border-faint`, becomes 1px `accent-border` when active (recording / online / running). Color change only — no glow, no shadow, no animation.
**Inline list row** — Containers, Users, Tokens, API tokens
- Not a card. Table row with extra breathing room. 44px tall, hairline divider (`border-faint`).
- Hover: `bg-hover` row tint. Selected: `bg-surface` tint + 4px leading accent dot (same indicator language as sidebar active state).
**Empty state**
- Centered 28px line icon (`text-tertiary`), 14px / 600 title, 13px body, primary action button. No card chrome — the empty state IS the page.
- Declarative copy. No exclamation points, no emojis.
### 5. Grids
- Asset grid: `repeat(auto-fill, minmax(220px, 1fr))` with 12px gap.
- Operational grid: `repeat(auto-fill, minmax(380px, 1fr))` with 14px gap.
- Page content padding: 20px sides, 16px top, 32px bottom.
### 6. Forms, slide-panels & inputs
**Slide-panel structure (the codec-clipping bug fix codified as a primitive):**
- 460px wide. `height: 100vh; display: flex; flex-direction: column; overflow: hidden`. Header `flex-shrink: 0`. Body `flex: 1; min-height: 0; overflow-y: auto`. Footer `flex-shrink: 0; bg-deep`.
- Header 52px, 18px padding, title + close button. Bottom border `border-faint`.
- Body 18px padding, `display: flex; flex-direction: column; gap: 16px`.
**Form primitives:**
- Label: 11px / 600 / 0.08em tracked / uppercase / `text-tertiary`.
- Input / select / textarea: 32px tall, 10px horizontal padding, 13px text, 1px `border` outline, 4px radius. Focus: `accent-border` outline + 2px `accent-subtle` ring.
- Form hint: 11px / `text-tertiary` / 1.5 line-height. JetBrains Mono code spans.
- Form row: `grid-template-columns: 1fr 1fr; gap: 14px`.
**Field-group (tabbed sections, generalized from the codec-block pattern):**
- Titled header strip (36px, `bg-surface`) + tab row (32px, `bg-deep`) + tab panels (14px padding).
- Active tab: 2px `accent` bottom border, text shifts from `text-tertiary` to `accent`. Tab switches are instant — no animation.
**Buttons:**
- Sizes: `sm` 28px / `md` 32px / `lg` 36px.
- Variants: `primary` (accent bg), `secondary` (`bg-surface` + border), `ghost` (transparent + secondary text, `bg-hover` on hover), `danger` (status-red bg).
- Leading icon: 12px svg, 6px gap. Disabled: 40% opacity. Active press: 60ms `opacity: 0.85`. No gradient, no shadow, no scale.
**Toggle:** 34×18, track `bg-hover``accent`, 200ms ease-out on dot only.
**Date / time inputs:** native `<input type="date">` styled to match the input primitive. No third-party picker library.
### 7. States, motion & feedback
**Loading:** skeleton blocks matched to content shape. Asset grid → 12 placeholder cards with 1.8s gradient shimmer (not opacity pulse). In-card actions get inline 12px ring spinner. In-button: label replaced by spinner, width preserved.
**Empty states:** fade in 240ms on first load; instant when user-initiated.
**Errors:**
- *Toast* (bottom-right, 320px): `bg-panel` + 1px `status-red` border + 4px `status-red` top strip. Auto-dismiss 4s success / 8s warning / manual error. Stack up to 3.
- *Inline*: red 11px text below offending field. No icon, no shake.
- *Page-level*: full-page card with icon + plain-English title + Retry + Get-help buttons. Never blocks the sidebar.
**Success:** "Recorder saved" toast. Affected card briefly tints (200ms `accent-subtle` background, fades back over 1.2s). One-time. No checkmark celebrations.
**Live / realtime (recording-in-progress):**
- Signal strip shimmer 1.8s ease-in-out (down from 2.4s linear).
- "LIVE" preview-stamp dot stutter pattern: bright 0.9s / dim 0.3s / bright 0.9s (broadcast tally light).
- Timer: 600 weight, `status-red` while recording. Already correct.
**Hover / focus:**
- All transitions 120ms ease-out on `border-color` and `background-color` only. Never on `width`, `height`, `transform`.
- Focus ring: 2px `accent-subtle` outline, 1px offset, `:focus-visible` only.
**Page transitions:** none. Click nav → page renders. Slide-panel keeps 240ms slide-in from right.
**Notifications:** no bell, no global status banner. Failures surface inline on affected pages.
### 8. Accessibility & responsive
**A11y floor:**
- WCAG AA contrast on every text/background pair. `text-tertiary` lightness bumped from 52% to 56% to clear AA cleanly.
- `:focus-visible` ring on every interactive element.
- Full keyboard nav. Slide-panel traps focus while open; Esc closes overlays.
- Every icon-only button gets `aria-label`. Toasts use `role="status" aria-live="polite"`.
- `prefers-reduced-motion: reduce` honored: kills shimmer / pulse / slide-in, state changes become instant.
**Responsive:**
- Desktop-first tool. *Fully supported* minimum viewport: 1280×800. Anything narrower is best-effort only.
- ≥1600px: standard layout, content max-width 1440px, centered.
- 12801599px: standard layout, no max-width cap.
- 7681279px (tablet, best-effort): sidebar collapses to a 56px icon-rail, breadcrumb truncates to last crumb. Functional but not polished.
- <768px (phone): explicit "Z-AMPP is desktop-only" splash. No fake mobile experience.
**Browser support:** Chromium latest 2 versions + Safari latest 2. Firefox best-effort. No IE / legacy Edge.
## Rollout
Four waves, ordered by blast radius. Each wave is its own commit / deploy / verify cycle on zampp1.
**Wave 1 — Foundation (zero user-visible change).** Build pipeline, Tailwind + flyon-ui config, theme port, primitive CSS components, shell markup migration. New primitives exist but no page uses them yet. *Validates the build system works end-to-end.*
**Wave 2 — Shell + low-risk pages.** `login.html`, `home.html`, `settings.html`, `tokens.html`, `users.html`, `containers.html`. New shell, new card / list patterns, new forms. Low risk: no live data, simple flows.
**Wave 3 — Content-heavy pages.** `index.html` (Library), `projects.html`, `upload.html`, `jobs.html`, `api-tokens.html`. Asset grid, project tree, upload queue, job queue.
**Wave 4 — Operational pages.** `recorders.html`, `cluster.html`, `capture.html`. Live data, HLS preview, signal polling, BMD picker, codec slide-panel. Done last so the primitives are battle-tested before they meet the most fragile pages.
**Excluded from rework:** `edit.html`, `editor.html` (in-development construction screen is the right treatment), `player.html` (standalone embed, no shell).
**Definition of done per page:** new shell + new primitives + AA contrast verified + keyboard-nav check + responsive at 1280/1440/1920 widths + no JS regressions (live-recording flow on recorders.html is the canary).
## Risks & mitigations
| Risk | Mitigation |
|---|---|
| Tailwind build pipeline introduction breaks docker build | Wave 1 ships the build *without* migrating any page. If build fails, we revert without losing functionality. |
| Theme port loses brand hue 266 character | Custom flyon-ui theme explicitly maps existing oklch tokens; QA on wave 1 includes side-by-side color comparison vs. current. |
| Recorders rewrite (just stabilized) gets re-touched in wave 4 | Wave 4 is last on purpose — primitives are battle-tested by then. The codec-tab pattern from the recent recorders rewrite is generalized into the `.field-group` primitive in wave 1, so wave 4's recorders rewrite is mostly markup migration, not pattern reinvention. |
| Density target is too aggressive — 13px text / 28px rows feel cramped on smaller monitors | Wave 1 ships density + AA verified at 1280×800. If feedback says cramped, bump base text to 14px in a single token change. |
| Page-level skeleton loaders are extra implementation work | Acceptable cost. Spinners-only would feel cheaper than the rest of the design. |
| Native `<input type="date">` looks inconsistent across Chromium / Safari | Acceptable. Inconsistency is small; bundle weight savings of avoiding a date-picker library is real. |
## Implementation plan handoff
Once this spec is approved by the user, the next step is invoking the `superpowers:writing-plans` skill to produce a wave-by-wave implementation plan with concrete commit / deploy steps. The plan will live at `docs/superpowers/plans/2026-05-21-ui-shell-rework-plan.md` and reference this spec.
## Open questions
None. All seven design sections were approved by the user (Zac) during brainstorming on 2026-05-21. No placeholder values remain in this spec.

View file

@ -1,272 +0,0 @@
# YouTube Importer — Design Spec
> Status: **design approved 2026-05-23**, awaiting user review of the spec before the implementation plan is written.
## Context
The Ingest group in Dragonflight today covers file Upload, Recorders (SRT/RTMP/SDI), Capture (DeckLink), Monitors, and Schedule. There is no path to bring in media that already lives on the public web. The frequent ask is: "I want to grab a YouTube link and have it become an asset in my project, with the same proxy/thumbnail pipeline as anything else." This spec adds a YouTube importer that mirrors the existing upload flow: paste a URL, pick a project, click Import, and the asset shows up in the Library once the worker is done.
The importer rides on the existing job pipeline. After the download lands in S3, the asset re-enters the same proxy → thumbnail → ready path as a regular upload, so there is no parallel "imported asset" lifecycle to maintain.
## Goals & non-goals
**Goals**
- Paste a public YouTube URL, end up with a `ready` asset in the chosen project.
- Reuse the existing `assets` table, S3 layout, BullMQ pipeline, and Jobs screen — no parallel state machine.
- Progress visible from both the import screen (queue rows) and the Jobs screen.
- Clear, actionable errors for the obvious failure modes (private, age-gated, removed, geo-blocked, network).
**Non-goals**
- Playlists, channels, or batch-paste of multiple URLs. Single URL per submission. (Easy to add later.)
- Cookies / login. Private, members-only, and age-gated videos are out of scope v1.
- Quality picker. Always grabs best MP4 (with M4A audio merge fallback).
- Non-YouTube sources (Vimeo, Twitch VODs, Dropbox links, etc.). The route is `/imports/youtube` precisely to leave room for siblings later.
- Auto-update of yt-dlp inside the running container. Updates land via image rebuild.
- Copyright enforcement. We surface a one-line "only import videos you have rights to use" note and stop there.
## Architecture
The importer threads through four existing layers:
```
[web-ui] YouTube screen ──POST /imports/youtube──▶ [mam-api]
assets row (status='ingesting')
jobs row (type='youtube_import')
BullMQ "import" queue
[worker]
yt-dlp download → S3 originals/
ffprobe metadata → assets row
status='processing'
BullMQ "proxy" queue ◀── existing path
proxy → thumbnail → ready
```
Once the worker hands off to the `proxy` queue, the asset is indistinguishable from one that came through Upload — same proxy worker, same thumbnail worker, same Library list.
## 1. UX
### Nav
A 6th child is added to the **Ingest** group in `shell.jsx`, between Upload and Recorders:
```js
{ id: "youtube", label: "YouTube", icon: "download" },
```
The `download` glyph already exists in `icons.jsx`. The matching `ingestChildren` array in `shell.jsx` and the crumbs map in `app.jsx` both gain `"youtube"`.
### Screen
A new `YouTubeImport` component lives in `screens-ingest.jsx` and is exported on `window` alongside `Upload`, `Recorders`, etc. It is registered as a route in `app.jsx`.
Layout — visually a sibling of the Upload screen:
- **Header**: title "YouTube", subtitle "Paste a link — we download and import the best available MP4."
- **Project selector**: same `select` element as Upload's, pre-selected to the first project.
- **URL input**: a single-line `field-input` with placeholder "Paste a YouTube URL (youtube.com/watch, youtu.be, or shorts)…" and an inline **Import** button. Enter submits. The button is disabled until a URL pattern matches.
- **Subtitle line under the input**: "Only import videos you have rights to use. Private, age-gated, and members-only videos are not supported."
- **Queue panel**: identical structure to Upload's queue — one row per submitted URL, showing:
- Source icon (use `link` glyph) and the URL (truncated middle, full URL in `title` tooltip).
- Title once known (filled in by a poll on the asset row).
- Progress bar tied to job `progress` (0100). The worker drives this between 5 and 60 % for download and 60 to 100 % for upload + DB writes.
- Status pill: queued → downloading → processing → done / failed.
- Error text if the job fails (red, one line).
- A "Clear done" button at the top of the queue.
The queue persists for the session in component state only — no separate UI table. Jobs screen remains the canonical history.
### URL validation (client-side, before POST)
Accept (case-insensitive) any of these patterns:
- `https?://(www\.|m\.)?youtube\.com/watch\?[^ ]*v=[A-Za-z0-9_-]{11}`
- `https?://youtu\.be/[A-Za-z0-9_-]{11}`
- `https?://(www\.)?youtube\.com/shorts/[A-Za-z0-9_-]{11}`
Anything else is rejected inline ("That doesn't look like a YouTube URL") without an API call. The server re-validates as a defense-in-depth check.
### Out-of-scope v1 (called out, not built)
- Pasting a playlist URL. Server returns 400 "Playlists aren't supported yet."
- Multi-line paste. Single URL only.
- Quality picker. yt-dlp format string is hard-coded.
- Cookies upload. Private videos fail with a clear message.
## 2. API
### Route
New file `services/mam-api/src/routes/imports.js`, mounted at `/api/v1/imports` in `services/mam-api/src/index.js`.
**`POST /api/v1/imports/youtube`**
Request body:
```json
{ "url": "https://youtu.be/dQw4w9WgXcQ", "projectId": "uuid", "binId": "uuid?" }
```
Behavior:
1. Validate `url` against the same three regexes as the client. 400 on miss.
2. Reject playlist URLs (URL contains `list=`) with 400 "Playlists aren't supported yet."
3. Generate `assetId = uuidv4()`.
4. Insert into `assets` with:
- `status='ingesting'`
- `media_type='video'`
- `filename = url` (placeholder; worker overwrites with the sanitized title once yt-dlp prints metadata — keeps the row queryable in the meantime)
- `display_name = url` (same; worker overwrites)
- `original_s3_key = NULL` (worker fills in)
- `source_url = url` (new column — see Schema)
- `project_id`, `bin_id`, timestamps.
5. Insert into `jobs` with `type='youtube_import'`, `asset_id`, `payload={ url }`, `status='queued'`, `progress=0`.
6. Enqueue BullMQ job on the `import` queue:
```js
await importQueue.add('youtube', { assetId, url });
```
7. Respond `200 { assetId, jobId }`.
Errors:
- Missing fields → 400.
- Bad URL → 400 with `error: 'Invalid YouTube URL'`.
- Playlist URL → 400 with `error: 'Playlists aren't supported yet'`.
- Project not found → 404.
- DB / queue failure → 500 (next(err)).
### Jobs screen integration
`services/web-ui/public/screens-jobs.jsx` already normalizes job types via a `kindMap`. Add one entry:
```js
const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', youtube_import: 'YouTube' };
```
Retry, delete, and the SSE event stream all work for the new type with no further changes because they key off `job.id`, not `job.type`.
## 3. Worker
### Container changes
`services/worker/Dockerfile` gains two packages:
```dockerfile
RUN apk add --no-cache ffmpeg yt-dlp python3
```
`yt-dlp` is in the Alpine `community` repo and pulls `python3` as a runtime dep — we list it explicitly for clarity. Image grows by ~25 MB.
### New worker
`services/worker/src/workers/youtube-import.js`, registered in `services/worker/src/index.js`:
```js
const workers = [
createWorker('proxy', proxyWorker),
createWorker('thumbnail', thumbnailWorker),
createWorker('conform', conformWorker),
createWorker('import', youtubeImportWorker),
];
```
### Job handler
For a job with `{ assetId, url }`:
1. `job.updateProgress(2)` — accepted.
2. Build a temp directory `tmpdir()/yt-${jobId}`.
3. Run yt-dlp:
```sh
yt-dlp \
--no-playlist \
--no-warnings \
--restrict-filenames \
-f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b" \
--merge-output-format mp4 \
--print-json \
--newline \
-o "<tmpdir>/<assetId>.%(ext)s" \
"<url>"
```
- `--print-json` writes one JSON line at the end with title, duration, width, height, uploader, etc.
- `--newline` makes progress lines newline-terminated so we can parse them.
- `--restrict-filenames` prevents shell-special characters in temp paths.
4. Stream stdout line-by-line. Lines matching `r'\[download\]\s+(\d+(\.\d+)?)%'` map to `job.updateProgress(5 + Math.floor(pct * 0.55))` so download takes us from 5 to 60 %.
5. On yt-dlp non-zero exit: parse stderr for the first line containing `ERROR:` and use it as the job's error message. Mark the asset `status='error'`, mark the job failed, throw so BullMQ records it. Surface a friendly substitution for the common cases:
- "Private video" → "Private video — not supported."
- "Sign in to confirm your age" → "Age-restricted video — not supported."
- "Video unavailable" → "Video unavailable or removed."
- "This video is not available in your country" → "Video is geo-blocked from this region."
- HTTP 429 → "YouTube rate-limited the importer — try again later."
- Anything else → use yt-dlp's stderr line verbatim, truncated to 300 chars.
6. Parse the last stdout line as JSON to read metadata. The resulting file is `<tmpdir>/<assetId>.mp4`.
7. `getMediaInfo` (existing helper in `services/worker/src/ffmpeg/executor.js`) on that path. Use ffprobe's values for codec/fps/duration when yt-dlp's are missing or wrong.
8. Sanitize the title for the S3 filename: keep `[A-Za-z0-9 ._-]`, collapse runs of whitespace, trim, cap at 120 chars, append `.mp4`. If the sanitized title is empty, fall back to `youtube-<videoId>.mp4`.
9. Upload to `originals/{assetId}/{sanitized-title}.mp4` via the existing `uploadToS3` helper. Progress 60 → 90 %.
10. UPDATE the assets row with:
- `filename = <sanitized title>.mp4`
- `display_name = <yt-dlp title untouched>`
- `original_s3_key = originals/<assetId>/<sanitized-title>.mp4`
- `codec`, `resolution`, `fps`, `duration_ms`, `file_size` from ffprobe.
- `status = 'processing'`
- `updated_at = NOW()`
11. Enqueue a `proxy` job on the existing `proxy` queue with the same payload shape `upload.js` uses:
```js
await proxyQueue.add('generate', {
assetId,
inputKey: asset.original_s3_key,
outputKey: `proxies/${assetId}.mp4`,
});
```
12. `job.updateProgress(100)`. Return — BullMQ marks the import job done. The proxy job picks up the rest exactly like a regular upload.
13. Always `rm -rf` the temp directory in a `finally`.
### Concurrency & retries
- Default BullMQ concurrency for this queue: **1** per worker process. Two simultaneous yt-dlp invocations risk YouTube rate-limiting more than they help throughput. Configurable later via env if needed.
- No automatic BullMQ retry — yt-dlp failures are almost always permanent (private, geo, removed) and a silent retry storm would chew through quota. The Jobs screen's manual Retry button is the right knob for "this should be transient" cases.
## 4. Schema migration
New file `services/mam-api/src/db/migrations/011-youtube-import.sql`:
```sql
-- 1. Add the new job type to the enum.
-- Postgres requires ALTER TYPE ... ADD VALUE for enum changes.
ALTER TYPE job_type ADD VALUE IF NOT EXISTS 'youtube_import';
-- 2. Remember where an asset came from. NULL for everything that
-- pre-dates the importer; populated for any imported asset.
ALTER TABLE assets ADD COLUMN IF NOT EXISTS source_url TEXT;
```
`source_url` is exposed on the asset drawer as a "Source" line ("imported from youtu.be/…") in a follow-up PR — out of scope for this spec, but worth noting that the column exists for it.
## 5. Files touched
**New**
- `services/mam-api/src/routes/imports.js`
- `services/mam-api/src/db/migrations/011-youtube-import.sql`
- `services/worker/src/workers/youtube-import.js`
**Edited**
- `services/mam-api/src/index.js` — mount the new route.
- `services/web-ui/public/screens-ingest.jsx` — add `YouTubeImport`, export on `window`.
- `services/web-ui/public/shell.jsx` — add the nav child, extend `ingestChildren`.
- `services/web-ui/public/app.jsx` — register the route and the crumb.
- `services/web-ui/public/screens-jobs.jsx` — extend `kindMap` with `youtube_import: 'YouTube'`.
- `services/worker/src/index.js` — register the `import` queue worker.
- `services/worker/Dockerfile` — add `yt-dlp` and `python3` to the apk install line.
## 6. Risks & trade-offs
- **Worker egress**. The worker container needs outbound HTTPS to YouTube. Fine in the current homelab; will fail in a locked-down cluster. Documented in the implementation plan.
- **yt-dlp drift**. YouTube changes break old yt-dlp versions every few weeks. The Alpine package lags upstream by days. Fix is to rebuild the worker image. We do not auto-update inside the running container — too risky for an offline / locked-down deploy. If imports start failing en masse, the runbook is `docker compose build worker && docker compose up -d worker`.
- **Single-URL UX feels light**. That is deliberate for v1. Adding multi-URL paste and playlist expansion are both small follow-ups once the single-URL path is stable.
- **No copyright enforcement**. We rely on the one-line notice in the UI. If misuse becomes a real concern, the next step would be an admin allowlist of domains or a per-user import quota — not in this spec.
- **`filename = url` placeholder**. Briefly, the asset row in the Library shows the URL as the name. The worker overwrites it within seconds for a successful import. Acceptable; the Library already handles "ingesting" assets with placeholder names from the upload path.
## 7. Acceptance
The feature is done when:
- A user can navigate to **Ingest → YouTube**, paste a public YouTube URL, pick a project, click Import, and within a minute or two see the asset appear in the Library with proxy and thumbnail.
- A failed import (private video, removed video, bogus URL) shows a clear error message on both the YouTube screen's queue row and the Jobs screen.
- The Jobs screen lists "YouTube" jobs alongside Proxy / Thumbnail / Conform, with Retry working.
- `source_url` is populated on the imported asset row.
- Image rebuild + `docker compose up -d worker` is the documented recovery path if YouTube changes break yt-dlp.

View file

@ -1,269 +0,0 @@
# Dragonflight User Authentication — Design
**Status:** Approved, ready for implementation planning
**Date:** 2026-05-27
**Brainstormed with:** Zac
## Problem
Dragonflight has the skeleton of an auth system spread across the codebase:
- `users` table (`id`, `username`, `password_hash`, `display_name`, `role`)
- `sessions` table (`sid`, `sess`, `expire`) for `connect-pg-simple`
- `groups`, `user_groups`, `api_tokens` tables
- `SESSION_SECRET` env var
- `AUTH_ENABLED` env flag with boot-log toggle
- PR #26 frontend handler that bounces to `/login.html` on 401
- Issue #94 "session security fixes" deployed 2026-05-26 (commit `3ebe5d6`)
But the actual `express-session` middleware was never mounted in `services/mam-api/src/index.js`. There is no `/api/v1/auth/*` router. There is no `requireAuth` middleware. As a result, when `AUTH_ENABLED=true` was tried:
1. User submits login, server returns 200 OK from a stub endpoint.
2. No `Set-Cookie` is ever sent (no session middleware mounted).
3. The next request to a protected route returns 401.
4. Frontend bounces to `/login.html`.
5. **Infinite redirect loop.**
The prior attempts failed because auth was being built reactively in pieces, with no single source of truth for what "logged in" means.
## Goals
- One coherent, readable auth code path.
- Web UI logins survive page reloads and container restarts.
- Premiere panel can authenticate via long-lived bearer tokens.
- First-run setup works on a fresh install with no env var or CLI gymnastics.
- The whole auth flow can be exercised by automated tests, including a regression test for the redirect-loop failure mode.
## Non-goals (v1)
- MFA / TOTP.
- OAuth / OIDC delegation (Forgejo, Google, etc.).
- Per-project or per-recorder permissions. Flat access: logged in = full access.
- Email-based "forgot password" (no SMTP assumed; admin-reset only).
- Audit log of who-did-what (the `last_login_at` column is the minimum).
- Service-to-service auth for `node-agent` — keeps existing `019-node-token-binding` mechanism.
## Decisions
| Decision | Choice | Reasoning |
|---|---|---|
| Client surface | Web UI + Premiere panel | Two transports (cookies + bearer), one identity backend |
| Permission model | Flat (logged in = full access) | Small homogeneous operator population. `groups` / `user_groups` schemas stay inert. |
| Identity provider | Local username/password | On-prem broadcast operators won't tolerate OIDC roundtrips. Matches existing schema. |
| First-user bootstrap | First-run setup page | Hardest to mis-configure. No env vars to leak. No CLI to remember. |
| Session lifetime | 8h absolute + 1h sliding idle | Operator security posture, tighter than typical SaaS. |
| Auth library | Hand-rolled (`express-session` + `connect-pg-simple`) | Explicit, debuggable. Rejected JWT and Passport for this codebase. |
## Architecture
### Single source of truth
"Logged in" means exactly one of two things:
1. The request carries a valid `dragonflight.sid` cookie whose row in `sessions` hasn't expired and isn't past its 1h-idle or 8h-absolute window, OR
2. The request carries `Authorization: Bearer <token>` whose SHA-256 matches an `api_tokens` row that hasn't been revoked or expired.
Nothing else counts. No `localStorage` flags, no JWT, no client-side "I think I'm logged in" hints.
### One middleware, one check
`services/mam-api/src/middleware/auth.js` exposes a single `requireAuth` function:
```js
export async function requireAuth(req, res, next) {
// Dev mode preserved. The 'dev' user is a real row in `users` seeded at
// boot when AUTH_ENABLED !== 'true', so FK-bearing routes (api_tokens,
// future comments, audit fields) keep working without conditional logic.
if (process.env.AUTH_ENABLED !== 'true') {
req.user = DEV_USER; // { id: <UUID of seeded 'dev' user>, username: 'dev' }
return next();
}
// 1. Session check
if (req.session?.user_id) {
const now = Date.now();
if (now - req.session.first_seen_at > 8 * 3600 * 1000) return destroyAnd401(req, res);
if (now - req.session.last_seen_at > 1 * 3600 * 1000) return destroyAnd401(req, res);
req.session.last_seen_at = now;
req.user = await loadUser(req.session.user_id);
if (!req.user) return destroyAnd401(req, res);
return next();
}
// 2. Bearer check
const bearer = parseBearer(req.headers.authorization);
if (bearer) {
const hash = sha256hex(bearer);
const row = await pool.query(
`SELECT t.id, t.user_id, t.expires_at, u.username
FROM api_tokens t JOIN users u ON u.id = t.user_id
WHERE t.token_hash = $1`, [hash]);
if (row.rows.length && (!row.rows[0].expires_at || row.rows[0].expires_at > new Date())) {
pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [row.rows[0].id]).catch(() => {});
req.user = { id: row.rows[0].user_id, username: row.rows[0].username };
return next();
}
}
// 3. Otherwise
return res.status(401).json({ error: 'unauthorized' });
}
```
Mounted at the `/api/v1` level in `services/mam-api/src/index.js`, **before** the individual route mounts, with an allowlist for the three pre-login auth paths:
```js
app.use('/api/v1', (req, res, next) => {
const unauth = ['/auth/login', '/auth/setup', '/auth/setup-required'];
if (unauth.some(p => req.path === p)) return next();
return requireAuth(req, res, next);
});
// then: app.use('/api/v1/assets', assetsRouter), etc.
```
`/health` lives at the root, outside the `/api/v1` mount, so it's naturally unaffected. `/api/v1/cluster/*` keeps its existing `019-node-token-binding` service-auth path: requireAuth runs first, fails with 401 for an unauthenticated request, **but** the cluster routes themselves do their own token check on request bodies, so node-agent traffic must include a valid user session OR an api_token (which is the change — node-agent will need to be issued an api_token at install time). Alternative: carve `/api/v1/cluster/*` out of the requireAuth gate too, and keep node-agent on its existing binding token alone. Implementer should pick — flagged in the implementation order.
### Session middleware (actually wired this time)
In `services/mam-api/src/index.js`, **before any route**:
```js
import session from 'express-session';
import connectPgSimple from 'connect-pg-simple';
const PgStore = connectPgSimple(session);
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);
app.use(session({
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 }),
secret: process.env.SESSION_SECRET,
name: 'dragonflight.sid',
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.TRUST_PROXY === 'true',
path: '/',
maxAge: 8 * 3600 * 1000,
},
rolling: false, // sliding renewal handled in requireAuth so we can enforce idle + absolute separately
resave: false,
saveUninitialized: false,
}));
```
### Auth router
`services/mam-api/src/routes/auth.js`:
| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/api/v1/auth/setup-required` | none | `{ required: bool }`. Cheap, no auth. |
| `POST` | `/api/v1/auth/setup` | none | Only succeeds if `users` is empty. Creates first user, logs them in. |
| `POST` | `/api/v1/auth/login` | none | `{ username, password }` -> 200 + cookie or 401 |
| `POST` | `/api/v1/auth/logout` | required | Destroys session row, clears cookie |
| `GET` | `/api/v1/auth/me` | required | `{ id, username, display_name }` |
| `POST` | `/api/v1/auth/password` | required | Change own password (requires current) |
| `GET/POST/DELETE` | `/api/v1/auth/users[/:id]` | required | User CRUD |
| `GET/POST/DELETE` | `/api/v1/auth/tokens[/:id]` | required | Current user's API tokens |
### Data model
Existing schema is almost right. One small migration:
```sql
-- services/mam-api/src/db/migrations/023-auth-session-timestamps.sql
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_updated_at TIMESTAMPTZ DEFAULT NOW();
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
-- idle / absolute timestamps live inside session.sess JSONB; no schema change needed
```
`groups` and `user_groups` stay as-is, unused for v1. `api_tokens` is already correctly shaped.
## Flows
### Browser login (the one that broke last time)
1. SPA boots, `<AuthGate>` calls `GET /api/v1/auth/me`.
2. `requireAuth` returns 401.
3. AuthGate calls `GET /api/v1/auth/setup-required`. If `true`, render Setup screen. Otherwise, render Login screen.
4. User submits `POST /api/v1/auth/login`. Server `bcrypt.compare`s, sets `req.session.user_id`, `first_seen_at`, `last_seen_at`. **Critical:** `await new Promise(r => req.session.save(r))` before responding, so the cookie is persisted to Postgres before the next request can arrive.
5. AuthGate re-calls `/api/v1/auth/me`, gets 200, renders the app.
**Why this doesn't loop:** the explicit `req.session.save()` callback before response guarantees the cookie row exists before the SPA can fire its next request. `requireAuth` returns a clean 401 (not a redirect) so the SPA decides what to render. The static `/login.html` is deleted; there is no HTML bounce.
### Premiere panel bearer
1. Web UI -> Settings -> API Tokens -> "New token" named "Premiere panel".
2. `POST /api/v1/auth/tokens` returns `{ token: 'dfl_<32 hex>', prefix: 'dfl_a3f2', id }` **exactly once**.
3. Premiere panel sends `Authorization: Bearer dfl_<...>` on every request. `requireAuth` SHA-256s it, looks up `api_tokens.token_hash`, updates `last_used_at`.
### Idle + absolute timeout (inside `requireAuth`)
```
if session present:
if now - session.first_seen_at > 8h -> destroy session, 401
if now - session.last_seen_at > 1h -> destroy session, 401
session.last_seen_at = now
req.user = lookup(session.user_id)
next()
```
Bearer tokens have their own optional `expires_at` (`NULL` = never expires); checked the same way.
## Frontend
- **`services/web-ui/src/auth-gate.jsx`** — new component that wraps the SPA. On mount: `GET /me`. On 401: check `setup-required`, render either Setup or Login. On 200: render the app shell.
- **Login screen** — layout B from brainstorm: 22px wordmark over "WILD DRAGON BROADCAST" tagline above a `--bg-1` card containing username, password, "Sign in" button. Matches DESIGN.md tokens.
- **Setup screen** — same chrome; fields = username, password, confirm password; button = "Create admin".
- **Settings -> Account section** — change password.
- **Settings -> API Tokens section** — list / create / revoke. New token shown exactly once with a copy affordance.
- **Fetch wrapper** — the central `ZAMPP_API.fetch` (already exists) gains a 401 handler that re-mounts AuthGate's Login state with the current path saved as `last_path`, restored after re-auth.
### Removed
- The static `/login.html` page (PR #26's bounce target) is deleted. SPA handles login internally; no full-page reload.
## Error handling
| Case | Behavior |
|---|---|
| Wrong username or password | `401 { error: 'invalid credentials' }`. Same message either way, no user enumeration. |
| Login rate limiting | Per-IP exponential backoff (1s, 2s, 4s, 8s, max 30s). In-memory `Map`. Single-instance limitation documented. |
| Idle / absolute expiry | 401 -> AuthGate Login. Last path saved, restored on re-auth. |
| Setup after first user exists | `409 { error: 'setup already complete' }`. Permanently disabled. |
| Token revoke | `DELETE /api/v1/auth/tokens/:id` — only owner can revoke. Subsequent bearer requests 401. |
| Delete-self when only user | `409 { error: 'cannot delete last user' }`. |
| Forgot password | No self-serve. Any logged-in user can reset another via `POST /api/v1/auth/users/:id/password`. Documented as the recovery path. |
| Password rules | Min 12 chars, no max, no character class requirements (NIST SP 800-63B). `bcrypt` cost 12. |
| CSRF | `SameSite=Lax` + same origin + required `X-Requested-With: dragonflight-ui` header on mutating requests (belt-and-suspenders). |
| Session table growth | `connect-pg-simple` `pruneSessionInterval: 60 * 15` (every 15 min). |
## Testing
- **Unit — `services/mam-api/test/middleware/auth.test.js`**: requireAuth with (a) no creds, (b) valid session, (c) idle-expired session, (d) absolute-expired session, (e) valid bearer, (f) invalid bearer, (g) bearer matching a deleted user.
- **Integration — `services/mam-api/test/auth.integration.test.js`**: spin up Express + test Postgres. Walks: setup -> login -> /me -> mutating call -> logout -> /me 401. Second pass: idle timeout simulated by mutating `last_seen_at` in DB. Third pass: bearer issue -> use -> revoke -> 401.
- **Regression test for the redirect-loop bug:** explicit test that after `POST /auth/login` returns 200, a subsequent `GET /auth/me` with the returned cookie returns 200 in the same test client. This is the test that would have caught the original failure.
- **Manual smoke (documented in PR):** fresh install -> setup -> create admin -> land on dashboard -> reload (stays logged in) -> wait 1h idle -> reload -> bounce to login.
## Implementation order
Suggested sequencing for the implementation plan (writing-plans will refine):
1. Migration `023-auth-session-timestamps.sql`. Add idempotent seed of the dev user (`INSERT ... ON CONFLICT DO NOTHING` with a fixed UUID) so dev mode FK-bearing routes work out of the box.
2. `express-session` + `connect-pg-simple` wiring in `index.js`.
3. `requireAuth` middleware (with `DEV_USER` constant resolved from the seeded row).
4. Auth router (setup, login, logout, me, password).
5. Apply `requireAuth` to API router with allowlist. Decide cluster carve-out (see Architecture).
6. Auth tests (unit + integration + regression).
7. Frontend `<AuthGate>` + Login screen + Setup screen.
8. Frontend Settings -> Account + API Tokens.
9. Delete `/login.html`.
10. User CRUD + token CRUD routes.
11. Rate limiting + CSRF header.
12. Documentation: README updates, `AUTH_ENABLED` transition notes.
## Out-of-band notes for the implementer
- The current `cors({ origin: true, credentials: true })` in `index.js` is too permissive once cookies start carrying authority. Tighten to a specific origin list (driven by an `ALLOWED_ORIGINS` env var) at the same time as wiring the session middleware — otherwise we're undoing the `SameSite=Lax` protection from the other side.
- node-agent -> mam-api traffic on `/api/v1/cluster/*` must keep working. Add a route-level carve-out comment that this path uses the existing `019-node-token-binding` token, not the user-auth path.
- The boot log currently says `Authentication: ENABLED` / `DISABLED (set AUTH_ENABLED=true for production)`. Once this lands, the recommended default flips: `AUTH_ENABLED=true` becomes the documented default in `.env.example` and the README, and `AUTH_ENABLED=false` is documented as a dev-only escape hatch.

View file

@ -1,84 +0,0 @@
# HLS VOD Playback for Browser
Date: 2026-05-29 | Status: design → implementation
Authors: Zac + Claude
## Purpose
Replace the browser playback path for **recorded (VOD) assets** with HLS, retiring
the MP4 range-stitching workaround. The MP4 proxy is **kept** (supplements, not
replaces) because the Premiere UXP panel and conform pipeline consume it.
## Background — current state
- `GET /assets/:id/stream` returns `{ url: /api/v1/assets/:id/video, type: 'mp4' }`
for ready assets.
- `GET /assets/:id/video` streams `proxies/<id>.mp4` through Node with the
**RustFS range-stitching hack** (`stitchedS3Stream`): RustFS mis-serves ranged
GETs whose start offset is past ~5.8 MB, so the endpoint streams from byte 0 and
drops bytes. Works, but wastes bandwidth/CPU per seek and is fragile.
- **Live** assets already use HLS (`type: 'hls'`, `/live/<id>/index.m3u8`), and
`hls.js` is already loaded and wired in `screens-asset.jsx` for `type === 'hls'`.
- The proxy worker (`services/worker/src/workers/proxy.js`) produces a single
H.264/AAC/yuv420p MP4 — already HLS-compatible.
## Decisions
- **Supplement, not replace.** Keep `proxies/<id>.mp4`; add an HLS rendition.
- **Generate in the proxy worker** via fast remux (`-c copy`) — no re-encode.
- **Serve segments through mam-api** as whole-file GETs (no Range) — sidesteps the
RustFS range bug entirely and reuses session auth.
## Architecture
### 1. Generation (worker/proxy.js)
After uploading `proxies/<id>.mp4`, remux it to HLS into a temp dir:
```
ffmpeg -i <proxy.mp4> -c copy -f hls \
-hls_time 6 -hls_playlist_type vod -hls_flags independent_segments \
-hls_segment_filename <tmp>/seg_%03d.ts <tmp>/index.m3u8
```
Upload every file in the temp dir to `hls/<assetId>/` (playlist + `.ts`). Set
`assets.hls_s3_key = 'hls/<assetId>/index.m3u8'`. Remux is seconds; failure is
non-fatal (MP4 path still works as fallback).
### 2. Storage / schema
Migration adds `assets.hls_s3_key TEXT` (nullable). Presence = HLS available.
Segment objects live under `hls/<assetId>/seg_NNN.ts`; playlist references
**relative** segment names so the serving endpoint is path-agnostic.
### 3. Serving (mam-api)
New `GET /assets/:id/hls/:file` (file = `index.m3u8` or `seg_NNN.ts`):
- Validate `:file` against `^(index\.m3u8|seg_\d+\.ts)$` (no traversal).
- Whole-object GET of `hls/<id>/<file>` from S3 — **no Range handling**.
- Content-Type: `application/vnd.apple.mpegurl` (m3u8) / `video/mp2t` (ts).
- `Cache-Control: private, max-age=3600` for segments; `no-cache` for the playlist.
- Covered by the existing `requireAuth` gate; `hls.js` carries the same-origin
session cookie (same mechanism the live HLS path already relies on).
### 4. Stream selection (mam-api `/stream`)
For non-live assets: if `hls_s3_key` is set →
`{ url: '/api/v1/assets/:id/hls/index.m3u8', type: 'hls' }`. Else fall back to the
existing MP4 `/video` response. Live unchanged.
### 5. Backfill (existing assets)
Add an `hls` BullMQ job + `POST /assets/:id/reprocess?type=hls`: downloads the
existing `proxy_s3_key`, remuxes to HLS, uploads, sets `hls_s3_key`. No re-encode.
### 6. Frontend
No change required — `screens-asset.jsx` already plays `type: 'hls'` via `hls.js`.
Verify `hls.js` xhr carries credentials (same-origin cookie) for the proxied
segments; add `xhrSetup` withCredentials only if needed.
## Out of scope
- Multi-bitrate/ABR ladders (single rendition for now).
- Replacing the MP4 proxy or the `/video` endpoint (kept as fallback + for panel).
- Live-asset playback changes (already HLS).
## Test plan
1. Upload/capture an asset → proxy job produces MP4 **and** `hls/<id>/index.m3u8`.
2. `/stream` returns `type: 'hls'`; `/assets/:id/hls/index.m3u8` → 200 m3u8;
`/assets/:id/hls/seg_000.ts` → 200 `video/mp2t`, whole-file (no 206/Range).
3. Browser: asset plays + seeks via hls.js (no range-stitching path hit).
4. `reprocess?type=hls` backfills an older asset; it then plays via HLS.
5. MP4 proxy + `/hires` download still work (panel workflow intact).

View file

@ -1,235 +0,0 @@
# 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

@ -1,97 +1,8 @@
# ── Stage 1: Build FFmpeg with DeckLink + NVENC (HEVC/H264) support ─────────
# All-Intra HEVC NVENC is the master codec for growing-file ingest (see
# docs/design/2026-05-29-all-intra-hevc-ingest.md). This stage gets the
# nv-codec-headers (header-only, no driver / no full CUDA toolkit needed)
# so ffmpeg's configure can light up hevc_nvenc / h264_nvenc / cuvid.
# At runtime, /dev/nvidia* + the host driver libs (via the NVIDIA Container
# Toolkit) supply the actual encoder.
FROM debian:bookworm AS ffmpeg-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential nasm yasm pkg-config git ca-certificates python3 \
libssl-dev libx264-dev libx265-dev libvpx-dev libopus-dev \
libmp3lame-dev libsrt-openssl-dev \
libzmq3-dev zlib1g-dev libstdc++-12-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy in BMD DeckLink SDK headers and patch script
COPY sdk/ /decklink-sdk/
COPY patch_decklink.py /patch_decklink.py
COPY decklink-sdk16.patch /decklink-sdk16.patch
# nv-codec-headers — just the ffnvcodec public headers + a pkg-config file.
# Pin to a tag known to work with FFmpeg 7.1 (n12.x series).
RUN git clone --depth=1 --branch n12.1.14.0 https://github.com/FFmpeg/nv-codec-headers.git /nv-codec-headers \
&& make -C /nv-codec-headers PREFIX=/usr/local install
# Pull FFmpeg 7.1 source
RUN git clone --depth=1 --branch release/7.1 https://git.ffmpeg.org/ffmpeg.git /ffmpeg
# Patch FFmpeg DeckLink code for SDK 16.x API changes
RUN python3 /patch_decklink.py
WORKDIR /ffmpeg
# NVENC adds: --enable-nvenc (encoder), --enable-cuvid (decoder), --enable-ffnvcodec.
# We deliberately do NOT enable --enable-cuda-nvcc / --enable-libnpp here — those
# require the full ~3GB CUDA toolkit and are only needed for GPU filters like
# yadif_cuda / scale_cuda. If §5's GPU deinterlace stretch goal goes ahead,
# rebuild this image off nvidia/cuda:12.x-devel and flip those flags on.
RUN ./configure \
--prefix=/usr/local \
--extra-cflags="-I/decklink-sdk -I/usr/local/include" \
--extra-ldflags="-L/usr/local/lib" \
--enable-gpl \
--enable-nonfree \
--enable-libx264 \
--enable-libx265 \
--enable-libvpx \
--enable-libopus \
--enable-libmp3lame \
--enable-libsrt \
--enable-libzmq \
--enable-decklink \
--enable-ffnvcodec \
--enable-nvenc \
--enable-cuvid \
--disable-doc \
--disable-debug \
--disable-ffplay \
&& make -j$(nproc) \
&& make install
# Sanity-check: hevc_nvenc and h264_nvenc must be present in the encoder list,
# otherwise the resulting image is useless for the All-Intra HEVC pipeline.
RUN /usr/local/bin/ffmpeg -hide_banner -encoders 2>&1 | grep -E 'nvenc' \
|| (echo 'FATAL: nvenc encoders missing from ffmpeg build' && exit 1)
# ── Stage 2: Runtime image ───────────────────────────────────────────────────
FROM node:20-bookworm FROM node:20-bookworm
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
# Runtime deps for compiled ffmpeg libs
RUN apt-get update && apt-get install -y --no-install-recommends \
libx264-164 libx265-199 libvpx7 libopus0 libmp3lame0 \
libsrt1.5-openssl libzmq5 libstdc++6 libc++1 libc++abi1 \
&& rm -rf /var/lib/apt/lists/*
# Copy compiled ffmpeg/ffprobe
COPY --from=ffmpeg-builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
COPY --from=ffmpeg-builder /usr/local/bin/ffprobe /usr/local/bin/ffprobe
COPY --from=ffmpeg-builder /usr/local/lib/ /usr/local/lib/
# DeckLink runtime .so
COPY lib/libDeckLinkAPI.so /usr/lib/libDeckLinkAPI.so
COPY lib/libDeckLinkPreviewAPI.so /usr/lib/libDeckLinkPreviewAPI.so
RUN ldconfig
# Mount points the recorder lifecycle expects to exist.
# /live — HLS preview output (bound from host LIVE_DIR by node-agent)
# /growing — growing-file master output (bound from host /mnt/NVME/MAM/growing)
RUN mkdir -p /live /growing
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install --omit=dev RUN npm install --omit=dev
COPY . . COPY . .
EXPOSE 3001 EXPOSE 3001
CMD ["node", "src/index.js"] CMD ["node", "src/index.js"]

View file

@ -1,30 +0,0 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
echo "=== Checking prerequisites ==="
if [ ! -f sdk/DeckLinkAPI.h ]; then
echo "ERROR: sdk/DeckLinkAPI.h not found."
echo ""
echo "Please download the Blackmagic DeckLink SDK 16.x from:"
echo " https://www.blackmagicdesign.com/developer/product/capture"
echo ""
echo "Then extract the Linux/include/ folder contents into:"
echo " $(pwd)/sdk/"
echo ""
echo "Required files: DeckLinkAPI.h DeckLinkAPIVersion.h DeckLinkAPIDispatch.cpp"
echo " LinuxCOM.h DeckLinkAPIModes.h DeckLinkAPITypes.h"
exit 1
fi
echo "SDK headers found:"
ls sdk/*.h sdk/*.cpp 2>/dev/null
echo ""
echo "=== Building capture container with DeckLink FFmpeg ==="
docker compose -f ../../docker-compose.yml build capture
echo ""
echo "=== Verifying DeckLink support in built image ==="
docker run --rm wild-dragon-capture ffmpeg -f decklink -list_devices true -i dummy 2>&1 | head -20

View file

@ -1,346 +0,0 @@
diff --git a/libavdevice/decklink_common.cpp b/libavdevice/decklink_common.cpp
index fe187cd..47de7ef 100644
--- a/libavdevice/decklink_common.cpp
+++ b/libavdevice/decklink_common.cpp
@@ -25,12 +25,7 @@ extern "C" {
#include "libavformat/internal.h"
}
-#include <DeckLinkAPIVersion.h>
#include <DeckLinkAPI.h>
-#if BLACKMAGIC_DECKLINK_API_VERSION >= 0x0e030000
-#include <DeckLinkAPI_v14_2_1.h>
-#endif
-
#ifdef _WIN32
#include <DeckLinkAPI_i.c>
#else
@@ -517,8 +512,8 @@ int ff_decklink_list_devices(AVFormatContext *avctx,
return AVERROR(EIO);
while (ret == 0 && iter->Next(&dl) == S_OK) {
- IDeckLinkOutput_v14_2_1 *output_config;
- IDeckLinkInput_v14_2_1 *input_config;
+ IDeckLinkOutput *output_config;
+ IDeckLinkInput *input_config;
const char *display_name = NULL;
const char *unique_name = NULL;
AVDeviceInfo *new_device = NULL;
@@ -532,14 +527,14 @@ int ff_decklink_list_devices(AVFormatContext *avctx,
goto next;
if (show_outputs) {
- if (dl->QueryInterface(IID_IDeckLinkOutput_v14_2_1, (void **)&output_config) == S_OK) {
+ if (dl->QueryInterface(IID_IDeckLinkOutput, (void **)&output_config) == S_OK) {
output_config->Release();
add = 1;
}
}
if (show_inputs) {
- if (dl->QueryInterface(IID_IDeckLinkInput_v14_2_1, (void **)&input_config) == S_OK) {
+ if (dl->QueryInterface(IID_IDeckLinkInput, (void **)&input_config) == S_OK) {
input_config->Release();
add = 1;
}
diff --git a/libavdevice/decklink_common.h b/libavdevice/decklink_common.h
index 095b438..6b32dc2 100644
--- a/libavdevice/decklink_common.h
+++ b/libavdevice/decklink_common.h
@@ -29,23 +29,6 @@
#define IDeckLinkProfileAttributes IDeckLinkAttributes
#endif
-#if BLACKMAGIC_DECKLINK_API_VERSION < 0x0e030000
-#define IDeckLinkInput_v14_2_1 IDeckLinkInput
-#define IDeckLinkInputCallback_v14_2_1 IDeckLinkInputCallback
-#define IDeckLinkMemoryAllocator_v14_2_1 IDeckLinkMemoryAllocator
-#define IDeckLinkOutput_v14_2_1 IDeckLinkOutput
-#define IDeckLinkVideoFrame_v14_2_1 IDeckLinkVideoFrame
-#define IDeckLinkVideoInputFrame_v14_2_1 IDeckLinkVideoInputFrame
-#define IDeckLinkVideoOutputCallback_v14_2_1 IDeckLinkVideoOutputCallback
-#define IID_IDeckLinkInput_v14_2_1 IID_IDeckLinkInput
-#define IID_IDeckLinkInputCallback_v14_2_1 IID_IDeckLinkInputCallback
-#define IID_IDeckLinkMemoryAllocator_v14_2_1 IID_IDeckLinkMemoryAllocator
-#define IID_IDeckLinkOutput_v14_2_1 IID_IDeckLinkOutput
-#define IID_IDeckLinkVideoFrame_v14_2_1 IID_IDeckLinkVideoFrame
-#define IID_IDeckLinkVideoInputFrame_v14_2_1 IID_IDeckLinkVideoInputFrame
-#define IID_IDeckLinkVideoOutputCallback_v14_2_1 IID_IDeckLinkVideoOutputCallback
-#endif
-
extern "C" {
#include "libavutil/mem.h"
#include "libavcodec/packet_internal.h"
@@ -93,16 +76,6 @@ static char *dup_cfstring_to_utf8(CFStringRef w)
#define DECKLINK_FREE(s) free((void *) s)
#endif
-#ifdef _WIN32
-#include <guiddef.h> // REFIID, IsEqualIID()
-#define DECKLINK_IsEqualIID IsEqualIID
-#else
-static inline bool DECKLINK_IsEqualIID(const REFIID& riid1, const REFIID& riid2)
-{
- return memcmp(&riid1, &riid2, sizeof(REFIID)) == 0;
-}
-#endif
-
class decklink_output_callback;
class decklink_input_callback;
@@ -120,8 +93,8 @@ typedef struct DecklinkPacketQueue {
struct decklink_ctx {
/* DeckLink SDK interfaces */
IDeckLink *dl;
- IDeckLinkOutput_v14_2_1 *dlo;
- IDeckLinkInput_v14_2_1 *dli;
+ IDeckLinkOutput *dlo;
+ IDeckLinkInput *dli;
IDeckLinkConfiguration *cfg;
IDeckLinkProfileAttributes *attr;
decklink_output_callback *output_callback;
diff --git a/libavdevice/decklink_dec.cpp b/libavdevice/decklink_dec.cpp
index 8830779..418701e 100644
--- a/libavdevice/decklink_dec.cpp
+++ b/libavdevice/decklink_dec.cpp
@@ -31,11 +31,7 @@ extern "C" {
#include "libavformat/internal.h"
}
-#include <DeckLinkAPIVersion.h>
#include <DeckLinkAPI.h>
-#if BLACKMAGIC_DECKLINK_API_VERSION >= 0x0e030000
-#include <DeckLinkAPI_v14_2_1.h>
-#endif
extern "C" {
#include "config.h"
@@ -109,7 +105,7 @@ static VANCLineNumber vanc_line_numbers[] = {
{bmdModeUnknown, 0, -1, -1, -1}
};
-class decklink_allocator : public IDeckLinkMemoryAllocator_v14_2_1
+class decklink_allocator : public IDeckLinkMemoryAllocator
{
public:
decklink_allocator(): _refs(1) { }
@@ -133,21 +129,7 @@ public:
virtual HRESULT STDMETHODCALLTYPE Decommit() { return S_OK; }
// IUnknown methods
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
- {
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
- *ppv = static_cast<IUnknown*>(this);
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkMemoryAllocator_v14_2_1)) {
- *ppv = static_cast<IDeckLinkMemoryAllocator_v14_2_1*>(this);
- } else {
- *ppv = NULL;
- return E_NOINTERFACE;
- }
-
- AddRef();
- return S_OK;
- }
-
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
virtual ULONG STDMETHODCALLTYPE AddRef(void) { return ++_refs; }
virtual ULONG STDMETHODCALLTYPE Release(void)
{
@@ -490,7 +472,7 @@ skip_packet:
}
-static void handle_klv(AVFormatContext *avctx, decklink_ctx *ctx, IDeckLinkVideoInputFrame_v14_2_1 *videoFrame, int64_t pts)
+static void handle_klv(AVFormatContext *avctx, decklink_ctx *ctx, IDeckLinkVideoInputFrame *videoFrame, int64_t pts)
{
const uint8_t KLV_DID = 0x44;
const uint8_t KLV_IN_VANC_SDID = 0x04;
@@ -592,30 +574,17 @@ static void handle_klv(AVFormatContext *avctx, decklink_ctx *ctx, IDeckLinkVideo
}
}
-class decklink_input_callback : public IDeckLinkInputCallback_v14_2_1
+class decklink_input_callback : public IDeckLinkInputCallback
{
public:
explicit decklink_input_callback(AVFormatContext *_avctx);
~decklink_input_callback();
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
- {
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
- *ppv = static_cast<IUnknown*>(this);
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkInputCallback_v14_2_1)) {
- *ppv = static_cast<IDeckLinkInputCallback_v14_2_1*>(this);
- } else {
- *ppv = NULL;
- return E_NOINTERFACE;
- }
-
- AddRef();
- return S_OK;
- }
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
virtual ULONG STDMETHODCALLTYPE AddRef(void);
virtual ULONG STDMETHODCALLTYPE Release(void);
virtual HRESULT STDMETHODCALLTYPE VideoInputFormatChanged(BMDVideoInputFormatChangedEvents, IDeckLinkDisplayMode*, BMDDetectedVideoInputFormatFlags);
- virtual HRESULT STDMETHODCALLTYPE VideoInputFrameArrived(IDeckLinkVideoInputFrame_v14_2_1*, IDeckLinkAudioInputPacket*);
+ virtual HRESULT STDMETHODCALLTYPE VideoInputFrameArrived(IDeckLinkVideoInputFrame*, IDeckLinkAudioInputPacket*);
private:
std::atomic<int> _refs;
@@ -624,7 +593,7 @@ private:
int no_video;
int64_t initial_video_pts;
int64_t initial_audio_pts;
- IDeckLinkVideoInputFrame_v14_2_1* last_video_frame;
+ IDeckLinkVideoInputFrame* last_video_frame;
};
decklink_input_callback::decklink_input_callback(AVFormatContext *_avctx) : _refs(1)
@@ -656,7 +625,7 @@ ULONG decklink_input_callback::Release(void)
return ret;
}
-static int64_t get_pkt_pts(IDeckLinkVideoInputFrame_v14_2_1 *videoFrame,
+static int64_t get_pkt_pts(IDeckLinkVideoInputFrame *videoFrame,
IDeckLinkAudioInputPacket *audioFrame,
int64_t wallclock,
int64_t abs_wallclock,
@@ -710,7 +679,7 @@ static int64_t get_pkt_pts(IDeckLinkVideoInputFrame_v14_2_1 *videoFrame,
return pts;
}
-static int get_bmd_timecode(AVFormatContext *avctx, AVTimecode *tc, AVRational frame_rate, BMDTimecodeFormat tc_format, IDeckLinkVideoInputFrame_v14_2_1 *videoFrame)
+static int get_bmd_timecode(AVFormatContext *avctx, AVTimecode *tc, AVRational frame_rate, BMDTimecodeFormat tc_format, IDeckLinkVideoInputFrame *videoFrame)
{
IDeckLinkTimecode *timecode;
int ret = AVERROR(ENOENT);
@@ -732,7 +701,7 @@ static int get_bmd_timecode(AVFormatContext *avctx, AVTimecode *tc, AVRational f
return ret;
}
-static int get_frame_timecode(AVFormatContext *avctx, decklink_ctx *ctx, AVTimecode *tc, IDeckLinkVideoInputFrame_v14_2_1 *videoFrame)
+static int get_frame_timecode(AVFormatContext *avctx, decklink_ctx *ctx, AVTimecode *tc, IDeckLinkVideoInputFrame *videoFrame)
{
AVRational frame_rate = ctx->video_st->r_frame_rate;
int ret;
@@ -757,7 +726,7 @@ static int get_frame_timecode(AVFormatContext *avctx, decklink_ctx *ctx, AVTimec
}
HRESULT decklink_input_callback::VideoInputFrameArrived(
- IDeckLinkVideoInputFrame_v14_2_1 *videoFrame, IDeckLinkAudioInputPacket *audioFrame)
+ IDeckLinkVideoInputFrame *videoFrame, IDeckLinkAudioInputPacket *audioFrame)
{
void *frameBytes;
void *audioFrameBytes;
@@ -1172,7 +1141,7 @@ av_cold int ff_decklink_read_header(AVFormatContext *avctx)
goto error;
/* Get input device. */
- if (ctx->dl->QueryInterface(IID_IDeckLinkInput_v14_2_1, (void **) &ctx->dli) != S_OK) {
+ if (ctx->dl->QueryInterface(IID_IDeckLinkInput, (void **) &ctx->dli) != S_OK) {
av_log(avctx, AV_LOG_ERROR, "Could not open input device from '%s'\n",
avctx->url);
ret = AVERROR(EIO);
diff --git a/libavdevice/decklink_enc.cpp b/libavdevice/decklink_enc.cpp
index d2e246c..cb8f917 100644
--- a/libavdevice/decklink_enc.cpp
+++ b/libavdevice/decklink_enc.cpp
@@ -28,11 +28,7 @@ extern "C" {
#include "libavformat/internal.h"
}
-#include <DeckLinkAPIVersion.h>
#include <DeckLinkAPI.h>
-#if BLACKMAGIC_DECKLINK_API_VERSION >= 0x0e030000
-#include <DeckLinkAPI_v14_2_1.h>
-#endif
extern "C" {
#include "libavformat/avformat.h"
@@ -52,7 +48,7 @@ extern "C" {
#endif
/* DeckLink callback class declaration */
-class decklink_frame : public IDeckLinkVideoFrame_v14_2_1
+class decklink_frame : public IDeckLinkVideoFrame
{
public:
decklink_frame(struct decklink_ctx *ctx, AVFrame *avframe, AVCodecID codec_id, int height, int width) :
@@ -115,20 +111,7 @@ public:
_ancillary->AddRef();
return S_OK;
}
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
- {
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
- *ppv = static_cast<IUnknown*>(this);
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkVideoFrame_v14_2_1)) {
- *ppv = static_cast<IDeckLinkVideoFrame_v14_2_1*>(this);
- } else {
- *ppv = NULL;
- return E_NOINTERFACE;
- }
-
- AddRef();
- return S_OK;
- }
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
virtual ULONG STDMETHODCALLTYPE AddRef(void) { return ++_refs; }
virtual ULONG STDMETHODCALLTYPE Release(void)
{
@@ -155,10 +138,10 @@ private:
std::atomic<int> _refs;
};
-class decklink_output_callback : public IDeckLinkVideoOutputCallback_v14_2_1
+class decklink_output_callback : public IDeckLinkVideoOutputCallback
{
public:
- virtual HRESULT STDMETHODCALLTYPE ScheduledFrameCompleted(IDeckLinkVideoFrame_v14_2_1 *_frame, BMDOutputFrameCompletionResult result)
+ virtual HRESULT STDMETHODCALLTYPE ScheduledFrameCompleted(IDeckLinkVideoFrame *_frame, BMDOutputFrameCompletionResult result)
{
decklink_frame *frame = static_cast<decklink_frame *>(_frame);
struct decklink_ctx *ctx = frame->_ctx;
@@ -176,20 +159,7 @@ public:
return S_OK;
}
virtual HRESULT STDMETHODCALLTYPE ScheduledPlaybackHasStopped(void) { return S_OK; }
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
- {
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
- *ppv = static_cast<IUnknown*>(this);
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkVideoOutputCallback_v14_2_1)) {
- *ppv = static_cast<IDeckLinkVideoOutputCallback_v14_2_1*>(this);
- } else {
- *ppv = NULL;
- return E_NOINTERFACE;
- }
-
- AddRef();
- return S_OK;
- }
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
virtual ULONG STDMETHODCALLTYPE AddRef(void) { return 1; }
virtual ULONG STDMETHODCALLTYPE Release(void) { return 1; }
};
@@ -769,7 +739,7 @@ static int decklink_write_video_packet(AVFormatContext *avctx, AVPacket *pkt)
ctx->first_pts = pkt->pts;
/* Schedule frame for playback. */
- hr = ctx->dlo->ScheduleVideoFrame(frame,
+ hr = ctx->dlo->ScheduleVideoFrame((class IDeckLinkVideoFrame *) frame,
pkt->pts * ctx->bmd_tb_num,
ctx->bmd_tb_num, ctx->bmd_tb_den);
/* Pass ownership to DeckLink, or release on failure */
@@ -904,7 +874,7 @@ av_cold int ff_decklink_write_header(AVFormatContext *avctx)
return ret;
/* Get output device. */
- if (ctx->dl->QueryInterface(IID_IDeckLinkOutput_v14_2_1, (void **) &ctx->dlo) != S_OK) {
+ if (ctx->dl->QueryInterface(IID_IDeckLinkOutput, (void **) &ctx->dlo) != S_OK) {
av_log(avctx, AV_LOG_ERROR, "Could not open output device from '%s'\n",
avctx->url);
ret = AVERROR(EIO);

View file

@ -1,21 +0,0 @@
#!/usr/bin/env python3
# Apply the upstream FFmpeg master decklink SDK-16 compatibility patch on top
# of the release/7.1 source. The patch renames every IDeckLink* interface and
# helper to its _v14_2_1 versioned form so the call sites keep working against
# SDK 16's headers (which only retain the versioned aliases). Cherry-picking
# individual replacements like the previous regex patch produced inconsistent
# code that compiled but silently dropped every video frame.
import subprocess, sys, pathlib
patch = pathlib.Path('/decklink-sdk16.patch')
if not patch.exists():
print('FATAL: /decklink-sdk16.patch not found in build context', file=sys.stderr)
sys.exit(1)
# Patch was produced as `git diff HEAD FETCH_HEAD` where HEAD=release/7.1 and
# FETCH_HEAD=master, so we apply it in REVERSE to move 7.1 → master.
result = subprocess.run(
['git', 'apply', '-R', '--verbose', str(patch)],
cwd='/ffmpeg', capture_output=True, text=True,
)
print(result.stdout)
print(result.stderr, file=sys.stderr)
sys.exit(result.returncode)

View file

@ -1,112 +1,9 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { mkdirSync } from 'node:fs';
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';
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon'; const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
// 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
// worker uploads the finalized file to S3 after the recording stops.
// Toggled per-process by `GROWING_ENABLED=true` on the capture container
// (see routes/recorders.js where the env is composed).
const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true';
const GROWING_PATH = process.env.GROWING_PATH || '/growing';
// ── Codec catalogue ──────────────────────────────────────────────────────
// 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.
const VIDEO_CODECS = {
prores_hq: { args: ['-c:v', 'prores_ks', '-profile:v', '3'], bitrateControl: false, pixFmt: 'yuv422p10le' },
prores_422: { args: ['-c:v', 'prores_ks', '-profile:v', '2'], bitrateControl: false, pixFmt: 'yuv422p10le' },
prores_lt: { args: ['-c:v', 'prores_ks', '-profile:v', '1'], bitrateControl: false, pixFmt: 'yuv422p10le' },
prores_proxy: { args: ['-c:v', 'prores_ks', '-profile:v', '0'], bitrateControl: false, pixFmt: 'yuv422p10le' },
dnxhd: { args: ['-c:v', 'dnxhd'], bitrateControl: true, pixFmt: 'yuv422p' },
dnxhr_hq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_hq'], bitrateControl: false, pixFmt: 'yuv422p' },
dnxhr_sq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_sq'], bitrateControl: false, pixFmt: 'yuv422p' },
h264: { args: ['-c:v', 'libx264', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
h264_nvenc: { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' },
h265: { args: ['-c:v', 'libx265', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
// All-Intra HEVC on NVENC — the growing-file master codec.
// Goal: every frame an IDR (all-intra), so a still-growing file is decodable
// to its last complete frame — the prerequisite for edit-while-record.
//
// NVENC will NOT accept `-g 1`: InitializeEncoder enforces
// "GopLength > numBFrames + 1", so with -bf 0 the minimum GOP is 2 and -g 1
// is rejected with EINVAL (validated on the L4, driver 595). The working
// recipe for true all-intra is therefore:
// -bf 0 no B-frames
// -g 600 large GOP just to satisfy the init check
// -forced-idr 1 forced keyframes are emitted as IDR
// -force_key_frames expr:1 force a keyframe on EVERY frame
// → ffprobe confirms pict_type = I for all frames.
//
// Container: fragmented MOV (+frag_keyframe+empty_moov+default_base_moof),
// NOT MXF — this ffmpeg's MXF muxer rejects HEVC ("Operation not permitted").
// The frag-MOV index is not deferred to EOF, so the file stays readable while
// growing. (See docs/design/2026-05-29-all-intra-hevc-ingest.md §8.)
//
// -profile:v main10 / p010le: 10-bit 4:2:0 — the closest NVENC HEVC can get
// to ProRes 4:2:2 10-bit. If strict 4:2:2 is required, use prores_hq (CPU).
hevc_nvenc: {
args: ['-c:v', 'hevc_nvenc', '-preset', 'p4', '-rc', 'vbr', '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1', '-profile:v', 'main10'],
bitrateControl: true,
pixFmt: 'p010le',
},
};
const AUDIO_CODECS = {
pcm_s16le: { args: ['-c:a', 'pcm_s16le'], bitrateControl: false },
pcm_s24le: { args: ['-c:a', 'pcm_s24le'], bitrateControl: false },
pcm_s32le: { args: ['-c:a', 'pcm_s32le'], bitrateControl: false },
aac: { args: ['-c:a', 'aac'], bitrateControl: true },
ac3: { args: ['-c:a', 'ac3'], bitrateControl: true },
opus: { args: ['-c:a', 'libopus'], bitrateControl: true },
flac: { args: ['-c:a', 'flac'], bitrateControl: false },
};
const CONTAINER_FMT = {
mov: 'mov',
mp4: 'mp4',
mkv: 'matroska',
mxf: 'mxf',
ts: 'mpegts',
};
const CONTAINER_EXT = {
mov: 'mov', mp4: 'mp4', mkv: 'mkv', mxf: 'mxf', ts: 'ts',
};
function buildEncodeArgs({
codec, videoBitrate, framerate,
audioCodec, audioBitrate, audioChannels,
container, isNetwork, isProxy = false,
}) {
const v = VIDEO_CODECS[codec] || (isProxy ? VIDEO_CODECS.h264 : VIDEO_CODECS.prores_hq);
const a = AUDIO_CODECS[audioCodec] || (isProxy ? AUDIO_CODECS.aac : AUDIO_CODECS.pcm_s24le);
const fmt = CONTAINER_FMT[container] || (isProxy ? 'mp4' : 'mov');
const args = [];
if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');
args.push(...v.args);
if (v.pixFmt) args.push('-pix_fmt', v.pixFmt);
if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate);
if (framerate && framerate !== 'native') args.push('-r', framerate);
args.push(...a.args);
if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate);
if (audioChannels) args.push('-ac', String(audioChannels));
if (fmt === 'mov' || fmt === 'mp4') {
args.push('-movflags', '+frag_keyframe+empty_moov');
}
args.push('-f', fmt);
return args;
}
class CaptureManager { class CaptureManager {
constructor() { constructor() {
this.state = { this.state = {
@ -114,10 +11,6 @@ class CaptureManager {
sessionId: null, sessionId: null,
processes: {}, processes: {},
currentSession: {}, currentSession: {},
framesReceived: 0,
currentFps: 0,
lastFrameAt: null,
lastError: null,
}; };
} }
@ -126,19 +19,20 @@ class CaptureManager {
* Returns { inputArgs, isNetwork } * Returns { inputArgs, isNetwork }
* @private * @private
*/ */
async _buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) { _buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) {
if (sourceType === 'srt') { if (sourceType === 'srt') {
let url; let url;
if (listen) { if (listen) {
const port = listenPort || 9000; const port = listenPort || 9000;
url = `srt://0.0.0.0:${port}?mode=listener`; url = `srt://0.0.0.0:${port}?mode=listener`;
} else { } else {
// Caller mode — ensure mode=caller is appended if not already present
url = sourceUrl; url = sourceUrl;
if (!url.includes('mode=')) { if (!url.includes('mode=')) {
url += (url.includes('?') ? '&' : '?') + 'mode=caller'; url += (url.includes('?') ? '&' : '?') + 'mode=caller';
} }
} }
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', url], isNetwork: true }; return { inputArgs: ['-i', url], isNetwork: true };
} }
if (sourceType === 'rtmp') { if (sourceType === 'rtmp') {
@ -146,104 +40,33 @@ class CaptureManager {
const port = listenPort || 1935; const port = listenPort || 1935;
const key = streamKey || 'stream'; const key = streamKey || 'stream';
return { return {
inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`], inputArgs: ['-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`],
isNetwork: true, isNetwork: true,
}; };
} }
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true }; return { inputArgs: ['-i', sourceUrl], isNetwork: true };
}
// Deltacast SDI via VideoMaster SDK FFmpeg plugin.
// FFmpeg input format is 'deltacast', device address is 'deltacast://<index>'.
// When the physical device is absent (/dev/deltacast<N> missing), fall back
// to a lavfi test card so development and integration testing work without hardware.
if (sourceType === 'deltacast') {
const idx = (typeof device === 'number' || /^\d+$/.test(String(device)))
? parseInt(device, 10)
: 0;
const { existsSync } = await import('node:fs');
const deviceNode = `/dev/deltacast${idx}`;
if (existsSync(deviceNode)) {
console.log(`[capture] Deltacast index ${idx}${deviceNode} (hardware)`);
return {
inputArgs: ['-f', 'deltacast', '-i', `deltacast://${idx}`],
isNetwork: false,
};
} else {
// No hardware — lavfi test card with port label + timecode burn-in.
// Matches the deltacast-sdi-recorder standalone app fallback exactly so
// recorded files look right in the MAM library during dev.
console.warn(`[capture] Deltacast device ${deviceNode} not found — using lavfi test card for port ${idx}`);
const testSrc = [
`testsrc2=size=1920x1080:rate=30`,
`drawtext=text='DELTACAST PORT ${idx} — TEST MODE':fontsize=48:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2`,
`drawtext=text='%{localtime\\:%H\\:%M\\:%S}':fontsize=32:fontcolor=yellow:x=10:y=10`,
].join(',');
return {
inputArgs: [
'-f', 'lavfi', '-i', testSrc,
'-f', 'lavfi', '-i', 'sine=frequency=1000:sample_rate=48000',
'-map', '0:v:0', '-map', '1:a:0',
],
isNetwork: false,
};
}
} }
// Default: SDI via DeckLink // Default: SDI via DeckLink
// device may be an integer index (0-based) or a full device name string.
// FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo 2 (2)').
// Map integer index -> name using ffmpeg -sources decklink at runtime.
//
// ffmpeg -sources decklink output format:
// Auto-detected sources for decklink:
// DeckLink Duo 2
// DeckLink Duo 2 (2)
// Lines containing device names start with whitespace; the header line
// starts with a non-space character. Previous code used a v4l2-style
// hex-address regex that never matched DeckLink output → index 1+ always
// fell through to a wrong fallback, producing black output from port 2+.
let deckLinkName = String(device);
if (typeof device === 'number' || /^\d+$/.test(String(device))) {
const idx = parseInt(device, 10);
try {
const { execSync } = await import('child_process');
const out = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
const names = [];
for (const line of out.split('\n')) {
// DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)"
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
if (m) names.push(m[1]);
}
if (names[idx]) {
deckLinkName = names[idx];
console.log(`[capture] DeckLink index ${idx} → "${deckLinkName}" (from ${names.length} detected: ${names.join(', ')})`);
} else {
// Fallback: cannot determine model name without enumeration.
// Log a warning — operator should check the detected device list.
console.warn(`[capture] DeckLink index ${idx} out of range (detected ${names.length} devices: ${names.join(', ')}). Falling back to index-only input — capture may fail.`);
deckLinkName = `DeckLink (${idx})`;
}
} catch (err) {
console.warn(`[capture] ffmpeg -sources decklink failed: ${err.message}. Using index ${device} directly.`);
// Pass the numeric index directly; some ffmpeg builds accept it.
deckLinkName = String(device);
}
}
return { return {
inputArgs: ['-f', 'decklink', '-i', deckLinkName], inputArgs: ['-f', 'decklink', '-i', String(device)],
isNetwork: false, isNetwork: false,
}; };
} }
/** /**
* Start a new capture session. * Start a new capture session
* * @param {Object} params
* Codec parameters all have sensible defaults so legacy callers (no codec * - projectId, binId, clipName always required
* args) still produce ProRes HQ master + H.264 proxy. * - device DeckLink device index (SDI only)
* - sourceType 'sdi' | 'srt' | 'rtmp' (default: 'sdi')
* - sourceUrl URL for caller mode (SRT/RTMP caller)
* - listen true for listener/server mode
* - listenPort port to bind in listener mode
* - streamKey RTMP stream key for listener mode
* @returns {Object} Session info
*/ */
async start({ async start({
assetId,
projectId, projectId,
binId, binId,
clipName, clipName,
@ -253,172 +76,96 @@ class CaptureManager {
listen = false, listen = false,
listenPort, listenPort,
streamKey, streamKey,
// ── Recording codec ─────────────────────────────────────────────
videoCodec = 'prores_hq',
videoBitrate = null,
framerate = null,
audioCodec = 'pcm_s24le',
audioBitrate = null,
audioChannels = 2,
container = 'mov',
// ── Proxy codec ─────────────────────────────────────────────────
proxyEnabled = true,
proxyVideoCodec = 'h264',
proxyVideoBitrate = '8M',
proxyFramerate = null,
proxyAudioCodec = 'aac',
proxyAudioBitrate = '192k',
proxyAudioChannels = 2,
proxyContainer = 'mp4',
}) { }) {
this._assetIdForHls = assetId || null;
if (this.state.recording) { if (this.state.recording) {
throw new Error('Capture already in progress'); throw new Error('Capture already in progress');
} }
const sessionId = uuidv4(); const sessionId = uuidv4();
const hiresExt = CONTAINER_EXT[container] || 'mov'; const hiresKey = `projects/${projectId}/masters/${clipName}.mov`;
const proxyExt = CONTAINER_EXT[proxyContainer] || 'mp4';
const hiresKey = `projects/${projectId}/masters/${clipName}.${hiresExt}`;
// Growing-files: write master to the local SMB share instead of streaming // Network sources cannot be opened by two FFmpeg processes simultaneously.
// to S3. Path is relative to the container's GROWING_PATH mount. // proxyKey is null for SRT/RTMP — the BullMQ worker generates the proxy
const growingPath = GROWING_ENABLED // after the recording stops (same pipeline used for uploaded files).
? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}` const proxyKey = sourceType === 'sdi'
? `projects/${projectId}/proxies/${clipName}.mp4`
: null; : null;
if (growingPath) {
try { mkdirSync(dirname(growingPath), { recursive: true }); }
catch (err) { console.error('[capture] could not create growing dir:', err.message); }
}
// DeckLink hardware does NOT support concurrent capture from the same port.
// Opening a second ffmpeg process on the same DeckLink input while the first
// is already capturing causes "Cannot Autodetect input stream or No signal"
// on the second process — making the proxy empty and potentially crashing the
// container before the hires upload completes.
//
// Treat SDI the same as SRT/RTMP: set proxyKey=null here and let the BullMQ
// worker generate the proxy from the hires master after the recording stops.
// The stop handler sets needsProxy=true so the worker picks it up.
const proxyKey = null;
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
const { inputArgs, isNetwork } = await this._buildInputArgs({ const { inputArgs, isNetwork } = this._buildInputArgs({
sourceType, device, sourceUrl, listen, listenPort, streamKey, sourceType,
device,
sourceUrl,
listen,
listenPort,
streamKey,
}); });
const hiresCodecArgs = buildEncodeArgs({ // ProRes hires — fragmented moov for pipe-safe output on network sources
codec: videoCodec, videoBitrate, framerate, const hiresCodecArgs = isNetwork
audioCodec, audioBitrate, audioChannels, ? [
container, '-c:v', 'prores_ks',
isNetwork, '-profile:v', '3',
isProxy: false, '-c:a', 'pcm_s24le',
'-movflags', '+frag_keyframe+empty_moov',
'-f', 'mov',
]
: [
'-c:v', 'prores_ks',
'-profile:v', '3',
'-c:a', 'pcm_s24le',
'-f', 'mov',
];
// Spawn hires FFmpeg process
const hiresProcess = spawn('ffmpeg', [
...inputArgs,
...hiresCodecArgs,
'pipe:1',
], {
stdio: ['ignore', 'pipe', 'pipe'],
}); });
console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' ')); const hiresUpload = createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);
const sdiFilterArgs = (sourceType === 'sdi') ? ['-vf', 'yadif=mode=1:deint=1'] : [];
// When growing-files is on, write directly to the SMB share so Premier
// can mount and edit the live file. Promotion worker uploads to S3 on EOF.
// Otherwise, stream the master to S3 via stdout pipe (legacy behavior).
const hiresOutput = growingPath ? growingPath : 'pipe:1';
const hiresStdio = growingPath ? ['ignore', 'ignore', 'pipe'] : ['ignore', 'pipe', 'pipe'];
// For SDI we cannot open the DeckLink device a second time for a preview
// tee, so the live HLS preview is produced as a SECOND OUTPUT of the hires
// ffmpeg: one decklink read -> yadif -> split -> [ProRes/S3] + [H.264/HLS].
let sdiHlsDir = null;
let hiresArgs;
if (sourceType === 'sdi' && this._assetIdForHls) {
const fsMod = await import('node:fs');
sdiHlsDir = '/live/' + this._assetIdForHls;
try { fsMod.mkdirSync(sdiHlsDir, { recursive: true }); } catch (_) {}
hiresArgs = [
...inputArgs,
'-filter_complex', '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]',
// Output 0 — ProRes master (S3 pipe or growing file)
'-map', '[vhi]', '-map', '0:a:0?',
...hiresCodecArgs,
hiresOutput,
// Output 1 — low-latency H.264 HLS preview for the UI monitor
'-map', '[vlo]', '-map', '0:a:0?',
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
'-pix_fmt', 'yuv420p', '-b:v', '2M', '-g', '60', '-sc_threshold', '0',
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
'-hls_flags', 'delete_segments+append_list+omit_endlist',
'-hls_segment_filename', sdiHlsDir + '/seg-%05d.ts',
sdiHlsDir + '/index.m3u8',
];
console.log('[HLS] SDI preview as 2nd output -> ' + sdiHlsDir);
} else {
hiresArgs = [ ...inputArgs, ...sdiFilterArgs, ...hiresCodecArgs, hiresOutput ];
}
const hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio });
const hiresUpload = growingPath
? Promise.resolve({ growingPath })
: createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);
const processes = { hires: hiresProcess }; const processes = { hires: hiresProcess };
const uploads = { hires: hiresUpload }; const uploads = { hires: hiresUpload };
// ── HLS tee for network sources (live preview in the UI) ──────────
let hlsProcess = null;
let hlsDir = null;
if (isNetwork && this._assetIdForHls) {
try {
const fs = await import('node:fs');
hlsDir = '/live/' + this._assetIdForHls;
fs.mkdirSync(hlsDir, { recursive: true });
const hlsArgs = [
...inputArgs,
'-map', '0:v:0?', '-map', '0:a:0?',
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
'-pix_fmt', 'yuv420p', '-b:v', '2M', '-g', '60', '-sc_threshold', '0',
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
'-hls_flags', 'delete_segments+append_list+omit_endlist',
'-hls_segment_filename', hlsDir + '/seg-%05d.ts',
hlsDir + '/index.m3u8',
];
hlsProcess = spawn('ffmpeg', hlsArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
hlsProcess.stderr.on('data', (d) => { console.error('[HLS] ' + d); });
hlsProcess.on('exit', (c) => console.log('[HLS] exited ' + c));
processes.hls = hlsProcess;
console.log('[HLS] tee started -> ' + hlsDir);
} catch (err) {
console.error('[HLS] tee failed:', err.message);
}
}
hiresProcess.stderr.on('data', (data) => { hiresProcess.stderr.on('data', (data) => {
const text = data.toString(); console.error(`[HIRES] ${data}`);
console.error(`[HIRES] ${text}`);
const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/);
if (m) {
this.state.framesReceived = parseInt(m[1], 10);
this.state.currentFps = parseFloat(m[2]);
this.state.lastFrameAt = new Date().toISOString();
}
if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
this.state.lastError = text.trim().slice(0, 240);
}
}); });
// Proxy is generated after stop by the BullMQ worker (same as SRT/RTMP). // SDI only: spawn a second FFmpeg process for the proxy.
// DeckLink hardware does not support two concurrent readers on the same port. // DeckLink cards can be opened simultaneously by multiple processes;
// network streams cannot.
if (!isNetwork) {
const proxyProcess = spawn('ffmpeg', [
...inputArgs,
'-c:v', 'libx264',
'-preset', 'fast',
'-b:v', '10M',
'-c:a', 'aac',
'-b:a', '192k',
'-movflags', '+frag_keyframe+empty_moov',
'-f', 'mp4',
'pipe:1',
], {
stdio: ['ignore', 'pipe', 'pipe'],
});
const proxyUpload = createUploadStream(S3_BUCKET, proxyKey, proxyProcess.stdout);
processes.proxy = proxyProcess;
uploads.proxy = proxyUpload;
proxyProcess.stderr.on('data', (data) => {
console.error(`[PROXY] ${data}`);
});
}
this.state.recording = true; this.state.recording = true;
this.state.sessionId = sessionId; this.state.sessionId = sessionId;
this.state.processes = processes; this.state.processes = processes;
this.state.framesReceived = 0;
this.state.currentFps = 0;
this.state.lastFrameAt = null;
this.state.lastError = null;
this.state.currentSession = { this.state.currentSession = {
sessionId, sessionId,
projectId, projectId,
@ -429,21 +176,19 @@ class CaptureManager {
sourceUrl, sourceUrl,
hiresKey, hiresKey,
proxyKey, proxyKey,
growingPath,
startedAt, startedAt,
duration: 0, duration: 0,
uploads, uploads,
codecs: {
videoCodec, videoBitrate, framerate,
audioCodec, audioBitrate, audioChannels, container,
proxyEnabled, proxyVideoCodec, proxyVideoBitrate,
proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer,
},
}; };
return this._formatSessionResponse(); return this._formatSessionResponse();
} }
/**
* Stop the current capture session
* @param {string} sessionId - Session ID to stop
* @returns {Object} Completed session info
*/
async stop(sessionId) { async stop(sessionId) {
if (!this.state.recording || this.state.sessionId !== sessionId) { if (!this.state.recording || this.state.sessionId !== sessionId) {
throw new Error('No active capture session or session ID mismatch'); throw new Error('No active capture session or session ID mismatch');
@ -451,13 +196,20 @@ class CaptureManager {
const { processes, currentSession } = this.state; const { processes, currentSession } = this.state;
if (processes.hires) processes.hires.kill('SIGINT'); // Gracefully terminate all FFmpeg processes
if (processes.proxy) processes.proxy.kill('SIGINT'); if (processes.hires) {
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} } processes.hires.kill('SIGINT');
}
if (processes.proxy) {
processes.proxy.kill('SIGINT');
}
try { try {
// Wait for all in-flight S3 uploads to complete
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);
}
await Promise.all(uploadPromises); await Promise.all(uploadPromises);
} catch (error) { } catch (error) {
console.error('Error during upload completion:', error); console.error('Error during upload completion:', error);
@ -468,15 +220,11 @@ class CaptureManager {
const stopTime = new Date(stoppedAt); const stopTime = new Date(stoppedAt);
const duration = Math.round((stopTime - startTime) / 1000); const duration = Math.round((stopTime - startTime) / 1000);
// Reset state
this.state.recording = false; this.state.recording = false;
this.state.sessionId = null; this.state.sessionId = null;
this.state.processes = {}; this.state.processes = {};
// No frames received → the upload (if any) produced a 0-byte object.
// Surface that so the shutdown handler can mark the asset as 'error'
// instead of posting a broken hi-res key downstream.
const framesReceived = this.state.framesReceived;
return { return {
sessionId, sessionId,
projectId: currentSession.projectId, projectId: currentSession.projectId,
@ -484,31 +232,28 @@ class CaptureManager {
clipName: currentSession.clipName, clipName: currentSession.clipName,
sourceType: currentSession.sourceType, sourceType: currentSession.sourceType,
hiresKey: currentSession.hiresKey, hiresKey: currentSession.hiresKey,
proxyKey: currentSession.proxyKey, proxyKey: currentSession.proxyKey, // null for SRT/RTMP
growingPath: currentSession.growingPath || null,
startedAt: currentSession.startedAt, startedAt: currentSession.startedAt,
stoppedAt, stoppedAt,
duration, duration,
framesReceived,
empty: framesReceived === 0,
}; };
} }
/**
* Get current capture status
* @returns {Object} Current state
*/
getStatus() { getStatus() {
if (!this.state.recording) return { recording: false }; if (!this.state.recording) {
return {
recording: false,
};
}
const startTime = new Date(this.state.currentSession.startedAt); const startTime = new Date(this.state.currentSession.startedAt);
const now = new Date(); const now = new Date();
const duration = Math.round((now - startTime) / 1000); const duration = Math.round((now - startTime) / 1000);
const lastFrameAt = this.state.lastFrameAt;
const msSinceFrame = lastFrameAt ? (Date.now() - new Date(lastFrameAt).getTime()) : null;
let signal = 'connecting';
if (this.state.framesReceived > 0) {
signal = (msSinceFrame !== null && msSinceFrame < 5000) ? 'receiving' : 'lost';
} else if (this.state.lastError) {
signal = 'error';
}
return { return {
recording: true, recording: true,
sessionId: this.state.sessionId, sessionId: this.state.sessionId,
@ -519,16 +264,13 @@ class CaptureManager {
binId: this.state.currentSession.binId, binId: this.state.currentSession.binId,
duration, duration,
startedAt: this.state.currentSession.startedAt, startedAt: this.state.currentSession.startedAt,
signal,
framesReceived: this.state.framesReceived,
currentFps: this.state.currentFps,
lastFrameAt,
msSinceFrame,
lastError: this.state.lastError,
codecs: this.state.currentSession.codecs,
}; };
} }
/**
* Format session response
* @private
*/
_formatSessionResponse() { _formatSessionResponse() {
const { currentSession, sessionId } = this.state; const { currentSession, sessionId } = this.state;
return { return {
@ -541,10 +283,8 @@ class CaptureManager {
hiresKey: currentSession.hiresKey, hiresKey: currentSession.hiresKey,
proxyKey: currentSession.proxyKey, proxyKey: currentSession.proxyKey,
startedAt: currentSession.startedAt, startedAt: currentSession.startedAt,
codecs: currentSession.codecs,
}; };
} }
} }
export default new CaptureManager(); export default new CaptureManager();
export { VIDEO_CODECS, AUDIO_CODECS, CONTAINER_FMT, CONTAINER_EXT };

View file

@ -9,7 +9,6 @@ dotenv.config();
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000'; const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
const MAM_API_TOKEN = process.env.MAM_API_TOKEN || '';
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
@ -22,27 +21,11 @@ app.use('/capture', captureRoutes);
const server = app.listen(PORT, () => { const server = app.listen(PORT, () => {
console.log(`Wild Dragon Capture Service listening on port ${PORT}`); console.log(`Wild Dragon Capture Service listening on port ${PORT}`);
bootstrapAutoStart(); bootstrapAutoStart().catch((err) => {
console.error('[bootstrap] auto-start failed:', err);
});
}); });
// Mapped from the env vars routes/recorders.js writes into the container.
// Empty strings collapse to undefined so capture-manager's defaults win.
function envOpt(name) {
const v = process.env[name];
return v === undefined || v === '' ? undefined : v;
}
function envInt(name) {
const v = envOpt(name);
if (v === undefined) return undefined;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : undefined;
}
function envBool(name) {
const v = envOpt(name);
if (v === undefined) return undefined;
return v === 'true' || v === '1' || v === 'yes';
}
async function bootstrapAutoStart() { async function bootstrapAutoStart() {
const recorderId = process.env.RECORDER_ID; const recorderId = process.env.RECORDER_ID;
const sourceType = process.env.SOURCE_TYPE; const sourceType = process.env.SOURCE_TYPE;
@ -59,42 +42,28 @@ async function bootstrapAutoStart() {
} }
const listen = process.env.LISTEN === '1' || process.env.LISTEN === 'true'; const listen = process.env.LISTEN === '1' || process.env.LISTEN === 'true';
const listenPort = envInt('LISTEN_PORT'); const listenPort = process.env.LISTEN_PORT
const streamKey = envOpt('STREAM_KEY'); ? parseInt(process.env.LISTEN_PORT, 10)
const sourceUrl = envOpt('SOURCE_URL'); : undefined;
const device = envInt('DEVICE_INDEX'); const streamKey = process.env.STREAM_KEY || undefined;
const sourceUrl = process.env.SOURCE_URL || undefined;
if (sourceType === 'sdi') {
console.warn('[bootstrap] SDI auto-start not supported');
return;
}
console.log(`[bootstrap] starting ${sourceType} ingest (listen=${listen} port=${listenPort || 'n/a'})...`); console.log(`[bootstrap] starting ${sourceType} ingest (listen=${listen} port=${listenPort || 'n/a'})...`);
try { try {
const session = await captureManager.start({ const session = await captureManager.start({
assetId: envOpt('ASSET_ID') || null,
projectId, projectId,
binId: envOpt('BIN_ID') || null, binId: process.env.BIN_ID || null,
clipName, clipName,
device,
sourceType, sourceType,
sourceUrl, sourceUrl,
listen, listen,
listenPort, listenPort,
streamKey, streamKey,
// Recording codec — recorders.js passes these straight through
videoCodec: envOpt('RECORDING_CODEC') || 'prores_hq',
videoBitrate: envOpt('RECORDING_VIDEO_BITRATE'),
framerate: envOpt('RECORDING_FRAMERATE'),
audioCodec: envOpt('RECORDING_AUDIO_CODEC') || 'pcm_s24le',
audioBitrate: envOpt('RECORDING_AUDIO_BITRATE'),
audioChannels: envInt('RECORDING_AUDIO_CHANNELS') ?? 2,
container: envOpt('RECORDING_CONTAINER') || 'mov',
proxyEnabled: envBool('PROXY_ENABLED') ?? true,
proxyVideoCodec: envOpt('PROXY_CODEC') || 'h264',
proxyVideoBitrate: envOpt('PROXY_VIDEO_BITRATE') || '8M',
proxyFramerate: envOpt('PROXY_FRAMERATE'),
proxyAudioCodec: envOpt('PROXY_AUDIO_CODEC') || 'aac',
proxyAudioBitrate: envOpt('PROXY_AUDIO_BITRATE') || '192k',
proxyAudioChannels: envInt('PROXY_AUDIO_CHANNELS') ?? 2,
proxyContainer: envOpt('PROXY_CONTAINER') || 'mp4',
}); });
console.log(`[bootstrap] session ${session.sessionId} started for clip ${clipName}`); console.log(`[bootstrap] session ${session.sessionId} started for clip ${clipName}`);
} catch (err) { } catch (err) {
@ -114,68 +83,31 @@ async function gracefulShutdown(signal) {
console.log(`[shutdown] stopping active session ${status.sessionId}...`); console.log(`[shutdown] stopping active session ${status.sessionId}...`);
try { try {
const completed = await captureManager.stop(status.sessionId); const completed = await captureManager.stop(status.sessionId);
console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s frames=${completed.framesReceived}`); console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s`);
const liveAssetId = process.env.ASSET_ID || null; try {
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
// No frames received → the source never connected (bad SRT URL, dead method: 'POST',
// SDI signal, RTMP stream key mismatch, etc.). The S3 upload at this headers: { 'Content-Type': 'application/json' },
// point is 0 bytes and would just clog the proxy queue with "moov body: JSON.stringify({
// atom not found" failures. Mark the pre-created live asset as projectId: completed.projectId,
// 'error' and skip the POST /assets registration entirely. binId: completed.binId,
if (completed.empty) { clipName: completed.clipName,
console.warn('[shutdown] no frames received — marking asset as error and skipping registration'); sourceType: completed.sourceType,
if (liveAssetId) { hiresKey: completed.hiresKey,
try { proxyKey: completed.proxyKey,
await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/mark-empty`, { needsProxy: completed.proxyKey === null,
method: 'POST', duration: completed.duration,
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) }, capturedAt: completed.startedAt,
}); }),
} catch (e) { });
console.error('[shutdown] failed to flag empty asset:', e.message); if (!res.ok) {
} console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
} } else {
} else if (liveAssetId) { console.log('[shutdown] asset registered with mam-api');
// Finalise the pre-created live asset by id (avoids POST / 409 collision).
try {
const res = await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/finalize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
body: JSON.stringify({ hiresKey: completed.hiresKey, proxyKey: completed.proxyKey, duration: completed.duration }),
});
if (!res.ok) {
console.warn(`[shutdown] mam-api finalize returned ${res.status}: ${await res.text()}`);
} else {
console.log('[shutdown] live asset finalised with mam-api');
}
} catch (mamErr) {
console.error('[shutdown] failed to finalise asset:', mamErr.message);
}
} else {
try {
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
body: JSON.stringify({
projectId: completed.projectId,
binId: completed.binId,
clipName: completed.clipName,
sourceType: completed.sourceType,
hiresKey: completed.hiresKey,
proxyKey: completed.proxyKey,
needsProxy: completed.proxyKey === null,
duration: completed.duration,
capturedAt: completed.startedAt,
}),
});
if (!res.ok) {
console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
} else {
console.log('[shutdown] asset registered with mam-api');
}
} catch (mamErr) {
console.error('[shutdown] failed to register asset:', mamErr.message);
} }
} catch (mamErr) {
console.error('[shutdown] failed to register asset:', mamErr.message);
} }
} catch (err) { } catch (err) {
console.error('[shutdown] error during stop:', err); console.error('[shutdown] error during stop:', err);

View file

@ -1,79 +1,7 @@
import express from 'express'; import express from 'express';
import { execSync, spawn } from 'child_process'; import { execSync } from 'child_process';
import { existsSync, readdirSync } from 'node:fs';
import captureManager from '../capture-manager.js'; import captureManager from '../capture-manager.js';
import dgram from 'dgram';
import net from 'net';
function parseUrl(u) {
try {
const m = String(u).match(/^[a-z]+:\/\/([^:\/?#]+)(?::(\d+))?/i);
if (!m) return null;
return { host: m[1], port: parseInt(m[2] || '0', 10) };
} catch (_) { return null; }
}
async function checkReachable(host, port, sourceType) {
if (!port) return { ok: true };
if (sourceType === 'srt') return await udpSendProbe(host, port);
if (sourceType === 'rtmp') return await tcpConnectProbe(host, port);
return { ok: true };
}
function udpSendProbe(host, port) {
return new Promise((resolve) => {
const sock = dgram.createSocket('udp4');
let done = false;
const finish = (result) => { if (done) return; done = true; try { sock.close(); } catch (_) {} resolve(result); };
sock.on('error', (err) => {
const msg = String(err && err.message || err);
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) {
finish({ ok: false, error: 'Host ' + host + ' is unreachable from the capture container (no route). Confirm the IP is correct and the machine is online.', diagnostic: msg });
} else if (/ECONNREFUSED|EPORTUNREACH/i.test(msg)) {
finish({ ok: false, error: 'Nothing is listening on UDP ' + host + ':' + port + '. In vMix, confirm the SRT output is Started and the port matches.', diagnostic: msg });
} else {
finish({ ok: false, error: 'UDP probe failed: ' + msg, diagnostic: msg });
}
});
sock.send(Buffer.from('Z-AMPP-PROBE'), port, host, () => {});
setTimeout(() => finish({ ok: true }), 1500);
});
}
function tcpConnectProbe(host, port) {
return new Promise((resolve) => {
const sock = new net.Socket();
let done = false;
const finish = (r) => { if (done) return; done = true; try { sock.destroy(); } catch (_) {} resolve(r); };
sock.setTimeout(2500);
sock.once('connect', () => finish({ ok: true }));
sock.once('timeout', () => finish({ ok: false, error: 'TCP connect to ' + host + ':' + port + ' timed out. Confirm the host is reachable and a TCP listener is running.' }));
sock.once('error', (err) => {
const msg = String(err && err.message || err);
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) finish({ ok: false, error: 'Host ' + host + ' unreachable (no route).', diagnostic: msg });
else if (/ECONNREFUSED/i.test(msg)) finish({ ok: false, error: 'Nothing is listening on TCP ' + host + ':' + port + '.', diagnostic: msg });
else finish({ ok: false, error: 'TCP probe failed: ' + msg, diagnostic: msg });
});
sock.connect(port, host);
});
}
function classifyProbeError(raw, sourceType) {
const r = (raw || '').toLowerCase();
if (sourceType === 'srt') {
if (/connection .* failed: (input\/output|timer expired|connection setup failure)/i.test(raw)) {
return 'SRT handshake failed. In vMix: confirm the External Output is Started, Type=SRT, Mode=Listener, port matches, and any passphrase / stream ID is empty (or copied exactly).';
}
}
if (sourceType === 'rtmp') {
if (/connection refused/i.test(r)) return 'Nothing is listening on RTMP at this address. Start your RTMP source.';
if (/end-of-file|invalid data found/i.test(r)) return 'Got a TCP connection but no RTMP stream. Confirm the source is publishing and the path / stream-key match.';
}
return raw;
}
const router = express.Router(); const router = express.Router();
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000'; const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
@ -88,7 +16,7 @@ router.get('/devices', (req, res) => {
let output = ''; let output = '';
try { try {
output = execSync('ffmpeg -sources decklink 2>&1', { output = execSync('ffmpeg -f decklink -list_devices 1 -i dummy 2>&1', {
encoding: 'utf-8', encoding: 'utf-8',
}); });
} catch (error) { } catch (error) {
@ -96,13 +24,13 @@ router.get('/devices', (req, res) => {
output = error.stderr ? error.stderr.toString() : error.toString(); output = error.stderr ? error.stderr.toString() : error.toString();
} }
// Parse ffmpeg output for DeckLink device names. // Parse ffmpeg output for DeckLink device names
// DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)" // Format: [decklink @ ...] "DeckLink Quad 2" (input #0)
const lines = output.split('\n'); const lines = output.split('\n');
let deviceIndex = 0; let deviceIndex = 0;
for (const line of lines) { for (const line of lines) {
const match = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/); const match = line.match(/^\s*\[decklink[^\]]*\]\s+"([^"]+)"/);
if (match) { if (match) {
devices.push({ devices.push({
index: deviceIndex, index: deviceIndex,
@ -119,57 +47,6 @@ router.get('/devices', (req, res) => {
} }
}); });
/**
* GET /devices/deltacast
* List available Deltacast ports.
* Reads /dev/deltacast<N> nodes; falls back to env DELTACAST_PORT_COUNT
* so nodes without hardware still report their configured port count
* (test-card mode).
*/
router.get('/devices/deltacast', (req, res) => {
try {
const devices = [];
// First: enumerate actual /dev/deltacast* device nodes.
try {
const devEntries = readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n));
devEntries.sort();
for (const entry of devEntries) {
const m = entry.match(/^deltacast(\d+)$/);
if (m) {
devices.push({
index: parseInt(m[1], 10),
name: `Deltacast Port ${m[1]}`,
device: `/dev/${entry}`,
present: true,
});
}
}
} catch (_) { /* /dev always exists; ignore */ }
// Second: if DELTACAST_PORT_COUNT env is set and larger than what we found,
// fill in the remaining slots as test-card entries (no physical device).
const envCount = parseInt(process.env.DELTACAST_PORT_COUNT || '0', 10);
const found = new Set(devices.map(d => d.index));
for (let i = 0; i < envCount; i++) {
if (!found.has(i)) {
devices.push({
index: i,
name: `Deltacast Port ${i} (test card)`,
device: `/dev/deltacast${i}`,
present: false,
});
}
}
devices.sort((a, b) => a.index - b.index);
res.json({ devices });
} catch (error) {
console.error('Error listing Deltacast devices:', error);
res.status(500).json({ error: 'Failed to list Deltacast devices' });
}
});
/** /**
* GET /status * GET /status
* Get current capture status * Get current capture status
@ -183,103 +60,6 @@ router.get('/status', (req, res) => {
res.status(500).json({ error: 'Failed to get status' }); res.status(500).json({ error: 'Failed to get status' });
} }
}); });
router.post('/probe', async (req, res) => {
try {
const { source_type = 'sdi', source_url, listen = false } = req.body || {};
if (source_type === 'sdi') {
try {
const raw = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
const devices = [];
for (const line of raw.split('\n')) {
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
if (m) devices.push(m[1]);
}
return res.json({ ok: true, source_type, devices });
} catch (err) {
const out = (err.stderr || err.stdout || err.toString()).toString();
return res.json({ ok: false, source_type, error: out.slice(0, 800) });
}
}
if (source_type === 'deltacast') {
// Enumerate /dev/deltacast* nodes; report present/absent per index.
try {
const envCount = parseInt(process.env.DELTACAST_PORT_COUNT || '0', 10);
const devEntries = readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n)).sort();
const found = devEntries.map(n => {
const m = n.match(/^deltacast(\d+)$/);
return { index: parseInt(m[1], 10), device: `/dev/${n}`, present: true };
});
const foundIdx = new Set(found.map(d => d.index));
for (let i = 0; i < envCount; i++) {
if (!foundIdx.has(i)) {
found.push({ index: i, device: `/dev/deltacast${i}`, present: false });
}
}
found.sort((a, b) => a.index - b.index);
return res.json({ ok: true, source_type, devices: found });
} catch (err) {
return res.json({ ok: false, source_type, error: err.message });
}
}
if (listen) {
return res.json({ ok: false, source_type, error: 'Listener-mode sources cannot be probed standalone. Start the recorder and watch the signal indicator.' });
}
if (!source_url) return res.status(400).json({ error: 'source_url is required' });
// Pre-flight: parse host:port and check L3/L4 reachability so we can give
// an actionable error instead of the opaque libsrt "Input/output error".
const parsed = parseUrl(source_url);
if (!parsed) {
return res.json({ ok: false, source_type, source_url, error: 'Could not parse host:port from URL.' });
}
const reach = await checkReachable(parsed.host, parsed.port, source_type);
if (!reach.ok) {
return res.json({ ok: false, source_type, source_url, error: reach.error, diagnostic: reach.diagnostic });
}
let url = source_url;
if (source_type === 'srt' && !/mode=/.test(url)) {
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
}
const args = ['-hide_banner','-v','error','-probesize','32M','-analyzeduration','8M','-rw_timeout','7000000','-i', url, '-show_streams','-show_format','-of','json'];
const ff = spawn('ffprobe', args);
let stdout = '', stderr = '';
ff.stdout.on('data', (c) => { stdout += c; });
ff.stderr.on('data', (c) => { stderr += c; });
const killer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 10000);
ff.on('close', (code) => {
clearTimeout(killer);
if (code !== 0) {
const rawErr = (stderr || 'ffprobe failed').slice(0, 800);
const friendly = classifyProbeError(rawErr, source_type);
return res.json({ ok: false, source_type, source_url, error: friendly, diagnostic: rawErr });
}
try {
const parsed = JSON.parse(stdout);
const streams = (parsed.streams || []).map(s => ({
index: s.index, codec_type: s.codec_type, codec_name: s.codec_name,
width: s.width, height: s.height, pix_fmt: s.pix_fmt,
r_frame_rate: s.r_frame_rate, avg_frame_rate: s.avg_frame_rate,
sample_rate: s.sample_rate, channels: s.channels,
channel_layout: s.channel_layout, bit_rate: s.bit_rate,
}));
return res.json({ ok: true, source_type, source_url,
format: { format_name: parsed.format && parsed.format.format_name, duration: parsed.format && parsed.format.duration, bit_rate: parsed.format && parsed.format.bit_rate },
streams });
} catch (err) {
return res.json({ ok: false, source_type, source_url, error: 'Could not parse ffprobe output: ' + err.message });
}
});
} catch (error) {
console.error('Probe error:', error);
res.status(500).json({ error: error.message });
}
});
/** /**
* POST /start * POST /start

View file

@ -1,330 +0,0 @@
import express from 'express';
import { execSync, spawn } from 'child_process';
import captureManager from '../capture-manager.js';
import dgram from 'dgram';
import net from 'net';
function parseUrl(u) {
try {
const m = String(u).match(/^[a-z]+:\/\/([^:\/?#]+)(?::(\d+))?/i);
if (!m) return null;
return { host: m[1], port: parseInt(m[2] || '0', 10) };
} catch (_) { return null; }
}
async function checkReachable(host, port, sourceType) {
if (!port) return { ok: true };
if (sourceType === 'srt') return await udpSendProbe(host, port);
if (sourceType === 'rtmp') return await tcpConnectProbe(host, port);
return { ok: true };
}
function udpSendProbe(host, port) {
return new Promise((resolve) => {
const sock = dgram.createSocket('udp4');
let done = false;
const finish = (result) => { if (done) return; done = true; try { sock.close(); } catch (_) {} resolve(result); };
sock.on('error', (err) => {
const msg = String(err && err.message || err);
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) {
finish({ ok: false, error: 'Host ' + host + ' is unreachable from the capture container (no route). Confirm the IP is correct and the machine is online.', diagnostic: msg });
} else if (/ECONNREFUSED|EPORTUNREACH/i.test(msg)) {
finish({ ok: false, error: 'Nothing is listening on UDP ' + host + ':' + port + '. In vMix, confirm the SRT output is Started and the port matches.', diagnostic: msg });
} else {
finish({ ok: false, error: 'UDP probe failed: ' + msg, diagnostic: msg });
}
});
sock.send(Buffer.from('Z-AMPP-PROBE'), port, host, () => {});
setTimeout(() => finish({ ok: true }), 1500);
});
}
function tcpConnectProbe(host, port) {
return new Promise((resolve) => {
const sock = new net.Socket();
let done = false;
const finish = (r) => { if (done) return; done = true; try { sock.destroy(); } catch (_) {} resolve(r); };
sock.setTimeout(2500);
sock.once('connect', () => finish({ ok: true }));
sock.once('timeout', () => finish({ ok: false, error: 'TCP connect to ' + host + ':' + port + ' timed out. Confirm the host is reachable and a TCP listener is running.' }));
sock.once('error', (err) => {
const msg = String(err && err.message || err);
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) finish({ ok: false, error: 'Host ' + host + ' unreachable (no route).', diagnostic: msg });
else if (/ECONNREFUSED/i.test(msg)) finish({ ok: false, error: 'Nothing is listening on TCP ' + host + ':' + port + '.', diagnostic: msg });
else finish({ ok: false, error: 'TCP probe failed: ' + msg, diagnostic: msg });
});
sock.connect(port, host);
});
}
function classifyProbeError(raw, sourceType) {
const r = (raw || '').toLowerCase();
if (sourceType === 'srt') {
if (/connection .* failed: (input\/output|timer expired|connection setup failure)/i.test(raw)) {
return 'SRT handshake failed. In vMix: confirm the External Output is Started, Type=SRT, Mode=Listener, port matches, and any passphrase / stream ID is empty (or copied exactly).';
}
}
if (sourceType === 'rtmp') {
if (/connection refused/i.test(r)) return 'Nothing is listening on RTMP at this address. Start your RTMP source.';
if (/end-of-file|invalid data found/i.test(r)) return 'Got a TCP connection but no RTMP stream. Confirm the source is publishing and the path / stream-key match.';
}
return raw;
}
const router = express.Router();
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
/**
* GET /devices
* List available DeckLink devices
*/
router.get('/devices', (req, res) => {
try {
const devices = [];
let output = '';
try {
output = execSync('ffmpeg -f decklink -list_devices 1 -i dummy 2>&1', {
encoding: 'utf-8',
});
} catch (error) {
// ffmpeg returns non-zero, but stderr is still captured
output = error.stderr ? error.stderr.toString() : error.toString();
}
// Parse ffmpeg output for DeckLink device names
// Format: [decklink @ ...] "DeckLink Quad 2" (input #0)
const lines = output.split('\n');
let deviceIndex = 0;
for (const line of lines) {
const match = line.match(/^\s*\[decklink[^\]]*\]\s+"([^"]+)"/);
if (match) {
devices.push({
index: deviceIndex,
name: match[1],
});
deviceIndex++;
}
}
res.json({ devices });
} catch (error) {
console.error('Error listing devices:', error);
res.status(500).json({ error: 'Failed to list devices' });
}
});
/**
* GET /status
* Get current capture status
*/
router.get('/status', (req, res) => {
try {
const status = captureManager.getStatus();
res.json(status);
} catch (error) {
console.error('Error getting status:', error);
res.status(500).json({ error: 'Failed to get status' });
}
});
router.post('/probe', async (req, res) => {
try {
const { source_type = 'sdi', source_url, listen = false } = req.body || {};
if (source_type === 'sdi') {
try {
const raw = execSync('ffmpeg -hide_banner -f decklink -list_devices 1 -i dummy 2>&1', { encoding: 'utf-8', timeout: 5000 });
const devices = [];
for (const line of raw.split('\n')) {
const m = line.match(/\[decklink[^\]]*\]\s+"([^"]+)"/);
if (m) devices.push(m[1]);
}
return res.json({ ok: true, source_type, devices });
} catch (err) {
const out = (err.stderr || err.stdout || err.toString()).toString();
return res.json({ ok: false, source_type, error: out.slice(0, 800) });
}
}
if (listen) {
return res.json({ ok: false, source_type, error: 'Listener-mode sources cannot be probed standalone. Start the recorder and watch the signal indicator.' });
}
if (!source_url) return res.status(400).json({ error: 'source_url is required' });
// Pre-flight: parse host:port and check L3/L4 reachability so we can give
// an actionable error instead of the opaque libsrt "Input/output error".
const parsed = parseUrl(source_url);
if (!parsed) {
return res.json({ ok: false, source_type, source_url, error: 'Could not parse host:port from URL.' });
}
const reach = await checkReachable(parsed.host, parsed.port, source_type);
if (!reach.ok) {
return res.json({ ok: false, source_type, source_url, error: reach.error, diagnostic: reach.diagnostic });
}
let url = source_url;
if (source_type === 'srt' && !/mode=/.test(url)) {
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
}
const args = ['-hide_banner','-v','error','-probesize','32M','-analyzeduration','8M','-rw_timeout','7000000','-i', url, '-show_streams','-show_format','-of','json'];
const ff = spawn('ffprobe', args);
let stdout = '', stderr = '';
ff.stdout.on('data', (c) => { stdout += c; });
ff.stderr.on('data', (c) => { stderr += c; });
const killer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 10000);
ff.on('close', (code) => {
clearTimeout(killer);
if (code !== 0) {
const rawErr = (stderr || 'ffprobe failed').slice(0, 800);
const friendly = classifyProbeError(rawErr, source_type);
return res.json({ ok: false, source_type, source_url, error: friendly, diagnostic: rawErr });
}
try {
const parsed = JSON.parse(stdout);
const streams = (parsed.streams || []).map(s => ({
index: s.index, codec_type: s.codec_type, codec_name: s.codec_name,
width: s.width, height: s.height, pix_fmt: s.pix_fmt,
r_frame_rate: s.r_frame_rate, avg_frame_rate: s.avg_frame_rate,
sample_rate: s.sample_rate, channels: s.channels,
channel_layout: s.channel_layout, bit_rate: s.bit_rate,
}));
return res.json({ ok: true, source_type, source_url,
format: { format_name: parsed.format && parsed.format.format_name, duration: parsed.format && parsed.format.duration, bit_rate: parsed.format && parsed.format.bit_rate },
streams });
} catch (err) {
return res.json({ ok: false, source_type, source_url, error: 'Could not parse ffprobe output: ' + err.message });
}
});
} catch (error) {
console.error('Probe error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /start
* Start a new capture session
*
* Body (SDI):
* { project_id, clip_name, device, bin_id?, source_type? }
*
* Body (SRT/RTMP caller):
* { project_id, clip_name, source_type, source_url, bin_id? }
*
* Body (SRT/RTMP listener):
* { project_id, clip_name, source_type, listen: true, listen_port?, stream_key?, bin_id? }
*/
router.post('/start', async (req, res) => {
try {
const {
project_id,
bin_id,
clip_name,
device,
source_type = 'sdi',
source_url,
listen = false,
listen_port,
stream_key,
} = req.body;
if (!project_id || !clip_name) {
return res.status(400).json({
error: 'Missing required fields: project_id, clip_name',
});
}
// Source-specific validation
if (source_type === 'sdi') {
if (device === undefined || device === null) {
return res.status(400).json({ error: 'SDI source requires: device' });
}
} else if (source_type === 'srt' || source_type === 'rtmp') {
if (!listen && !source_url) {
return res.status(400).json({
error: `${source_type.toUpperCase()} caller mode requires: source_url`,
});
}
} else {
return res.status(400).json({
error: `Unknown source_type: ${source_type}. Must be sdi, srt, or rtmp`,
});
}
const session = await captureManager.start({
projectId: project_id,
binId: bin_id || null,
clipName: clip_name,
device,
sourceType: source_type,
sourceUrl: source_url,
listen,
listenPort: listen_port,
streamKey: stream_key,
});
res.json(session);
} catch (error) {
console.error('Error starting capture:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /stop
* Stop the current capture session
* Body: { session_id }
*/
router.post('/stop', async (req, res) => {
try {
const { session_id } = req.body;
if (!session_id) {
return res.status(400).json({ error: 'Missing required field: session_id' });
}
const completedSession = await captureManager.stop(session_id);
// Register asset with mam-api.
// If proxyKey is null (SRT/RTMP source), set needsProxy=true so the
// worker generates a proxy from the hires file asynchronously.
try {
const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId: completedSession.projectId,
binId: completedSession.binId,
clipName: completedSession.clipName,
sourceType: completedSession.sourceType,
hiresKey: completedSession.hiresKey,
proxyKey: completedSession.proxyKey,
needsProxy: completedSession.proxyKey === null,
duration: completedSession.duration,
capturedAt: completedSession.startedAt,
}),
});
if (!mamResponse.ok) {
console.warn(
`MAM API registration returned ${mamResponse.status}: ${await mamResponse.text()}`,
);
}
} catch (mamError) {
console.warn('Failed to register asset with MAM API:', mamError.message);
}
res.json(completedSession);
} catch (error) {
console.error('Error stopping capture:', error);
res.status(500).json({ error: error.message });
}
});
export default router;

View file

@ -1,8 +1,4 @@
FROM node:22-slim FROM node:22-alpine
# unzip/tar needed for SDK upload extraction (see routes/sdk.js)
RUN apt-get update \
&& apt-get install -y --no-install-recommends unzip tar ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install --omit=dev RUN npm install --omit=dev

File diff suppressed because it is too large Load diff

View file

@ -2,12 +2,10 @@
"name": "wild-dragon-mam-api", "name": "wild-dragon-mam-api",
"version": "0.1.0", "version": "0.1.0",
"description": "Media Asset Management API for Wild Dragon", "description": "Media Asset Management API for Wild Dragon",
"type": "module",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"dev": "node --watch src/index.js", "dev": "node --watch src/index.js"
"test": "node --test $(find test -name '*.test.js' | sort)"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.2", "express": "^4.18.2",
@ -22,9 +20,7 @@
"bullmq": "^5.5.0", "bullmq": "^5.5.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5"
"qrcode": "^1.5.4",
"google-auth-library": "^9.14.0"
}, },
"engines": { "engines": {
"node": ">=22.0.0" "node": ">=22.0.0"

View file

@ -1,90 +0,0 @@
// Per-project authorization — the single source of truth for "can this user
// touch this project?". v1 auth answers "are you logged in?"; this answers
// "which projects, and at what level?".
//
// Model (locked with Zac):
// - role 'admin' → global bypass; every project at 'edit'.
// - role 'editor'/'viewer' → scoped to projects granted to them directly
// (project_access subject_type='user') or via a
// group they belong to (subject_type='group').
// - grant level 'view' → read-only; 'edit' → read-write.
//
// A user's effective level on a project is the MAX of every matching grant
// (direct + each group). 'edit' outranks 'view'.
//
// All functions take an optional `db` (defaults to the shared pool) so tests
// can inject an isolated test pool.
import defaultPool from '../db/pool.js';
const LEVEL_RANK = { view: 1, edit: 2 };
export function isAdmin(user) {
return user?.role === 'admin';
}
// Returns the higher of two levels (either may be null/undefined).
function maxLevel(a, b) {
const ra = LEVEL_RANK[a] || 0;
const rb = LEVEL_RANK[b] || 0;
if (ra === 0 && rb === 0) return null;
return ra >= rb ? a : b;
}
// Resolve every project the user can see, with their effective level.
// admin → { all: true, ids: null, levelByProject: null }
// else → { all: false, ids: Set<projectId>, levelByProject: Map<projectId, 'view'|'edit'> }
export async function accessibleProjectIds(user, db = defaultPool) {
if (isAdmin(user)) return { all: true, ids: null, levelByProject: null };
const levelByProject = new Map();
if (!user?.id) return { all: false, ids: new Set(), levelByProject };
const { rows } = await db.query(
`SELECT pa.project_id, pa.level
FROM project_access pa
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
))`,
[user.id]
);
for (const r of rows) {
levelByProject.set(r.project_id, maxLevel(levelByProject.get(r.project_id), r.level));
}
return { all: false, ids: new Set(levelByProject.keys()), levelByProject };
}
// Effective level on a single project: 'edit' | 'view' | null.
export async function projectLevel(user, projectId, db = defaultPool) {
if (isAdmin(user)) return 'edit';
if (!user?.id || !projectId) return null;
const { rows } = await db.query(
`SELECT pa.level
FROM project_access pa
WHERE pa.project_id = $1
AND ( (pa.subject_type = 'user' AND pa.subject_id = $2)
OR (pa.subject_type = 'group' AND pa.subject_id IN (
SELECT group_id FROM user_groups WHERE user_id = $2
)) )`,
[projectId, user.id]
);
let level = null;
for (const r of rows) level = maxLevel(level, r.level);
return level;
}
// Throw a 403-shaped error (caught by errorHandler) unless the user has at
// least `need` access on the project. `need` ∈ 'view' | 'edit'.
export async function assertProjectAccess(user, projectId, need = 'view', db = defaultPool) {
if (isAdmin(user)) return;
const have = await projectLevel(user, projectId, db);
if (!have || (LEVEL_RANK[have] || 0) < (LEVEL_RANK[need] || 0)) {
const err = new Error('forbidden');
err.status = 403;
throw err;
}
}

View file

@ -1,90 +0,0 @@
// Google OAuth (OIDC) sign-in helpers.
//
// Entirely config-gated: if GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET /
// OAUTH_REDIRECT_URL aren't set, isConfigured() is false and the routes 404, so
// a deployment without Google SSO behaves exactly as before. google-auth-library
// is imported lazily so the dependency is only required when the feature is on.
//
// Flow: /auth/google redirects to Google's consent screen with a signed `state`;
// /auth/google/callback exchanges the code, verifies the ID token, enforces the
// allowed Workspace domain, and auto-provisions a viewer account on first login.
const SCOPES = ['openid', 'email', 'profile'];
export function isConfigured() {
return !!(process.env.GOOGLE_CLIENT_ID
&& process.env.GOOGLE_CLIENT_SECRET
&& process.env.OAUTH_REDIRECT_URL);
}
export function allowedDomain() {
return (process.env.GOOGLE_ALLOWED_DOMAIN || '').trim().toLowerCase() || null;
}
// Lazily build an OAuth2 client (throws a clear error if the dep is missing).
async function makeClient() {
let OAuth2Client;
try {
({ OAuth2Client } = await import('google-auth-library'));
} catch {
const err = new Error('google-auth-library is not installed');
err.status = 500;
throw err;
}
return new OAuth2Client({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
redirectUri: process.env.OAUTH_REDIRECT_URL,
});
}
// URL of Google's consent screen. `state` is an opaque anti-CSRF token we also
// stash in the session and re-check on callback.
export async function buildAuthUrl(state) {
const client = await makeClient();
return client.generateAuthUrl({
access_type: 'online',
scope: SCOPES,
state,
prompt: 'select_account',
// If a Workspace domain is configured, hint Google to scope the picker to it.
...(allowedDomain() ? { hd: allowedDomain() } : {}),
});
}
// Exchange the authorization code and verify the returned ID token. Returns the
// verified { sub, email, name, hd } payload. Throws { status } on any failure.
export async function exchangeAndVerify(code) {
const client = await makeClient();
const { tokens } = await client.getToken(code);
if (!tokens.id_token) {
const err = new Error('no id_token from Google'); err.status = 401; throw err;
}
const ticket = await client.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID,
});
const p = ticket.getPayload();
if (!p || !p.sub) {
const err = new Error('invalid id_token'); err.status = 401; throw err;
}
// Require an explicitly verified email — a missing/undefined claim is NOT
// treated as verified, since the email drives account linking/provisioning.
if (!p.email || p.email_verified !== true) {
const err = new Error('email not verified'); err.status = 403; throw err;
}
const domain = allowedDomain();
if (domain) {
// ONLY trust Google's `hd` (hosted-domain) claim — it's present iff the
// account is a member of a Google Workspace domain that Google itself
// has verified. The email-suffix fallback we used to allow let any
// non-Workspace account with a spoof-friendly email through; if a
// GOOGLE_ALLOWED_DOMAIN is set, the operator means "only this Workspace,"
// and consumer accounts (no hd) must be rejected.
const hd = (p.hd || '').toLowerCase();
if (hd !== domain) {
const err = new Error('domain not allowed'); err.status = 403; throw err;
}
}
return { sub: p.sub, email: p.email, name: p.name || p.email, hd: p.hd || null };
}

View file

@ -1,58 +0,0 @@
// Short-lived MFA tickets bridging the two login steps.
//
// When a user with TOTP enabled passes password auth, we don't create a session
// yet — we hand back an opaque ticket. The second request (code or recovery
// code) redeems the ticket to finish login. Tickets are single-use and expire
// fast so a stolen ticket is near-useless.
//
// Tickets are bound to the issuing request's IP and User-Agent (hashed). A
// stolen ticket replayed from a different origin redeems to null. This is
// defense in depth against ticket exfiltration via a logged proxy, browser
// extension, or shoulder-surf; it does not stop an attacker who is on the same
// IP and UA.
//
// In-memory + single-instance, matching the existing login rate-limiter
// (auth/rate-limit.js). Documented limitation: in a multi-instance deployment
// the second step must hit the same node. Acceptable for Dragonflight's
// one-mam-api-per-node shape; revisit if that changes.
import { randomBytes, createHash } from 'node:crypto';
const TTL_MS = 5 * 60 * 1000; // 5 minutes to enter a code
const tickets = new Map(); // id -> { userId, ipHash, uaHash, expiresAt }
function sweep() {
const now = Date.now();
for (const [id, t] of tickets) if (t.expiresAt <= now) tickets.delete(id);
}
function hashBinding(value) {
return createHash('sha256').update(String(value || '')).digest('hex');
}
export function issueTicket(userId, { ip, userAgent } = {}) {
sweep();
const id = randomBytes(32).toString('hex');
tickets.set(id, {
userId,
ipHash: hashBinding(ip),
uaHash: hashBinding(userAgent),
expiresAt: Date.now() + TTL_MS,
});
return id;
}
// Redeem (and consume) a ticket. Returns the userId, or null if missing,
// expired, or the binding doesn't match the redeeming request.
export function redeemTicket(id, { ip, userAgent } = {}) {
if (!id) return null;
const t = tickets.get(id);
if (!t) return null;
tickets.delete(id); // single-use — burn even on binding mismatch so a
// wrong-binding probe can't be retried.
if (t.expiresAt <= Date.now()) return null;
// If a caller doesn't supply bindings (e.g. tests), accept — the issue side
// controls whether bindings get recorded.
if (ip !== undefined && t.ipHash !== hashBinding(ip)) return null;
if (userAgent !== undefined && t.uaHash !== hashBinding(userAgent)) return null;
return t.userId;
}

View file

@ -1,19 +0,0 @@
// Thin bcrypt wrapper. Cost 12 per the spec (NIST SP 800-63B-friendly).
// comparePassword must never throw on a malformed hash — that path is hit
// by the seeded dev user's placeholder hash and by any partially-imported
// row. Throwing here would 500 on a wrong-password attempt.
import bcrypt from 'bcrypt';
const COST = 12;
export async function hashPassword(plain) {
return bcrypt.hash(plain, COST);
}
export async function comparePassword(plain, hash) {
try {
return await bcrypt.compare(plain, hash);
} catch {
return false;
}
}

View file

@ -1,24 +0,0 @@
// Per-IP exponential backoff for /auth/login. Single-instance — fine for
// Dragonflight's deployment shape (one mam-api per node). Documented limitation.
const failures = new Map(); // ip -> count
const STEPS = [1000, 2000, 4000, 8000, 16000, 30000];
const MAX_ENTRIES = 10000; // bounded to prevent unbounded growth under spray attacks
export const ipBackoff = {
delayMs(ip) {
const n = failures.get(ip) || 0;
if (n === 0) return 0;
return STEPS[Math.min(n - 1, STEPS.length - 1)];
},
recordFailure(ip) {
// Evict the oldest entry if we're at the cap. Map preserves insertion order,
// so .keys().next().value is the oldest.
if (failures.size >= MAX_ENTRIES && !failures.has(ip)) {
failures.delete(failures.keys().next().value);
}
failures.set(ip, (failures.get(ip) || 0) + 1);
},
recordSuccess(ip) { failures.delete(ip); },
reset(ip) { failures.delete(ip); },
};

View file

@ -1,22 +0,0 @@
import { randomBytes, createHash } from 'node:crypto';
const PREFIX = 'dfl_';
export function generateToken() {
return PREFIX + randomBytes(32).toString('hex');
}
export function hashToken(token) {
return createHash('sha256').update(token).digest('hex');
}
export function parseBearer(authorizationHeader) {
if (!authorizationHeader || typeof authorizationHeader !== 'string') return null;
const m = authorizationHeader.match(/^Bearer\s+(\S+)$/i);
return m ? m[1] : null;
}
export const TOKEN_PREFIX_DISPLAY_LEN = 8; // for api_tokens.token_prefix
export function tokenDisplayPrefix(token) {
return token.slice(0, TOKEN_PREFIX_DISPLAY_LEN);
}

View file

@ -1,118 +0,0 @@
// TOTP (RFC 6238) implemented on node:crypto — no runtime dependency.
//
// Why hand-rolled: the algorithm is small and stable, and avoiding a dep keeps
// the auth core auditable. Verified against the RFC 6238 Appendix B test vectors
// in test/auth/totp.test.js.
//
// Defaults match every mainstream authenticator app (Google Authenticator,
// Authy, 1Password): SHA-1, 6 digits, 30-second step.
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
const DIGITS = 6;
const STEP_SECONDS = 30;
const RFC4648_B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
// ── base32 (RFC 4648, no padding) ──────────────────────────────────────────
export function base32Encode(buf) {
let bits = 0, value = 0, out = '';
for (const byte of buf) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += RFC4648_B32[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += RFC4648_B32[(value << (5 - bits)) & 31];
return out;
}
export function base32Decode(str) {
const clean = str.replace(/=+$/,'').toUpperCase().replace(/\s+/g, '');
let bits = 0, value = 0;
const out = [];
for (const ch of clean) {
const idx = RFC4648_B32.indexOf(ch);
if (idx === -1) continue; // skip stray chars
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(out);
}
// Generate a new base32 secret (20 random bytes = 160 bits, the RFC-recommended
// SHA-1 key length).
export function generateSecret() {
return base32Encode(randomBytes(20));
}
// HOTP for a specific counter (RFC 4226).
function hotp(secretBuf, counter) {
const buf = Buffer.alloc(8);
// 64-bit big-endian counter.
buf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
buf.writeUInt32BE(counter >>> 0, 4);
const hmac = createHmac('sha1', secretBuf).update(buf).digest();
const offset = hmac[hmac.length - 1] & 0x0f;
const code = ((hmac[offset] & 0x7f) << 24)
| ((hmac[offset + 1] & 0xff) << 16)
| ((hmac[offset + 2] & 0xff) << 8)
| (hmac[offset + 3] & 0xff);
return String(code % (10 ** DIGITS)).padStart(DIGITS, '0');
}
// The TOTP code for a given time (defaults to now).
export function generateToken(base32Secret, atMs = Date.now()) {
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
return hotp(base32Decode(base32Secret), counter);
}
// Verify a user-supplied code, allowing ±`window` steps of clock drift
// (default ±1 = 90s total tolerance). Constant-time compare per candidate.
//
// Returns the matched counter on success (so callers can persist it for
// replay protection — RFC 6238 §5.2), or null on failure. Boolean truthiness
// still works for the common case (`if (verifyToken(...))`).
export function verifyToken(base32Secret, token, atMs = Date.now(), window = 1) {
if (!base32Secret || !token) return null;
const cleaned = String(token).replace(/\s+/g, '');
if (!/^\d{6}$/.test(cleaned)) return null;
const secretBuf = base32Decode(base32Secret);
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
const want = Buffer.from(cleaned);
for (let w = -window; w <= window; w++) {
const candidate = Buffer.from(hotp(secretBuf, counter + w));
if (candidate.length === want.length && timingSafeEqual(candidate, want)) return counter + w;
}
return null;
}
// The otpauth:// URI an authenticator app scans. label/issuer show in the app.
export function otpauthURI(base32Secret, accountName, issuer = 'Dragonflight') {
const label = encodeURIComponent(`${issuer}:${accountName}`);
const params = new URLSearchParams({
secret: base32Secret,
issuer,
algorithm: 'SHA1',
digits: String(DIGITS),
period: String(STEP_SECONDS),
});
return `otpauth://totp/${label}?${params.toString()}`;
}
// Generate N human-friendly one-time recovery codes (raw form). Caller hashes
// them before storage and shows the raw set to the user exactly once.
export function generateRecoveryCodes(n = 10) {
const codes = [];
for (let i = 0; i < n; i++) {
// 10 hex chars in two dash-separated groups, e.g. "a1b2c-3d4e5".
const hex = randomBytes(5).toString('hex');
codes.push(hex.slice(0, 5) + '-' + hex.slice(5));
}
return codes;
}

View file

@ -1,7 +0,0 @@
-- 2026-05: add 'live' to asset_status for growing-file ingest
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'live' AND enumtypid = 'asset_status'::regtype) THEN
ALTER TYPE asset_status ADD VALUE 'live' BEFORE 'ingesting';
END IF;
END $$;

View file

@ -1,36 +0,0 @@
-- Wild Dragon MAM Groups & API Tokens
-- Idempotent: safe to re-run (IF NOT EXISTS guards throughout)
-- User groups
CREATE TABLE IF NOT EXISTS groups (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- User ↔ group memberships
CREATE TABLE IF NOT EXISTS user_groups (
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
group_id UUID NOT NULL REFERENCES groups ON DELETE CASCADE,
PRIMARY KEY (user_id, group_id)
);
-- Personal API tokens (Bearer auth alternative to session cookies)
-- token_hash : SHA-256(raw_token) stored as hex
-- token_prefix: first 8 chars of raw token for display only
CREATE TABLE IF NOT EXISTS api_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_user_groups_user ON user_groups(user_id);
CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups(group_id);

View file

@ -1,74 +0,0 @@
-- Wild Dragon MAM Editor sequences
-- Idempotent: safe to re-run (IF NOT EXISTS / DO $$ BEGIN guards throughout)
-- Named timelines within a project (multiple per project, like Premiere)
CREATE TABLE IF NOT EXISTS sequences (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Sequence 1',
frame_rate NUMERIC(6,3) NOT NULL DEFAULT 59.94,
width INTEGER NOT NULL DEFAULT 1920,
height INTEGER NOT NULL DEFAULT 1080,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sequences_project_id ON sequences(project_id);
CREATE INDEX IF NOT EXISTS idx_sequences_updated_at ON sequences(updated_at DESC);
-- Clips placed on a sequence timeline
CREATE TABLE IF NOT EXISTS sequence_clips (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
sequence_id UUID NOT NULL REFERENCES sequences ON DELETE CASCADE,
asset_id UUID NOT NULL REFERENCES assets ON DELETE CASCADE,
track INTEGER NOT NULL DEFAULT 0 CHECK (track >= 0),
-- track encoding: 0=V1, 1=V2, 100=A1, 101=A2
timeline_in_frames BIGINT NOT NULL,
timeline_out_frames BIGINT NOT NULL,
source_in_frames BIGINT NOT NULL DEFAULT 0,
source_out_frames BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sequence_clips_sequence_id ON sequence_clips(sequence_id);
CREATE INDEX IF NOT EXISTS idx_sequence_clips_track_position ON sequence_clips(sequence_id, track, timeline_in_frames);
CREATE INDEX IF NOT EXISTS idx_sequence_clips_asset_id ON sequence_clips(asset_id);
-- Unique sequence name per project
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uq_sequences_project_name' AND conrelid = 'sequences'::regclass
) THEN
ALTER TABLE sequences ADD CONSTRAINT uq_sequences_project_name UNIQUE (project_id, name);
END IF;
END $$;
-- Timeline range constraints
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'chk_timeline_range' AND conrelid = 'sequence_clips'::regclass
) THEN
ALTER TABLE sequence_clips ADD CONSTRAINT chk_timeline_range CHECK (timeline_out_frames > timeline_in_frames);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'chk_source_range' AND conrelid = 'sequence_clips'::regclass
) THEN
ALTER TABLE sequence_clips ADD CONSTRAINT chk_source_range CHECK (source_out_frames > source_in_frames);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'chk_track_valid' AND conrelid = 'sequence_clips'::regclass
) THEN
ALTER TABLE sequence_clips ADD CONSTRAINT chk_track_valid CHECK (track >= 0);
END IF;
END $$;

View file

@ -1,15 +0,0 @@
CREATE TABLE IF NOT EXISTS cluster_nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hostname TEXT NOT NULL,
ip_address TEXT,
role TEXT NOT NULL DEFAULT 'worker',
version TEXT,
api_url TEXT,
cpu_usage NUMERIC(5,2),
mem_used_mb INTEGER,
mem_total_mb INTEGER,
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB,
CONSTRAINT cluster_nodes_hostname_uq UNIQUE (hostname)
);

View file

@ -1,4 +0,0 @@
-- Add hardware capabilities column to cluster_nodes
-- Stores GPUs and capture cards detected/reported by node-agent
ALTER TABLE cluster_nodes
ADD COLUMN IF NOT EXISTS capabilities JSONB DEFAULT '{}';

View file

@ -1,5 +0,0 @@
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View file

@ -1,20 +0,0 @@
-- 007 — De-duplicate cluster_nodes by hostname and enforce uniqueness.
--
-- Migration 004 created the table with `CREATE TABLE IF NOT EXISTS` and an
-- inline UNIQUE constraint; on deploys where the table predated 004 the
-- constraint was never applied, which let the same hostname accumulate
-- multiple rows (one per container restart in some setups).
--
-- This migration:
-- 1. Deletes older duplicates keeping only the most-recently-seen row
-- per hostname.
-- 2. Adds a UNIQUE INDEX on (hostname) which is idempotent and satisfies
-- the ON CONFLICT (hostname) upsert in routes/cluster.js.
DELETE FROM cluster_nodes a
USING cluster_nodes b
WHERE a.hostname = b.hostname
AND a.last_seen < b.last_seen;
CREATE UNIQUE INDEX IF NOT EXISTS cluster_nodes_hostname_uniq
ON cluster_nodes (hostname);

View file

@ -1,26 +0,0 @@
-- 008 — Extended codec controls for recorders.
--
-- Adds video bitrate, framerate, audio codec / bitrate / channels, and
-- container format columns to recorders so the UI can offer granular
-- control instead of the four-options dropdown. capture-manager.js reads
-- these via env vars and builds ffmpeg args from them.
ALTER TABLE recorders
ADD COLUMN IF NOT EXISTS recording_video_bitrate TEXT,
ADD COLUMN IF NOT EXISTS recording_framerate TEXT,
ADD COLUMN IF NOT EXISTS recording_audio_codec TEXT DEFAULT 'pcm_s24le',
ADD COLUMN IF NOT EXISTS recording_audio_bitrate TEXT,
ADD COLUMN IF NOT EXISTS recording_audio_channels INTEGER DEFAULT 2,
ADD COLUMN IF NOT EXISTS recording_container TEXT DEFAULT 'mov',
ADD COLUMN IF NOT EXISTS proxy_video_bitrate TEXT DEFAULT '8M',
ADD COLUMN IF NOT EXISTS proxy_framerate TEXT,
ADD COLUMN IF NOT EXISTS proxy_audio_codec TEXT DEFAULT 'aac',
ADD COLUMN IF NOT EXISTS proxy_audio_bitrate TEXT DEFAULT '192k',
ADD COLUMN IF NOT EXISTS proxy_audio_channels INTEGER DEFAULT 2,
ADD COLUMN IF NOT EXISTS proxy_container TEXT DEFAULT 'mp4',
ADD COLUMN IF NOT EXISTS node_id UUID,
ADD COLUMN IF NOT EXISTS device_index INTEGER;
-- node_id is the cluster_nodes.id the recorder is pinned to (for SDI
-- recorders this is the node hosting the DeckLink card). device_index is
-- the DeckLink port index on that node.

View file

@ -1,32 +0,0 @@
-- Recorder schedules
--
-- Lets operators schedule a recorder to start at a future time and stop
-- after a duration. The scheduler tick loop in mam-api (src/scheduler.js)
-- watches this table every 15s and triggers the existing /recorders/:id
-- start + stop endpoints when each schedule's window opens or closes.
--
-- recurrence: 'none' (one-shot) or 'daily' for the MVP. When a 'daily'
-- schedule completes, the tick loop clones it forward by 24h.
CREATE TABLE IF NOT EXISTS recorder_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
recorder_id UUID NOT NULL REFERENCES recorders(id) ON DELETE CASCADE,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
recurrence TEXT NOT NULL DEFAULT 'none',
status TEXT NOT NULL DEFAULT 'pending',
last_asset_id UUID,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (end_at > start_at),
CHECK (recurrence IN ('none','daily','weekly')),
CHECK (status IN ('pending','running','completed','failed','cancelled'))
);
CREATE INDEX IF NOT EXISTS idx_recorder_schedules_status_start
ON recorder_schedules (status, start_at);
CREATE INDEX IF NOT EXISTS idx_recorder_schedules_recorder
ON recorder_schedules (recorder_id);

View file

@ -1,22 +0,0 @@
-- Asset comments — frame-anchored notes on the Asset Detail page.
--
-- Comments are scoped to an asset and optionally to a timecode within that
-- asset. `frame_ms` is the playhead position when the comment was posted.
-- `resolved` lets editors hide rolled-up notes once addressed.
--
-- User ID is optional (nullable) so comments still attach when AUTH_ENABLED
-- is off and there's no real session user.
CREATE TABLE IF NOT EXISTS asset_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
body TEXT NOT NULL,
frame_ms INTEGER,
resolved 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_asset_comments_asset
ON asset_comments (asset_id, created_at);

View file

@ -1,15 +0,0 @@
-- 2026-05: YouTube importer — new job type + remember source URL on assets.
--
-- Job type enum gains 'youtube_import' so the Jobs screen can show imports
-- alongside proxy / thumbnail / conform. Assets gain source_url so an
-- imported asset remembers where it came from (used by the Asset Detail
-- page and, later, dedup checks).
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'youtube_import' AND enumtypid = 'job_type'::regtype) THEN
ALTER TYPE job_type ADD VALUE 'youtube_import';
END IF;
END $$;
ALTER TABLE assets ADD COLUMN IF NOT EXISTS source_url TEXT;

View file

@ -1,31 +0,0 @@
-- 2026-05: Advanced features — trim jobs, temp segments, conform tracking.
-- Idempotent: safe to re-run (IF NOT EXISTS / DO $$ BEGIN guards throughout).
-- 1. Add 'trim' to job_type enum
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'trim' AND enumtypid = 'job_type'::regtype) THEN
ALTER TYPE job_type ADD VALUE 'trim';
END IF;
END $$;
-- 2. Temp segments table — tracks per-clip trimmed hi-res segments
-- with a 24-hour TTL for auto-cleanup.
CREATE TABLE IF NOT EXISTS temp_segments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
clip_instance_id UUID NOT NULL,
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
s3_key TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_temp_segments_expires_at ON temp_segments(expires_at);
CREATE INDEX IF NOT EXISTS idx_temp_segments_job_id ON temp_segments(job_id);
CREATE INDEX IF NOT EXISTS idx_temp_segments_asset_id ON temp_segments(asset_id);
-- 3. Asset conform tracking — remember which sequence this asset was
-- conformed from so the UI can show lineage and prevent double-conform.
ALTER TABLE assets ADD COLUMN IF NOT EXISTS conform_source_sequence_id UUID REFERENCES sequences(id);
CREATE INDEX IF NOT EXISTS idx_assets_conform_source ON assets(conform_source_sequence_id);

View file

@ -1,3 +0,0 @@
-- 2026-05: Add missing updated_at column to bins table.
-- The INSERT and PATCH handlers already reference updated_at.
ALTER TABLE bins ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW();

View file

@ -1,7 +0,0 @@
-- Migration 014: Per-recorder growing_enabled override
-- Adds a nullable boolean to the recorders table so each recorder can
-- independently override the global growing_enabled setting. NULL means
-- "use global"; TRUE/FALSE means "force on/off for this recorder".
ALTER TABLE recorders
ADD COLUMN IF NOT EXISTS growing_enabled BOOLEAN DEFAULT NULL;

View file

@ -1,6 +0,0 @@
-- Migration 015: Add growing_retention_days to settings table
-- Default 30 days. ON CONFLICT DO NOTHING is idempotent -- safe to re-run.
INSERT INTO settings (key, value)
VALUES ('growing_retention_days', '30')
ON CONFLICT (key) DO NOTHING;

View file

@ -1,37 +0,0 @@
-- Migration 016: Fix job_type/job_status enums and add jobs TTL (#75, #70)
--
-- 1. Add 'proxy' and 'import' to job_type so queue names match enum values.
-- 2. Add 'completed' to job_status to match the trimWorker status string.
-- 3. Add expires_at column to jobs so stale trim rows auto-expire (#70).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'proxy' AND enumtypid = 'job_type'::regtype
) THEN
ALTER TYPE job_type ADD VALUE 'proxy';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'import' AND enumtypid = 'job_type'::regtype
) THEN
ALTER TYPE job_type ADD VALUE 'import';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'completed' AND enumtypid = 'job_status'::regtype
) THEN
ALTER TYPE job_status ADD VALUE 'completed';
END IF;
END $$;
-- Add TTL column to jobs (NULL = no expiry; trim jobs set 24h from creation)
ALTER TABLE jobs
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ DEFAULT NULL;
-- Backfill: give any existing trim rows a 24h TTL from their creation time
UPDATE jobs SET expires_at = created_at + INTERVAL '24 hours'
WHERE type = 'trim' AND expires_at IS NULL;

View file

@ -1,13 +0,0 @@
-- Migration 017: Partial unique index on live assets (#63)
--
-- Prevents two simultaneous captures from registering the same
-- (project_id, display_name) pair with status='live'. The INSERT in
-- POST /assets will fail with a unique-constraint violation instead of
-- silently overwriting the first capture's metadata.
--
-- Only applies to live rows — archived, ready, error etc. are unaffected,
-- so duplicate names are still allowed across historical recordings.
CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_live_unique
ON assets (project_id, display_name)
WHERE status = 'live';

View file

@ -1,7 +0,0 @@
-- Migration 018: Add filmstrip_s3_key to assets
-- Stores the S3 path to a JSON array of base64 JPEG frames generated
-- server-side by the filmstrip worker. Allows the UI to fetch a pre-built
-- filmstrip instead of seeking through the proxy in the browser.
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS filmstrip_s3_key TEXT DEFAULT NULL;

View file

@ -1,15 +0,0 @@
-- Issue #106 — bind cluster tokens to a specific hostname so a compromised
-- worker token can't be used to hijack another node's `api_url` via
-- POST /cluster/heartbeat.
--
-- `bound_hostname` is NULL for ordinary user tokens (no binding) and set
-- to the node's hostname for node-agent tokens. The heartbeat handler
-- checks that body.hostname === token.bound_hostname when bound_hostname
-- is non-null.
ALTER TABLE api_tokens
ADD COLUMN IF NOT EXISTS bound_hostname TEXT;
CREATE INDEX IF NOT EXISTS api_tokens_bound_hostname_idx
ON api_tokens (bound_hostname)
WHERE bound_hostname IS NOT NULL;

View file

@ -1,19 +0,0 @@
-- Issue #77 — AMPP sync used to be fire-and-forget: failures were swallowed
-- with a console.error and never retried. Track the state of every asset's
-- AMPP sync so the scheduler tick can retry pending/failed rows on a
-- backoff schedule.
--
-- ampp_sync_status: 'pending' | 'synced' | 'failed' | 'disabled'
-- ampp_sync_attempts: count, used for exponential backoff
-- ampp_sync_next_attempt_at: when the scheduler should next try this asset
-- ampp_sync_last_error: short error message for the operator (truncated)
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS ampp_sync_status TEXT NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS ampp_sync_attempts INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS ampp_sync_next_attempt_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS ampp_sync_last_error TEXT;
CREATE INDEX IF NOT EXISTS assets_ampp_sync_idx
ON assets (ampp_sync_status, ampp_sync_next_attempt_at)
WHERE ampp_sync_status IN ('pending', 'failed');

View file

@ -1,14 +0,0 @@
-- connect-pg-simple's session store needs this table. It's defined in
-- schema.sql which only runs on first DB init via the postgres entrypoint;
-- on instances bootstrapped via migrations only (no entrypoint init), the
-- table never existed and every login silently failed to persist the
-- session — manifesting as a redirect loop after submitting valid creds.
-- Idempotent so this is safe to re-run.
CREATE TABLE IF NOT EXISTS sessions (
sid TEXT PRIMARY KEY,
sess JSONB NOT NULL,
expire TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expire ON sessions (expire);

View file

@ -1,12 +0,0 @@
-- 022-audio-metadata.sql
-- Store per-track audio metadata extracted by ffprobe during proxy generation.
-- Shape: JSON array of objects, one per audio stream, e.g.:
-- [
-- {"index":1,"codec":"pcm_s24le","channels":2,"channel_layout":"stereo",
-- "sample_rate":48000,"bit_depth":24,"bit_rate":2304000,"language":null},
-- {"index":2,"codec":"aac","channels":2,"channel_layout":"stereo",
-- "sample_rate":48000,"bit_depth":null,"bit_rate":128000,"language":"en"}
-- ]
-- NULL means the asset has not been probed yet or has no audio streams.
ALTER TABLE assets ADD COLUMN IF NOT EXISTS audio_metadata JSONB;

View file

@ -1,25 +0,0 @@
-- Migration 023 — auth-related user timestamps + idempotent dev user.
--
-- See docs/superpowers/specs/2026-05-27-auth-system-design.md
--
-- password_updated_at + last_login_at are operator visibility, no logic depends on them yet.
-- The dev user is seeded with a fixed UUID so FK-bearing routes (api_tokens,
-- future audit fields) keep working when AUTH_ENABLED=false. The seeded
-- password_hash is a placeholder that no bcrypt.compare will accept, so the
-- dev row cannot be used to log in even if AUTH_ENABLED is later flipped on.
--
-- password_updated_at is backfilled with NOW() for existing rows at migration time;
-- treat values from before this deploy as approximate.
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_updated_at TIMESTAMPTZ DEFAULT NOW();
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
INSERT INTO users (id, username, password_hash, display_name, role)
VALUES (
'00000000-0000-4000-8000-000000000000',
'dev',
'!disabled-no-login!',
'Dev (AUTH_ENABLED=false)',
'admin'
)
ON CONFLICT DO NOTHING;

View file

@ -1,13 +0,0 @@
-- Migration 024: add 'deltacast' to the source_type enum
-- Allows recorders to be configured for Deltacast VideoMaster SDI cards.
-- ALTER TYPE ... ADD VALUE is not transactional in PG < 12 but is safe in PG 12+.
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumtypid = 'source_type'::regtype
AND enumlabel = 'deltacast'
) THEN
ALTER TYPE source_type ADD VALUE 'deltacast';
END IF;
END $$;

View file

@ -1,5 +0,0 @@
-- HLS VOD playback: per-asset HLS rendition (fMP4) generated by the proxy
-- worker alongside the MP4 proxy. Presence of hls_s3_key means an HLS
-- playlist exists at hls/<asset_id>/playlist.m3u8 and /assets/:id/stream
-- should prefer it (type: 'hls') over the MP4 range-stitched /video path.
ALTER TABLE assets ADD COLUMN IF NOT EXISTS hls_s3_key TEXT;

View file

@ -1,30 +0,0 @@
-- Migration 026 — per-project access grants (RBAC v2).
--
-- v1 auth is flat: any logged-in user can do everything. This adds per-project
-- scoping. A grant targets either a user or a group (polymorphic subject) and
-- carries a level: 'view' (read-only) or 'edit' (read-write). Admins bypass all
-- of this in code (authz.js) and need no rows here.
--
-- subject_id is intentionally NOT a foreign key — it points at either users.id
-- or groups.id depending on subject_type. Rows are cleaned up when the project
-- is deleted (FK cascade). A deleted user/group leaves an orphan row that
-- resolves to nobody (harmless); a later sweep can prune them if desired.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'access_level') THEN
CREATE TYPE access_level AS ENUM ('view', 'edit');
END IF;
END $$;
CREATE TABLE IF NOT EXISTS project_access (
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
subject_id UUID NOT NULL,
level access_level NOT NULL DEFAULT 'view',
granted_by UUID REFERENCES users ON DELETE SET NULL,
granted_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (project_id, subject_type, subject_id)
);
CREATE INDEX IF NOT EXISTS idx_project_access_subject
ON project_access (subject_type, subject_id);

View file

@ -1,20 +0,0 @@
-- Migration 027 — TOTP two-factor auth.
--
-- totp_secret holds the base32 shared secret once enrollment is confirmed
-- (NULL while disabled or mid-enrollment). totp_enabled flips true only after
-- the user verifies their first code, so a half-finished enrollment never locks
-- anyone out. Recovery codes are one-time bcrypt-hashed fallbacks; used_at marks
-- a code as spent.
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS user_recovery_codes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
code_hash TEXT NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_recovery_codes_user ON user_recovery_codes(user_id);

View file

@ -1,13 +0,0 @@
-- Migration 028 — Google OAuth (OIDC) sign-in.
--
-- google_sub is Google's stable subject identifier — the join key for a linked
-- or auto-provisioned account (unique, but NULL for password-only users).
-- email is captured for display + domain checks. password_hash becomes nullable
-- so an OAuth-only account can exist without a local password; such an account
-- simply can't use the password login path until an admin sets one.
ALTER TABLE users ADD COLUMN IF NOT EXISTS google_sub TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT;
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_google_sub ON users(google_sub) WHERE google_sub IS NOT NULL;

View file

@ -1,165 +0,0 @@
-- 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

@ -1,9 +0,0 @@
-- Migration 030 — TOTP replay protection.
--
-- RFC 6238 §5.2 hardening: track the last counter value we accepted for each
-- user and reject codes at counters ≤ the last one. Without this, the same
-- 6-digit code can be submitted N times within its 30s step. Low impact in
-- practice (the code is only valid for ~90s with ±1 drift) but standard.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS totp_last_counter BIGINT NOT NULL DEFAULT 0;

View file

@ -1,10 +0,0 @@
-- 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

@ -49,8 +49,7 @@ CREATE TABLE bins (
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE, project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
parent_id UUID REFERENCES bins ON DELETE SET NULL, parent_id UUID REFERENCES bins ON DELETE SET NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW()
updated_at TIMESTAMPTZ DEFAULT NOW()
); );
-- Assets table -- Assets table
@ -139,7 +138,7 @@ CREATE INDEX idx_bins_project_id ON bins(project_id);
CREATE INDEX idx_sessions_expire ON sessions(expire); CREATE INDEX idx_sessions_expire ON sessions(expire);
-- Recorder source types -- Recorder source types
CREATE TYPE source_type AS ENUM ('sdi', 'srt', 'rtmp', 'deltacast'); CREATE TYPE source_type AS ENUM ('sdi', 'srt', 'rtmp');
-- Recorder instances table -- Recorder instances table
-- NOTE: current_session_id is TEXT (stores a human-readable clip name, not a UUID) -- NOTE: current_session_id is TEXT (stores a human-readable clip name, not a UUID)

View file

@ -1,86 +0,0 @@
-- Wild Dragon MAM Editor sequences schema patch
-- Run with: psql $DATABASE_URL -f schema_patch_editor.sql
-- Named timelines within a project (multiple per project, like Premiere)
CREATE TABLE IF NOT EXISTS sequences (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Sequence 1',
frame_rate NUMERIC(6,3) NOT NULL DEFAULT 59.94,
width INTEGER NOT NULL DEFAULT 1920,
height INTEGER NOT NULL DEFAULT 1080,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT uq_sequences_project_name UNIQUE (project_id, name)
);
CREATE INDEX IF NOT EXISTS idx_sequences_project_id ON sequences(project_id);
CREATE INDEX IF NOT EXISTS idx_sequences_updated_at ON sequences(updated_at DESC);
-- Clips placed on a sequence timeline
CREATE TABLE IF NOT EXISTS sequence_clips (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
sequence_id UUID NOT NULL REFERENCES sequences ON DELETE CASCADE,
asset_id UUID NOT NULL REFERENCES assets ON DELETE CASCADE,
track INTEGER NOT NULL DEFAULT 0 CHECK (track >= 0),
-- track encoding: 0=V1, 1=V2, 100=A1, 101=A2
-- Open-ended CHECK (track >= 0) used instead of an enumerated list so that
-- additional tracks can be added in the future without a schema migration.
timeline_in_frames BIGINT NOT NULL,
timeline_out_frames BIGINT NOT NULL,
source_in_frames BIGINT NOT NULL DEFAULT 0,
source_out_frames BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT chk_timeline_range CHECK (timeline_out_frames > timeline_in_frames),
CONSTRAINT chk_source_range CHECK (source_out_frames > source_in_frames)
);
CREATE INDEX IF NOT EXISTS idx_sequence_clips_sequence_id ON sequence_clips(sequence_id);
CREATE INDEX IF NOT EXISTS idx_sequence_clips_track_position ON sequence_clips(sequence_id, track, timeline_in_frames);
CREATE INDEX IF NOT EXISTS idx_sequence_clips_asset_id ON sequence_clips(asset_id);
-- ---------------------------------------------------------------------------
-- Idempotent ALTER TABLE block — applies the new constraints and index to
-- tables that were already created by an earlier run of this file.
-- Uses DO blocks because PostgreSQL does not support ADD CONSTRAINT IF NOT EXISTS.
-- Safe to re-run.
-- ---------------------------------------------------------------------------
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uq_sequences_project_name' AND conrelid = 'sequences'::regclass
) THEN
ALTER TABLE sequences ADD CONSTRAINT uq_sequences_project_name UNIQUE (project_id, name);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'chk_timeline_range' AND conrelid = 'sequence_clips'::regclass
) THEN
ALTER TABLE sequence_clips ADD CONSTRAINT chk_timeline_range CHECK (timeline_out_frames > timeline_in_frames);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'chk_source_range' AND conrelid = 'sequence_clips'::regclass
) THEN
ALTER TABLE sequence_clips ADD CONSTRAINT chk_source_range CHECK (source_out_frames > source_in_frames);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'chk_track_valid' AND conrelid = 'sequence_clips'::regclass
) THEN
ALTER TABLE sequence_clips ADD CONSTRAINT chk_track_valid CHECK (track >= 0);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_sequence_clips_asset_id ON sequence_clips(asset_id);

View file

@ -1,36 +0,0 @@
-- Wild Dragon MAM Groups & API Tokens schema patch
-- Run with: psql $DATABASE_URL -f schema_patch_groups_tokens.sql
-- User groups
CREATE TABLE IF NOT EXISTS groups (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- User ↔ group memberships
CREATE TABLE IF NOT EXISTS user_groups (
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
group_id UUID NOT NULL REFERENCES groups ON DELETE CASCADE,
PRIMARY KEY (user_id, group_id)
);
-- Personal API tokens (Bearer auth alternative to session cookies)
-- token_hash : SHA-256(raw_token) stored as hex
-- token_prefix: first 8 chars of raw token for display only
CREATE TABLE IF NOT EXISTS api_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_user_groups_user ON user_groups(user_id);
CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups(group_id);

View file

@ -2,19 +2,12 @@ import 'dotenv/config';
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import session from 'express-session'; import session from 'express-session';
import connectPgSimple from 'connect-pg-simple'; import ConnectPgSimple from 'connect-pg-simple';
const PgStore = connectPgSimple(session);
import os from 'node:os';
import { exec } from 'node:child_process';
import pool from './db/pool.js'; import pool from './db/pool.js';
import { errorHandler } from './middleware/errors.js'; import { errorHandler } from './middleware/errors.js';
import { requireAuth, requireUiHeader, requireAdmin } from './middleware/auth.js';
import { loadS3ConfigFromDb } from './s3/client.js';
import authRouter from './routes/auth.js';
import tokensRouter from './routes/tokens.js';
import usersRouter from './routes/users.js';
// Routes // Routes
import authRouter from './routes/auth.js';
import assetsRouter from './routes/assets.js'; import assetsRouter from './routes/assets.js';
import projectsRouter from './routes/projects.js'; import projectsRouter from './routes/projects.js';
import binsRouter from './routes/bins.js'; import binsRouter from './routes/bins.js';
@ -22,84 +15,45 @@ 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 sequencesRouter from './routes/sequences.js';
import systemRouter from './routes/system.js';
import clusterRouter from './routes/cluster.js';
import sdkRouter from './routes/sdk.js';
import schedulesRouter from './routes/schedules.js';
import metricsRouter from './routes/metrics.js';
import commentsRouter from './routes/comments.js';
import importsRouter from './routes/imports.js';
import storageRouter from './routes/storage.js';
import { startSchedulerLoop, stopSchedulerLoop } from './scheduler.js';
import { startCleanupLoop } from './tasks/cleanupTempSegments.js';
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '') // ── Middleware ────────────────────────────────────────────────────────────────
.split(',').map(s => s.trim()).filter(Boolean); app.use(cors({ origin: true, credentials: true }));
app.use(cors({
origin: (origin, cb) => {
if (!origin) return cb(null, true);
if (allowedOrigins.length === 0 || allowedOrigins.includes(origin)) return cb(null, true);
console.warn('[cors] rejected origin:', origin);
return cb(null, false);
},
credentials: true,
}));
app.use(express.json({ limit: '50mb' })); app.use(express.json({ limit: '50mb' }));
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1); const PgSession = ConnectPgSimple(session);
if (process.env.AUTH_ENABLED === 'true') { app.use(
app.use((req, res, next) => { session({
if (req.secure) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); store: new PgSession({
next(); pool,
}); tableName: 'sessions',
} // Prune expired sessions every hour
pruneSessionInterval: 3600,
if (process.env.AUTH_ENABLED === 'true' && !process.env.SESSION_SECRET) { }),
console.error('[fatal] SESSION_SECRET is required when AUTH_ENABLED=true'); secret: process.env.SESSION_SECRET || 'change-me-in-production',
process.exit(1); resave: false,
} saveUninitialized: false,
cookie: {
app.use(session({ secure: process.env.NODE_ENV === 'production',
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 }), httpOnly: true,
secret: process.env.SESSION_SECRET, maxAge: 1000 * 60 * 60 * 24, // 24 h
name: 'dragonflight.sid', },
cookie: { })
httpOnly: true, );
sameSite: 'lax',
secure: process.env.TRUST_PROXY === 'true',
path: '/',
maxAge: 8 * 3600 * 1000,
},
rolling: false,
resave: false,
saveUninitialized: false,
}));
// ── Health (no auth) ──────────────────────────────────────────────────────────
app.get('/health', (_req, res) => res.json({ status: 'ok' })); app.get('/health', (_req, res) => res.json({ status: 'ok' }));
const UNAUTH_PATHS = new Set([ // ── API Routes ────────────────────────────────────────────────────────────────
'/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required', // Auth routes are always open (login/logout don't require a session)
'/auth/google', '/auth/google/callback', '/auth/google/enabled', app.use('/api/v1/auth', authRouter);
]);
app.use('/api/v1', requireUiHeader);
app.use('/api/v1', (req, res, next) => {
if (UNAUTH_PATHS.has(req.path)) return next();
return requireAuth(req, res, next);
});
app.use('/api/v1/auth', authRouter); // All other routes are gated by requireAuth (no-op unless AUTH_ENABLED=true)
app.use('/api/v1/auth/users', requireAdmin, usersRouter);
app.use('/api/v1/users', requireAdmin, usersRouter);
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);
app.use('/api/v1/bins', binsRouter); app.use('/api/v1/bins', binsRouter);
@ -107,188 +61,15 @@ 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/sequences', sequencesRouter);
app.use('/api/v1/system', systemRouter);
app.use('/api/v1/cluster', clusterRouter);
app.use('/api/v1/sdk', sdkRouter);
app.use('/api/v1/schedules', schedulesRouter);
app.use('/api/v1/metrics', metricsRouter);
app.use('/api/v1/assets/:assetId/comments', commentsRouter);
app.use('/api/v1/imports', importsRouter);
app.use('/api/v1/storage', storageRouter);
// ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler); app.use(errorHandler);
import { readdirSync, readFileSync } from 'node:fs'; // ── Start ────────────────────────────────────────────────────────────────────
import { fileURLToPath } from 'node:url'; app.listen(PORT, () => {
import { dirname, join } from 'node:path'; const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
const __dirnameMig = dirname(fileURLToPath(import.meta.url));
async function runMigrations() {
const dir = join(__dirnameMig, 'db', 'migrations');
let files = [];
try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
checksum_sha TEXT
)
`);
const force = process.env.MIGRATIONS_FORCE === '1';
const allowFailures = process.env.MIGRATIONS_ALLOW_FAILURES === '1';
const appliedRes = await pool.query('SELECT filename FROM schema_migrations');
const applied = new Set(appliedRes.rows.map(r => r.filename));
for (const f of files) {
if (!force && applied.has(f)) continue;
const sql = readFileSync(join(dir, f), 'utf8');
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(sql);
await client.query(
`INSERT INTO schema_migrations (filename) VALUES ($1)
ON CONFLICT (filename) DO UPDATE SET applied_at = NOW()`,
[f]
);
await client.query('COMMIT');
console.log('[migration] applied ' + f);
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
console.error('[migration] FAILED ' + f + ': ' + err.message);
client.release();
if (allowFailures) continue;
console.error('[migration] aborting startup. Set MIGRATIONS_ALLOW_FAILURES=1 to override.');
process.exit(1);
}
client.release();
}
}
await runMigrations();
await loadS3ConfigFromDb();
function getLocalIp() {
if (process.env.NODE_IP) return process.env.NODE_IP;
const ifaces = os.networkInterfaces();
for (const name of Object.keys(ifaces)) {
for (const iface of (ifaces[name] || [])) {
if (iface.family === 'IPv4' && !iface.internal) return iface.address;
}
}
return '127.0.0.1';
}
function detectGpus() {
return new Promise(resolve => {
exec(
'nvidia-smi --query-gpu=index,name,memory.total --format=csv,noheader,nounits',
{ timeout: 5000 },
(err, stdout) => {
if (err || !stdout.trim()) return resolve([]);
const gpus = stdout.trim().split('\n').map(line => {
const parts = line.split(',').map(s => s.trim());
return {
index: parseInt(parts[0], 10),
name: parts[1] || 'Unknown GPU',
memory_mb: parseInt(parts[2], 10) || 0,
};
}).filter(g => !isNaN(g.index));
resolve(gpus);
}
);
});
}
// 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() {
const load = os.loadavg()[0];
const total = os.totalmem();
const used = total - os.freemem();
const gpus = await detectGpus();
const capabilities = { gpus, blackmagic: [] };
pool.query(
`INSERT INTO cluster_nodes
(hostname, ip_address, role, version, api_url,
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(),NOW())
ON CONFLICT (hostname) DO UPDATE SET
ip_address = EXCLUDED.ip_address,
cpu_usage = EXCLUDED.cpu_usage,
mem_used_mb = EXCLUDED.mem_used_mb,
mem_total_mb = EXCLUDED.mem_total_mb,
capabilities = EXCLUDED.capabilities,
last_seen_at = NOW(),
last_seen = NOW()`,
[
process.env.NODE_HOSTNAME || os.hostname(),
getLocalIp(),
process.env.npm_package_version || null,
`http://${getLocalIp()}:${PORT}`,
parseFloat(load.toFixed(2)),
Math.round(used / 1024 / 1024),
Math.round(total / 1024 / 1024),
JSON.stringify(capabilities),
]
).catch(err => console.error('[cluster] heartbeat failed:', err.message));
}
setInterval(selfHeartbeat, 30_000);
selfHeartbeat();
const server = app.listen(PORT, () => {
const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED — dev mode (dev user attached to every request)';
console.log(`MAM API listening on port ${PORT}`); console.log(`MAM API listening on port ${PORT}`);
console.log(`Authentication: ${authMode}`); console.log(`Authentication: ${authMode}`);
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.');
}
startSchedulerLoop();
startCleanupLoop();
});
let _shuttingDown = false;
async function gracefulShutdown(signal) {
if (_shuttingDown) return;
_shuttingDown = true;
console.log(`[shutdown] received ${signal} — closing gracefully…`);
try { stopSchedulerLoop(); } catch (_) {}
const killSwitch = setTimeout(() => {
console.error('[shutdown] forced exit after 25s timeout');
process.exit(1);
}, 25_000);
killSwitch.unref();
await new Promise(resolve => server.close(resolve));
try { await pool.end(); } catch (e) { console.warn('[shutdown] pool.end:', e.message); }
console.log('[shutdown] clean exit');
process.exit(0);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('uncaughtException', (err) => {
console.error('[fatal] uncaughtException:', err);
gracefulShutdown('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
console.error('[fatal] unhandledRejection:', reason);
}); });

View file

@ -1,134 +1,18 @@
import crypto from 'crypto'; /**
import pool from '../db/pool.js'; * Authentication middleware.
import { parseBearer, hashToken } from '../auth/tokens.js'; *
* When AUTH_ENABLED=true in the environment, every protected route requires
// In-process service token for the scheduler's loopback self-calls * an active session (set by POST /api/v1/auth/login).
// (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: * When AUTH_ENABLED is unset or any other value, the middleware is a no-op
// it only travels over the loopback fetch inside the same process. Multi-replica * so the stack can be deployed and tested without setting up users first.
// is safe because each replica's scheduler only ever calls 127.0.0.1 (itself), * Set AUTH_ENABLED=true in production after running POST /api/v1/auth/setup
// matching that replica's token. Requests bearing it are treated as the seeded * to create the first admin account.
// admin (DEV_USER) so RBAC + FK-bearing routes work. */
export const INTERNAL_TOKEN = crypto.randomBytes(32).toString('hex'); export const requireAuth = (req, res, next) => {
const INTERNAL_HEADER = 'x-internal-token'; if (process.env.AUTH_ENABLED !== 'true') return next();
if (!req.session || !req.session.userId) {
function isInternalCall(req) { return res.status(401).json({ error: 'Unauthorized' });
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.
/** UUID of the seeded dev-mode placeholder. NOT a sentinel for "any unauthenticated user". */
export const DEV_USER_ID = '00000000-0000-4000-8000-000000000000';
// role 'admin' so dev mode (AUTH_ENABLED=false) keeps full access through the
// RBAC v2 gates — matches migration 023's seeded dev row.
export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)', role: 'admin' };
const ABSOLUTE_MS = 8 * 3600 * 1000;
const IDLE_MS = 1 * 3600 * 1000;
async function destroyAnd401(req, res) {
if (req.session?.destroy) {
await new Promise(r => req.session.destroy(() => r()));
} }
return res.status(401).json({ error: 'unauthorized' }); next();
} };
async function loadUser(id) {
const { rows } = await pool.query(
`SELECT id, username, display_name, role, totp_enabled FROM users WHERE id = $1`, [id]);
return rows[0] || null;
}
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.
if (process.env.AUTH_ENABLED !== 'true') {
req.user = DEV_USER;
return next();
}
// 1. Session
if (req.session?.user_id) {
const now = Date.now();
const first = req.session.first_seen_at || 0;
const last = req.session.last_seen_at || 0;
if (now - first > ABSOLUTE_MS) return destroyAnd401(req, res);
if (now - last > IDLE_MS) return destroyAnd401(req, res);
const u = await loadUser(req.session.user_id);
if (!u) return destroyAnd401(req, res);
req.session.last_seen_at = now; // stamp only after user is confirmed; avoids extending idle window if loadUser throws or the user was deleted
req.user = u;
return next();
}
// 2. Bearer
const bearer = parseBearer(req.headers.authorization);
if (bearer) {
const hash = hashToken(bearer);
const { rows } = await pool.query(
`SELECT t.id AS token_id, t.user_id, t.expires_at, t.bound_hostname,
u.username, u.display_name, u.role
FROM api_tokens t JOIN users u ON u.id = t.user_id
WHERE t.token_hash = $1`, [hash]);
if (rows.length && (!rows[0].expires_at || rows[0].expires_at > new Date())) {
pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [rows[0].token_id])
.catch(err => console.error('[auth] token last_used_at update failed:', err.message));
req.user = {
id: rows[0].user_id,
username: rows[0].username,
display_name: rows[0].display_name,
role: rows[0].role,
};
// Per migration 019: tokens with a bound_hostname can only be used by
// node-agents reporting that hostname. The /cluster/heartbeat handler
// enforces this; we just surface the binding here.
if (rows[0].bound_hostname) req.tokenBoundHostname = rows[0].bound_hostname;
return next();
}
}
// 3. Nothing matched
return res.status(401).json({ error: 'unauthorized' });
}
// Gate a route to admins only. requireAuth must run first (it sets req.user).
// 401 when unauthenticated, 403 when authenticated but not an admin.
export function requireAdmin(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'unauthorized' });
if (req.user.role !== 'admin') return res.status(403).json({ error: 'forbidden' });
return next();
}
// Belt-and-suspenders CSRF: SameSite=Lax already blocks most cross-site
// cookie sends, but a custom header that no <form> can produce hardens
// against the edge cases. Applied to mutating verbs only.
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const REQUIRED_HEADER = 'dragonflight-ui';
// Paths exempt from the CSRF header check. The bearer-auth exemption (above)
// already covers node-agent because it sends Authorization: Bearer; this set
// is the belt for any future service path that might call us without a
// bearer header. Today it just lets an unauthenticated heartbeat probe
// surface as a clean 401 from requireAuth instead of a confusing 403 CSRF.
const CSRF_EXEMPT_PATHS = new Set(['/cluster/heartbeat']);
export function requireUiHeader(req, res, 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
// browsers and can't be drive-by'd from another origin.
if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next();
// Service path carve-outs (e.g. node-agent heartbeat — not a browser).
if (CSRF_EXEMPT_PATHS.has(req.path)) return next();
if (req.headers['x-requested-with'] === REQUIRED_HEADER) return next();
return res.status(403).json({ error: 'missing X-Requested-With header' });
}

View file

@ -1,73 +1,11 @@
// Error & validation middleware.
//
// Issue #101 — the previous handler echoed every error's `.message` straight
// to the client, leaking raw Postgres column names, schema details, and
// invalid UUID syntax errors to anyone hitting a malformed route.
//
// Issue #102 — every /:id route was hitting Postgres with the raw param,
// returning a 500 (with a PG error in the body) instead of a clean 400.
//
// Both are addressed here: `validateUuid` checks param shape before the
// route runs; `errorHandler` keeps detailed messages server-side and only
// surfaces a generic message + the response status to the client.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function validateUuid(paramName = 'id') {
return (req, res, next) => {
const v = req.params[paramName];
if (!v || !UUID_RE.test(v)) {
return res.status(400).json({ error: `Invalid ${paramName} — must be a UUID` });
}
next();
};
}
// Patterns Postgres uses for its error codes that are operator-only noise.
const PG_LEAKY_CODES = new Set([
'22P02', // invalid_text_representation (bad UUID, etc.)
'23502', // not_null_violation
'23503', // foreign_key_violation
'23505', // unique_violation
'42703', // undefined_column
'42P01', // undefined_table
'42601', // syntax_error
]);
const GENERIC_MESSAGES = {
'22P02': 'Invalid input format',
'23502': 'Required field missing',
'23503': 'Referenced record not found',
'23505': 'Record already exists',
'42703': 'Internal database error',
'42P01': 'Internal database error',
'42601': 'Internal database error',
};
export const errorHandler = (err, req, res, next) => { export const errorHandler = (err, req, res, next) => {
// Log the full error server-side; operators get the detail. console.error('Error:', err);
console.error('[error]', req.method, req.originalUrl, err);
// Postgres errors carry a `.code` (string from SQLSTATE).
if (err && err.code && PG_LEAKY_CODES.has(err.code)) {
const generic = GENERIC_MESSAGES[err.code] || 'Database error';
const status = err.code === '22P02' || err.code === '23502' ? 400 : 409;
return res.status(status).json({ error: generic, code: err.code });
}
const status = err.status || 500; const status = err.status || 500;
const message = err.message || 'Internal Server Error';
// 5xx — never let a raw Error.message escape; clients get a stable shape. res.status(status).json({
if (status >= 500) { error: message,
return res.status(status).json({
error: 'Internal Server Error',
status,
});
}
// 4xx — operator-authored messages are safe to surface.
return res.status(status).json({
error: err.message || 'Bad request',
status, status,
}); });
}; };

File diff suppressed because it is too large Load diff

View file

@ -1,461 +1,136 @@
/**
* Authentication routes
*
* POST /api/v1/auth/login exchange username+password for a session cookie
* POST /api/v1/auth/logout destroy the current session
* GET /api/v1/auth/me return the currently authenticated user
* POST /api/v1/auth/setup one-time admin bootstrap (disabled after first user exists)
*/
import express from 'express'; import express from 'express';
import bcrypt from 'bcrypt';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { DEV_USER_ID, requireAuth } from '../middleware/auth.js';
import { hashPassword, comparePassword } from '../auth/passwords.js';
import { ipBackoff } from '../auth/rate-limit.js';
import {
generateSecret, verifyToken, otpauthURI, generateRecoveryCodes,
} from '../auth/totp.js';
import { issueTicket, redeemTicket } from '../auth/mfa-tickets.js';
import {
isConfigured as googleConfigured, buildAuthUrl, exchangeAndVerify,
} from '../auth/google-oauth.js';
import { randomBytes } from 'node:crypto';
const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K';
const router = express.Router(); const router = express.Router();
// Real users = anyone except the seeded dev row. // ---------------------------------------------------------------------------
async function realUserCount() { // POST /login
const { rows } = await pool.query( // ---------------------------------------------------------------------------
`SELECT COUNT(*)::int AS n FROM users WHERE id <> $1`, [DEV_USER_ID]);
return rows[0].n;
}
// GET /api/v1/auth/setup-required
// Cheap, no auth. Used by AuthGate to decide between Login and Setup screens.
router.get('/setup-required', async (_req, res, next) => {
try {
res.json({ required: (await realUserCount()) === 0 });
} catch (err) { next(err); }
});
const MIN_PASSWORD_LEN = 12;
function badRequest(res, msg) { return res.status(400).json({ error: msg }); }
// POST /api/v1/auth/setup — first-run admin creation, locked out forever once a real user exists.
router.post('/setup', async (req, res, next) => {
try {
const { username, password } = req.body || {};
if (!username || typeof username !== 'string') return badRequest(res, 'username required');
if (!password || typeof password !== 'string') return badRequest(res, 'password required');
if (password.length < MIN_PASSWORD_LEN) return badRequest(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' characters');
if ((await realUserCount()) > 0) {
return res.status(409).json({ error: 'setup already complete' });
}
const hash = await hashPassword(password);
const { rows } = await pool.query(
`INSERT INTO users (username, password_hash, display_name, role)
VALUES ($1, $2, $1, 'admin')
RETURNING id, username, display_name`,
[username.trim(), hash]
);
const user = rows[0];
// Immediately log them in.
req.session.user_id = user.id;
req.session.first_seen_at = Date.now();
req.session.last_seen_at = Date.now();
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
res.json({ user });
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'username already exists' });
next(err);
}
});
// POST /api/v1/auth/login — authenticate an existing user by username + password.
router.post('/login', async (req, res, next) => { router.post('/login', async (req, res, next) => {
try { try {
const ip = req.ip || req.socket?.remoteAddress || 'unknown'; const { username, password } = req.body;
const delay = ipBackoff.delayMs(ip);
if (delay > 0) await new Promise(r => setTimeout(r, delay));
const { username, password } = req.body || {};
if (!username || !password) { if (!username || !password) {
ipBackoff.recordFailure(ip); return res.status(400).json({ error: 'Username and password are required' });
return res.status(401).json({ error: 'invalid credentials' });
} }
const { rows } = await pool.query( const result = await pool.query(
`SELECT id, username, display_name, password_hash, totp_enabled FROM users WHERE username = $1 AND id <> $2`, 'SELECT * FROM users WHERE username = $1',
[username.trim(), DEV_USER_ID] [username.trim().toLowerCase()]
); );
if (rows.length === 0) {
// Pre-computed bcrypt hash of a value that no real password input will match. if (result.rows.length === 0) {
// Used to keep the user-not-found response time uniform with the wrong-password // Timing-safe: still run compare on a dummy hash so response time is constant
// path (~180ms at cost 12) so user enumeration via timing isn't possible. await bcrypt.compare(password, '$2b$12$invalidhashpadding000000000000000000000000000000000000');
await comparePassword(password, DUMMY_PASSWORD_HASH); return res.status(401).json({ error: 'Invalid credentials' });
ipBackoff.recordFailure(ip);
return res.status(401).json({ error: 'invalid credentials' });
}
const user = rows[0];
if (!(await comparePassword(password, user.password_hash))) {
ipBackoff.recordFailure(ip);
return res.status(401).json({ error: 'invalid credentials' });
} }
// Second factor: if TOTP is enabled, don't create a session yet. Hand back const user = result.rows[0];
// a short-lived ticket the client redeems via /login/totp with a code. const valid = await bcrypt.compare(password, user.password_hash);
// Crucially: do NOT clear the per-IP failure counter here. If we did, each
// /login retry would reset the backoff and let an attacker brute the 6-digit if (!valid) {
// TOTP space (10^6) with no per-attempt delay. The counter is cleared return res.status(401).json({ error: 'Invalid credentials' });
// inside establishSession() once MFA has actually passed. }
if (user.totp_enabled) {
return res.json({ // Regenerate session ID to prevent fixation attacks
mfa_required: true, req.session.regenerate((err) => {
ticket: issueTicket(user.id, { ip, userAgent: req.get('user-agent') }), if (err) return next(err);
req.session.userId = user.id;
req.session.username = user.username;
req.session.role = user.role;
res.json({
id: user.id,
username: user.username,
display_name: user.display_name,
role: user.role,
}); });
} });
await establishSession(req, user, ip);
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
} catch (err) { next(err); }
});
// Write the session and wait for it to persist before responding. Extracted so
// both the password-only and the MFA-completion paths share one implementation.
// Clears the per-IP failure counter only here — after every required factor has
// actually been proven (password [+ TOTP if enabled, or OAuth + TOTP]).
async function establishSession(req, user, ip) {
req.session.user_id = user.id;
req.session.first_seen_at = Date.now();
req.session.last_seen_at = Date.now();
// The critical line — wait for the row to land in `sessions` before responding.
// Without this, the SPA's next request races the store write, hits 401, and
// the prior bounce-to-login logic produced an infinite loop.
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
if (ip) ipBackoff.recordSuccess(ip);
pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id])
.catch(err => console.error('[auth] last_login_at update failed:', err.message));
}
// POST /api/v1/auth/login/totp { ticket?, code } — second login step. `code` is
// either a 6-digit TOTP or a one-time recovery code. The ticket comes from the
// request body (password-login path) or req.session.mfa_ticket (Google path).
router.post('/login/totp', async (req, res, next) => {
try {
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
// Rate-limit the second factor with the same per-IP backoff as /login so
// the 6-digit code space can't be hammered.
const delay = ipBackoff.delayMs(ip);
if (delay > 0) await new Promise(r => setTimeout(r, delay));
const { ticket: bodyTicket, code } = req.body || {};
const ticket = bodyTicket || req.session?.mfa_ticket;
if (req.session?.mfa_ticket) delete req.session.mfa_ticket;
// Bound to the issuing request's IP + UA — replays from a different origin
// redeem to null. See mfa-tickets.js for the binding model.
const userId = redeemTicket(ticket, { ip, userAgent: req.get('user-agent') });
if (!userId) {
ipBackoff.recordFailure(ip);
return res.status(401).json({ error: 'invalid or expired ticket' });
}
if (!code) return res.status(400).json({ error: 'code required' });
const { rows } = await pool.query(
`SELECT id, username, display_name, totp_secret, totp_enabled, totp_last_counter
FROM users WHERE id = $1`, [userId]);
const user = rows[0];
if (!user || !user.totp_enabled || !user.totp_secret) {
return res.status(401).json({ error: 'invalid credentials' });
}
// verifyToken returns the matched counter on success. Reject codes at
// counters ≤ totp_last_counter to prevent replay within the same step.
// The CAS-style UPDATE makes this race-free under concurrent submissions.
const matchedCounter = verifyToken(user.totp_secret, code);
let ok = false;
if (matchedCounter !== null) {
const lastCounter = BigInt(user.totp_last_counter || 0);
if (BigInt(matchedCounter) > lastCounter) {
const upd = await pool.query(
`UPDATE users SET totp_last_counter = $1
WHERE id = $2 AND totp_last_counter < $1`,
[String(matchedCounter), user.id]
);
ok = upd.rowCount === 1;
}
// matchedCounter ≤ last → silent replay; falls through to recovery-code
// path which also fails → 401. Same UX as a wrong code, no info leak.
}
if (!ok) ok = await consumeRecoveryCode(user.id, code);
if (!ok) {
ipBackoff.recordFailure(ip);
// The ticket was single-use; the client must restart from /login.
return res.status(401).json({ error: 'invalid code' });
}
// recordSuccess is called by establishSession once the session lands —
// that's the first moment we know every required factor has passed.
await establishSession(req, user, ip);
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
} catch (err) { next(err); }
});
// Check a recovery code against the user's unused codes; mark it spent on match.
// The marking is atomic (UPDATE ... WHERE used_at IS NULL with a rowCount check)
// so two concurrent redemptions of the same code can't both succeed.
async function consumeRecoveryCode(userId, code) {
const cleaned = String(code).trim().toLowerCase();
if (!/^[0-9a-f]{5}-[0-9a-f]{5}$/.test(cleaned)) return false;
const { rows } = await pool.query(
`SELECT id, code_hash FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL`, [userId]);
for (const row of rows) {
if (await comparePassword(cleaned, row.code_hash)) {
const upd = await pool.query(
`UPDATE user_recovery_codes SET used_at = NOW() WHERE id = $1 AND used_at IS NULL`, [row.id]);
// Lost the race if another request already consumed it.
return upd.rowCount === 1;
}
}
return false;
}
// POST /api/v1/auth/logout — destroys the session and clears the cookie.
router.post('/logout', (req, res) => {
if (!req.session) return res.status(204).end();
req.session.destroy(err => {
if (err) console.error('[auth] session destroy failed:', err.message);
res.clearCookie('dragonflight.sid', { path: '/' });
res.status(204).end();
});
});
// GET /api/v1/auth/me
router.get('/me', requireAuth, (req, res) => {
res.json({
id: req.user.id,
username: req.user.username,
display_name: req.user.display_name,
role: req.user.role,
totp_enabled: !!req.user.totp_enabled,
});
});
// POST /api/v1/auth/password { current_password, new_password }
router.post('/password', requireAuth, async (req, res, next) => {
try {
const { current_password, new_password } = req.body || {};
if (!current_password || !new_password) return badRequest(res, 'current_password and new_password required');
if (new_password.length < MIN_PASSWORD_LEN) return badRequest(res, 'new password must be at least ' + MIN_PASSWORD_LEN + ' characters');
const { rows } = await pool.query(`SELECT password_hash FROM users WHERE id = $1`, [req.user.id]);
if (!rows.length) return res.status(401).json({ error: 'unauthorized' });
if (!(await comparePassword(current_password, rows[0].password_hash))) {
return badRequest(res, 'current password is incorrect');
}
const newHash = await hashPassword(new_password);
await pool.query(
`UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2`,
[newHash, req.user.id]
);
res.status(204).end();
} catch (err) { next(err); }
});
// ── TOTP enrollment (all require an active session) ─────────────────────────
// POST /api/v1/auth/totp/setup — begin enrollment. Generates a fresh secret,
// stores it (but leaves totp_enabled=false), and returns the otpauth URI + the
// base32 secret for manual entry. Enrollment isn't active until /enable
// confirms a code, so a started-but-abandoned setup never locks the user out.
router.post('/totp/setup', requireAuth, async (req, res, next) => {
try {
const { rows } = await pool.query(`SELECT totp_enabled FROM users WHERE id = $1`, [req.user.id]);
if (rows[0]?.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled' });
const secret = generateSecret();
await pool.query(`UPDATE users SET totp_secret = $1 WHERE id = $2`, [secret, req.user.id]);
const uri = otpauthURI(secret, req.user.username || 'user');
// QR rendering is optional — the otpauth URI + manual secret are sufficient
// to enroll. Render a data-URL QR only if the optional `qrcode` dep is
// present, so a missing dependency degrades instead of 500-ing.
let qr = null;
try {
const QRCode = (await import('qrcode')).default;
qr = await QRCode.toDataURL(uri);
} catch { /* qrcode not installed — client falls back to manual entry */ }
res.json({ secret, otpauth_uri: uri, qr });
} catch (err) { next(err); }
});
// POST /api/v1/auth/totp/enable { code } — confirm enrollment with a code from
// the authenticator. On success, flips totp_enabled and returns one-time
// recovery codes (shown exactly once).
router.post('/totp/enable', requireAuth, async (req, res, next) => {
try {
const { code } = req.body || {};
if (!code) return badRequest(res, 'code required');
const { rows } = await pool.query(
`SELECT totp_secret, totp_enabled FROM users WHERE id = $1`, [req.user.id]);
const row = rows[0];
if (!row?.totp_secret) return badRequest(res, 'start setup first');
if (row.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled' });
const enrollCounter = verifyToken(row.totp_secret, code);
if (enrollCounter === null) return badRequest(res, 'incorrect code');
const recovery = generateRecoveryCodes(10);
const hashes = await Promise.all(recovery.map(c => hashPassword(c)));
// Enable + seed totp_last_counter to the enrollment code's counter so the
// same code can't be reused on first login. Replace any stale recovery
// codes atomically.
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
`UPDATE users SET totp_enabled = TRUE, totp_last_counter = $2 WHERE id = $1`,
[req.user.id, String(enrollCounter)]
);
await client.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.user.id]);
for (const h of hashes) {
await client.query(
`INSERT INTO user_recovery_codes (user_id, code_hash) VALUES ($1, $2)`, [req.user.id, h]);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
throw e;
} finally { client.release(); }
res.json({ enabled: true, recovery_codes: recovery });
} catch (err) { next(err); }
});
// POST /api/v1/auth/totp/disable { password } — turn off 2FA. Requires the
// account password as a confirmation so a hijacked live session can't silently
// strip the second factor.
router.post('/totp/disable', requireAuth, async (req, res, next) => {
try {
const { password } = req.body || {};
if (!password) return badRequest(res, 'password required');
const { rows } = await pool.query(`SELECT password_hash FROM users WHERE id = $1`, [req.user.id]);
if (!rows.length) return res.status(401).json({ error: 'unauthorized' });
if (!(await comparePassword(password, rows[0].password_hash))) {
return badRequest(res, 'incorrect password');
}
await pool.query(
`UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_last_counter = 0 WHERE id = $1`,
[req.user.id]
);
await pool.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.user.id]);
res.status(204).end();
} catch (err) { next(err); }
});
// ── Google OAuth (OIDC) sign-in ─────────────────────────────────────────────
// All Google routes are config-gated: without GOOGLE_CLIENT_ID/SECRET and
// OAUTH_REDIRECT_URL they 404, so a deployment without SSO is unaffected.
// GET /api/v1/auth/google/enabled — cheap, no auth. Lets the login screen decide
// whether to render the "Sign in with Google" button.
router.get('/google/enabled', (_req, res) => {
res.json({ enabled: googleConfigured() });
});
// GET /api/v1/auth/google — kick off the OAuth dance. Stores an anti-CSRF state
// in the session and redirects to Google's consent screen.
router.get('/google', async (req, res, next) => {
try {
if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' });
const state = randomBytes(16).toString('hex');
req.session.oauth_state = state;
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
res.redirect(await buildAuthUrl(state));
} catch (err) { next(err); }
});
// GET /api/v1/auth/google/callback — Google redirects back here with ?code&state.
// Verifies the ID token, enforces the allowed domain, auto-provisions a viewer
// on first login, establishes the session, then redirects to the SPA.
router.get('/google/callback', async (req, res, next) => {
try {
if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' });
const { code, state } = req.query;
const expected = req.session.oauth_state;
delete req.session.oauth_state;
if (!code || !state || !expected || state !== expected) {
return res.status(400).json({ error: 'invalid oauth state' });
}
const profile = await exchangeAndVerify(code);
const user = await resolveGoogleUser(profile);
// If this account has TOTP enabled, Google is only the FIRST factor — route
// through the same second-factor step as password login. The ticket lives in
// the session (not the URL) and the SPA prompts for the code.
if (user.totp_enabled) {
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
req.session.mfa_ticket = issueTicket(user.id, {
ip,
userAgent: req.get('user-agent'),
});
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
return res.redirect('/?mfa=1');
}
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
await establishSession(req, user, ip);
// Redirect to the SPA root; AuthGate will re-check /auth/me and render the app.
res.redirect('/');
} catch (err) { } catch (err) {
// Surface a friendly message on the login screen rather than a raw 500.
if (err.status === 403) return res.redirect('/?auth_error=domain');
if (err.status === 401) return res.redirect('/?auth_error=google');
next(err); next(err);
} }
}); });
// Map a verified Google profile to a Dragonflight user row. // ---------------------------------------------------------------------------
// // POST /logout
// Resolution order: // ---------------------------------------------------------------------------
// 1. Existing link by google_sub → that user. router.post('/logout', (req, res, next) => {
// 2. Otherwise auto-provision a fresh 'viewer'. req.session.destroy((err) => {
// if (err) return next(err);
// We deliberately do NOT auto-link to an existing account by matching email: res.clearCookie('connect.sid');
// that would let anyone who controls a Google address with the same email sign res.json({ message: 'Logged out' });
// in as a pre-existing local (possibly admin) account, bypassing its password });
// and TOTP. Linking an existing account to Google is an explicit, authenticated });
// action (a future "connect Google" under Settings), not something a login does.
async function resolveGoogleUser(profile) {
const found = await pool.query(
`SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]);
if (found.rows.length) return found.rows[0];
const base = (profile.email.split('@')[0] || 'user').replace(/[^a-z0-9._-]/gi, '').toLowerCase() || 'user'; // ---------------------------------------------------------------------------
let username = base, n = 1; // GET /me
while ((await pool.query(`SELECT 1 FROM users WHERE username = $1`, [username])).rows.length) { // ---------------------------------------------------------------------------
username = base + (++n); router.get('/me', async (req, res) => {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' });
} }
try { try {
const ins = await pool.query( const result = await pool.query(
`INSERT INTO users (username, password_hash, display_name, role, email, google_sub) 'SELECT id, username, display_name, role FROM users WHERE id = $1',
VALUES ($1, NULL, $2, 'viewer', $3, $4) [req.session.userId]
RETURNING id, username, display_name, totp_enabled`, );
[username, profile.name, profile.email, profile.sub]); if (result.rows.length === 0) {
return ins.rows[0]; req.session.destroy(() => {});
} catch (err) { return res.status(401).json({ error: 'User not found' });
// Concurrent first-login race: the unique google_sub index rejected our
// INSERT because a sibling request just created the row. Re-resolve.
if (err.code === '23505') {
const retry = await pool.query(
`SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]);
if (retry.rows.length) return retry.rows[0];
} }
throw err; res.json(result.rows[0]);
} catch (err) {
// Fallback to session data if DB unreachable
res.json({
id: req.session.userId,
username: req.session.username,
role: req.session.role,
});
} }
} });
// ---------------------------------------------------------------------------
// POST /setup — one-time first-admin bootstrap
// ---------------------------------------------------------------------------
router.post('/setup', async (req, res, next) => {
try {
const { username, password, display_name } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
// Block if any user already exists
const count = await pool.query('SELECT COUNT(*) FROM users');
if (parseInt(count.rows[0].count, 10) > 0) {
return res.status(403).json({
error: 'Setup is already complete. Use an existing admin account to add more users.',
});
}
const hash = await bcrypt.hash(password, 12);
const result = await pool.query(
`INSERT INTO users (username, password_hash, display_name, role)
VALUES ($1, $2, $3, 'admin')
RETURNING id, username, display_name, role`,
[username.trim().toLowerCase(), hash, display_name || username]
);
res.status(201).json(result.rows[0]);
} catch (err) {
next(err);
}
});
export default router; export default router;
export { realUserCount, resolveGoogleUser, consumeRecoveryCode };

View file

@ -1,76 +1,33 @@
import express from 'express'; import express from 'express';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { validateUuid } from '../middleware/errors.js'; import { requireAuth } from '../middleware/auth.js';
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
const router = express.Router(); const router = express.Router();
// Resolve the owning project for a /:id bin, assert 'view' baseline, stash the router.use(requireAuth);
// project_id for mutating routes to escalate to 'edit'.
router.param('id', async (req, res, next) => {
validateUuid('id')(req, res, () => {});
if (res.headersSent) return;
try {
const { rows } = await pool.query('SELECT project_id FROM bins WHERE id = $1', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Bin not found' });
req.binProjectId = rows[0].project_id;
await assertProjectAccess(req.user, req.binProjectId, 'view');
next();
} catch (err) { next(err); }
});
async function requireBinEdit(req, res, next) { // GET / - List bins for a project_id
try {
await assertProjectAccess(req.user, req.binProjectId, 'edit');
next();
} catch (err) { next(err); }
}
// GET / - List bins. When project_id is supplied, scope to it (after an access
// check); otherwise return bins across every project the caller can access.
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {
const { project_id } = req.query; const { project_id } = req.query;
if (project_id) { if (!project_id) {
await assertProjectAccess(req.user, project_id, 'view'); return res.status(400).json({ error: 'project_id is required' });
const result = await pool.query(
`SELECT b.*, p.name AS project_name,
(SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count
FROM bins b
LEFT JOIN projects p ON p.id = b.project_id
WHERE b.project_id = $1
ORDER BY b.created_at DESC`,
[project_id]
);
return res.json(result.rows);
} }
const access = await accessibleProjectIds(req.user);
let where = '';
const params = [];
if (!access.all) {
if (access.ids.size === 0) return res.json([]);
where = 'WHERE b.project_id = ANY($1::uuid[])';
params.push([...access.ids]);
}
const result = await pool.query( const result = await pool.query(
`SELECT b.*, p.name AS project_name, `SELECT * FROM bins WHERE project_id = $1 ORDER BY created_at DESC`,
(SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count [project_id]
FROM bins b
LEFT JOIN projects p ON p.id = b.project_id
${where}
ORDER BY b.created_at DESC`,
params
); );
res.json(result.rows); res.json(result.rows);
} catch (err) { } catch (err) {
next(err); next(err);
} }
}); });
// POST / - Create bin (requires edit on the target project). // POST / - Create bin
router.post('/', async (req, res, next) => { router.post('/', async (req, res, next) => {
try { try {
const { project_id, name, parent_id } = req.body; const { project_id, name, parent_id } = req.body;
@ -78,7 +35,6 @@ router.post('/', async (req, res, next) => {
if (!project_id || !name) { if (!project_id || !name) {
return res.status(400).json({ error: 'project_id and name are required' }); return res.status(400).json({ error: 'project_id and name are required' });
} }
await assertProjectAccess(req.user, project_id, 'edit');
const id = uuidv4(); const id = uuidv4();
@ -96,7 +52,7 @@ router.post('/', async (req, res, next) => {
}); });
// PATCH /:id - Update bin // PATCH /:id - Update bin
router.patch('/:id', requireBinEdit, async (req, res, next) => { router.patch('/:id', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { name, parent_id } = req.body; const { name, parent_id } = req.body;
@ -142,7 +98,7 @@ router.patch('/:id', requireBinEdit, async (req, res, next) => {
}); });
// DELETE /:id - Delete bin // DELETE /:id - Delete bin
router.delete('/:id', requireBinEdit, async (req, res, next) => { router.delete('/:id', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
@ -161,8 +117,8 @@ router.delete('/:id', requireBinEdit, async (req, res, next) => {
} }
}); });
// POST /:id/assets - Add asset to bin (requires edit on the bin's project). // POST /:id/assets - Add asset to bin
router.post('/:id/assets', requireBinEdit, async (req, res, next) => { router.post('/:id/assets', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { asset_id } = req.body; const { asset_id } = req.body;
@ -171,13 +127,10 @@ router.post('/:id/assets', requireBinEdit, async (req, res, next) => {
return res.status(400).json({ error: 'asset_id is required' }); return res.status(400).json({ error: 'asset_id is required' });
} }
// Asset must live in the bin's own project. Without this, an editor in // Verify bin exists
// project A (where the bin lives) could pull an asset from project B (no const binCheck = await pool.query('SELECT id FROM bins WHERE id = $1', [id]);
// grant) into A's bin tree, exposing it in A's views. if (binCheck.rows.length === 0) {
const a = await pool.query('SELECT project_id FROM assets WHERE id = $1', [asset_id]); return res.status(404).json({ error: 'Bin not found' });
if (a.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
if (a.rows[0].project_id !== req.binProjectId) {
return res.status(400).json({ error: 'asset belongs to a different project than the bin' });
} }
// Update asset's bin_id // Update asset's bin_id
@ -196,8 +149,8 @@ router.post('/:id/assets', requireBinEdit, async (req, res, next) => {
} }
}); });
// DELETE /:id/assets/:assetId - Remove asset from bin (requires edit). // DELETE /:id/assets/:assetId - Remove asset from bin
router.delete('/:id/assets/:assetId', requireBinEdit, async (req, res, next) => { router.delete('/:id/assets/:assetId', async (req, res, next) => {
try { try {
const { id, assetId } = req.params; const { id, assetId } = req.params;

View file

@ -1,65 +1,73 @@
// authz: intentionally any-logged-in (no per-project scoping). This is a thin
// proxy to shared capture hardware with no project_id of its own; the resulting
// asset is scoped when it's registered via the /assets route. Gated by the
// global requireAuth in index.js, like the rest of /api/v1.
import express from 'express'; import express from 'express';
import { requireAuth } from '../middleware/auth.js';
const router = express.Router(); const router = express.Router();
router.use(requireAuth);
const CAPTURE_URL = process.env.CAPTURE_URL || 'http://capture:3001'; const CAPTURE_URL = process.env.CAPTURE_URL || 'http://capture:3001';
async function proxyRequest(method, path, body = null) { // Helper to proxy requests
const proxyRequest = async (method, path, body = null) => {
const options = { const options = {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: {
signal: AbortSignal.timeout(8000), 'Content-Type': 'application/json',
},
}; };
if (body) options.body = JSON.stringify(body);
const response = await fetch(`${CAPTURE_URL}${path}`, options); if (body) {
const text = await response.text(); options.body = JSON.stringify(body);
let data;
try {
data = JSON.parse(text);
} catch {
// Capture service returned non-JSON (HTML error page, plain text, etc.)
data = { message: text.slice(0, 300) || '(empty response)' };
} }
return { status: response.status, data }; try {
} const response = await fetch(`${CAPTURE_URL}${path}`, options);
const data = await response.json();
return { status: response.status, data };
} catch (err) {
console.error('Capture service error:', err);
throw err;
}
};
// POST /start // POST /start - Forward start request
router.post('/start', async (req, res, next) => { router.post('/start', async (req, res, next) => {
try { try {
const { status, data } = await proxyRequest('POST', '/start', req.body); const { status, data } = await proxyRequest('POST', '/start', req.body);
res.status(status).json(data); res.status(status).json(data);
} catch (err) { next(err); } } catch (err) {
next(err);
}
}); });
// POST /stop // POST /stop - Forward stop request
router.post('/stop', async (req, res, next) => { router.post('/stop', async (req, res, next) => {
try { try {
const { status, data } = await proxyRequest('POST', '/stop', req.body); const { status, data } = await proxyRequest('POST', '/stop', req.body);
res.status(status).json(data); res.status(status).json(data);
} catch (err) { next(err); } } catch (err) {
next(err);
}
}); });
// GET /status // GET /status - Forward status request
router.get('/status', async (req, res, next) => { router.get('/status', async (req, res, next) => {
try { try {
const { status, data } = await proxyRequest('GET', '/status'); const { status, data } = await proxyRequest('GET', '/status');
res.status(status).json(data); res.status(status).json(data);
} catch (err) { next(err); } } catch (err) {
next(err);
}
}); });
// GET /devices // GET /devices - Forward devices request
router.get('/devices', async (req, res, next) => { router.get('/devices', async (req, res, next) => {
try { try {
const { status, data } = await proxyRequest('GET', '/devices'); const { status, data } = await proxyRequest('GET', '/devices');
res.status(status).json(data); res.status(status).json(data);
} catch (err) { next(err); } } catch (err) {
next(err);
}
}); });
export default router; export default router;

View file

@ -1,378 +0,0 @@
import express from 'express';
import http from 'http';
import pool from '../db/pool.js';
const router = express.Router();
function pickIp(reportedIp, reqIp) {
const clean = (s) => (s || '').replace(/^::ffff:/, '');
const isDockerBridge = (ip) => /^172\.17\./.test(ip || '');
const r = clean(reqIp);
if (!reportedIp) return r || null;
if (isDockerBridge(reportedIp) && r && !isDockerBridge(r)) return r;
return reportedIp;
}
function dockerRequest(path, method = 'GET', body = null) {
return new Promise((resolve, reject) => {
const opts = {
socketPath: '/var/run/docker.sock',
path: `/v1.41${path}`,
method,
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
};
const req = http.request(opts, (res) => {
let data = '';
res.on('data', d => { data += d; });
res.on('end', () => {
if (!data.trim()) return resolve(null);
try { resolve(JSON.parse(data)); }
catch (e) { resolve(null); }
});
});
req.on('error', reject);
req.setTimeout(5000, () => { req.destroy(); reject(new Error('Docker socket timeout')); });
if (body) req.write(JSON.stringify(body));
req.end();
});
}
router.get('/', async (req, res, next) => {
try {
const r = await pool.query(
`SELECT *,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes
ORDER BY registered_at ASC`
);
res.json(r.rows.map(row => ({
...row,
online: Number(row.stale_seconds) < 120,
})));
} catch (err) { next(err); }
});
router.get('/containers', async (req, res, next) => {
try {
const containers = await dockerRequest('/containers/json?all=true');
if (!Array.isArray(containers)) return res.json([]);
const out = containers.map(c => {
const rawName = (c.Names[0] || '').replace(/^\//, '');
const name = rawName.replace(/^wild-dragon-/, '').replace(/-\d+$/, '');
const ports = (c.Ports || [])
.filter(p => p.PublicPort)
.map(p => `${p.PublicPort}${p.PrivatePort}`)
.join(', ');
return {
id: c.Id.slice(0, 12),
name,
image: (c.Image || '').replace(/^sha256:/, '').slice(0, 40),
state: c.State,
uptime: (c.Status || '').replace(/\s*\(.*\)/, '').trim(),
healthy: (c.Status || '').includes('healthy'),
ports,
cpu: 0,
mem: 0,
};
});
res.json(out);
} catch (err) {
if (err.code === 'ENOENT' || err.code === 'EACCES') return res.json([]);
next(err);
}
});
router.post('/containers/:nameOrId/restart', async (req, res, next) => {
try {
await dockerRequest(`/containers/${encodeURIComponent(req.params.nameOrId)}/restart`, 'POST');
res.json({ ok: true });
} catch (err) { next(err); }
});
router.post('/heartbeat', async (req, res, next) => {
try {
const {
hostname, ip_address,
role = 'worker', version, api_url,
cpu_usage, mem_used_mb, mem_total_mb,
capabilities, metadata, metrics,
} = req.body;
if (!hostname) return res.status(400).json({ error: 'hostname is required' });
if (process.env.AUTH_ENABLED === 'true') {
const bound = req.tokenBoundHostname;
if (bound && bound !== hostname) {
return res.status(403).json({
error: `Token is bound to "${bound}" but heartbeat reported "${hostname}"`,
});
}
if (!bound && req.user?.role !== 'admin') {
return res.status(403).json({
error: 'Heartbeat requires a node-bound token or admin session',
});
}
}
const effectiveIp = pickIp(ip_address, req.ip || req.socket?.remoteAddress);
const r = await pool.query(
`INSERT INTO cluster_nodes
(hostname, ip_address, role, version, api_url,
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(),NOW(),$9,$10,$11)
ON CONFLICT (hostname) DO UPDATE SET
ip_address = EXCLUDED.ip_address,
role = EXCLUDED.role,
version = EXCLUDED.version,
api_url = EXCLUDED.api_url,
cpu_usage = EXCLUDED.cpu_usage,
mem_used_mb = EXCLUDED.mem_used_mb,
mem_total_mb = EXCLUDED.mem_total_mb,
last_seen = NOW(),
last_seen_at = NOW(),
capabilities = EXCLUDED.capabilities,
metadata = EXCLUDED.metadata,
metrics = COALESCE(EXCLUDED.metrics, cluster_nodes.metrics)
RETURNING *`,
[
hostname,
effectiveIp,
role,
version || null,
api_url || null,
cpu_usage != null ? cpu_usage : null,
mem_used_mb != null ? mem_used_mb : null,
mem_total_mb != null ? mem_total_mb : null,
capabilities != null ? JSON.stringify(capabilities) : '{}',
metadata != null ? JSON.stringify(metadata) : null,
metrics != null ? JSON.stringify(metrics) : null,
]
);
res.json(r.rows[0]);
} catch (err) { next(err); }
});
router.get('/devices/blackmagic/signal', async (req, res, next) => {
try {
const nodesResult = await pool.query(
`SELECT id, hostname, ip_address, api_url, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes
WHERE capabilities IS NOT NULL`
);
const recResult = await pool.query(
`SELECT id, name, status, container_id, node_id, device_index,
source_config
FROM recorders
WHERE source_type = 'sdi' AND node_id IS NOT NULL`
);
const recByPort = new Map();
for (const r of recResult.rows) {
const devIdx = r.device_index ?? r.source_config?.device ?? 0;
recByPort.set(`${r.node_id}:${devIdx}`, r);
}
const tasks = [];
for (const node of nodesResult.rows) {
const nodeOnline = Number(node.stale_seconds) < 120;
const bm = (node.capabilities && node.capabilities.blackmagic) || [];
const model = (node.capabilities && node.capabilities.blackmagic_model) || null;
const localHostname = process.env.NODE_HOSTNAME || '';
const isRemote = node.api_url && node.hostname !== localHostname;
bm.forEach((d, idx) => {
const portIndex = d.index !== undefined ? d.index : idx;
const rec = recByPort.get(`${node.id}:${portIndex}`);
tasks.push((async () => {
const base = {
node_id: node.id, hostname: node.hostname, index: portIndex,
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,
signal: 'no-recorder', framesReceived: null, currentFps: null,
};
if (!rec || rec.status !== 'recording' || !rec.container_id) {
if (rec && rec.status !== 'recording') base.signal = 'idle';
return base;
}
try {
let live = null;
if (isRemote) {
const r = await fetch(`${node.api_url}/sidecar/${rec.container_id}/status`, { signal: AbortSignal.timeout(2500) });
if (r.ok) live = (await r.json()).live;
} else {
const r = await fetch(`http://recorder-${rec.id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
if (r.ok) live = await r.json();
}
if (live && live.signal) {
base.signal = live.signal;
base.framesReceived = live.framesReceived ?? null;
base.currentFps = live.currentFps ?? null;
} else { base.signal = 'connecting'; }
} catch (_) { base.signal = 'connecting'; }
return base;
})());
});
}
const results = await Promise.all(tasks);
res.json(results);
} catch (err) { next(err); }
});
router.get('/devices/blackmagic', async (req, res, next) => {
try {
const r = await pool.query(
`SELECT id, hostname, ip_address, role, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes WHERE capabilities IS NOT NULL`
);
const out = [];
for (const row of r.rows) {
const online = Number(row.stale_seconds) < 120;
const bm = (row.capabilities && row.capabilities.blackmagic) || [];
const model = (row.capabilities && row.capabilities.blackmagic_model) || null;
bm.forEach((d, idx) => {
out.push({ node_id: row.id, 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);
} catch (err) { next(err); }
});
router.get('/devices/deltacast', async (req, res, next) => {
try {
const r = await pool.query(
`SELECT id, hostname, ip_address, role, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes WHERE capabilities IS NOT NULL`
);
const out = [];
for (const row of r.rows) {
const online = Number(row.stale_seconds) < 120;
const dc = (row.capabilities && row.capabilities.deltacast) || [];
const model = (row.capabilities && row.capabilities.deltacast_model) || null;
dc.forEach((d, idx) => {
out.push({ node_id: row.id, hostname: row.hostname, ip_address: row.ip_address,
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);
} catch (err) { next(err); }
});
router.get('/devices/deltacast/signal', async (req, res, next) => {
try {
const [nodesRes, recordersRes] = await Promise.all([
pool.query(`SELECT id, hostname, ip_address, api_url, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes WHERE capabilities IS NOT NULL`),
pool.query(`SELECT id, node_id, device_index, status, source_type, container_id
FROM recorders WHERE source_type = 'deltacast'`),
]);
const recByNodePort = {};
for (const rec of recordersRes.rows) {
recByNodePort[`${rec.node_id}:${rec.device_index}`] = rec;
}
const results = [];
const fetchPromises = [];
for (const node of nodesRes.rows) {
const online = Number(node.stale_seconds) < 120;
const dc = (node.capabilities && node.capabilities.deltacast) || [];
const model = (node.capabilities && node.capabilities.deltacast_model) || 'Deltacast';
for (const port of dc) {
const idx = port.index !== undefined ? port.index : dc.indexOf(port);
const rec = recByNodePort[`${node.id}:${idx}`];
const base = { node_id: node.id, hostname: node.hostname, ip_address: node.ip_address,
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.status !== 'recording') { base.signal = 'idle'; results.push(base); continue; }
const fetchIdx = results.length;
results.push(base);
fetchPromises.push((async () => {
try {
const url = node.api_url ? `${node.api_url}/sidecar/${rec.container_id}/status`
: `http://recorder-${rec.id}:3001/capture/status`;
const r = await fetch(url, { signal: AbortSignal.timeout(2500) });
if (r.ok) {
const live = await r.json();
if (live && live.signal) {
results[fetchIdx].signal = live.signal;
results[fetchIdx].framesReceived = live.framesReceived ?? null;
results[fetchIdx].currentFps = live.currentFps ?? null;
}
}
} catch (_) { results[fetchIdx].signal = 'connecting'; }
})());
}
}
await Promise.all(fetchPromises);
res.json(results);
} catch (err) { next(err); }
});
router.get('/:id/ping', async (req, res, next) => {
try {
const r = await pool.query('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' });
const node = r.rows[0];
if (!node.api_url) return res.json({ reachable: false, reason: 'no api_url registered' });
const start = Date.now();
try {
const upstream = await fetch(`${node.api_url}/health`, { signal: AbortSignal.timeout(4000) });
const latency_ms = Date.now() - start;
const body = await upstream.json().catch(() => ({}));
res.json({ reachable: upstream.ok, latency_ms, status: upstream.status, agent: body });
} catch (err) {
res.json({ reachable: false, latency_ms: Date.now() - start, reason: err.message });
}
} catch (err) { next(err); }
});
router.get('/metrics', async (req, res, next) => {
try {
const r = await pool.query(
`SELECT id, hostname, role, last_seen,
cpu_usage, mem_used_mb, mem_total_mb,
capabilities, metrics,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes ORDER BY registered_at ASC`
);
const nodes = r.rows.map(row => {
const capGpus = (row.capabilities && row.capabilities.gpus) || [];
const liveGpus = (row.metrics && row.metrics.gpus) || [];
const gpus = capGpus.map((g, idx) => {
const live = liveGpus.find(l => l.index === g.index) || liveGpus[idx] || {};
return { name: g.name || null, util_pct: live.util_pct != null ? live.util_pct : 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) };
});
for (const lg of liveGpus) {
if (!capGpus.some(g => g.index === lg.index)) {
gpus.push({ name: lg.name || null, util_pct: lg.util_pct != null ? lg.util_pct : 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,
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 });
} catch (err) { next(err); }
});
router.delete('/:id', async (req, res, next) => {
try {
const r = await pool.query('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' });
res.json({ ok: true });
} catch (err) { next(err); }
});
export default router;

View file

@ -1,128 +0,0 @@
// Asset-scoped comments for the Asset Detail page.
//
// Mounted at /api/v1/assets/:assetId/comments via app.use('/api/v1/assets/:assetId/comments', router).
// Express's :assetId param flows through from the parent mount.
import express from 'express';
import pool from '../db/pool.js';
import { assertProjectAccess } from '../auth/authz.js';
const router = express.Router({ mergeParams: true });
// Scope every comment route to the parent asset's project: resolve project_id
// via the asset, then require 'view' to read and 'edit' to write. A non-UUID or
// unknown asset is a clean 404 before any access decision leaks its existence.
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
router.use(async (req, res, next) => {
try {
const { rows } = await pool.query('SELECT project_id FROM assets WHERE id = $1', [req.params.assetId]);
if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
await assertProjectAccess(req.user, rows[0].project_id, MUTATING.has(req.method) ? 'edit' : 'view');
next();
} catch (err) { next(err); }
});
function rowToJson(r) {
return {
id: r.id,
asset_id: r.asset_id,
user_id: r.user_id,
body: r.body,
frame_ms: r.frame_ms,
resolved: r.resolved,
created_at: r.created_at,
updated_at: r.updated_at,
author_name: r.author_name || null,
author_initials: r.author_initials || null,
};
}
// GET /api/v1/assets/:assetId/comments
router.get('/', async (req, res, next) => {
try {
const { assetId } = req.params;
const result = await pool.query(
`SELECT c.*,
u.display_name AS author_name,
UPPER(SUBSTRING(COALESCE(u.display_name, u.username, '?'), 1, 2)) AS author_initials
FROM asset_comments c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.asset_id = $1
ORDER BY c.created_at ASC`,
[assetId]
);
res.json({ comments: result.rows.map(rowToJson) });
} catch (err) { next(err); }
});
// POST /api/v1/assets/:assetId/comments
router.post('/', async (req, res, next) => {
try {
const { assetId } = req.params;
const { body, frame_ms } = req.body || {};
if (!body || !String(body).trim()) {
return res.status(400).json({ error: 'body is required' });
}
// Author is the authenticated user (requireAuth sets req.user for both
// session and bearer auth, and the dev user when AUTH_ENABLED=false).
const userId = req.user?.id || null;
const ins = await pool.query(
`INSERT INTO asset_comments (asset_id, user_id, body, frame_ms)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[assetId, userId, String(body).trim(), frame_ms != null ? Math.max(0, Math.round(Number(frame_ms))) : null]
);
// Re-fetch with the author join so the response has the same shape as list.
const result = await pool.query(
`SELECT c.*,
u.display_name AS author_name,
UPPER(SUBSTRING(COALESCE(u.display_name, u.username, '?'), 1, 2)) AS author_initials
FROM asset_comments c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.id = $1`,
[ins.rows[0].id]
);
res.status(201).json(rowToJson(result.rows[0]));
} catch (err) { next(err); }
});
// PATCH /api/v1/assets/:assetId/comments/:id
router.patch('/:id', async (req, res, next) => {
try {
const { id, assetId } = req.params;
const { body, resolved } = req.body || {};
const fields = [];
const values = [];
let i = 1;
if (body !== undefined) { fields.push(`body = $${i++}`); values.push(String(body).trim()); }
if (resolved !== undefined) { fields.push(`resolved = $${i++}`); values.push(!!resolved); }
if (fields.length === 0) return res.status(400).json({ error: 'Nothing to update' });
fields.push('updated_at = NOW()');
values.push(id, assetId);
const result = await pool.query(
`UPDATE asset_comments SET ${fields.join(', ')}
WHERE id = $${i++} AND asset_id = $${i}
RETURNING *`,
values
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Comment not found' });
res.json(rowToJson(result.rows[0]));
} catch (err) { next(err); }
});
// DELETE /api/v1/assets/:assetId/comments/:id
router.delete('/:id', async (req, res, next) => {
try {
const { id, assetId } = req.params;
const result = await pool.query(
`DELETE FROM asset_comments WHERE id = $1 AND asset_id = $2 RETURNING id`,
[id, assetId]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Comment not found' });
res.json({ id });
} catch (err) { next(err); }
});
export default router;

View file

@ -1,112 +0,0 @@
/**
* Group management routes (admin-only when AUTH_ENABLED=true)
*
* GET /api/v1/groups list all groups
* POST /api/v1/groups create group
* PATCH /api/v1/groups/:id update group
* DELETE /api/v1/groups/:id delete group
* GET /api/v1/groups/:id/members list members
* POST /api/v1/groups/:id/members add member { user_id }
* DELETE /api/v1/groups/:id/members/:uid remove member
*/
import express from 'express';
import pool from '../db/pool.js';
const router = express.Router();
// ── List ──────────────────────────────────────────────────────
router.get('/', async (_req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT g.id, g.name, g.description, g.created_at,
COUNT(ug.user_id)::int AS member_count
FROM groups g
LEFT JOIN user_groups ug ON ug.group_id = g.id
GROUP BY g.id
ORDER BY g.name`
);
res.json(rows);
} catch (err) { next(err); }
});
// ── Create ────────────────────────────────────────────────────
router.post('/', async (req, res, next) => {
try {
const { name, description } = req.body;
if (!name) return res.status(400).json({ error: 'name required' });
const { rows } = await pool.query(
`INSERT INTO groups (name, description) VALUES ($1, $2) RETURNING *`,
[name.trim(), description || null]
);
res.status(201).json(rows[0]);
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Group name already exists' });
next(err);
}
});
// ── Update ────────────────────────────────────────────────────
router.patch('/:id', async (req, res, next) => {
try {
const { name, description } = req.body;
const sets = []; const vals = [];
if (name !== undefined) { sets.push(`name = $${sets.length + 1}`); vals.push(name); }
if (description !== undefined) { sets.push(`description = $${sets.length + 1}`); vals.push(description); }
if (!sets.length) return res.status(400).json({ error: 'Nothing to update' });
vals.push(req.params.id);
const { rows } = await pool.query(
`UPDATE groups SET ${sets.join(', ')} WHERE id = $${vals.length} RETURNING *`,
vals
);
if (!rows.length) return res.status(404).json({ error: 'Group not found' });
res.json(rows[0]);
} catch (err) { next(err); }
});
// ── Delete ────────────────────────────────────────────────────
router.delete('/:id', async (req, res, next) => {
try {
const { rowCount } = await pool.query('DELETE FROM groups WHERE id = $1', [req.params.id]);
if (!rowCount) return res.status(404).json({ error: 'Group not found' });
res.json({ message: 'Group deleted' });
} catch (err) { next(err); }
});
// ── Members ───────────────────────────────────────────────────
router.get('/:id/members', async (req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT u.id, u.username, u.display_name, u.role
FROM user_groups ug
JOIN users u ON u.id = ug.user_id
WHERE ug.group_id = $1
ORDER BY u.username`,
[req.params.id]
);
res.json(rows);
} catch (err) { next(err); }
});
router.post('/:id/members', async (req, res, next) => {
try {
const { user_id } = req.body;
if (!user_id) return res.status(400).json({ error: 'user_id required' });
await pool.query(
`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[user_id, req.params.id]
);
res.status(201).json({ message: 'Member added' });
} catch (err) { next(err); }
});
router.delete('/:id/members/:uid', async (req, res, next) => {
try {
await pool.query(
`DELETE FROM user_groups WHERE group_id = $1 AND user_id = $2`,
[req.params.id, req.params.uid]
);
res.json({ message: 'Member removed' });
} catch (err) { next(err); }
});
export default router;

View file

@ -1,97 +0,0 @@
// External media imports — currently YouTube only.
//
// The flow mirrors upload.js: create the asset row up front with a placeholder
// filename (the worker fills in the real title once yt-dlp prints metadata),
// then enqueue a BullMQ job. The worker downloads, lands the file in S3 at the
// same originals/{assetId}/... path uploads use, and hands off to the existing
// proxy queue — so an imported asset travels the same lifecycle as any upload.
import express from 'express';
import { Queue } from 'bullmq';
import { v4 as uuidv4 } from 'uuid';
import pool from '../db/pool.js';
import { assertProjectAccess } from '../auth/authz.js';
const router = express.Router();
const parseRedisUrl = (url) => {
try {
const parsed = new URL(url);
return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 };
} catch {
return { host: 'localhost', port: 6379 };
}
};
const importQueue = new Queue('import', {
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});
// Match the same three forms the client UI validates against. Server is the
// authoritative check — never trust the client to have validated.
const YT_PATTERNS = [
/^https?:\/\/(?:www\.|m\.)?youtube\.com\/watch\?[^ ]*v=[A-Za-z0-9_-]{11}/i,
/^https?:\/\/youtu\.be\/[A-Za-z0-9_-]{11}/i,
/^https?:\/\/(?:www\.)?youtube\.com\/shorts\/[A-Za-z0-9_-]{11}/i,
];
function isYouTubeUrl(url) {
return typeof url === 'string' && YT_PATTERNS.some((re) => re.test(url));
}
// POST /api/v1/imports/youtube — body { url, projectId, binId? }
router.post('/youtube', async (req, res, next) => {
try {
const { url, projectId, binId } = req.body || {};
if (!url || !projectId) {
return res.status(400).json({ error: 'url and projectId are required' });
}
if (!isYouTubeUrl(url)) {
return res.status(400).json({ error: 'Invalid YouTube URL' });
}
// A playlist URL has `list=…` — yt-dlp's --no-playlist would still grab
// the single video, but the operator probably meant "import the list" and
// we don't support that yet. Reject so the intent is explicit.
if (/[?&]list=/i.test(url)) {
return res.status(400).json({ error: "Playlists aren't supported yet" });
}
const projCheck = await pool.query('SELECT id FROM projects WHERE id = $1', [projectId]);
if (projCheck.rows.length === 0) {
return res.status(404).json({ error: 'Project not found' });
}
// Importing writes an asset into the project — require edit access.
await assertProjectAccess(req.user, projectId, 'edit');
const assetId = uuidv4();
// Placeholder filename/display_name — the worker overwrites both once
// yt-dlp resolves the video title (usually within a second or two).
await pool.query(
`INSERT INTO assets (
id, project_id, bin_id, filename, display_name, status,
media_type, original_s3_key, source_url, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $4, 'ingesting', 'video', NULL, $5, NOW(), NOW())`,
[assetId, projectId, binId || null, url, url]
);
const bullJob = await importQueue.add('youtube', {
assetId,
url,
// Surface the URL in the Jobs screen until the worker fills in the title.
assetName: url,
});
res.status(202).json({
assetId,
jobId: `import:${bullJob.id}`,
status: 'queued',
});
} catch (err) {
next(err);
}
});
export default router;

View file

@ -1,14 +1,11 @@
import express from 'express'; import express from 'express';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { assertProjectAccess } from '../auth/authz.js'; import { v4 as uuidv4 } from 'uuid';
const router = express.Router(); const router = express.Router();
// Note: jobs use BullMQ id format "<queueType>:<bullId>" (e.g. "conform:42"), router.use(requireAuth);
// NOT UUIDs. The GET/:id, POST/:id/retry, and DELETE/:id handlers below split
// on the colon themselves and look up the queue. Adding a UUID validator
// here would 400 every BullMQ poll the panel makes (which is exactly what
// caused Export Timeline to stall "Rendering Hi-Res" forever — fixed 2026-05-28).
// ── Redis connection ────────────────────────────────────────────────────────── // ── Redis connection ──────────────────────────────────────────────────────────
const parseRedisUrl = (url) => { const parseRedisUrl = (url) => {
@ -22,35 +19,26 @@ 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 conformQueue = new Queue('conform', { connection: redisConn });
const conformQueue = new Queue('conform', { connection: redisConn });
const importQueue = new Queue('import', { connection: redisConn });
const trimQueue = new Queue('trim', { 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: conformQueue, type: 'conform' },
{ queue: conformQueue, type: 'conform' },
{ queue: importQueue, type: 'import' },
{ queue: trimQueue, type: 'trim' },
]; ];
// BullMQ state → API status mapping // BullMQ state → API status mapping
const STATE_MAP = { const STATE_MAP = {
waiting: 'waiting', waiting: 'waiting',
active: 'active', active: 'active',
completed: 'completed', completed:'completed',
failed: 'failed', failed: 'failed',
delayed: 'waiting', delayed: 'waiting',
paused: 'waiting', paused: 'waiting',
}; };
// Ordered state buckets used for bulk fetch — avoids N+1 getState() calls.
const STATE_BUCKETS = ['active', 'waiting', 'completed', 'failed', 'delayed', 'paused'];
function normalizeJob(bullJob, type, apiStatus) { function normalizeJob(bullJob, type, apiStatus) {
const isCompleted = apiStatus === 'completed'; const isCompleted = apiStatus === 'completed';
const isFailed = apiStatus === 'failed'; const isFailed = apiStatus === 'failed';
@ -70,129 +58,28 @@ function normalizeJob(bullJob, type, apiStatus) {
}; };
} }
// Fetch all jobs from all queues in bulk by state bucket (no per-job getState() calls).
async function getAllBullMQJobs() { async function getAllBullMQJobs() {
const results = []; const results = [];
for (const { queue, type } of QUEUES) { for (const { queue, type } of QUEUES) {
for (const bucket of STATE_BUCKETS) { for (const [bullState, apiStatus] of Object.entries(STATE_MAP)) {
try { try {
const apiStatus = STATE_MAP[bucket] || bucket; const jobs = await queue.getJobs([bullState], 0, 200);
const jobs = await queue.getJobs([bucket], 0, 200);
for (const job of jobs) { for (const job of jobs) {
results.push(normalizeJob(job, type, apiStatus)); results.push(normalizeJob(job, type, apiStatus));
} }
} catch { } catch {
// queue or bucket unavailable — skip // queue may be empty or unavailable for this state skip
} }
} }
} }
return results; return results;
} }
// Mutate `jobs` in place to fill in asset_name from the assets table for any // ── GET / - List jobs (BullMQ queues) ────────────────────────────────────────
// job that has an assetId but no inline assetName in its payload. One bulk
// SQL query per refresh — cheap, and means we don't have to remember to pass
// assetName at every enqueue site (upload.js, capture stop, scheduler, etc.).
async function attachAssetNames(jobs) {
const idsNeedingLookup = [...new Set(
jobs.filter(j => j.asset_id && !j.asset_name).map(j => j.asset_id)
)];
if (idsNeedingLookup.length === 0) return;
let rows = [];
try {
const result = await pool.query(
'SELECT id, display_name, filename FROM assets WHERE id = ANY($1::uuid[])',
[idsNeedingLookup]
);
rows = result.rows;
} catch {
// If the lookup fails (DB down, bad UUID in a stale BullMQ payload), keep
// serving jobs without names rather than 500-ing the whole list.
return;
}
const byId = new Map(rows.map(r => [r.id, r.display_name || r.filename]));
for (const j of jobs) {
if (j.asset_id && !j.asset_name) {
const name = byId.get(j.asset_id);
if (name) j.asset_name = name;
}
}
}
// ── GET /events Server-Sent Events stream of live job updates ───────────────
router.get('/events', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
let closed = false;
req.on('close', () => { closed = true; });
const push = async () => {
if (closed) return;
try {
const jobs = await getAllBullMQJobs();
await attachAssetNames(jobs);
if (!closed) res.write(`data: ${JSON.stringify({ type: 'jobs', jobs })}\n\n`);
} catch (err) {
if (!closed) res.write(`data: ${JSON.stringify({ type: 'error', message: err.message })}\n\n`);
}
if (!closed) setTimeout(push, 2000);
};
await push();
});
// Fetch DB-tracked jobs (e.g. trim) and normalize to the same shape as BullMQ jobs.
// Only returns non-expired rows.
async function getDbJobs() {
try {
const result = await pool.query(
`SELECT j.id, j.type, j.status, j.payload, j.created_at, j.updated_at,
ts.asset_id
FROM jobs j
LEFT JOIN temp_segments ts ON ts.job_id = j.id
WHERE (j.expires_at IS NULL OR j.expires_at > NOW())
ORDER BY j.created_at DESC
LIMIT 200`
);
// Dedupe — multiple temp_segments per job, take first asset_id found
const seen = new Map();
for (const row of result.rows) {
if (!seen.has(row.id)) {
seen.set(row.id, {
id: `trim:${row.id}`,
type: row.type,
status: row.status === 'completed' ? 'completed' : row.status,
progress: row.status === 'completed' ? 100 : (row.status === 'failed' ? 0 : 50),
asset_id: row.asset_id || null,
asset_name: null,
created_at: row.created_at ? new Date(row.created_at).toISOString() : null,
started_at: null,
completed_at: row.status === 'completed' && row.updated_at ? new Date(row.updated_at).toISOString() : null,
failed_at: row.status === 'failed' && row.updated_at ? new Date(row.updated_at).toISOString() : null,
error: null,
metadata: row.payload || {},
});
}
}
return [...seen.values()];
} catch {
return [];
}
}
// ── GET / - List jobs (BullMQ queues + DB trim jobs) ─────────────────────────
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {
const { type, status, asset_id } = req.query; const { type, status, asset_id } = req.query;
let jobs = await getAllBullMQJobs(); let jobs = await getAllBullMQJobs();
const dbJobs = await getDbJobs();
jobs = jobs.concat(dbJobs);
await attachAssetNames(jobs);
if (type) jobs = jobs.filter(j => j.type === type); if (type) jobs = jobs.filter(j => j.type === type);
if (status) jobs = jobs.filter(j => j.status === status); if (status) jobs = jobs.filter(j => j.status === status);
@ -209,6 +96,7 @@ router.get('/', async (req, res, next) => {
router.get('/:id', async (req, res, next) => { router.get('/:id', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
// id format: "type:bullId" e.g. "proxy:1"
const colonIdx = id.indexOf(':'); const colonIdx = id.indexOf(':');
const qType = colonIdx > -1 ? id.slice(0, colonIdx) : null; const qType = colonIdx > -1 ? id.slice(0, colonIdx) : null;
const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id; const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id;
@ -220,9 +108,7 @@ router.get('/:id', async (req, res, next) => {
if (job) { if (job) {
const state = await job.getState(); const state = await job.getState();
const apiStatus = STATE_MAP[state] || state; const apiStatus = STATE_MAP[state] || state;
const normalized = normalizeJob(job, type, apiStatus); return res.json(normalizeJob(job, type, apiStatus));
await attachAssetNames([normalized]);
return res.json(normalized);
} }
} catch { /* try next queue */ } } catch { /* try next queue */ }
} }
@ -232,8 +118,8 @@ router.get('/:id', async (req, res, next) => {
} }
}); });
// ── POST /:id/retry - Retry a failed job ────────────────────────────────────── // ── DELETE /:id - Remove a job ────────────────────────────────────────────────
router.post('/:id/retry', async (req, res, next) => { router.delete('/:id', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const colonIdx = id.indexOf(':'); const colonIdx = id.indexOf(':');
@ -245,8 +131,8 @@ router.post('/:id/retry', async (req, res, next) => {
try { try {
const job = await queue.getJob(bullId); const job = await queue.getJob(bullId);
if (job) { if (job) {
await job.retry(); await job.remove();
return res.json({ id, status: 'queued' }); return res.json({ success: true });
} }
} catch { /* try next queue */ } } catch { /* try next queue */ }
} }
@ -256,65 +142,7 @@ router.post('/:id/retry', async (req, res, next) => {
} }
}); });
// ── DELETE /:id - Remove a job (also handles cancel for active jobs) ───────── // ── POST /conform - Submit a conform job ──────────────────────────────────────
// BullMQ refuses job.remove() while a job is in the 'active' state. Before this
// fix the route caught that error and fell through to a misleading 404, so
// operators couldn't kill a stalled-active job from the UI. Now we detect the
// active state explicitly: moveToFailed with the magic '0' token bypasses the
// per-worker lock check and transitions active → failed (freeing the queue's
// concurrency slot), then remove() drops the row.
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const colonIdx = id.indexOf(':');
const qType = colonIdx > -1 ? id.slice(0, colonIdx) : null;
const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id;
let lastErr = null;
for (const { queue, type } of QUEUES) {
if (qType && type !== qType) continue;
let job;
try {
job = await queue.getJob(bullId);
} catch (err) {
// Queue-level lookup error: remember it so we don't mask it with 404.
lastErr = err;
continue;
}
if (!job) continue;
const state = await job.getState();
if (state === 'active') {
// Token '0' tells BullMQ to skip the worker-lock check — necessary
// because the operator-side cancel doesn't hold the worker's lock.
try {
await job.moveToFailed(new Error('Cancelled by operator'), '0', false);
} catch (err) {
// Lock owned by a still-living worker; fall back to discard + remove
// so at least the result is thrown away and the row is gone.
try { await job.discard(); } catch (_) {}
}
}
try {
await job.remove();
} catch (err) {
// Last-resort obliteration of the job row via raw Redis. This is
// the path stalled jobs hit when moveToFailed couldn't transition
// them either.
const client = await queue.client;
const prefix = queue.toKey(bullId);
await client.del(prefix);
}
return res.json({ success: true, cancelled: state === 'active' });
}
if (lastErr) return next(lastErr);
res.status(404).json({ error: 'Job not found' });
} catch (err) {
next(err);
}
});
// ── POST /conform - Submit a conform (EDL export) job ────────────────────────
router.post('/conform', async (req, res, next) => { router.post('/conform', async (req, res, next) => {
try { try {
const { edl, project_id, output_format } = req.body; const { edl, project_id, output_format } = req.body;
@ -325,17 +153,25 @@ router.post('/conform', async (req, res, next) => {
}); });
} }
// Conform writes back into a project — require edit on that project. Without const jobId = uuidv4();
// this, any logged-in user could enqueue conform jobs targeting any project.
await assertProjectAccess(req.user, project_id, 'edit');
const bullJob = await conformQueue.add('conform-task', { const result = await pool.query(
`INSERT INTO jobs (id, type, status, project_id, metadata, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
RETURNING *`,
[jobId, 'conform', 'pending', project_id, JSON.stringify({ edl, output_format })]
);
const job = result.rows[0];
await conformQueue.add('conform-task', {
jobId,
edl, edl,
projectId: project_id, projectId: project_id,
outputFormat: output_format, outputFormat: output_format,
}); });
res.status(202).json({ id: `conform:${bullJob.id}`, status: 'queued' }); res.status(201).json(job);
} catch (err) { } catch (err) {
next(err); next(err);
} }

View file

@ -1,102 +0,0 @@
// Real metrics for the Home page sparklines.
//
// Buckets the last N hours into N points, counting rows in each window.
// Returns a flat shape that's easy for the React Sparkline to consume.
import express from 'express';
import pool from '../db/pool.js';
const router = express.Router();
const DEFAULT_HOURS = 24;
const DEFAULT_POINTS = 13;
function bucketCountSQL(table, statusFilter) {
// Use date_trunc + generate_series so we always return `points` buckets
// (even hours with no rows show up as 0). All times are UTC.
return `
WITH series AS (
SELECT generate_series(
date_trunc('hour', NOW() - ($1 || ' hours')::interval),
date_trunc('hour', NOW()),
('1 hour')::interval
) AS bucket
)
SELECT s.bucket,
COALESCE(COUNT(t.created_at), 0)::int AS count
FROM series s
LEFT JOIN ${table} t
ON date_trunc('hour', t.created_at) = s.bucket
${statusFilter ? ` AND ${statusFilter}` : ''}
GROUP BY s.bucket
ORDER BY s.bucket ASC
`;
}
async function bucketSeries(table, hours, statusFilter = null) {
const result = await pool.query(bucketCountSQL(table, statusFilter), [hours]);
return result.rows.map(r => ({ t: r.bucket, v: r.count }));
}
router.get('/home', async (req, res, next) => {
try {
const hours = Math.min(parseInt(req.query.hours || DEFAULT_HOURS, 10), 168); // cap at 1 week
const [assets, jobsDone, jobsFailed, recordersTotal, recordersLive, jobsRunning, jobsDoneTotal, jobsFailedTotal] = await Promise.all([
bucketSeries('assets', hours),
bucketSeries('jobs', hours, `t.status = 'complete'`),
bucketSeries('jobs', hours, `t.status = 'failed'`),
pool.query(`SELECT COUNT(*)::int AS n FROM recorders`),
pool.query(`SELECT COUNT(*)::int AS n FROM recorders WHERE status = 'recording'`),
pool.query(`SELECT COUNT(*)::int AS n FROM jobs WHERE status IN ('queued','processing')`),
pool.query(`SELECT COUNT(*)::int AS n FROM jobs WHERE status = 'complete'`),
pool.query(`SELECT COUNT(*)::int AS n FROM jobs WHERE status = 'failed'`),
]);
// Cluster snapshot — heartbeat freshness drives online/offline
const cluster = await pool.query(
`SELECT id, hostname, role,
EXTRACT(EPOCH FROM (NOW() - last_seen))::int AS stale_seconds
FROM cluster_nodes`
);
const nodes = cluster.rows.map(n => ({
id: n.id, hostname: n.hostname, role: n.role,
online: n.stale_seconds != null && n.stale_seconds < 120,
}));
res.json({
hours,
generated_at: new Date().toISOString(),
cards: {
assets: {
total: (await pool.query(`SELECT COUNT(*)::int AS n FROM assets`)).rows[0].n,
series: assets,
},
recorders: {
total: recordersTotal.rows[0].n,
live: recordersLive.rows[0].n,
// No historical "active" metric yet — synthesize as the live count
// replayed across the window so the card has *something* to graph.
series: assets.map(p => ({ t: p.t, v: recordersLive.rows[0].n })),
},
jobs: {
running: jobsRunning.rows[0].n,
done_total: jobsDoneTotal.rows[0].n,
failed_total: jobsFailedTotal.rows[0].n,
series_done: jobsDone,
series_failed: jobsFailed,
},
cluster: {
total: nodes.length,
online: nodes.filter(n => n.online).length,
nodes,
// Heartbeat liveness is binary — emit a 1/0 across the window keyed
// to current state so the sparkline shows a sensible bar shape.
series: assets.map(p => ({ t: p.t, v: nodes.filter(n => n.online).length })),
},
},
});
} catch (err) { next(err); }
});
export default router;

View file

@ -1,620 +0,0 @@
// 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 { 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 });
}
}
const { rows } = await pool.query(
`UPDATE playout_channels
SET status = 'running', container_id = $1, container_meta = $2, 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 });
}
});
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,
})),
};
const out = await callSidecar(req.channel, '/playlist/load', 'POST', payload);
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

@ -1,12 +1,11 @@
import express from 'express'; import express from 'express';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { validateUuid } from '../middleware/errors.js'; import { requireAuth } from '../middleware/auth.js';
import { requireAdmin } from '../middleware/auth.js';
import { accessibleProjectIds, assertProjectAccess } from '../auth/authz.js';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
const router = express.Router(); const router = express.Router();
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
router.use(requireAuth);
// Helper function to slugify // Helper function to slugify
const slugify = (str) => { const slugify = (str) => {
@ -18,29 +17,18 @@ const slugify = (str) => {
.replace(/-+/g, '-'); .replace(/-+/g, '-');
}; };
// GET / - List projects the caller can access (admins see all). // GET / - List all projects
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {
const access = await accessibleProjectIds(req.user); const result = await pool.query('SELECT * FROM projects ORDER BY created_at DESC');
if (access.all) {
const result = await pool.query('SELECT * FROM projects ORDER BY created_at DESC');
return res.json(result.rows);
}
if (access.ids.size === 0) return res.json([]);
const ids = [...access.ids];
const result = await pool.query(
`SELECT * FROM projects WHERE id = ANY($1::uuid[]) ORDER BY created_at DESC`,
[ids]
);
res.json(result.rows); res.json(result.rows);
} catch (err) { } catch (err) {
next(err); next(err);
} }
}); });
// POST / - Create project (admin only; new projects have no grants, so a // POST / - Create project
// scoped user could never reach one they just made). router.post('/', async (req, res, next) => {
router.post('/', requireAdmin, async (req, res, next) => {
try { try {
const { name, description } = req.body; const { name, description } = req.body;
@ -64,11 +52,10 @@ router.post('/', requireAdmin, async (req, res, next) => {
} }
}); });
// GET /:id - Single project with asset count (requires view access). // GET /:id - Single project with asset count
router.get('/:id', async (req, res, next) => { router.get('/:id', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
await assertProjectAccess(req.user, id, 'view');
const result = await pool.query( const result = await pool.query(
`SELECT p.*, `SELECT p.*,
@ -90,11 +77,10 @@ router.get('/:id', async (req, res, next) => {
} }
}); });
// PATCH /:id - Update project (requires edit access). // PATCH /:id - Update project
router.patch('/:id', async (req, res, next) => { router.patch('/:id', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
await assertProjectAccess(req.user, id, 'edit');
const { name, description } = req.body; const { name, description } = req.body;
const updates = []; const updates = [];
@ -137,9 +123,8 @@ router.patch('/:id', async (req, res, next) => {
} }
}); });
// DELETE /:id - Delete project and cascade (admin only — destructive, wipes // DELETE /:id - Delete project and cascade
// every asset/bin/recorder under it). router.delete('/:id', async (req, res, next) => {
router.delete('/:id', requireAdmin, async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
@ -159,78 +144,4 @@ router.delete('/:id', requireAdmin, async (req, res, next) => {
} }
}); });
// ── Per-project access grants (admin only) ──────────────────────────────────
// GET /:id/access — list grants with resolved user/group display names.
router.get('/:id/access', requireAdmin, async (req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT pa.subject_type, pa.subject_id, pa.level, pa.granted_at,
CASE pa.subject_type
WHEN 'user' THEN u.display_name
WHEN 'group' THEN g.name
END AS subject_name,
CASE pa.subject_type
WHEN 'user' THEN u.username
ELSE NULL
END AS username
FROM project_access pa
LEFT JOIN users u ON pa.subject_type = 'user' AND u.id = pa.subject_id
LEFT JOIN groups g ON pa.subject_type = 'group' AND g.id = pa.subject_id
WHERE pa.project_id = $1
ORDER BY pa.subject_type, subject_name`,
[req.params.id]
);
res.json(rows);
} catch (err) { next(err); }
});
// POST /:id/access { subject_type, subject_id, level } — grant or update.
router.post('/:id/access', requireAdmin, async (req, res, next) => {
try {
const { subject_type, subject_id, level } = req.body || {};
if (!['user', 'group'].includes(subject_type)) {
return res.status(400).json({ error: "subject_type must be 'user' or 'group'" });
}
if (!subject_id) return res.status(400).json({ error: 'subject_id required' });
const lvl = level || 'view';
if (!['view', 'edit'].includes(lvl)) {
return res.status(400).json({ error: "level must be 'view' or 'edit'" });
}
// Validate the subject actually exists so we don't create dead grants.
const tbl = subject_type === 'user' ? 'users' : 'groups';
const exists = await pool.query(`SELECT 1 FROM ${tbl} WHERE id = $1`, [subject_id]);
if (exists.rows.length === 0) {
return res.status(404).json({ error: subject_type + ' not found' });
}
const { rows } = await pool.query(
`INSERT INTO project_access (project_id, subject_type, subject_id, level, granted_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (project_id, subject_type, subject_id)
DO UPDATE SET level = EXCLUDED.level, granted_by = EXCLUDED.granted_by, granted_at = NOW()
RETURNING project_id, subject_type, subject_id, level, granted_at`,
[req.params.id, subject_type, subject_id, lvl, req.user?.id || null]
);
res.status(201).json(rows[0]);
} catch (err) { next(err); }
});
// DELETE /:id/access/:subjectType/:subjectId — revoke a grant.
router.delete('/:id/access/:subjectType/:subjectId', requireAdmin, async (req, res, next) => {
try {
const { id, subjectType, subjectId } = req.params;
if (!['user', 'group'].includes(subjectType)) {
return res.status(400).json({ error: "subjectType must be 'user' or 'group'" });
}
const { rowCount } = await pool.query(
`DELETE FROM project_access
WHERE project_id = $1 AND subject_type = $2 AND subject_id = $3`,
[id, subjectType, subjectId]
);
if (rowCount === 0) return res.status(404).json({ error: 'grant not found' });
res.status(204).end();
} catch (err) { next(err); }
});
export default router; export default router;

View file

@ -1,43 +1,12 @@
import express from 'express'; import express from 'express';
import http from 'http'; import http from 'http';
import fs from 'fs';
import net from 'net';
import dgram from 'dgram';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { getS3Bucket } from '../s3/client.js'; import { requireAuth } from '../middleware/auth.js';
import { validateUuid } from '../middleware/errors.js';
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
const router = express.Router(); const router = express.Router();
// Every /:id recorder route is scoped to the recorder's project. The param router.use(requireAuth);
// handler validates the UUID, resolves the owning project_id, and asserts the
// 'view' baseline; mutating routes escalate to 'edit' via requireRecorderEdit.
// A recorder with a NULL project_id resolves to admin-only (assertProjectAccess
// throws 403 for non-admins on a null project).
router.param('id', async (req, res, next) => {
validateUuid('id')(req, res, () => {});
if (res.headersSent) return;
try {
const { rows } = await pool.query('SELECT project_id FROM recorders WHERE id = $1', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
req.recorderProjectId = rows[0].project_id;
await assertProjectAccess(req.user, req.recorderProjectId, 'view');
next();
} catch (err) { next(err); }
});
async function requireRecorderEdit(req, res, next) {
try {
await assertProjectAccess(req.user, req.recorderProjectId, 'edit');
next();
} catch (err) { next(err); }
}
// Base port for on-demand SDI sidecar containers on remote worker nodes.
// Device index 0 → 7438, index 1 → 7439, etc.
const SIDECAR_PORT_BASE = 7438;
// Docker API helper function // Docker API helper function
function dockerApi(method, path, body = null) { function dockerApi(method, path, body = null) {
@ -60,31 +29,11 @@ function dockerApi(method, path, body = null) {
}); });
}); });
req.on('error', reject); req.on('error', reject);
// Add 10-second timeout to prevent indefinite hangs if Docker daemon is unresponsive
req.setTimeout(10000, () => {
req.destroy(new Error('Docker API timeout after 10s'));
});
if (body) req.write(JSON.stringify(body)); if (body) req.write(JSON.stringify(body));
req.end(); req.end();
}); });
} }
// Look up the cluster node for a recorder and decide if it is remote.
// Returns { remote: false } when the node is local or unset;
// { remote: true, apiUrl, ip } when it is a different host.
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 };
}
// Helper function to generate clip name with timestamp // Helper function to generate clip name with timestamp
function generateClipName(recorderName) { function generateClipName(recorderName) {
const now = new Date(); const now = new Date();
@ -94,31 +43,12 @@ function generateClipName(recorderName) {
const hours = String(now.getHours()).padStart(2, '0'); const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0'); const seconds = String(now.getSeconds()).padStart(2, '0');
// Strip filesystem-hostile characters out of the recorder name (spaces return `${recorderName}_${year}${month}${day}_${hours}${minutes}${seconds}`;
// become underscores, anything outside [A-Za-z0-9._-] is dropped) so the
// clipName flows cleanly through S3 keys, SMB paths, and ffmpeg args.
const safe = String(recorderName || 'rec')
.replace(/\s+/g, '_')
.replace(/[^A-Za-z0-9._-]/g, '')
.slice(0, 40) || 'rec';
return `${safe}_${year}${month}${day}_${hours}${minutes}${seconds}`;
}
// Sanitize an operator-provided clip name so it's safe as both an S3 key
// segment and an SMB/POSIX filename. Allow letters, digits, dot, dash,
// underscore, and spaces; collapse runs of whitespace; cap at 80 chars.
function sanitizeClipName(raw) {
if (typeof raw !== 'string') return null;
const cleaned = raw
.replace(/[^A-Za-z0-9._\- ]+/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 80);
return cleaned.length > 0 ? cleaned : null;
} }
/** /**
* Build Docker PortBindings and ExposedPorts for listener-mode recorders. * Build Docker PortBindings and ExposedPorts for listener-mode recorders.
* Returns { portBindings, exposedPorts } both empty objects for non-listener sources.
*/ */
function buildPortConfig(sourceType, sourceConfig) { function buildPortConfig(sourceType, sourceConfig) {
const portBindings = {}; const portBindings = {};
@ -141,79 +71,13 @@ function buildPortConfig(sourceType, sourceConfig) {
return { portBindings, exposedPorts }; return { portBindings, exposedPorts };
} }
// Whitelist of recorder columns the API accepts on POST/PATCH. Keeping it
// explicit prevents accidental writes to status / container_id / timestamps.
const RECORDER_FIELDS = [
'name', 'source_type', 'source_config',
'recording_codec', 'recording_resolution',
'recording_video_bitrate', 'recording_framerate',
'recording_audio_codec', 'recording_audio_bitrate', 'recording_audio_channels',
'recording_container',
'proxy_enabled', 'proxy_codec', 'proxy_resolution',
'proxy_video_bitrate', 'proxy_framerate',
'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
'proxy_container',
'project_id', 'node_id', 'device_index',
];
function pickRecorderFields(body) {
const out = {};
for (const k of RECORDER_FIELDS) {
if (body[k] !== undefined) out[k] = body[k];
}
return out;
}
// GET / - List all recorders // GET / - List all recorders
//
// Issue #121 — previous version fired N PG queries + N Docker inspects per
// list call. Now we resolve `live_asset_id` for every recording row in a
// single LATERAL JOIN, and the Docker `started_at` lookups are bounded by
// the number of currently-recording rows (typically <10) and run in
// parallel with a per-call timeout from `dockerApi`.
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {
// Scope to recorders in projects the caller can access (admins unfiltered). const result = await pool.query(
// Recorders with a NULL project are admin-only and never appear for scoped 'SELECT * FROM recorders ORDER BY created_at DESC'
// users (accessibleProjectIds never yields a null id). );
const access = await accessibleProjectIds(req.user); res.json(result.rows);
let scopeClause = '';
const params = [];
if (!access.all) {
if (access.ids.size === 0) return res.json([]);
scopeClause = 'WHERE r.project_id = ANY($1::uuid[])';
params.push([...access.ids]);
}
const result = await pool.query(`
SELECT r.*, la.live_asset_id
FROM recorders r
LEFT JOIN LATERAL (
SELECT a.id AS live_asset_id
FROM assets a
WHERE r.status = 'recording'
AND a.project_id = r.project_id
AND a.display_name = r.current_session_id
AND a.status = 'live'
ORDER BY a.created_at DESC
LIMIT 1
) la ON TRUE
${scopeClause}
ORDER BY r.created_at DESC
`, params);
const rows = result.rows;
// Only inspect containers for recorders that actually claim to be recording.
const inspectable = rows.filter(r => r.status === 'recording' && r.container_id);
await Promise.all(inspectable.map(async (r) => {
try {
const insp = await dockerApi('GET', `/containers/${r.container_id}/json`);
if (insp.status === 200 && insp.data && insp.data.State) {
r.started_at = insp.data.State.StartedAt;
}
} catch (_) { /* leave started_at undefined */ }
}));
res.json(rows);
} catch (err) { } catch (err) {
next(err); next(err);
} }
@ -222,51 +86,56 @@ router.get('/', async (req, res, next) => {
// POST / - Create a new recorder // POST / - Create a new recorder
router.post('/', async (req, res, next) => { router.post('/', async (req, res, next) => {
try { try {
const fields = pickRecorderFields(req.body); const b = req.body || {};
const name = b.name;
const source_type = b.source_type;
const source_config = b.source_config;
const recording_codec = b.recording_codec || b.codec;
const recording_resolution = b.recording_resolution || b.resolution;
const proxy_enabled = b.proxy_enabled !== undefined ? b.proxy_enabled : (b.proxy_config ? true : undefined);
const proxy_codec = b.proxy_codec || (b.proxy_config && b.proxy_config.codec);
const proxy_resolution = b.proxy_resolution || (b.proxy_config && (b.proxy_config.resolution || b.proxy_config.bitrate));
const project_id = b.project_id;
if (!fields.name || !fields.source_type) { if (!name || !source_type) {
return res return res
.status(400) .status(400)
.json({ error: 'Name and source_type are required' }); .json({ error: 'Name and source_type are required' });
} }
// Creating a recorder writes into a project — require edit there. A recorder const id = uuidv4();
// with no project_id is admin-only (assertProjectAccess denies non-admins on
// a null project).
await assertProjectAccess(req.user, fields.project_id ?? null, 'edit');
// Defaults — written on insert so the DB row is always self-contained.
const defaults = {
source_config: {},
recording_codec: 'hevc_nvenc',
recording_resolution: 'native',
recording_audio_codec: 'pcm_s24le',
recording_audio_channels: 2,
recording_container: 'mov',
proxy_enabled: true,
proxy_codec: 'h264',
proxy_resolution: '1920x1080',
proxy_video_bitrate: '2M',
proxy_audio_codec: 'aac',
proxy_audio_bitrate: '128k',
proxy_audio_channels: 2,
proxy_container: 'mp4',
};
const row = { id: uuidv4(), status: 'stopped', ...defaults, ...fields };
// Build INSERT dynamically so adding columns later means one place to update.
const cols = Object.keys(row);
const placeholders = cols.map((_, i) => `$${i + 1}`).join(', ');
const values = cols.map(k => {
const v = row[k];
if (k === 'source_config') return v && typeof v === 'object' ? v : {};
return v;
});
const result = await pool.query( const result = await pool.query(
`INSERT INTO recorders (${cols.join(', ')}, created_at, updated_at) `INSERT INTO recorders (
VALUES (${placeholders}, NOW(), NOW()) id,
RETURNING *`, name,
values source_type,
source_config,
recording_codec,
recording_resolution,
proxy_enabled,
proxy_codec,
proxy_resolution,
project_id,
status,
created_at,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW())
RETURNING *`,
[
id,
name,
source_type,
source_config || {},
recording_codec || 'prores_hq',
recording_resolution || 'native',
proxy_enabled !== false,
proxy_codec || 'libx264',
proxy_resolution || '1920x1080',
project_id || null,
'stopped',
]
); );
res.status(201).json(result.rows[0]); res.status(201).json(result.rows[0]);
@ -295,51 +164,12 @@ router.get('/:id', async (req, res, next) => {
} }
}); });
// PATCH /:id - Edit recorder settings
// Blocked while recorder is actively recording to prevent config drift.
router.patch('/:id', requireRecorderEdit, async (req, res, next) => {
try {
const { id } = req.params;
const recorderResult = await pool.query(
'SELECT * FROM recorders WHERE id = $1',
[id]
);
if (recorderResult.rows.length === 0) {
return res.status(404).json({ error: 'Recorder not found' });
}
const recorder = recorderResult.rows[0];
if (recorder.status === 'recording') {
return res.status(409).json({ error: 'Cannot edit a recorder while it is recording — stop it first' });
}
const fields = pickRecorderFields(req.body);
const cols = Object.keys(fields);
if (cols.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
const setClause = cols.map((k, i) => `${k} = $${i + 1}`).join(', ');
const params = cols.map(k => fields[k]);
params.push(id);
const result = await pool.query(
`UPDATE recorders SET ${setClause}, updated_at = NOW() WHERE id = $${params.length} RETURNING *`,
params
);
res.json(result.rows[0]);
} catch (err) {
next(err);
}
});
// POST /:id/start - Start recording // POST /:id/start - Start recording
router.post('/:id/start', requireRecorderEdit, async (req, res, next) => { router.post('/:id/start', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
// Get recorder config from DB
const recorderResult = await pool.query( const recorderResult = await pool.query(
'SELECT * FROM recorders WHERE id = $1', 'SELECT * FROM recorders WHERE id = $1',
[id] [id]
@ -355,67 +185,27 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
return res.status(400).json({ error: 'Recorder is already recording' }); return res.status(400).json({ error: 'Recorder is already recording' });
} }
// Get S3 config from environment
const s3Endpoint = process.env.S3_ENDPOINT; const s3Endpoint = process.env.S3_ENDPOINT;
const s3Bucket = getS3Bucket(); // Use live config, not stale env snapshot (#61) const s3Bucket = process.env.S3_BUCKET;
const s3AccessKey = process.env.S3_ACCESS_KEY; const s3AccessKey = process.env.S3_ACCESS_KEY;
const s3SecretKey = process.env.S3_SECRET_KEY; const s3SecretKey = process.env.S3_SECRET_KEY;
const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000'; const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000';
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 // Generate clip name with timestamp
// capture container writes the master to its /growing/ mount instead of const clipName = generateClipName(recorder.name);
// streaming it to S3 — Premiere can mount the SMB share and edit it live.
const growingRow = await pool.query(
`SELECT value FROM settings WHERE key = 'growing_enabled'`
);
const growingEnabled =
growingRow.rows[0]?.value === 'true' || growingRow.rows[0]?.value === true;
// Operator-supplied clip name wins over the auto-timestamped fallback.
// The Recorders UI passes this on the start request when the user types
// something into the "Clip name" field; otherwise it's blank and we
// generate `<recorder>_<timestamp>` as before.
const customClipName = sanitizeClipName(req.body && req.body.clipName);
const clipName = customClipName || generateClipName(recorder.name);
// Per-take project override: the Recorders UI can pass projectId on the
// start request to send clips to a different project than the recorder's
// default. Falls back to the recorder's configured project_id.
const takeProjectId = (req.body && req.body.projectId && typeof req.body.projectId === 'string')
? req.body.projectId
: recorder.project_id;
// requireRecorderEdit only covered the recorder's own project. If this take
// is being routed into a DIFFERENT project, the caller must have edit there
// too — otherwise edit on recorder A's project would let them write live
// assets into any project B.
if (takeProjectId !== recorder.project_id) {
await assertProjectAccess(req.user, takeProjectId, 'edit');
}
// live-asset: create the asset row right now (status='live') so the
// library shows the recording while it is happening.
const assetIdLive = uuidv4();
try {
const ext = recorder.recording_container || 'mov';
await pool.query(
`INSERT INTO assets (
id, project_id, bin_id, filename, display_name, status, media_type,
original_s3_key, created_at, updated_at
) VALUES ($1, $2, NULL, $3, $3, 'live', 'video', $4, NOW(), NOW())`,
[assetIdLive, takeProjectId, clipName, `projects/${takeProjectId}/masters/${clipName}.${ext}`]
);
} catch (e) {
console.warn('[recorders] could not pre-create live asset:', e.message);
}
// Determine source config and whether this is a listener-mode recorder
const sourceConfig = recorder.source_config || {}; const sourceConfig = recorder.source_config || {};
const isListener = sourceConfig.mode === 'listener'; const isListener = sourceConfig.mode === 'listener';
const sourceType = recorder.source_type; const sourceType = recorder.source_type;
const deviceIndex = recorder.device_index ?? sourceConfig.device ?? 0;
// Build container env — all codec controls flow through here. // Build port bindings for listener-mode SRT/RTMP containers
const { portBindings, exposedPorts } = buildPortConfig(sourceType, sourceConfig);
// Build container environment — pass all source params so the capture
// service can auto-start recording on container startup
const env = [ const env = [
`S3_ENDPOINT=${s3Endpoint}`, `S3_ENDPOINT=${s3Endpoint}`,
`S3_BUCKET=${s3Bucket}`, `S3_BUCKET=${s3Bucket}`,
@ -426,44 +216,16 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
`RECORDER_ID=${id}`, `RECORDER_ID=${id}`,
`SOURCE_TYPE=${sourceType}`, `SOURCE_TYPE=${sourceType}`,
`SOURCE_CONFIG=${JSON.stringify(sourceConfig)}`, `SOURCE_CONFIG=${JSON.stringify(sourceConfig)}`,
`DEVICE_INDEX=${deviceIndex}`, `RECORDING_CODEC=${recorder.recording_codec}`,
`RECORDING_RESOLUTION=${recorder.recording_resolution}`,
// Recording codec controls `PROXY_ENABLED=${recorder.proxy_enabled}`,
`RECORDING_CODEC=${recorder.recording_codec || 'prores_hq'}`, `PROXY_CODEC=${recorder.proxy_codec}`,
`RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`, `PROXY_RESOLUTION=${recorder.proxy_resolution}`,
`RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`, `PROJECT_ID=${recorder.project_id}`,
`RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`,
`RECORDING_AUDIO_CODEC=${recorder.recording_audio_codec || 'pcm_s24le'}`,
`RECORDING_AUDIO_BITRATE=${recorder.recording_audio_bitrate || ''}`,
`RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`,
`RECORDING_CONTAINER=${recorder.recording_container || 'mov'}`,
// Proxy codec controls
`PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`,
`PROXY_CODEC=${recorder.proxy_codec || 'h264'}`,
`PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`,
`PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '2M'}`,
`PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`,
`PROXY_AUDIO_CODEC=${recorder.proxy_audio_codec || 'aac'}`,
`PROXY_AUDIO_BITRATE=${recorder.proxy_audio_bitrate || '128k'}`,
`PROXY_AUDIO_CHANNELS=${recorder.proxy_audio_channels ?? 2}`,
`PROXY_CONTAINER=${recorder.proxy_container || 'mp4'}`,
`PROJECT_ID=${takeProjectId}`,
`CLIP_NAME=${clipName}`, `CLIP_NAME=${clipName}`,
`ASSET_ID=${assetIdLive}`,
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
`GROWING_PATH=/growing`,
]; ];
// Deltacast: pass port count so the capture container can enumerate // Add source-specific env vars for SRT/RTMP
// test-card slots even without physical /dev/deltacast* nodes.
if (sourceType === 'deltacast') {
const dcCount = process.env.DELTACAST_PORT_COUNT || sourceConfig.port_count || '';
if (dcCount) env.push(`DELTACAST_PORT_COUNT=${dcCount}`);
}
if (sourceType === 'srt' || sourceType === 'rtmp') { if (sourceType === 'srt' || sourceType === 'rtmp') {
env.push(`LISTEN=${isListener ? '1' : '0'}`); env.push(`LISTEN=${isListener ? '1' : '0'}`);
if (isListener) { if (isListener) {
@ -476,115 +238,49 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
} }
} }
// GPU-accelerated codecs require the NVIDIA container runtime on the node. // Build container config
// hevc_nvenc / h264_nvenc are the only two we currently support; extend const containerConfig = {
// this list if av1_nvenc or others are added later. Image: 'wild-dragon-capture:latest',
const GPU_CODECS = ['hevc_nvenc', 'h264_nvenc']; Env: env,
const useGpu = GPU_CODECS.includes(recorder.recording_codec); ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
HostConfig: {
// Determine whether to spawn locally or via a remote node-agent.
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
// For remote sidecars, the capture container runs on the worker host network and cannot
// resolve the Docker-internal mam-api hostname — replace with the external URL.
if (isRemote) {
const idx = env.findIndex(e => e.startsWith('MAM_API_URL='));
if (idx !== -1) env[idx] = `MAM_API_URL=${externalMamApiUrl}`;
}
let containerId;
if (isRemote) {
// Remote node: delegate container lifecycle to that node's agent.
const capturePort = SIDECAR_PORT_BASE + (deviceIndex || 0);
const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: 'wild-dragon-capture:latest', env, capturePort, sourceType, useGpu }),
signal: AbortSignal.timeout(15000),
});
if (!sidecarRes.ok) {
// #105 — never proxy the remote node's raw response back to the
// browser; it could contain echoed env vars on bad-request paths.
const details = await sidecarRes.json().catch(() => ({}));
console.error('[recorders] remote sidecar start failed:', JSON.stringify(details));
return res.status(502).json({
error: 'Remote node failed to start sidecar',
details: (details && details.message) || 'see server logs',
});
}
const sidecarData = await sidecarRes.json();
containerId = sidecarData.containerId;
} else {
// Local spawn via Docker socket.
const { portBindings, exposedPorts } = buildPortConfig(sourceType, sourceConfig);
const alias = `recorder-${id}`;
const hostBinds = ['/mnt/NVME/MAM/wild-dragon-live:/live'];
if (sourceType === 'sdi') hostBinds.push('/dev/blackmagic:/dev/blackmagic');
if (sourceType === 'deltacast') {
// Bind each /dev/deltacast* device node the host has into the container.
// The capture service falls back to test-card if none are present.
try {
const { readdirSync } = await import('node:fs');
const dcEntries = readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n));
for (const d of dcEntries) hostBinds.push(`/dev/${d}:/dev/${d}`);
} catch (_) { /* no /dev/deltacast* nodes on this host */ }
}
if (growingEnabled) hostBinds.push('/mnt/NVME/MAM/wild-dragon-growing:/growing');
const localEnv = [...env];
if (useGpu) {
localEnv.push('NVIDIA_VISIBLE_DEVICES=all');
localEnv.push('NVIDIA_DRIVER_CAPABILITIES=video,compute,utility');
}
const localHostConfig = {
Privileged: true, Privileged: true,
NetworkMode: dockerNetwork, NetworkMode: dockerNetwork,
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined, PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
Binds: hostBinds, },
...(useGpu && { Hostname: `recorder-${recorder.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`,
Runtime: 'nvidia', };
DeviceRequests: [{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] }],
}),
};
const containerConfig = { // Create container
Image: 'wild-dragon-capture:latest', const createRes = await dockerApi('POST', '/containers/create', containerConfig);
Env: localEnv,
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
HostConfig: localHostConfig,
NetworkingConfig: {
EndpointsConfig: {
[dockerNetwork]: { Aliases: [alias] },
},
},
Hostname: alias,
};
const createRes = await dockerApi('POST', '/containers/create', containerConfig); if (createRes.status !== 201) {
if (createRes.status !== 201) { return res.status(500).json({
// Issue #105 — log the full Docker error server-side, but never echo error: 'Failed to create container',
// the create payload (which contains S3_ACCESS_KEY / STREAM_KEY in details: createRes.data,
// Env) back to the client. Send a short, generic message. });
console.error('[recorders] container create failed:', JSON.stringify(createRes.data));
return res.status(500).json({
error: 'Failed to create container',
details: (createRes.data && createRes.data.message) || 'see server logs',
});
}
containerId = createRes.data.Id;
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
if (startRes.status !== 204) {
console.error('[recorders] container start failed:', JSON.stringify(startRes.data));
return res.status(500).json({
error: 'Failed to start container',
details: (startRes.data && startRes.data.message) || 'see server logs',
});
}
} }
const containerId = createRes.data.Id;
// Start container
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
if (startRes.status !== 204) {
// Clean up the unstarted container so it doesn't accumulate as an orphan
// (e.g. when the requested host port is already bound by another process).
try {
await dockerApi('DELETE', `/containers/${containerId}?force=true`);
} catch (cleanupErr) {
console.error('Failed to remove unstarted container:', cleanupErr.message);
}
return res.status(500).json({
error: 'Failed to start container',
details: startRes.data,
});
}
// Update recorder in DB
const updateResult = await pool.query( const updateResult = await pool.query(
`UPDATE recorders `UPDATE recorders
SET container_id = $1, status = $2, current_session_id = $3, updated_at = NOW() SET container_id = $1, status = $2, current_session_id = $3, updated_at = NOW()
@ -600,10 +296,11 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
}); });
// POST /:id/stop - Stop recording // POST /:id/stop - Stop recording
router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => { router.post('/:id/stop', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
// Get recorder from DB
const recorderResult = await pool.query( const recorderResult = await pool.query(
'SELECT * FROM recorders WHERE id = $1', 'SELECT * FROM recorders WHERE id = $1',
[id] [id]
@ -616,55 +313,37 @@ router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
const recorder = recorderResult.rows[0]; const recorder = recorderResult.rows[0];
if (!recorder.container_id) { if (!recorder.container_id) {
// No container tracked — reset stuck status gracefully. return res.status(400).json({ error: 'No container running' });
const result = await pool.query(
`UPDATE recorders SET container_id = NULL, status = 'stopped', updated_at = NOW()
WHERE id = $1 RETURNING *`,
[id]
);
return res.json(result.rows[0]);
} }
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id); // Stop container with 5-min grace so SRT/RTMP captures can flush S3 upload
const stopRes = await dockerApi(
'POST',
`/containers/${recorder.container_id}/stop?t=300`
);
if (isRemote) { // 204 = stopped, 304 = already stopped — both are acceptable
const stopRes = await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, { if (stopRes.status !== 204 && stopRes.status !== 304) {
method: 'DELETE', return res.status(500).json({
signal: AbortSignal.timeout(15000), error: 'Failed to stop container',
details: stopRes.data,
}); });
if (!stopRes.ok && stopRes.status !== 404) {
return res.status(502).json({ error: 'Remote node failed to stop sidecar' });
}
} else {
const stopRes = await dockerApi(
'POST',
`/containers/${recorder.container_id}/stop`
);
// 204 = stopped, 304 = already stopped, 404 = container gone — all acceptable.
if (stopRes.status !== 204 && stopRes.status !== 304 && stopRes.status !== 404) {
return res.status(500).json({
error: 'Failed to stop container',
details: stopRes.data,
});
}
// Only attempt remove if the container existed (not 404).
if (stopRes.status !== 404) {
const removeRes = await dockerApi(
'DELETE',
`/containers/${recorder.container_id}`
);
if (removeRes.status !== 204 && removeRes.status !== 404) {
return res.status(500).json({
error: 'Failed to remove container',
details: removeRes.data,
});
}
}
} }
// Remove container — 204 = removed, 404 = already gone (both acceptable)
const removeRes = await dockerApi(
'DELETE',
`/containers/${recorder.container_id}`
);
if (removeRes.status !== 204 && removeRes.status !== 404) {
return res.status(500).json({
error: 'Failed to remove container',
details: removeRes.data,
});
}
// Update recorder in DB
const updateResult = await pool.query( const updateResult = await pool.query(
`UPDATE recorders `UPDATE recorders
SET container_id = NULL, status = $1, updated_at = NOW() SET container_id = NULL, status = $1, updated_at = NOW()
@ -684,6 +363,7 @@ router.get('/:id/status', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
// Get recorder from DB
const recorderResult = await pool.query( const recorderResult = await pool.query(
'SELECT * FROM recorders WHERE id = $1', 'SELECT * FROM recorders WHERE id = $1',
[id] [id]
@ -703,67 +383,29 @@ router.get('/:id/status', async (req, res, next) => {
}); });
} }
const deviceIndex = recorder.device_index ?? (recorder.source_config?.device ?? 0); // Query Docker API for container status
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id); const inspectRes = await dockerApi(
'GET',
`/containers/${recorder.container_id}/json`
);
let isRunning = false; if (inspectRes.status !== 200) {
let duration = 0; return res.json({
let signal = 'connecting'; status: 'unknown',
let signalKnown = false; duration: 0,
let live = null; containerId: recorder.container_id,
});
if (isRemote) {
try {
const statusRes = await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}/status`, {
signal: AbortSignal.timeout(4000),
});
if (statusRes.ok) {
const data = await statusRes.json();
isRunning = data.running;
if (data.startedAt) {
duration = Math.floor((Date.now() - new Date(data.startedAt).getTime()) / 1000);
}
live = data.live;
}
} catch (_) { /* node unreachable */ }
} else {
const inspectRes = await dockerApi(
'GET',
`/containers/${recorder.container_id}/json`
);
if (inspectRes.status !== 200) {
return res.json({
status: 'unknown',
duration: 0,
containerId: recorder.container_id,
});
}
const container = inspectRes.data;
isRunning = container.State.Running;
duration = Math.floor((Date.now() - new Date(container.State.StartedAt).getTime()) / 1000);
try {
const captureRes = await fetch(`http://recorder-${id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
if (captureRes.ok) live = await captureRes.json();
} catch (_) { /* not ready yet */ }
} }
if (isRunning) signal = 'receiving'; const container = inspectRes.data;
if (!isRunning) signal = 'stopped'; const startedAt = new Date(container.State.StartedAt).getTime();
if (live && live.signal) { signal = live.signal; signalKnown = true; } const now = Date.now();
const duration = Math.floor((now - startedAt) / 1000);
res.json({ res.json({
status: isRunning ? 'recording' : 'stopped', status: container.State.Running ? 'recording' : 'stopped',
duration, duration,
containerId: recorder.container_id, containerId: recorder.container_id,
signal,
signalKnown,
framesReceived: live ? live.framesReceived : null,
currentFps: live ? live.currentFps : null,
lastFrameAt: live ? live.lastFrameAt : null,
lastError: live ? live.lastError : null,
}); });
} catch (err) { } catch (err) {
next(err); next(err);
@ -771,10 +413,11 @@ router.get('/:id/status', async (req, res, next) => {
}); });
// DELETE /:id - Delete recorder // DELETE /:id - Delete recorder
router.delete('/:id', requireRecorderEdit, async (req, res, next) => { router.delete('/:id', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
// Get recorder from DB
const recorderResult = await pool.query( const recorderResult = await pool.query(
'SELECT * FROM recorders WHERE id = $1', 'SELECT * FROM recorders WHERE id = $1',
[id] [id]
@ -786,23 +429,17 @@ router.delete('/:id', requireRecorderEdit, async (req, res, next) => {
const recorder = recorderResult.rows[0]; const recorder = recorderResult.rows[0];
// If recording, stop the container first
if (recorder.container_id) { if (recorder.container_id) {
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
try { try {
if (isRemote) { await dockerApi('POST', `/containers/${recorder.container_id}/stop`);
await fetch(`${targetNodeApiUrl}/sidecar/${recorder.container_id}`, { await dockerApi('DELETE', `/containers/${recorder.container_id}`);
method: 'DELETE',
signal: AbortSignal.timeout(10000),
});
} else {
await dockerApi('POST', `/containers/${recorder.container_id}/stop`);
await dockerApi('DELETE', `/containers/${recorder.container_id}`);
}
} catch (err) { } catch (err) {
console.error('Error stopping container during delete:', err); console.error('Error stopping container during delete:', err);
} }
} }
// Delete from DB
const deleteResult = await pool.query( const deleteResult = await pool.query(
'DELETE FROM recorders WHERE id = $1 RETURNING *', 'DELETE FROM recorders WHERE id = $1 RETURNING *',
[id] [id]
@ -814,161 +451,4 @@ router.delete('/:id', requireRecorderEdit, async (req, res, next) => {
} }
}); });
// Issue #104 — limit probe targets so an authed user can't scan the cluster's
// internal services (Docker socket, DB, metadata endpoints).
const ALLOWED_PROBE_SCHEMES = new Set(['srt', 'rtmp', 'rtmps', 'rtsp', 'udp', 'rtp']);
const BLOCKED_PROBE_PORTS = new Set([22, 25, 53, 80, 443, 5432, 6379, 9000, 9100, 9229]);
function isPrivateOrLoopback(host) {
if (!host) return true;
const h = host.toLowerCase();
if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.internal')) return true;
// Hostname lookups happen later by the socket; here we just bail on the
// obvious cases. IPv4 private ranges + IPv6 link-local + AWS metadata IP.
if (/^127\./.test(h)) return true;
if (/^10\./.test(h)) return true;
if (/^192\.168\./.test(h)) return true;
if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(h)) return true;
if (/^169\.254\./.test(h)) return true; // link-local / AWS metadata
if (/^100\.6[4-9]\./.test(h) || /^100\.[7-9]\d\./.test(h) || /^100\.1[0-1]\d\./.test(h) || /^100\.12[0-7]\./.test(h)) return true;
if (/^0\./.test(h) || /^::1$/.test(h) || /^fe80:/.test(h) || /^fc/.test(h) || /^fd/.test(h)) return true;
return false;
}
function isAdmin(req) {
if (process.env.AUTH_ENABLED !== 'true') return true;
return req.user?.role === 'admin';
}
// POST /probe - Probe a source URL for reachability.
// Tries the capture service first; falls back to basic TCP/UDP connectivity
// check when capture is not running.
router.post('/probe', async (req, res) => {
const { source_type, url } = req.body || {};
// Validate URL up-front so we don't even let the capture service see junk.
let parsed = null;
if (url) {
try { parsed = new URL(url); }
catch { return res.status(400).json({ error: 'Invalid URL' }); }
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
if (!ALLOWED_PROBE_SCHEMES.has(proto)) {
return res.status(400).json({ error: `Scheme "${proto}" is not permitted for probe (#104)` });
}
// Non-admin users can only probe public hostnames. Admins may probe LAN.
if (!isAdmin(req) && isPrivateOrLoopback(parsed.hostname)) {
return res.status(403).json({ error: 'Probe target must be a public host (#104)' });
}
}
// Try the capture service first (5s timeout)
try {
const r = await fetch('http://capture:3001/capture/probe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req.body || {}),
signal: AbortSignal.timeout(5000),
});
const data = await r.json().catch(() => ({}));
return res.status(r.status).json(data);
} catch (_) {
// capture service not running — fall through to basic connectivity probe
}
if (!parsed) {
return res.json({
reachable: false,
mode: 'basic',
note: 'Capture service offline. Provide a URL for connectivity check.',
});
}
const host = parsed.hostname;
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
const isUdp = proto === 'srt' || source_type === 'srt';
const port = parseInt(parsed.port, 10) || (isUdp ? 9000 : 1935);
if (BLOCKED_PROBE_PORTS.has(port) && !isAdmin(req)) {
return res.status(403).json({ error: `Port ${port} is not permitted for probe (#104)` });
}
const reachable = await (isUdp ? probeUdp(host, port) : probeTcp(host, port));
return res.json({
reachable,
mode: 'basic',
note: `Capture service offline · ${isUdp ? 'UDP' : 'TCP'} connectivity check only`,
...(reachable
? { source: `${host}:${port}` }
: { error: `${host}:${port} did not respond` }
),
});
});
function probeTcp(host, port) {
return new Promise((resolve) => {
const sock = new net.Socket();
let done = false;
const finish = (ok) => { if (!done) { done = true; sock.destroy(); resolve(ok); } };
sock.setTimeout(4000);
sock.connect(port, host, () => finish(true));
sock.on('error', () => finish(false));
sock.on('timeout', () => finish(false));
});
}
function probeUdp(host, port) {
return new Promise((resolve) => {
const sock = dgram.createSocket('udp4');
let done = false;
const finish = (ok) => {
if (done) return;
done = true;
try { sock.close(); } catch (_) {}
resolve(ok);
};
// ICMP port-unreachable will fire sock.on('error') within ~100ms if nothing is listening
sock.on('error', () => finish(false));
sock.send(Buffer.alloc(16, 0), 0, 16, port, host, (err) => {
if (err) return finish(false);
// No ICMP error after 2.5s → assume something is listening
setTimeout(() => finish(true), 2500);
});
setTimeout(() => finish(false), 5000);
});
}
// GET /:id/live/* — reverse-proxy the live HLS preview from the recorder's node.
// Remote recorders: segments live on the worker node, served by its node-agent
// (/live/...). Local recorders: served from this host's /live mount. Browser
// media requests carry the session cookie (same-origin) so auth passes.
router.get('/:id/live/:rest(*)', async (req, res, next) => {
try {
const { id } = req.params;
const rest = req.params.rest;
if (!rest || rest.includes('..')) return res.status(400).end();
const rec = await pool.query('SELECT node_id FROM recorders WHERE id = $1', [id]);
if (rec.rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
const ct = rest.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl'
: rest.endsWith('.ts') ? 'video/mp2t'
: 'application/octet-stream';
res.set('Cache-Control', 'no-cache');
res.set('Content-Type', ct);
const target = await resolveNodeTarget(rec.rows[0].node_id);
if (!target.remote) {
return fs.readFile('/live/' + rest, (err, data) => {
if (err) return res.status(404).end();
res.end(data);
});
}
const base = String(target.apiUrl).replace(/\/$/, '');
const upstream = await fetch(`${base}/live/${rest}`).catch(() => null);
if (!upstream || !upstream.ok) return res.status(upstream ? upstream.status : 502).end();
res.end(Buffer.from(await upstream.arrayBuffer()));
} catch (err) { next(err); }
});
export default router; export default router;

View file

@ -1,157 +0,0 @@
// Recorder scheduler — CRUD for upcoming + historic recording windows.
//
// The actual start/stop transitions happen in src/scheduler.js; this route
// just owns the recorder_schedules rows.
import express from 'express';
import pool from '../db/pool.js';
import { validateUuid } from '../middleware/errors.js';
const router = express.Router();
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
const ALLOWED_RECURRENCE = new Set(['none', 'daily', 'weekly']);
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
function rowToJson(r) {
return {
id: r.id,
name: r.name,
recorder_id: r.recorder_id,
recorder_name: r.recorder_name || null,
start_at: r.start_at,
end_at: r.end_at,
recurrence: r.recurrence,
status: r.status,
last_asset_id: r.last_asset_id,
error_message: r.error_message,
created_at: r.created_at,
updated_at: r.updated_at,
};
}
const ALLOWED_STATUS_FILTER = new Set(['all', 'upcoming', 'past']);
// GET /api/v1/schedules?status=upcoming|past|all
router.get('/', async (req, res, next) => {
try {
const status = (req.query.status || 'all').toLowerCase();
if (!ALLOWED_STATUS_FILTER.has(status)) {
return res.status(400).json({ error: `status must be one of: ${[...ALLOWED_STATUS_FILTER].join(', ')}` });
}
let where = 'TRUE';
if (status === 'upcoming') where = `(s.status IN ('pending','running') OR s.end_at >= NOW() - INTERVAL '1 hour')`;
else if (status === 'past') where = `s.status IN ('completed','failed','cancelled') AND s.end_at < NOW()`;
const result = await pool.query(
`SELECT s.*, r.name AS recorder_name
FROM recorder_schedules s
LEFT JOIN recorders r ON r.id = s.recorder_id
WHERE ${where}
ORDER BY s.start_at ASC
LIMIT 200`
);
res.json({ schedules: result.rows.map(rowToJson) });
} catch (err) { next(err); }
});
// POST /api/v1/schedules
router.post('/', async (req, res, next) => {
try {
const { name, recorder_id, start_at, end_at, recurrence } = req.body || {};
if (!name || !recorder_id || !start_at || !end_at) {
return res.status(400).json({ error: 'name, recorder_id, start_at and end_at are required' });
}
const rec = (recurrence || 'none').toLowerCase();
if (!ALLOWED_RECURRENCE.has(rec)) {
return res.status(400).json({ error: `recurrence must be one of: ${[...ALLOWED_RECURRENCE].join(', ')}` });
}
if (new Date(end_at) <= new Date(start_at)) {
return res.status(400).json({ error: 'end_at must be after start_at' });
}
// Make sure the recorder exists before binding to it.
const rExists = await pool.query('SELECT id FROM recorders WHERE id = $1', [recorder_id]);
if (rExists.rows.length === 0) {
return res.status(400).json({ error: 'Unknown recorder_id' });
}
const ins = await pool.query(
`INSERT INTO recorder_schedules (name, recorder_id, start_at, end_at, recurrence, status)
VALUES ($1, $2, $3, $4, $5, 'pending')
RETURNING *`,
[name.trim(), recorder_id, start_at, end_at, rec]
);
res.status(201).json(rowToJson(ins.rows[0]));
} catch (err) { next(err); }
});
// PUT /api/v1/schedules/:id — edit a not-yet-started schedule
router.put('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const current = await pool.query('SELECT * FROM recorder_schedules WHERE id = $1', [id]);
if (current.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
if (current.rows[0].status === 'running') {
return res.status(400).json({ error: 'Cannot edit a running schedule; cancel it first' });
}
const fields = ['name','start_at','end_at','recurrence'];
const updates = [];
const values = [];
let i = 1;
for (const f of fields) {
if (req.body[f] !== undefined) {
if (f === 'recurrence' && !ALLOWED_RECURRENCE.has(String(req.body[f]).toLowerCase())) {
return res.status(400).json({ error: 'invalid recurrence' });
}
updates.push(`${f} = $${i++}`);
values.push(req.body[f]);
}
}
if (updates.length === 0) return res.json(rowToJson(current.rows[0]));
updates.push('updated_at = NOW()');
values.push(id);
const result = await pool.query(
`UPDATE recorder_schedules SET ${updates.join(', ')} WHERE id = $${i} RETURNING *`,
values
);
res.json(rowToJson(result.rows[0]));
} catch (err) { next(err); }
});
// POST /api/v1/schedules/:id/cancel — cancel a pending or running schedule
router.post('/:id/cancel', async (req, res, next) => {
try {
const { id } = req.params;
const cur = await pool.query('SELECT * FROM recorder_schedules WHERE id = $1', [id]);
if (cur.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
if (TERMINAL.has(cur.rows[0].status)) {
return res.status(400).json({ error: `Schedule is already ${cur.rows[0].status}` });
}
// Just mark as cancelled — the tick loop will stop the recorder if it's
// currently running and the schedule has just been cancelled.
const result = await pool.query(
`UPDATE recorder_schedules SET status = 'cancelled', updated_at = NOW()
WHERE id = $1 RETURNING *`,
[id]
);
res.json(rowToJson(result.rows[0]));
} catch (err) { next(err); }
});
// DELETE /api/v1/schedules/:id — hard delete (terminal schedules only)
router.delete('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const cur = await pool.query('SELECT status FROM recorder_schedules WHERE id = $1', [id]);
if (cur.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
if (!TERMINAL.has(cur.rows[0].status) && cur.rows[0].status !== 'pending') {
return res.status(400).json({ error: 'Cancel a running schedule before deleting' });
}
await pool.query('DELETE FROM recorder_schedules WHERE id = $1', [id]);
res.json({ message: 'Schedule deleted' });
} catch (err) { next(err); }
});
export default router;

View file

@ -1,208 +0,0 @@
// Capture SDK deployment — Blackmagic / AJA / Deltacast.
//
// Vendor SDKs are licensed and not redistributable, so they can't ship in
// the repo. This route lets the operator upload an SDK archive through the
// Settings UI; we extract it under /sdk/<vendor>/ (bind-mounted into mam-api)
// so the capture image build can pick the files up.
//
// Today the Dockerfile only wires Blackmagic into FFmpeg via patch_decklink.py;
// AJA and Deltacast files are staged for the next image revision but don't yet
// produce a working FFmpeg build — see the issue tracker.
import express from 'express';
import multer from 'multer';
import { promises as fs, createWriteStream } from 'fs';
import { spawn } from 'child_process';
import path from 'path';
const router = express.Router();
const SDK_ROOT = process.env.SDK_ROOT || '/sdk';
const VENDORS = {
blackmagic: {
name: 'Blackmagic DeckLink',
// Header that must be present once the archive is extracted
sentinel: 'DeckLinkAPI.h',
},
aja: {
name: 'AJA NTV2',
sentinel: 'ntv2card.h',
},
deltacast: {
name: 'Deltacast VideoMaster',
sentinel: 'VideoMasterHD_Core.h',
},
};
const upload = multer({
storage: multer.memoryStorage(),
// 512 MB ceiling — Blackmagic's full SDK is ~150 MB, plenty of headroom.
limits: { fileSize: 512 * 1024 * 1024 },
});
async function statusFor(vendor) {
const dir = path.join(SDK_ROOT, vendor);
try {
const entries = await listFilesRecursive(dir);
if (entries.length === 0) return { file_count: 0, uploaded_at: null };
const stat = await fs.stat(dir);
const sentinel = VENDORS[vendor].sentinel;
const hasSentinel = entries.some(p => p.endsWith('/' + sentinel) || p === sentinel);
return {
file_count: entries.length,
uploaded_at: stat.mtime.toISOString(),
sentinel_present: hasSentinel,
};
} catch {
return { file_count: 0, uploaded_at: null };
}
}
async function listFilesRecursive(dir, base = '') {
let out = [];
let entries;
try { entries = await fs.readdir(dir, { withFileTypes: true }); }
catch { return out; }
for (const e of entries) {
const full = path.join(dir, e.name);
const rel = base ? `${base}/${e.name}` : e.name;
if (e.isDirectory()) {
out = out.concat(await listFilesRecursive(full, rel));
} else if (e.isFile()) {
out.push(rel);
}
}
return out;
}
router.get('/', async (req, res, next) => {
try {
const out = {};
for (const vendor of Object.keys(VENDORS)) {
out[vendor] = await statusFor(vendor);
}
res.json(out);
} catch (err) { next(err); }
});
// Safe archive entry — only basic relative paths, no parent traversal, no symlinks.
function isUnsafeEntry(rel) {
if (!rel) return true;
if (path.isAbsolute(rel)) return true;
// Normalize without leaving the staging directory.
const normalized = path.posix.normalize(rel.replace(/\\/g, '/'));
if (normalized.startsWith('..') || normalized.includes('/../') || normalized === '..') return true;
return false;
}
router.post('/:vendor', upload.single('archive'), async (req, res, next) => {
try {
const vendor = req.params.vendor;
if (!VENDORS[vendor]) return res.status(400).json({ error: 'Unknown vendor: ' + vendor });
if (!req.file) return res.status(400).json({ error: 'No archive uploaded (field "archive")' });
const dir = path.join(SDK_ROOT, vendor);
const dirReal = path.resolve(dir);
// Wipe any previous staging so partial uploads don't leave stale headers.
await fs.rm(dir, { recursive: true, force: true });
await fs.mkdir(dir, { recursive: true });
// Issue #118 — never trust the client-supplied filename. Sanitise to a
// basename with no path separators, drop nul bytes, and force into `dir`.
const safeName = path.basename((req.file.originalname || 'sdk.bin').replace(/\u0000/g, '')) || 'sdk.bin';
const archivePath = path.join(dir, safeName);
await fs.writeFile(archivePath, req.file.buffer);
// Pick an extractor based on extension. tar handles .tar / .tar.gz / .tgz;
// unzip handles .zip. The capture container will be built separately on
// the host with a DeckLink/AJA/Deltacast card; this route just stages.
const lower = safeName.toLowerCase();
let cmd, args, listCmd, listArgs;
if (lower.endsWith('.zip')) {
cmd = 'unzip'; args = ['-q', '-o', archivePath, '-d', dir];
listCmd = 'unzip'; listArgs = ['-Z1', archivePath];
} else if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.tar')) {
// --absolute-names=no would be ideal, but isn't portable. Block via
// post-extract scan + reject any entry with a parent-traversal path.
cmd = 'tar'; args = ['-xf', archivePath, '-C', dir];
listCmd = 'tar'; listArgs = ['-tf', archivePath];
} else {
return res.status(400).json({ error: 'Unsupported archive format — use .zip, .tar.gz, .tgz, or .tar' });
}
// Pre-flight: list entries and reject the upload if any escape the dir
// (zip-slip / tar-slip). Cheaper than extracting then deleting.
const entries = await new Promise((resolve, reject) => {
const child = spawn(listCmd, listArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
child.stdout.on('data', d => { stdout += d.toString(); });
child.stderr.on('data', d => { stderr += d.toString(); });
child.on('error', reject);
child.on('exit', code => {
if (code === 0) resolve(stdout.split('\n').map(s => s.trim()).filter(Boolean));
else reject(new Error(`${listCmd} listing exited ${code}: ${stderr.slice(0, 500)}`));
});
});
const bad = entries.find(isUnsafeEntry);
if (bad) {
await fs.unlink(archivePath).catch(() => {});
return res.status(400).json({ error: `Refusing archive with unsafe entry: ${bad}` });
}
await new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stderr = '';
child.stderr.on('data', d => { stderr += d.toString(); });
child.on('error', reject);
child.on('exit', code => {
if (code === 0) resolve();
else reject(new Error(`${cmd} exited ${code}: ${stderr.slice(0, 500)}`));
});
});
// Defense-in-depth: walk the staged tree and remove anything that's not a
// regular file or directory (symlinks/device nodes can still escape).
async function walkAndSanitize(p) {
const entries = await fs.readdir(p, { withFileTypes: true });
for (const e of entries) {
const full = path.join(p, e.name);
const real = await fs.realpath(full).catch(() => null);
if (!real || !real.startsWith(dirReal + path.sep) && real !== dirReal) {
await fs.rm(full, { recursive: true, force: true });
continue;
}
if (e.isSymbolicLink() || (!e.isFile() && !e.isDirectory())) {
await fs.rm(full, { recursive: true, force: true });
continue;
}
if (e.isDirectory()) await walkAndSanitize(full);
}
}
await walkAndSanitize(dir);
// Best-effort: remove the archive after a successful extract so we only
// keep the unpacked headers/.so files on disk.
await fs.unlink(archivePath).catch(() => {});
const status = await statusFor(vendor);
res.json({ message: VENDORS[vendor].name + ' SDK staged.', status });
} catch (err) {
console.error('[sdk] upload failed:', err);
res.status(500).json({ error: err.message });
}
});
router.delete('/:vendor', async (req, res, next) => {
try {
const vendor = req.params.vendor;
if (!VENDORS[vendor]) return res.status(400).json({ error: 'Unknown vendor: ' + vendor });
const dir = path.join(SDK_ROOT, vendor);
await fs.rm(dir, { recursive: true, force: true });
res.json({ message: VENDORS[vendor].name + ' SDK cleared.' });
} catch (err) { next(err); }
});
export default router;

Some files were not shown because too many files have changed in this diff Show more