Compare commits
1 commit
main
...
fix/srt-rt
| Author | SHA1 | Date | |
|---|---|---|---|
| 79369c378a |
283 changed files with 6593 additions and 58208 deletions
50
.env.example
50
.env.example
|
|
@ -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
16
.gitignore
vendored
|
|
@ -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
153
DESIGN.md
|
|
@ -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.06–0.08em letterspacing | Column heads, section labels |
|
|
||||||
| 11–11.5px | Metadata, secondary rows |
|
|
||||||
| 12–12.5px | Body in lists/tables |
|
|
||||||
| 13px, 500–600 | Row labels, button text |
|
|
||||||
| 14px | Default body |
|
|
||||||
| 15px, 600 | Modal titles, panel heads |
|
|
||||||
| 22–28px, 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: 80–120ms 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 — 9–10px 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 (~6–8px) 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.
|
|
||||||
61
PRODUCT.md
61
PRODUCT.md
|
|
@ -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
264
README.md
|
|
@ -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:00–15: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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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 ]
|
|
||||||
|
|
@ -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."
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
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:00–15: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
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
@ -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.
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
# NLE Editor: React SPA Polish — Phases 1–3 Implementation Plan
|
|
||||||
|
|
||||||
> **Date:** 2026-05-24
|
|
||||||
> **Status:** Phase 1 IN PROGRESS
|
|
||||||
> **Progress:** Tasks 1.1–1.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
|
|
@ -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
|
|
||||||
|
|
@ -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.010–0.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.
|
|
||||||
- 1280–1599px: standard layout, no max-width cap.
|
|
||||||
- 768–1279px (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.
|
|
||||||
|
|
@ -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` (0–100). 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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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).
|
|
||||||
|
|
@ -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. ~4–6s 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 ~5–30 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 |
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
2992
services/mam-api/package-lock.json
generated
2992
services/mam-api/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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); },
|
|
||||||
};
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 $$;
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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 $$;
|
|
||||||
|
|
@ -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)
|
|
||||||
);
|
|
||||||
|
|
@ -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 '{}';
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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';
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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');
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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 $$;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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 };
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue