Compare commits
10 commits
main
...
redesign/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17bf086ef2 | ||
|
|
dac5213354 | ||
|
|
3f203f326e | ||
|
|
7e9f1277d4 | ||
|
|
9d8adbbbc1 | ||
|
|
3430ef823e | ||
|
|
08a0fb1b60 | ||
|
|
dd438b597a | ||
|
|
8746d71af1 | ||
|
|
6a161c7133 |
101 changed files with 880 additions and 8381 deletions
34
.env.example
34
.env.example
|
|
@ -25,13 +25,6 @@ MAM_API_URL=http://mam-api:3000
|
||||||
# Auth — default to ON in production. Setting to 'false' is a dev-only escape
|
# Auth — default to ON in production. Setting to 'false' is a dev-only escape
|
||||||
# hatch that disables all auth checks and attaches a synthetic 'dev' user to
|
# hatch that disables all auth checks and attaches a synthetic 'dev' user to
|
||||||
# every request. Never run with AUTH_ENABLED=false on a network you don't control.
|
# 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
|
AUTH_ENABLED=true
|
||||||
|
|
||||||
# CORS allowlist — comma-separated origins that may carry credentials to the API.
|
# CORS allowlist — comma-separated origins that may carry credentials to the API.
|
||||||
|
|
@ -43,30 +36,3 @@ ALLOWED_ORIGINS=
|
||||||
# so secure-cookie + X-Forwarded-Proto behave correctly. ALSO required for accurate
|
# 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).
|
# per-IP login rate-limiting (otherwise req.ip is always the nginx IP).
|
||||||
TRUST_PROXY=false
|
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
|
|
||||||
|
|
|
||||||
10
DESIGN.md
10
DESIGN.md
|
|
@ -90,16 +90,6 @@ Never use `gradient text` (impeccable absolute ban). Emphasis via weight and siz
|
||||||
- Panels (`.panel`): `--bg-1` + 1px `--border` + `--r-lg`. Use for grouped lists, not for every section.
|
- Panels (`.panel`): `--bg-1` + 1px `--border` + `--r-lg`. Use for grouped lists, not for every section.
|
||||||
- Do NOT nest panels.
|
- 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
|
## Shadow
|
||||||
|
|
||||||
Two tokens, used sparingly:
|
Two tokens, used sparingly:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -58,7 +58,6 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- /dev:/dev:ro
|
- /dev:/dev:ro
|
||||||
- /mnt/NVME/MAM/wild-dragon-live:/mnt/NVME/MAM/wild-dragon-live:ro
|
|
||||||
devices:
|
devices:
|
||||||
- /dev/blackmagic:/dev/blackmagic
|
- /dev/blackmagic:/dev/blackmagic
|
||||||
|
|
||||||
|
|
@ -100,31 +99,6 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- wild-dragon-worker
|
- 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:
|
networks:
|
||||||
wild-dragon-worker:
|
wild-dragon-worker:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ services:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- /mnt/NVME/MAM/wild-dragon-live:/live
|
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||||
- /mnt/NVME/MAM/wild-dragon-growing:/growing
|
- /mnt/NVME/MAM/wild-dragon-growing:/growing
|
||||||
- /mnt/NVME/MAM/wild-dragon-media:/media
|
|
||||||
- /mnt/NVME/MAM/sdk:/sdk
|
- /mnt/NVME/MAM/sdk:/sdk
|
||||||
- /dev/shm:/dev/shm
|
- /dev/shm:/dev/shm
|
||||||
- /run/dbus:/run/dbus
|
- /run/dbus:/run/dbus
|
||||||
|
|
@ -61,9 +60,6 @@ services:
|
||||||
DOCKER_NETWORK: wild-dragon_wild-dragon
|
DOCKER_NETWORK: wild-dragon_wild-dragon
|
||||||
NODE_IP: ${NODE_IP}
|
NODE_IP: ${NODE_IP}
|
||||||
NODE_HOSTNAME: ${NODE_HOSTNAME:-}
|
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:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
reservations:
|
reservations:
|
||||||
|
|
@ -98,15 +94,8 @@ services:
|
||||||
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
|
||||||
|
|
@ -119,59 +108,8 @@ 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}
|
||||||
GROWING_PATH: /growing
|
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:
|
volumes:
|
||||||
- /mnt/NVME/MAM/wild-dragon-growing:/growing
|
- /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
|
||||||
|
|
||||||
|
|
@ -181,22 +119,12 @@ services:
|
||||||
- "${PORT_WEB_UI:-7434}:80"
|
- "${PORT_WEB_UI:-7434}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/NVME/MAM/wild-dragon-live:/live
|
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||||
- /mnt/NVME/MAM/wild-dragon-media:/media:ro
|
|
||||||
- /dev/shm:/dev/shm
|
- /dev/shm:/dev/shm
|
||||||
- /run/dbus:/run/dbus
|
- /run/dbus:/run/dbus
|
||||||
- /run/systemd:/run/systemd
|
- /run/systemd:/run/systemd
|
||||||
networks:
|
networks:
|
||||||
- wild-dragon
|
- wild-dragon
|
||||||
|
|
||||||
# Build-only: the CasparCG sidecar image. mam-api spawns these on-demand per
|
|
||||||
# channel (one container per playout channel), so this service is never up'd —
|
|
||||||
# it exists so `docker compose build playout` produces the image the API tags
|
|
||||||
# via PLAYOUT_IMAGE. Profile excludes it from default `up`.
|
|
||||||
playout:
|
|
||||||
profiles: ["build-only"]
|
|
||||||
build: ./services/playout
|
|
||||||
image: wild-dragon-playout:latest
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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,10 +1,4 @@
|
||||||
# ── Stage 1: Build FFmpeg with DeckLink + NVENC (HEVC/H264) support ─────────
|
# ── Stage 1: Build FFmpeg with DeckLink 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
|
FROM debian:bookworm AS ffmpeg-builder
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
|
@ -19,11 +13,6 @@ COPY sdk/ /decklink-sdk/
|
||||||
COPY patch_decklink.py /patch_decklink.py
|
COPY patch_decklink.py /patch_decklink.py
|
||||||
COPY decklink-sdk16.patch /decklink-sdk16.patch
|
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
|
# Pull FFmpeg 7.1 source
|
||||||
RUN git clone --depth=1 --branch release/7.1 https://git.ffmpeg.org/ffmpeg.git /ffmpeg
|
RUN git clone --depth=1 --branch release/7.1 https://git.ffmpeg.org/ffmpeg.git /ffmpeg
|
||||||
|
|
||||||
|
|
@ -31,15 +20,8 @@ RUN git clone --depth=1 --branch release/7.1 https://git.ffmpeg.org/ffmpeg.git /
|
||||||
RUN python3 /patch_decklink.py
|
RUN python3 /patch_decklink.py
|
||||||
|
|
||||||
WORKDIR /ffmpeg
|
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 \
|
RUN ./configure \
|
||||||
--prefix=/usr/local \
|
--prefix=/usr/local \
|
||||||
--extra-cflags="-I/decklink-sdk -I/usr/local/include" \
|
|
||||||
--extra-ldflags="-L/usr/local/lib" \
|
|
||||||
--enable-gpl \
|
--enable-gpl \
|
||||||
--enable-nonfree \
|
--enable-nonfree \
|
||||||
--enable-libx264 \
|
--enable-libx264 \
|
||||||
|
|
@ -50,20 +32,13 @@ RUN ./configure \
|
||||||
--enable-libsrt \
|
--enable-libsrt \
|
||||||
--enable-libzmq \
|
--enable-libzmq \
|
||||||
--enable-decklink \
|
--enable-decklink \
|
||||||
--enable-ffnvcodec \
|
--extra-cflags="-I/decklink-sdk" \
|
||||||
--enable-nvenc \
|
|
||||||
--enable-cuvid \
|
|
||||||
--disable-doc \
|
--disable-doc \
|
||||||
--disable-debug \
|
--disable-debug \
|
||||||
--disable-ffplay \
|
--disable-ffplay \
|
||||||
&& make -j$(nproc) \
|
&& make -j$(nproc) \
|
||||||
&& make install
|
&& 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 ───────────────────────────────────────────────────
|
# ── Stage 2: Runtime image ───────────────────────────────────────────────────
|
||||||
FROM node:20-bookworm
|
FROM node:20-bookworm
|
||||||
|
|
||||||
|
|
@ -83,11 +58,6 @@ COPY lib/libDeckLinkAPI.so /usr/lib/libDeckLinkAPI.so
|
||||||
COPY lib/libDeckLinkPreviewAPI.so /usr/lib/libDeckLinkPreviewAPI.so
|
COPY lib/libDeckLinkPreviewAPI.so /usr/lib/libDeckLinkPreviewAPI.so
|
||||||
RUN ldconfig
|
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
|
||||||
|
|
|
||||||
|
|
@ -28,32 +28,7 @@ const VIDEO_CODECS = {
|
||||||
h264: { args: ['-c:v', 'libx264', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
|
h264: { args: ['-c:v', 'libx264', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
|
||||||
h264_nvenc: { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'], 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' },
|
h265: { args: ['-c:v', 'libx265', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
|
||||||
// All-Intra HEVC on NVENC — the growing-file master codec.
|
hevc_nvenc: { args: ['-c:v', 'hevc_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' },
|
||||||
// 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 = {
|
const AUDIO_CODECS = {
|
||||||
|
|
@ -153,81 +128,25 @@ class CaptureManager {
|
||||||
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
|
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-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.
|
// 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)').
|
// FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo (2)').
|
||||||
// Map integer index -> name using ffmpeg -sources decklink at runtime.
|
// 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);
|
let deckLinkName = String(device);
|
||||||
if (typeof device === 'number' || /^\d+$/.test(String(device))) {
|
if (typeof device === 'number' || /^\d+$/.test(String(device))) {
|
||||||
const idx = parseInt(device, 10);
|
const idx = parseInt(device, 10);
|
||||||
try {
|
try {
|
||||||
const { execSync } = await import('child_process');
|
const { execSync } = await import('child_process');
|
||||||
const out = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
const out = execSync('ffmpeg -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
||||||
const names = [];
|
const names = [];
|
||||||
for (const line of out.split('\n')) {
|
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+\[([^\]]+)\]/);
|
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
|
||||||
if (m) names.push(m[1]);
|
if (m) names.push(m[1]);
|
||||||
}
|
}
|
||||||
if (names[idx]) {
|
if (names[idx]) deckLinkName = names[idx];
|
||||||
deckLinkName = names[idx];
|
else deckLinkName = `DeckLink Duo (${idx + 1})`;
|
||||||
console.log(`[capture] DeckLink index ${idx} → "${deckLinkName}" (from ${names.length} detected: ${names.join(', ')})`);
|
} catch (_) {
|
||||||
} else {
|
deckLinkName = `DeckLink Duo (${parseInt(device, 10) + 1})`;
|
||||||
// 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 {
|
||||||
|
|
@ -291,16 +210,12 @@ class CaptureManager {
|
||||||
catch (err) { console.error('[capture] could not create growing dir:', err.message); }
|
catch (err) { console.error('[capture] could not create growing dir:', err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeckLink hardware does NOT support concurrent capture from the same port.
|
// Network sources cannot be opened by two FFmpeg processes simultaneously
|
||||||
// Opening a second ffmpeg process on the same DeckLink input while the first
|
// (one socket = one consumer). For SRT/RTMP the BullMQ worker generates
|
||||||
// is already capturing causes "Cannot Autodetect input stream or No signal"
|
// the proxy after the recording stops.
|
||||||
// on the second process — making the proxy empty and potentially crashing the
|
const proxyKey = (sourceType === 'sdi' && proxyEnabled)
|
||||||
// container before the hires upload completes.
|
? `projects/${projectId}/proxies/${clipName}.${proxyExt}`
|
||||||
//
|
: null;
|
||||||
// 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();
|
||||||
|
|
||||||
|
|
@ -326,38 +241,12 @@ class CaptureManager {
|
||||||
const hiresOutput = growingPath ? growingPath : 'pipe:1';
|
const hiresOutput = growingPath ? growingPath : 'pipe:1';
|
||||||
const hiresStdio = growingPath ? ['ignore', 'ignore', 'pipe'] : ['ignore', 'pipe', 'pipe'];
|
const hiresStdio = growingPath ? ['ignore', 'ignore', 'pipe'] : ['ignore', 'pipe', 'pipe'];
|
||||||
|
|
||||||
// For SDI we cannot open the DeckLink device a second time for a preview
|
const hiresProcess = spawn('ffmpeg', [
|
||||||
// tee, so the live HLS preview is produced as a SECOND OUTPUT of the hires
|
...inputArgs,
|
||||||
// ffmpeg: one decklink read -> yadif -> split -> [ProRes/S3] + [H.264/HLS].
|
...sdiFilterArgs,
|
||||||
let sdiHlsDir = null;
|
...hiresCodecArgs,
|
||||||
let hiresArgs;
|
hiresOutput,
|
||||||
if (sourceType === 'sdi' && this._assetIdForHls) {
|
], { stdio: hiresStdio });
|
||||||
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
|
const hiresUpload = growingPath
|
||||||
? Promise.resolve({ growingPath })
|
? Promise.resolve({ growingPath })
|
||||||
|
|
@ -409,8 +298,39 @@ class CaptureManager {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Proxy is generated after stop by the BullMQ worker (same as SRT/RTMP).
|
// SDI only: spawn a second ffmpeg for the proxy.
|
||||||
// DeckLink hardware does not support two concurrent readers on the same port.
|
// DeckLink cards allow concurrent reads; network sockets do not.
|
||||||
|
if (!isNetwork && proxyEnabled) {
|
||||||
|
const proxyCodecArgs = buildEncodeArgs({
|
||||||
|
codec: proxyVideoCodec,
|
||||||
|
videoBitrate: proxyVideoBitrate,
|
||||||
|
framerate: proxyFramerate,
|
||||||
|
audioCodec: proxyAudioCodec,
|
||||||
|
audioBitrate: proxyAudioBitrate,
|
||||||
|
audioChannels: proxyAudioChannels,
|
||||||
|
container: proxyContainer,
|
||||||
|
isNetwork: false,
|
||||||
|
isProxy: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[capture] proxy ffmpeg args:', proxyCodecArgs.join(' '));
|
||||||
|
|
||||||
|
const proxyProcess = spawn('ffmpeg', [
|
||||||
|
...inputArgs,
|
||||||
|
...sdiFilterArgs,
|
||||||
|
...proxyCodecArgs,
|
||||||
|
'-movflags', '+frag_keyframe+empty_moov',
|
||||||
|
'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;
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
@ -129,33 +128,17 @@ async function gracefulShutdown(signal) {
|
||||||
try {
|
try {
|
||||||
await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/mark-empty`, {
|
await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/mark-empty`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[shutdown] failed to flag empty asset:', e.message);
|
console.error('[shutdown] failed to flag empty asset:', e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (liveAssetId) {
|
|
||||||
// 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 {
|
} else {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
|
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
projectId: completed.projectId,
|
projectId: completed.projectId,
|
||||||
binId: completed.binId,
|
binId: completed.binId,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { execSync, spawn } from 'child_process';
|
import { execSync, spawn } 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 dgram from 'dgram';
|
||||||
|
|
@ -96,8 +95,8 @@ 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;
|
||||||
|
|
||||||
|
|
@ -119,57 +118,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
|
||||||
|
|
@ -202,28 +150,6 @@ router.post('/probe', async (req, res) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
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.' });
|
return res.json({ ok: false, source_type, error: 'Listener-mode sources cannot be probed standalone. Start the recorder and watch the signal indicator.' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,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,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,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;
|
|
||||||
|
|
@ -139,7 +139,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)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import os from 'node:os';
|
||||||
import { exec } from 'node:child_process';
|
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 { requireAuth, requireUiHeader } from './middleware/auth.js';
|
||||||
import { loadS3ConfigFromDb } from './s3/client.js';
|
import { loadS3ConfigFromDb } from './s3/client.js';
|
||||||
|
|
||||||
import authRouter from './routes/auth.js';
|
import authRouter from './routes/auth.js';
|
||||||
|
|
@ -22,7 +22,6 @@ import jobsRouter from './routes/jobs.js';
|
||||||
import captureRouter from './routes/capture.js';
|
import captureRouter from './routes/capture.js';
|
||||||
import uploadRouter from './routes/upload.js';
|
import uploadRouter from './routes/upload.js';
|
||||||
import recordersRouter from './routes/recorders.js';
|
import recordersRouter from './routes/recorders.js';
|
||||||
import playoutRouter from './routes/playout.js';
|
|
||||||
import settingsRouter from './routes/settings.js';
|
import settingsRouter from './routes/settings.js';
|
||||||
import amppRouter from './routes/ampp.js';
|
import amppRouter from './routes/ampp.js';
|
||||||
import groupsRouter from './routes/groups.js';
|
import groupsRouter from './routes/groups.js';
|
||||||
|
|
@ -41,12 +40,18 @@ import { startCleanupLoop } from './tasks/cleanupTempSegments.js';
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// ── Middleware ────────────────────────────────────────────────────────────────
|
||||||
|
// Tightened CORS — once cookies carry authority, `origin: true` would let
|
||||||
|
// any site forge requests with the cookie. Drive the allowlist from env.
|
||||||
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
|
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
|
||||||
.split(',').map(s => s.trim()).filter(Boolean);
|
.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: (origin, cb) => {
|
origin: (origin, cb) => {
|
||||||
|
// No Origin header (same-origin or curl) — allow.
|
||||||
if (!origin) return cb(null, true);
|
if (!origin) return cb(null, true);
|
||||||
if (allowedOrigins.length === 0 || allowedOrigins.includes(origin)) return cb(null, true);
|
if (allowedOrigins.length === 0 || allowedOrigins.includes(origin)) return cb(null, true);
|
||||||
|
// Reject cleanly: omit the Allow-Origin header so the browser surfaces
|
||||||
|
// a real CORS error instead of a 500 from a thrown Error in the callback.
|
||||||
console.warn('[cors] rejected origin:', origin);
|
console.warn('[cors] rejected origin:', origin);
|
||||||
return cb(null, false);
|
return cb(null, false);
|
||||||
},
|
},
|
||||||
|
|
@ -54,8 +59,14 @@ app.use(cors({
|
||||||
}));
|
}));
|
||||||
app.use(express.json({ limit: '50mb' }));
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
|
||||||
|
// Trust the reverse proxy only when explicitly told to (production HTTPS).
|
||||||
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);
|
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);
|
||||||
|
|
||||||
|
// HSTS — once a browser has seen this header over HTTPS for dragonflight.live,
|
||||||
|
// it auto-upgrades every future http:// request to https:// before hitting the
|
||||||
|
// wire. Cookies are Secure-only (below) and the CORS allowlist rejects HTTP,
|
||||||
|
// so without HSTS a user who lands on http:// silently can't log in.
|
||||||
|
// Only emit on actual HTTPS responses; req.secure honors trust proxy + X-Forwarded-Proto.
|
||||||
if (process.env.AUTH_ENABLED === 'true') {
|
if (process.env.AUTH_ENABLED === 'true') {
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (req.secure) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
if (req.secure) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||||
|
|
@ -63,13 +74,17 @@ if (process.env.AUTH_ENABLED === 'true') {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hard-fail when production-mode auth has no stable session secret. Without
|
||||||
|
// this, express-session falls back to an in-memory random secret which
|
||||||
|
// invalidates every session on restart and breaks multi-node deployments.
|
||||||
if (process.env.AUTH_ENABLED === 'true' && !process.env.SESSION_SECRET) {
|
if (process.env.AUTH_ENABLED === 'true' && !process.env.SESSION_SECRET) {
|
||||||
console.error('[fatal] SESSION_SECRET is required when AUTH_ENABLED=true');
|
console.error('[fatal] SESSION_SECRET is required when AUTH_ENABLED=true');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Session — actually wired this time. See specs/2026-05-27-auth-system-design.md.
|
||||||
app.use(session({
|
app.use(session({
|
||||||
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 }),
|
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 /* seconds = 15 min */ }),
|
||||||
secret: process.env.SESSION_SECRET,
|
secret: process.env.SESSION_SECRET,
|
||||||
name: 'dragonflight.sid',
|
name: 'dragonflight.sid',
|
||||||
cookie: {
|
cookie: {
|
||||||
|
|
@ -79,26 +94,31 @@ app.use(session({
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: 8 * 3600 * 1000,
|
maxAge: 8 * 3600 * 1000,
|
||||||
},
|
},
|
||||||
rolling: false,
|
rolling: false, // sliding renewal handled in requireAuth so idle + absolute can be enforced separately
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ── Health ────────────────────────────────────────────────────────────────────
|
||||||
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
||||||
|
|
||||||
const UNAUTH_PATHS = new Set([
|
// ── Auth gate ─────────────────────────────────────────────────────────────────
|
||||||
'/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required',
|
// req.path is relative to the /api/v1 mount, so /auth/login NOT /api/v1/auth/login.
|
||||||
'/auth/google', '/auth/google/callback', '/auth/google/enabled',
|
const UNAUTH_PATHS = new Set(['/auth/login', '/auth/setup', '/auth/setup-required']);
|
||||||
]);
|
// node-agent now authenticates /cluster/heartbeat with a bound api_token
|
||||||
|
// (migration 019 + bound_hostname on the token). requireAuth handles the
|
||||||
|
// bearer lookup and sets req.tokenBoundHostname; the heartbeat handler in
|
||||||
|
// routes/cluster.js verifies body.hostname matches that binding.
|
||||||
app.use('/api/v1', requireUiHeader);
|
app.use('/api/v1', requireUiHeader);
|
||||||
app.use('/api/v1', (req, res, next) => {
|
app.use('/api/v1', (req, res, next) => {
|
||||||
if (UNAUTH_PATHS.has(req.path)) return next();
|
if (UNAUTH_PATHS.has(req.path)) return next();
|
||||||
return requireAuth(req, res, next);
|
return requireAuth(req, res, next);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── API Routes ────────────────────────────────────────────────────────────────
|
||||||
app.use('/api/v1/auth', authRouter);
|
app.use('/api/v1/auth', authRouter);
|
||||||
app.use('/api/v1/auth/users', requireAdmin, usersRouter);
|
app.use('/api/v1/auth/users', usersRouter);
|
||||||
app.use('/api/v1/users', requireAdmin, usersRouter);
|
app.use('/api/v1/users', usersRouter); // alias for the existing SPA Users page that calls /api/v1/users; keeps the same auth gate
|
||||||
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
|
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
|
||||||
app.use('/api/v1/assets', assetsRouter);
|
app.use('/api/v1/assets', assetsRouter);
|
||||||
app.use('/api/v1/projects', projectsRouter);
|
app.use('/api/v1/projects', projectsRouter);
|
||||||
|
|
@ -107,10 +127,9 @@ app.use('/api/v1/jobs', jobsRouter);
|
||||||
app.use('/api/v1/capture', captureRouter);
|
app.use('/api/v1/capture', captureRouter);
|
||||||
app.use('/api/v1/upload', uploadRouter);
|
app.use('/api/v1/upload', uploadRouter);
|
||||||
app.use('/api/v1/recorders', recordersRouter);
|
app.use('/api/v1/recorders', recordersRouter);
|
||||||
app.use('/api/v1/playout', playoutRouter);
|
|
||||||
app.use('/api/v1/settings', settingsRouter);
|
app.use('/api/v1/settings', settingsRouter);
|
||||||
app.use('/api/v1/ampp', amppRouter);
|
app.use('/api/v1/ampp', amppRouter);
|
||||||
app.use('/api/v1/groups', requireAdmin, groupsRouter);
|
app.use('/api/v1/groups', groupsRouter);
|
||||||
app.use('/api/v1/sequences', sequencesRouter);
|
app.use('/api/v1/sequences', sequencesRouter);
|
||||||
app.use('/api/v1/system', systemRouter);
|
app.use('/api/v1/system', systemRouter);
|
||||||
app.use('/api/v1/cluster', clusterRouter);
|
app.use('/api/v1/cluster', clusterRouter);
|
||||||
|
|
@ -121,14 +140,21 @@ app.use('/api/v1/assets/:assetId/comments', commentsRouter);
|
||||||
app.use('/api/v1/imports', importsRouter);
|
app.use('/api/v1/imports', importsRouter);
|
||||||
app.use('/api/v1/storage', storageRouter);
|
app.use('/api/v1/storage', storageRouter);
|
||||||
|
|
||||||
|
// ── Error handler ─────────────────────────────────────────────────────────────
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
// ── Start ────────────────────────────────────────────────────────────────────
|
||||||
import { readdirSync, readFileSync } from 'node:fs';
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
const __dirnameMig = dirname(fileURLToPath(import.meta.url));
|
const __dirnameMig = dirname(fileURLToPath(import.meta.url));
|
||||||
async function runMigrations() {
|
async function runMigrations() {
|
||||||
|
// Issue #107 — previously the loop swallowed errors and let the server boot
|
||||||
|
// on a half-migrated schema. Now: track applied migrations in a table, run
|
||||||
|
// every pending one inside a transaction, and exit non-zero on failure so
|
||||||
|
// the orchestrator restarts (and so an operator notices) instead of serving
|
||||||
|
// 500s for the next month.
|
||||||
const dir = join(__dirnameMig, 'db', 'migrations');
|
const dir = join(__dirnameMig, 'db', 'migrations');
|
||||||
let files = [];
|
let files = [];
|
||||||
try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
|
try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
|
||||||
|
|
@ -141,6 +167,7 @@ async function runMigrations() {
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Allow forcing a re-run via env when iterating locally.
|
||||||
const force = process.env.MIGRATIONS_FORCE === '1';
|
const force = process.env.MIGRATIONS_FORCE === '1';
|
||||||
const allowFailures = process.env.MIGRATIONS_ALLOW_FAILURES === '1';
|
const allowFailures = process.env.MIGRATIONS_ALLOW_FAILURES === '1';
|
||||||
|
|
||||||
|
|
@ -166,6 +193,7 @@ async function runMigrations() {
|
||||||
console.error('[migration] FAILED ' + f + ': ' + err.message);
|
console.error('[migration] FAILED ' + f + ': ' + err.message);
|
||||||
client.release();
|
client.release();
|
||||||
if (allowFailures) continue;
|
if (allowFailures) continue;
|
||||||
|
// Hard fail — better to crash now than serve traffic on a broken schema.
|
||||||
console.error('[migration] aborting startup. Set MIGRATIONS_ALLOW_FAILURES=1 to override.');
|
console.error('[migration] aborting startup. Set MIGRATIONS_ALLOW_FAILURES=1 to override.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
@ -174,9 +202,13 @@ async function runMigrations() {
|
||||||
}
|
}
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
|
||||||
|
// Load S3 config from DB so any settings saved via the Settings page override env vars
|
||||||
await loadS3ConfigFromDb();
|
await loadS3ConfigFromDb();
|
||||||
|
|
||||||
|
// ── Cluster self-heartbeat ────────────────────────────────────────────────────
|
||||||
function getLocalIp() {
|
function getLocalIp() {
|
||||||
|
// Prefer an explicit override — useful when running inside Docker where
|
||||||
|
// os.networkInterfaces() returns container bridge IPs, not the host LAN IP.
|
||||||
if (process.env.NODE_IP) return process.env.NODE_IP;
|
if (process.env.NODE_IP) return process.env.NODE_IP;
|
||||||
|
|
||||||
const ifaces = os.networkInterfaces();
|
const ifaces = os.networkInterfaces();
|
||||||
|
|
@ -188,6 +220,9 @@ function getLocalIp() {
|
||||||
return '127.0.0.1';
|
return '127.0.0.1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect NVIDIA GPUs available to this container via nvidia-smi.
|
||||||
|
// Returns an array like [{ index: 0, name: 'Tesla P4', memory_mb: 7680 }, ...]
|
||||||
|
// or an empty array if nvidia-smi is unavailable or no GPUs found.
|
||||||
function detectGpus() {
|
function detectGpus() {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
exec(
|
exec(
|
||||||
|
|
@ -209,10 +244,6 @@ function detectGpus() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primary mam-api node self-registers in cluster_nodes every 30s. Must write
|
|
||||||
// BOTH last_seen (legacy column) and last_seen_at (added by mig 031, used by
|
|
||||||
// playout failover) — otherwise the primary appears stale to the failover
|
|
||||||
// query and channels get re-placed off it incorrectly.
|
|
||||||
async function selfHeartbeat() {
|
async function selfHeartbeat() {
|
||||||
const load = os.loadavg()[0];
|
const load = os.loadavg()[0];
|
||||||
const total = os.totalmem();
|
const total = os.totalmem();
|
||||||
|
|
@ -224,15 +255,14 @@ async function selfHeartbeat() {
|
||||||
pool.query(
|
pool.query(
|
||||||
`INSERT INTO cluster_nodes
|
`INSERT INTO cluster_nodes
|
||||||
(hostname, ip_address, role, version, api_url,
|
(hostname, ip_address, role, version, api_url,
|
||||||
cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen, last_seen_at)
|
cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen)
|
||||||
VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW(),NOW())
|
VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW())
|
||||||
ON CONFLICT (hostname) DO UPDATE SET
|
ON CONFLICT (hostname) DO UPDATE SET
|
||||||
ip_address = EXCLUDED.ip_address,
|
ip_address = EXCLUDED.ip_address,
|
||||||
cpu_usage = EXCLUDED.cpu_usage,
|
cpu_usage = EXCLUDED.cpu_usage,
|
||||||
mem_used_mb = EXCLUDED.mem_used_mb,
|
mem_used_mb = EXCLUDED.mem_used_mb,
|
||||||
mem_total_mb = EXCLUDED.mem_total_mb,
|
mem_total_mb = EXCLUDED.mem_total_mb,
|
||||||
capabilities = EXCLUDED.capabilities,
|
capabilities = EXCLUDED.capabilities,
|
||||||
last_seen_at = NOW(),
|
|
||||||
last_seen = NOW()`,
|
last_seen = NOW()`,
|
||||||
[
|
[
|
||||||
process.env.NODE_HOSTNAME || os.hostname(),
|
process.env.NODE_HOSTNAME || os.hostname(),
|
||||||
|
|
@ -257,26 +287,39 @@ const server = app.listen(PORT, () => {
|
||||||
if (process.env.AUTH_ENABLED === 'true' && process.env.TRUST_PROXY !== 'true') {
|
if (process.env.AUTH_ENABLED === 'true' && process.env.TRUST_PROXY !== 'true') {
|
||||||
console.warn('[auth] WARNING: AUTH_ENABLED=true but TRUST_PROXY=false — req.ip will be the proxy IP, login rate-limit will throttle all clients together. Set TRUST_PROXY=true when behind nginx/HTTPS.');
|
console.warn('[auth] WARNING: AUTH_ENABLED=true but TRUST_PROXY=false — req.ip will be the proxy IP, login rate-limit will throttle all clients together. Set TRUST_PROXY=true when behind nginx/HTTPS.');
|
||||||
}
|
}
|
||||||
|
// Boot the recorder scheduler tick loop after the HTTP server is live so
|
||||||
|
// the loop's self-calls to /recorders/:id/start|stop reach a ready socket.
|
||||||
startSchedulerLoop();
|
startSchedulerLoop();
|
||||||
|
|
||||||
|
// Boot the temp-segment cleanup loop (runs hourly).
|
||||||
startCleanupLoop();
|
startCleanupLoop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Issue #100 — graceful shutdown. Without this, `docker stop` (SIGTERM) killed
|
||||||
|
// the process mid-scheduler-tick, leaving Redis connections and Docker
|
||||||
|
// sockets dangling and producing partial DB writes. Now: stop the scheduler,
|
||||||
|
// finish in-flight HTTP requests, close PG/Redis pools, and exit cleanly
|
||||||
|
// (or hard-exit after 25 s if something is stuck).
|
||||||
let _shuttingDown = false;
|
let _shuttingDown = false;
|
||||||
async function gracefulShutdown(signal) {
|
async function gracefulShutdown(signal) {
|
||||||
if (_shuttingDown) return;
|
if (_shuttingDown) return;
|
||||||
_shuttingDown = true;
|
_shuttingDown = true;
|
||||||
console.log(`[shutdown] received ${signal} — closing gracefully…`);
|
console.log(`[shutdown] received ${signal} — closing gracefully…`);
|
||||||
|
|
||||||
|
// Stop accepting new requests + wind down the scheduler tick.
|
||||||
try { stopSchedulerLoop(); } catch (_) {}
|
try { stopSchedulerLoop(); } catch (_) {}
|
||||||
|
|
||||||
|
// Force-exit watchdog so a hung connection can't keep us alive forever.
|
||||||
const killSwitch = setTimeout(() => {
|
const killSwitch = setTimeout(() => {
|
||||||
console.error('[shutdown] forced exit after 25s timeout');
|
console.error('[shutdown] forced exit after 25s timeout');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}, 25_000);
|
}, 25_000);
|
||||||
killSwitch.unref();
|
killSwitch.unref();
|
||||||
|
|
||||||
|
// Stop the HTTP server (waits for in-flight requests to finish).
|
||||||
await new Promise(resolve => server.close(resolve));
|
await new Promise(resolve => server.close(resolve));
|
||||||
|
|
||||||
|
// Close DB pool + S3 client + any other resources. Best-effort.
|
||||||
try { await pool.end(); } catch (e) { console.warn('[shutdown] pool.end:', e.message); }
|
try { await pool.end(); } catch (e) { console.warn('[shutdown] pool.end:', e.message); }
|
||||||
|
|
||||||
console.log('[shutdown] clean exit');
|
console.log('[shutdown] clean exit');
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,10 @@
|
||||||
import crypto from 'crypto';
|
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { parseBearer, hashToken } from '../auth/tokens.js';
|
import { parseBearer, hashToken } from '../auth/tokens.js';
|
||||||
|
|
||||||
// In-process service token for the scheduler's loopback self-calls
|
|
||||||
// (scheduler.js -> /recorders|/playout). The scheduler runs in THIS process, so
|
|
||||||
// a per-boot random constant needs no env/compose config and is never exposed:
|
|
||||||
// it only travels over the loopback fetch inside the same process. Multi-replica
|
|
||||||
// is safe because each replica's scheduler only ever calls 127.0.0.1 (itself),
|
|
||||||
// matching that replica's token. Requests bearing it are treated as the seeded
|
|
||||||
// admin (DEV_USER) so RBAC + FK-bearing routes work.
|
|
||||||
export const INTERNAL_TOKEN = crypto.randomBytes(32).toString('hex');
|
|
||||||
const INTERNAL_HEADER = 'x-internal-token';
|
|
||||||
|
|
||||||
function isInternalCall(req) {
|
|
||||||
const got = req.headers[INTERNAL_HEADER];
|
|
||||||
if (typeof got !== 'string' || got.length !== INTERNAL_TOKEN.length) return false;
|
|
||||||
return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(INTERNAL_TOKEN));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stable UUID matching migration 023's seeded dev user.
|
// Stable UUID matching migration 023's seeded dev user.
|
||||||
/** UUID of the seeded dev-mode placeholder. NOT a sentinel for "any unauthenticated user". */
|
/** UUID of the seeded dev-mode placeholder. NOT a sentinel for "any unauthenticated user". */
|
||||||
export const DEV_USER_ID = '00000000-0000-4000-8000-000000000000';
|
export const DEV_USER_ID = '00000000-0000-4000-8000-000000000000';
|
||||||
// role 'admin' so dev mode (AUTH_ENABLED=false) keeps full access through the
|
export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)' };
|
||||||
// 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 ABSOLUTE_MS = 8 * 3600 * 1000;
|
||||||
const IDLE_MS = 1 * 3600 * 1000;
|
const IDLE_MS = 1 * 3600 * 1000;
|
||||||
|
|
@ -37,18 +18,11 @@ async function destroyAnd401(req, res) {
|
||||||
|
|
||||||
async function loadUser(id) {
|
async function loadUser(id) {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT id, username, display_name, role, totp_enabled FROM users WHERE id = $1`, [id]);
|
`SELECT id, username, display_name, role FROM users WHERE id = $1`, [id]);
|
||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireAuth(req, res, next) {
|
export async function requireAuth(req, res, next) {
|
||||||
// Internal loopback self-call (scheduler). Acts as the seeded admin so RBAC
|
|
||||||
// and FK-bearing routes work, regardless of AUTH_ENABLED.
|
|
||||||
if (isInternalCall(req)) {
|
|
||||||
req.user = DEV_USER;
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dev mode — attach the seeded dev user so FK-bearing routes work.
|
// Dev mode — attach the seeded dev user so FK-bearing routes work.
|
||||||
if (process.env.AUTH_ENABLED !== 'true') {
|
if (process.env.AUTH_ENABLED !== 'true') {
|
||||||
req.user = DEV_USER;
|
req.user = DEV_USER;
|
||||||
|
|
@ -99,14 +73,6 @@ export async function requireAuth(req, res, next) {
|
||||||
return res.status(401).json({ error: 'unauthorized' });
|
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
|
// Belt-and-suspenders CSRF: SameSite=Lax already blocks most cross-site
|
||||||
// cookie sends, but a custom header that no <form> can produce hardens
|
// cookie sends, but a custom header that no <form> can produce hardens
|
||||||
// against the edge cases. Applied to mutating verbs only.
|
// against the edge cases. Applied to mutating verbs only.
|
||||||
|
|
@ -122,8 +88,6 @@ const CSRF_EXEMPT_PATHS = new Set(['/cluster/heartbeat']);
|
||||||
|
|
||||||
export function requireUiHeader(req, res, next) {
|
export function requireUiHeader(req, res, next) {
|
||||||
if (!MUTATING.has(req.method)) return next();
|
if (!MUTATING.has(req.method)) return next();
|
||||||
// Internal loopback self-call (scheduler) — not a browser, can't be drive-by'd.
|
|
||||||
if (isInternalCall(req)) return next();
|
|
||||||
// Bearer-authed requests (Premiere panel, scripts) are exempt — they're not
|
// Bearer-authed requests (Premiere panel, scripts) are exempt — they're not
|
||||||
// browsers and can't be drive-by'd from another origin.
|
// browsers and can't be drive-by'd from another origin.
|
||||||
if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next();
|
if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next();
|
||||||
|
|
|
||||||
|
|
@ -7,36 +7,9 @@ import pool from '../db/pool.js';
|
||||||
import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js';
|
import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js';
|
||||||
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||||
import { validateUuid } from '../middleware/errors.js';
|
import { validateUuid } from '../middleware/errors.js';
|
||||||
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
|
|
||||||
import { requireAdmin } from '../middleware/auth.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||||
// Every /:id asset route is scoped to the asset's project. The param handler
|
|
||||||
// validates the UUID, resolves the owning project_id, and asserts at least
|
|
||||||
// 'view' access (the baseline for touching an asset at all). Mutating routes
|
|
||||||
// additionally assert 'edit' via req.assetProjectId. A missing asset is a clean
|
|
||||||
// 404 here rather than leaking existence to users without access.
|
|
||||||
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 assets WHERE id = $1', [req.params.id]);
|
|
||||||
if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
|
||||||
req.assetProjectId = rows[0].project_id;
|
|
||||||
await assertProjectAccess(req.user, req.assetProjectId, 'view');
|
|
||||||
next();
|
|
||||||
} catch (err) { next(err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Route-level guard for mutating /:id endpoints — escalates the param handler's
|
|
||||||
// 'view' baseline to 'edit'. Reuses req.assetProjectId (already resolved).
|
|
||||||
async function requireAssetEdit(req, res, next) {
|
|
||||||
try {
|
|
||||||
await assertProjectAccess(req.user, req.assetProjectId, 'edit');
|
|
||||||
next();
|
|
||||||
} catch (err) { next(err); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// BullMQ queue connection (mirrors worker/src/index.js)
|
// BullMQ queue connection (mirrors worker/src/index.js)
|
||||||
const parseRedisUrl = (url) => {
|
const parseRedisUrl = (url) => {
|
||||||
|
|
@ -60,10 +33,6 @@ const filmstripQueue = new Queue('filmstrip', {
|
||||||
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
|
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const hlsQueue = new Queue('hls', {
|
|
||||||
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET / - List assets with filtering
|
// GET / - List assets with filtering
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -93,15 +62,6 @@ router.get('/', async (req, res, next) => {
|
||||||
const params = [];
|
const params = [];
|
||||||
let paramCount = 1;
|
let paramCount = 1;
|
||||||
|
|
||||||
// Scope to projects the caller can access (admins are unfiltered). Without
|
|
||||||
// this, a granted user would see every asset across every project.
|
|
||||||
const access = await accessibleProjectIds(req.user);
|
|
||||||
if (!access.all) {
|
|
||||||
if (access.ids.size === 0) return res.json({ assets: [], total: 0 });
|
|
||||||
query += ` AND a.project_id = ANY($${paramCount++}::uuid[])`;
|
|
||||||
params.push([...access.ids]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exclude archived unless explicitly requested — independent of status filter
|
// Exclude archived unless explicitly requested — independent of status filter
|
||||||
if (include_archived !== 'true') {
|
if (include_archived !== 'true') {
|
||||||
query += ` AND a.status <> 'archived'`;
|
query += ` AND a.status <> 'archived'`;
|
||||||
|
|
@ -168,9 +128,6 @@ router.post('/', async (req, res, next) => {
|
||||||
return res.status(400).json({ error: 'projectId and clipName are required' });
|
return res.status(400).json({ error: 'projectId and clipName are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registering an asset writes into a project — require edit access there.
|
|
||||||
await assertProjectAccess(req.user, projectId, 'edit');
|
|
||||||
|
|
||||||
const durationNum = duration !== undefined && duration !== null ? Number(duration) : null;
|
const durationNum = duration !== undefined && duration !== null ? Number(duration) : null;
|
||||||
if (durationNum !== null && !Number.isFinite(durationNum)) {
|
if (durationNum !== null && !Number.isFinite(durationNum)) {
|
||||||
return res.status(400).json({ error: 'duration must be a finite number (seconds)' });
|
return res.status(400).json({ error: 'duration must be a finite number (seconds)' });
|
||||||
|
|
@ -259,8 +216,8 @@ router.post('/', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /cleanup-live — cross-project maintenance, admin only.
|
// POST /cleanup-live
|
||||||
router.post('/cleanup-live', requireAdmin, async (req, res, next) => {
|
router.post('/cleanup-live', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const maxAgeHours = Math.max(1, parseInt(req.query.max_age_hours || '4', 10));
|
const maxAgeHours = Math.max(1, parseInt(req.query.max_age_hours || '4', 10));
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
|
|
@ -273,8 +230,8 @@ router.post('/cleanup-live', requireAdmin, async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /cleanup-live-orphans — cross-project maintenance, admin only.
|
// POST /cleanup-live-orphans
|
||||||
router.post('/cleanup-live-orphans', requireAdmin, async (_req, res, next) => {
|
router.post('/cleanup-live-orphans', async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const liveRoot = process.env.LIVE_DIR || '/live';
|
const liveRoot = process.env.LIVE_DIR || '/live';
|
||||||
let entries;
|
let entries;
|
||||||
|
|
@ -316,22 +273,10 @@ router.get('/:id', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /:id
|
// PATCH /:id
|
||||||
router.patch('/:id', requireAssetEdit, async (req, res, next) => {
|
router.patch('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { display_name, tags, notes, bin_id } = req.body;
|
const { display_name, tags, notes, bin_id } = req.body;
|
||||||
|
|
||||||
// bin_id must reference a bin in the asset's OWN project — otherwise an
|
|
||||||
// editor in project A could stuff their asset into project B's bin tree.
|
|
||||||
// Null/empty clears the bin, which is always allowed.
|
|
||||||
if (bin_id) {
|
|
||||||
const bin = await pool.query('SELECT project_id FROM bins WHERE id = $1', [bin_id]);
|
|
||||||
if (bin.rows.length === 0) return res.status(400).json({ error: 'bin_id not found' });
|
|
||||||
if (bin.rows[0].project_id !== req.assetProjectId) {
|
|
||||||
return res.status(400).json({ error: 'bin_id belongs to a different project' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updates = [], params = [];
|
const updates = [], params = [];
|
||||||
let paramCount = 1;
|
let paramCount = 1;
|
||||||
if (display_name !== undefined) { updates.push(`display_name = $${paramCount++}`); params.push(display_name); }
|
if (display_name !== undefined) { updates.push(`display_name = $${paramCount++}`); params.push(display_name); }
|
||||||
|
|
@ -350,32 +295,13 @@ router.patch('/:id', requireAssetEdit, async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/copy
|
// POST /:id/copy
|
||||||
router.post('/:id/copy', requireAssetEdit, async (req, res, next) => {
|
router.post('/:id/copy', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { binId, projectId } = req.body;
|
const { binId, projectId } = req.body;
|
||||||
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
||||||
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
const src = r.rows[0];
|
const src = r.rows[0];
|
||||||
|
|
||||||
// Destination project defaults to source's. If the caller overrides it,
|
|
||||||
// assert edit on the target — without this, an editor in project A could
|
|
||||||
// clone any asset they can see into project B with no grant on B.
|
|
||||||
const destProjectId = projectId || src.project_id;
|
|
||||||
if (projectId && projectId !== src.project_id) {
|
|
||||||
await assertProjectAccess(req.user, destProjectId, 'edit');
|
|
||||||
}
|
|
||||||
// Destination bin (if any) must belong to the destination project — same
|
|
||||||
// class of bug as the PATCH bin_id hole.
|
|
||||||
const destBinId = binId === undefined ? src.bin_id : (binId || null);
|
|
||||||
if (destBinId) {
|
|
||||||
const bin = await pool.query('SELECT project_id FROM bins WHERE id = $1', [destBinId]);
|
|
||||||
if (bin.rows.length === 0) return res.status(400).json({ error: 'binId not found' });
|
|
||||||
if (bin.rows[0].project_id !== destProjectId) {
|
|
||||||
return res.status(400).json({ error: 'binId belongs to a different project than the destination' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newId = uuidv4();
|
const newId = uuidv4();
|
||||||
// Bug #60: null out proxy_s3_key and thumbnail_s3_key on the copy to avoid
|
// Bug #60: null out proxy_s3_key and thumbnail_s3_key on the copy to avoid
|
||||||
// sharing S3 objects with the source. Set status to 'processing' so the copy
|
// sharing S3 objects with the source. Set status to 'processing' so the copy
|
||||||
|
|
@ -390,8 +316,8 @@ router.post('/:id/copy', requireAssetEdit, async (req, res, next) => {
|
||||||
$1,$2,$3,$4,$5,$6,$7,$8,NULL,NULL,$9,$10,$11,$12,$13,$14,$15,$16,NOW(),NOW()
|
$1,$2,$3,$4,$5,$6,$7,$8,NULL,NULL,$9,$10,$11,$12,$13,$14,$15,$16,NOW(),NOW()
|
||||||
) RETURNING *`,
|
) RETURNING *`,
|
||||||
[
|
[
|
||||||
newId, destProjectId,
|
newId, projectId || src.project_id,
|
||||||
destBinId,
|
binId === undefined ? src.bin_id : (binId || null),
|
||||||
src.filename, src.display_name, 'processing', src.media_type,
|
src.filename, src.display_name, 'processing', src.media_type,
|
||||||
src.original_s3_key,
|
src.original_s3_key,
|
||||||
src.codec, src.resolution, src.fps, src.duration_ms, src.start_tc,
|
src.codec, src.resolution, src.fps, src.duration_ms, src.start_tc,
|
||||||
|
|
@ -416,7 +342,7 @@ router.post('/:id/copy', requireAssetEdit, async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/mark-empty
|
// POST /:id/mark-empty
|
||||||
router.post('/:id/mark-empty', requireAssetEdit, async (req, res, next) => {
|
router.post('/:id/mark-empty', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
// Bug #66: first check the asset exists and what status it is in
|
// Bug #66: first check the asset exists and what status it is in
|
||||||
|
|
@ -447,66 +373,8 @@ router.post('/:id/mark-empty', requireAssetEdit, async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/finalize
|
|
||||||
// Capture sidecar calls this on a SUCCESSFUL recording stop to finalise the
|
|
||||||
// pre-created 'live' asset (created at recorder start, id passed as ASSET_ID).
|
|
||||||
// Previously the sidecar did POST / to create a NEW asset, which collided with
|
|
||||||
// the existing live row -> 409 -> asset stuck 'live', no jobs. Finalising by id
|
|
||||||
// flips it out of 'live', records duration + S3 keys, and kicks off the
|
|
||||||
// proxy -> thumbnail -> filmstrip job chain.
|
|
||||||
router.post('/:id/finalize', requireAssetEdit, async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { hiresKey, proxyKey, duration } = req.body;
|
|
||||||
|
|
||||||
const check = await pool.query(`SELECT * FROM assets WHERE id = $1`, [id]);
|
|
||||||
if (check.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
|
||||||
const current = check.rows[0].status;
|
|
||||||
// Already terminal — idempotent no-op (handles shutdown retries).
|
|
||||||
if (current === 'ready' || current === 'error') {
|
|
||||||
return res.status(200).json({ ...check.rows[0], skipped: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const durationNum = duration !== undefined && duration !== null ? Number(duration) : null;
|
|
||||||
const durationMs = (durationNum !== null && Number.isFinite(durationNum)) ? Math.round(durationNum * 1000) : null;
|
|
||||||
|
|
||||||
const upd = await pool.query(
|
|
||||||
`UPDATE assets
|
|
||||||
SET status = 'processing',
|
|
||||||
original_s3_key = COALESCE($2, original_s3_key),
|
|
||||||
proxy_s3_key = COALESCE($3, proxy_s3_key),
|
|
||||||
duration_ms = COALESCE($4, duration_ms),
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING *`,
|
|
||||||
[id, hiresKey || null, proxyKey || null, durationMs]
|
|
||||||
);
|
|
||||||
const asset = upd.rows[0];
|
|
||||||
const thumbnailKey = `thumbnails/${id}.jpg`;
|
|
||||||
|
|
||||||
if (asset.proxy_s3_key) {
|
|
||||||
// Proxy already produced by the capture sidecar — just build the
|
|
||||||
// thumbnail (which then chains filmstrip). Worker flips status->ready.
|
|
||||||
await thumbnailQueue.add('generate', { assetId: id, proxyKey: asset.proxy_s3_key, outputKey: thumbnailKey });
|
|
||||||
console.log(`[assets] finalize ${id}: queued thumbnail (proxy present)`);
|
|
||||||
} else if (asset.original_s3_key) {
|
|
||||||
// No proxy yet — generate it from the hi-res master. The proxy worker
|
|
||||||
// chains thumbnail -> filmstrip on completion.
|
|
||||||
const generatedProxyKey = `proxies/${id}.mp4`;
|
|
||||||
await proxyQueue.add('generate', { assetId: id, inputKey: asset.original_s3_key, outputKey: generatedProxyKey });
|
|
||||||
console.log(`[assets] finalize ${id}: queued proxy from master`);
|
|
||||||
} else {
|
|
||||||
await pool.query(`UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`, [id]);
|
|
||||||
asset.status = 'ready';
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[assets] finalized live asset ${id} (${asset.display_name}) -> ${asset.status}`);
|
|
||||||
res.json(asset);
|
|
||||||
} catch (err) { next(err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /:id/generate-proxy
|
// POST /:id/generate-proxy
|
||||||
router.post('/:id/generate-proxy', requireAssetEdit, async (req, res, next) => {
|
router.post('/:id/generate-proxy', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
||||||
|
|
@ -522,8 +390,8 @@ router.post('/:id/generate-proxy', requireAssetEdit, async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /backfill-proxies — cross-project maintenance, admin only.
|
// POST /backfill-proxies
|
||||||
router.post('/backfill-proxies', requireAdmin, async (_req, res, next) => {
|
router.post('/backfill-proxies', async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const targets = await pool.query(
|
const targets = await pool.query(
|
||||||
`SELECT id, original_s3_key FROM assets
|
`SELECT id, original_s3_key FROM assets
|
||||||
|
|
@ -547,12 +415,12 @@ router.post('/backfill-proxies', requireAdmin, async (_req, res, next) => {
|
||||||
|
|
||||||
// POST /:id/reprocess?type=proxy|thumbnail|filmstrip
|
// POST /:id/reprocess?type=proxy|thumbnail|filmstrip
|
||||||
// Force-requeue a processing job regardless of current asset status.
|
// Force-requeue a processing job regardless of current asset status.
|
||||||
router.post('/:id/reprocess', requireAssetEdit, async (req, res, next) => {
|
router.post('/:id/reprocess', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const type = req.query.type || 'proxy';
|
const type = req.query.type || 'proxy';
|
||||||
if (!['proxy', 'thumbnail', 'filmstrip', 'hls'].includes(type)) {
|
if (!['proxy', 'thumbnail', 'filmstrip'].includes(type)) {
|
||||||
return res.status(400).json({ error: 'type must be "proxy", "thumbnail", "filmstrip", or "hls"' });
|
return res.status(400).json({ error: 'type must be "proxy", "thumbnail", or "filmstrip"' });
|
||||||
}
|
}
|
||||||
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
||||||
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
|
|
@ -575,12 +443,6 @@ router.post('/:id/reprocess', requireAssetEdit, async (req, res, next) => {
|
||||||
await filmstripQueue.add('generate', { assetId: id, proxyKey: asset.proxy_s3_key });
|
await filmstripQueue.add('generate', { assetId: id, proxyKey: asset.proxy_s3_key });
|
||||||
return res.json({ queued: 'filmstrip', assetId: id });
|
return res.json({ queued: 'filmstrip', assetId: id });
|
||||||
}
|
}
|
||||||
if (type === 'hls') {
|
|
||||||
// Backfill: remux the existing proxy MP4 into an HLS rendition (no re-encode).
|
|
||||||
if (!asset.proxy_s3_key) return res.status(400).json({ error: 'Asset has no proxy — generate proxy first' });
|
|
||||||
await hlsQueue.add('generate', { assetId: id, proxyKey: asset.proxy_s3_key });
|
|
||||||
return res.json({ queued: 'hls', assetId: id });
|
|
||||||
}
|
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -598,7 +460,7 @@ router.get('/:id/filmstrip', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/retry
|
// POST /:id/retry
|
||||||
router.post('/:id/retry', requireAssetEdit, async (req, res, next) => {
|
router.post('/:id/retry', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
||||||
|
|
@ -617,7 +479,7 @@ router.post('/:id/retry', requireAssetEdit, async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /:id
|
// DELETE /:id
|
||||||
router.delete('/:id', requireAssetEdit, async (req, res, next) => {
|
router.delete('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { hard } = req.query;
|
const { hard } = req.query;
|
||||||
|
|
@ -665,20 +527,6 @@ router.get('/:id/stream', async (req, res, next) => {
|
||||||
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
const a = r.rows[0];
|
const a = r.rows[0];
|
||||||
if (a.status === 'live') return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true });
|
if (a.status === 'live') return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true });
|
||||||
// `url` is the directly-downloadable MP4 proxy; `hls_url` is the HLS
|
|
||||||
// rendition for in-browser playback (whole-file segment GETs avoid the
|
|
||||||
// RustFS ranged-GET stitching the MP4 path needs). The Premiere plugin
|
|
||||||
// downloads `url` to a file and imports it, so `url` must NOT be the
|
|
||||||
// .m3u8 playlist — Premiere can't import a playlist ("unsupported
|
|
||||||
// compression type"). The web player prefers `hls_url` when present.
|
|
||||||
if (a.hls_s3_key) {
|
|
||||||
return res.json({
|
|
||||||
url: `/api/v1/assets/${id}/video`,
|
|
||||||
type: 'mp4',
|
|
||||||
source: a.proxy_s3_key ? 'proxy' : 'original',
|
|
||||||
hls_url: `/api/v1/assets/${id}/hls/playlist.m3u8`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const VIDEO_EXTS = ['.mp4', '.mov', '.mxf', '.ts', '.m4v', '.mkv', '.avi', '.webm'];
|
const VIDEO_EXTS = ['.mp4', '.mov', '.mxf', '.ts', '.m4v', '.mkv', '.avi', '.webm'];
|
||||||
const key = a.proxy_s3_key ||
|
const key = a.proxy_s3_key ||
|
||||||
(a.original_s3_key && VIDEO_EXTS.some(ext => a.original_s3_key.toLowerCase().endsWith(ext))
|
(a.original_s3_key && VIDEO_EXTS.some(ext => a.original_s3_key.toLowerCase().endsWith(ext))
|
||||||
|
|
@ -690,42 +538,6 @@ router.get('/:id/stream', async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id/hls/:file — serve an HLS rendition file (playlist / init / segment).
|
|
||||||
// Whole-object passthrough from S3: no Range handling, so this sidesteps the
|
|
||||||
// RustFS ranged-GET bug entirely (every segment is a small, complete GET).
|
|
||||||
// :file is strictly validated to prevent path traversal into the bucket.
|
|
||||||
const HLS_FILE_RE = /^(playlist\.m3u8|init\.mp4|segment_\d+\.m4s)$/;
|
|
||||||
router.get('/:id/hls/:file', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const { id, file } = req.params;
|
|
||||||
if (!HLS_FILE_RE.test(file)) return res.status(400).json({ error: 'Invalid HLS file' });
|
|
||||||
|
|
||||||
const r = await pool.query('SELECT hls_s3_key FROM assets WHERE id = $1', [id]);
|
|
||||||
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
|
||||||
const playlistKey = r.rows[0].hls_s3_key;
|
|
||||||
if (!playlistKey) return res.status(404).json({ error: 'No HLS rendition for this asset' });
|
|
||||||
|
|
||||||
// Derive the prefix from the stored playlist key (hls/<id>/playlist.m3u8)
|
|
||||||
// and request the specific file under it.
|
|
||||||
const prefix = playlistKey.replace(/\/[^/]+$/, '');
|
|
||||||
const key = `${prefix}/${file}`;
|
|
||||||
|
|
||||||
const isPlaylist = file.endsWith('.m3u8');
|
|
||||||
const s3Res = await s3Client.send(new GetObjectCommand({ Bucket: getS3Bucket(), Key: key }));
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Type': isPlaylist ? 'application/vnd.apple.mpegurl' : 'video/mp4',
|
|
||||||
'Cache-Control': isPlaylist ? 'no-cache' : 'private, max-age=3600',
|
|
||||||
...(s3Res.ContentLength ? { 'Content-Length': String(s3Res.ContentLength) } : {}),
|
|
||||||
});
|
|
||||||
s3Res.Body.pipe(res);
|
|
||||||
} catch (err) {
|
|
||||||
if (err && (err.name === 'NoSuchKey' || err.$metadata?.httpStatusCode === 404)) {
|
|
||||||
return res.status(404).json({ error: 'HLS file not found' });
|
|
||||||
}
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /:id/live-path
|
// GET /:id/live-path
|
||||||
router.get('/:id/live-path', async (req, res, next) => {
|
router.get('/:id/live-path', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -978,15 +790,6 @@ router.post('/batch-trim', async (req, res, next) => {
|
||||||
return res.status(400).json({ error: 'Each clip must have assetId, filename, sourceInFrames, sourceOutFrames, timelineInFrames, timelineOutFrames, and a non-negative integer trackIndex' });
|
return res.status(400).json({ error: 'Each clip must have assetId, filename, sourceInFrames, sourceOutFrames, timelineInFrames, timelineOutFrames, and a non-negative integer trackIndex' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Authorize every source asset's project (edit) before queuing any work.
|
|
||||||
const trimAssetIds = [...new Set(clips.map(c => c.assetId))];
|
|
||||||
const owning = await pool.query('SELECT id, project_id FROM assets WHERE id = ANY($1::uuid[])', [trimAssetIds]);
|
|
||||||
const projById = new Map(owning.rows.map(r => [r.id, r.project_id]));
|
|
||||||
for (const aid of trimAssetIds) {
|
|
||||||
const pid = projById.get(aid);
|
|
||||||
if (!pid) return res.status(404).json({ error: 'Asset not found: ' + aid });
|
|
||||||
await assertProjectAccess(req.user, pid, 'edit');
|
|
||||||
}
|
|
||||||
const jobId = uuidv4();
|
const jobId = uuidv4();
|
||||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,6 @@ import pool from '../db/pool.js';
|
||||||
import { DEV_USER_ID, requireAuth } from '../middleware/auth.js';
|
import { DEV_USER_ID, requireAuth } from '../middleware/auth.js';
|
||||||
import { hashPassword, comparePassword } from '../auth/passwords.js';
|
import { hashPassword, comparePassword } from '../auth/passwords.js';
|
||||||
import { ipBackoff } from '../auth/rate-limit.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 DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K';
|
||||||
|
|
||||||
|
|
@ -84,7 +76,7 @@ router.post('/login', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT id, username, display_name, password_hash, totp_enabled FROM users WHERE username = $1 AND id <> $2`,
|
`SELECT id, username, display_name, password_hash FROM users WHERE username = $1 AND id <> $2`,
|
||||||
[username.trim(), DEV_USER_ID]
|
[username.trim(), DEV_USER_ID]
|
||||||
);
|
);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
|
|
@ -101,123 +93,21 @@ router.post('/login', async (req, res, next) => {
|
||||||
return res.status(401).json({ error: 'invalid credentials' });
|
return res.status(401).json({ error: 'invalid credentials' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second factor: if TOTP is enabled, don't create a session yet. Hand back
|
req.session.user_id = user.id;
|
||||||
// a short-lived ticket the client redeems via /login/totp with a code.
|
req.session.first_seen_at = Date.now();
|
||||||
// Crucially: do NOT clear the per-IP failure counter here. If we did, each
|
req.session.last_seen_at = Date.now();
|
||||||
// /login retry would reset the backoff and let an attacker brute the 6-digit
|
// The critical line — wait for the row to land in `sessions` before responding.
|
||||||
// TOTP space (10^6) with no per-attempt delay. The counter is cleared
|
// Without this, the SPA's next request races the store write, hits 401, and
|
||||||
// inside establishSession() once MFA has actually passed.
|
// the prior bounce-to-login logic produced an infinite loop.
|
||||||
if (user.totp_enabled) {
|
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||||
return res.json({
|
|
||||||
mfa_required: true,
|
|
||||||
ticket: issueTicket(user.id, { ip, userAgent: req.get('user-agent') }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await establishSession(req, user, ip);
|
pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id])
|
||||||
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
|
.catch(err => console.error('[auth] last_login_at update failed:', err.message));
|
||||||
} catch (err) { next(err); }
|
|
||||||
|
ipBackoff.recordSuccess(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.
|
// POST /api/v1/auth/logout — destroys the session and clears the cookie.
|
||||||
router.post('/logout', (req, res) => {
|
router.post('/logout', (req, res) => {
|
||||||
if (!req.session) return res.status(204).end();
|
if (!req.session) return res.status(204).end();
|
||||||
|
|
@ -235,7 +125,6 @@ router.get('/me', requireAuth, (req, res) => {
|
||||||
username: req.user.username,
|
username: req.user.username,
|
||||||
display_name: req.user.display_name,
|
display_name: req.user.display_name,
|
||||||
role: req.user.role,
|
role: req.user.role,
|
||||||
totp_enabled: !!req.user.totp_enabled,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -260,202 +149,5 @@ router.post('/password', requireAuth, async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} 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) {
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Map a verified Google profile to a Dragonflight user row.
|
|
||||||
//
|
|
||||||
// Resolution order:
|
|
||||||
// 1. Existing link by google_sub → that user.
|
|
||||||
// 2. Otherwise auto-provision a fresh 'viewer'.
|
|
||||||
//
|
|
||||||
// We deliberately do NOT auto-link to an existing account by matching email:
|
|
||||||
// that would let anyone who controls a Google address with the same email sign
|
|
||||||
// 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;
|
|
||||||
while ((await pool.query(`SELECT 1 FROM users WHERE username = $1`, [username])).rows.length) {
|
|
||||||
username = base + (++n);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ins = await pool.query(
|
|
||||||
`INSERT INTO users (username, password_hash, display_name, role, email, google_sub)
|
|
||||||
VALUES ($1, NULL, $2, 'viewer', $3, $4)
|
|
||||||
RETURNING id, username, display_name, totp_enabled`,
|
|
||||||
[username, profile.name, profile.email, profile.sub]);
|
|
||||||
return ins.rows[0];
|
|
||||||
} catch (err) {
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
export { realUserCount, resolveGoogleUser, consumeRecoveryCode };
|
export { realUserCount };
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,25 @@
|
||||||
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 { 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();
|
||||||
|
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||||
|
|
||||||
// Resolve the owning project for a /:id bin, assert 'view' baseline, stash the
|
// GET / - List bins. Filter by project_id when supplied; otherwise return
|
||||||
// project_id for mutating routes to escalate to 'edit'.
|
// every bin across every project so the Library / asset-context-menu can
|
||||||
router.param('id', async (req, res, next) => {
|
// present a global "move to bin" picker.
|
||||||
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) {
|
|
||||||
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;
|
||||||
|
|
||||||
|
const params = [];
|
||||||
|
let where = '';
|
||||||
if (project_id) {
|
if (project_id) {
|
||||||
await assertProjectAccess(req.user, project_id, 'view');
|
where = 'WHERE b.project_id = $1';
|
||||||
const result = await pool.query(
|
params.push(project_id);
|
||||||
`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 b.*, p.name AS project_name,
|
||||||
(SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count
|
(SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count
|
||||||
|
|
@ -64,13 +29,14 @@ router.get('/', async (req, res, next) => {
|
||||||
ORDER BY b.created_at DESC`,
|
ORDER BY b.created_at DESC`,
|
||||||
params
|
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 +44,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 +61,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 +107,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 +126,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 +136,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 +158,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,7 +1,3 @@
|
||||||
// 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';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import pool from '../db/pool.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// If the agent reported Docker's default bridge IP (172.17.x) but the request
|
||||||
|
// itself came from a real LAN address, prefer the request source IP instead.
|
||||||
|
// We only check 172.17.x — the default docker0 bridge — not the full RFC1918
|
||||||
|
// 172.16/12 block, since real LANs (e.g. 172.18.91.x) fall in that range.
|
||||||
function pickIp(reportedIp, reqIp) {
|
function pickIp(reportedIp, reqIp) {
|
||||||
const clean = (s) => (s || '').replace(/^::ffff:/, '');
|
const clean = (s) => (s || '').replace(/^::ffff:/, '');
|
||||||
const isDockerBridge = (ip) => /^172\.17\./.test(ip || '');
|
const isDockerBridge = (ip) => /^172\.17\./.test(ip || '');
|
||||||
|
|
@ -37,6 +41,7 @@ function dockerRequest(path, method = 'GET', body = null) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET / – list all registered cluster nodes with online status
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const r = await pool.query(
|
const r = await pool.query(
|
||||||
|
|
@ -52,6 +57,7 @@ router.get('/', async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /containers – list all containers on the local Docker host
|
||||||
router.get('/containers', async (req, res, next) => {
|
router.get('/containers', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const containers = await dockerRequest('/containers/json?all=true');
|
const containers = await dockerRequest('/containers/json?all=true');
|
||||||
|
|
@ -82,6 +88,7 @@ router.get('/containers', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /containers/:nameOrId/restart
|
||||||
router.post('/containers/:nameOrId/restart', async (req, res, next) => {
|
router.post('/containers/:nameOrId/restart', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
await dockerRequest(`/containers/${encodeURIComponent(req.params.nameOrId)}/restart`, 'POST');
|
await dockerRequest(`/containers/${encodeURIComponent(req.params.nameOrId)}/restart`, 'POST');
|
||||||
|
|
@ -89,17 +96,23 @@ router.post('/containers/:nameOrId/restart', async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /heartbeat – upsert this node's registration (includes hardware capabilities)
|
||||||
router.post('/heartbeat', async (req, res, next) => {
|
router.post('/heartbeat', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
hostname, ip_address,
|
hostname, ip_address,
|
||||||
role = 'worker', version, api_url,
|
role = 'worker', version, api_url,
|
||||||
cpu_usage, mem_used_mb, mem_total_mb,
|
cpu_usage, mem_used_mb, mem_total_mb,
|
||||||
capabilities, metadata, metrics,
|
capabilities, metadata,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!hostname) return res.status(400).json({ error: 'hostname is required' });
|
if (!hostname) return res.status(400).json({ error: 'hostname is required' });
|
||||||
|
|
||||||
|
// Issue #106 — any authenticated user used to be able to POST a heartbeat
|
||||||
|
// for an arbitrary hostname and overwrite the primary node's `api_url`,
|
||||||
|
// effectively hijacking job dispatch. Now: if the caller's token is bound
|
||||||
|
// to a hostname (node-agent tokens are bound at issue time), the body
|
||||||
|
// hostname must match. Admin users with no binding are allowed for ops.
|
||||||
if (process.env.AUTH_ENABLED === 'true') {
|
if (process.env.AUTH_ENABLED === 'true') {
|
||||||
const bound = req.tokenBoundHostname;
|
const bound = req.tokenBoundHostname;
|
||||||
if (bound && bound !== hostname) {
|
if (bound && bound !== hostname) {
|
||||||
|
|
@ -119,8 +132,8 @@ router.post('/heartbeat', async (req, res, next) => {
|
||||||
const r = await pool.query(
|
const r = await pool.query(
|
||||||
`INSERT INTO cluster_nodes
|
`INSERT INTO cluster_nodes
|
||||||
(hostname, ip_address, role, version, api_url,
|
(hostname, ip_address, role, version, api_url,
|
||||||
cpu_usage, mem_used_mb, mem_total_mb, last_seen, last_seen_at, capabilities, metadata, metrics)
|
cpu_usage, mem_used_mb, mem_total_mb, last_seen, capabilities, metadata)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),NOW(),$9,$10,$11)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9,$10)
|
||||||
ON CONFLICT (hostname) DO UPDATE SET
|
ON CONFLICT (hostname) DO UPDATE SET
|
||||||
ip_address = EXCLUDED.ip_address,
|
ip_address = EXCLUDED.ip_address,
|
||||||
role = EXCLUDED.role,
|
role = EXCLUDED.role,
|
||||||
|
|
@ -130,10 +143,8 @@ router.post('/heartbeat', async (req, res, next) => {
|
||||||
mem_used_mb = EXCLUDED.mem_used_mb,
|
mem_used_mb = EXCLUDED.mem_used_mb,
|
||||||
mem_total_mb = EXCLUDED.mem_total_mb,
|
mem_total_mb = EXCLUDED.mem_total_mb,
|
||||||
last_seen = NOW(),
|
last_seen = NOW(),
|
||||||
last_seen_at = NOW(),
|
|
||||||
capabilities = EXCLUDED.capabilities,
|
capabilities = EXCLUDED.capabilities,
|
||||||
metadata = EXCLUDED.metadata,
|
metadata = EXCLUDED.metadata
|
||||||
metrics = COALESCE(EXCLUDED.metrics, cluster_nodes.metrics)
|
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
hostname,
|
hostname,
|
||||||
|
|
@ -146,32 +157,48 @@ router.post('/heartbeat', async (req, res, next) => {
|
||||||
mem_total_mb != null ? mem_total_mb : null,
|
mem_total_mb != null ? mem_total_mb : null,
|
||||||
capabilities != null ? JSON.stringify(capabilities) : '{}',
|
capabilities != null ? JSON.stringify(capabilities) : '{}',
|
||||||
metadata != null ? JSON.stringify(metadata) : null,
|
metadata != null ? JSON.stringify(metadata) : null,
|
||||||
metrics != null ? JSON.stringify(metrics) : null,
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
res.json(r.rows[0]);
|
res.json(r.rows[0]);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /devices/blackmagic/signal – live video-presence state for every
|
||||||
|
// DeckLink port across the cluster. For each port we check whether there is
|
||||||
|
// an active SDI recorder assigned to it and, if so, query the capture
|
||||||
|
// container for its real signal state (receiving / lost / connecting /
|
||||||
|
// error). Ports without a recorder get signal = 'no-recorder'.
|
||||||
|
//
|
||||||
|
// Response shape (array):
|
||||||
|
// { node_id, hostname, index, device, model,
|
||||||
|
// signal, framesReceived, currentFps, recorder_id, recorder_status }
|
||||||
router.get('/devices/blackmagic/signal', async (req, res, next) => {
|
router.get('/devices/blackmagic/signal', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
// 1. Fetch all cluster nodes with DeckLink capabilities.
|
||||||
const nodesResult = await pool.query(
|
const nodesResult = await pool.query(
|
||||||
`SELECT id, hostname, ip_address, api_url, capabilities,
|
`SELECT id, hostname, ip_address, api_url, capabilities,
|
||||||
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
||||||
FROM cluster_nodes
|
FROM cluster_nodes
|
||||||
WHERE capabilities IS NOT NULL`
|
WHERE capabilities IS NOT NULL`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 2. Fetch all SDI recorders that are pinned to a node+device_index.
|
||||||
const recResult = await pool.query(
|
const recResult = await pool.query(
|
||||||
`SELECT id, name, status, container_id, node_id, device_index,
|
`SELECT id, name, status, container_id, node_id, device_index,
|
||||||
source_config
|
source_config
|
||||||
FROM recorders
|
FROM recorders
|
||||||
WHERE source_type = 'sdi' AND node_id IS NOT NULL`
|
WHERE source_type = 'sdi' AND node_id IS NOT NULL`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Build a fast lookup: "${node_id}:${device_index}" → recorder row.
|
||||||
const recByPort = new Map();
|
const recByPort = new Map();
|
||||||
for (const r of recResult.rows) {
|
for (const r of recResult.rows) {
|
||||||
const devIdx = r.device_index ?? r.source_config?.device ?? 0;
|
const devIdx = r.device_index ?? r.source_config?.device ?? 0;
|
||||||
recByPort.set(`${r.node_id}:${devIdx}`, r);
|
recByPort.set(`${r.node_id}:${devIdx}`, r);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. For each port, determine signal state. We fire all capture-container
|
||||||
|
// fetches concurrently so the endpoint stays fast even with many ports.
|
||||||
const tasks = [];
|
const tasks = [];
|
||||||
for (const node of nodesResult.rows) {
|
for (const node of nodesResult.rows) {
|
||||||
const nodeOnline = Number(node.stale_seconds) < 120;
|
const nodeOnline = Number(node.stale_seconds) < 120;
|
||||||
|
|
@ -179,51 +206,79 @@ router.get('/devices/blackmagic/signal', async (req, res, next) => {
|
||||||
const model = (node.capabilities && node.capabilities.blackmagic_model) || null;
|
const model = (node.capabilities && node.capabilities.blackmagic_model) || null;
|
||||||
const localHostname = process.env.NODE_HOSTNAME || '';
|
const localHostname = process.env.NODE_HOSTNAME || '';
|
||||||
const isRemote = node.api_url && node.hostname !== localHostname;
|
const isRemote = node.api_url && node.hostname !== localHostname;
|
||||||
|
|
||||||
bm.forEach((d, idx) => {
|
bm.forEach((d, idx) => {
|
||||||
const portIndex = d.index !== undefined ? d.index : idx;
|
const portIndex = d.index !== undefined ? d.index : idx;
|
||||||
const rec = recByPort.get(`${node.id}:${portIndex}`);
|
const rec = recByPort.get(`${node.id}:${portIndex}`);
|
||||||
|
|
||||||
tasks.push((async () => {
|
tasks.push((async () => {
|
||||||
const base = {
|
const base = {
|
||||||
node_id: node.id, hostname: node.hostname, index: portIndex,
|
node_id: node.id,
|
||||||
device: d.device || null, model, node_online: nodeOnline,
|
hostname: node.hostname,
|
||||||
recorder_id: rec ? rec.id : null, recorder_name: rec ? rec.name : null,
|
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,
|
recorder_status: rec ? rec.status : null,
|
||||||
signal: 'no-recorder', framesReceived: null, currentFps: null,
|
signal: 'no-recorder',
|
||||||
|
framesReceived: null,
|
||||||
|
currentFps: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!rec || rec.status !== 'recording' || !rec.container_id) {
|
if (!rec || rec.status !== 'recording' || !rec.container_id) {
|
||||||
|
// No active capture — if there's a recorder but it's not recording,
|
||||||
|
// report that; otherwise the port is unassigned.
|
||||||
if (rec && rec.status !== 'recording') base.signal = 'idle';
|
if (rec && rec.status !== 'recording') base.signal = 'idle';
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Active recording — query the capture container for real signal.
|
||||||
try {
|
try {
|
||||||
let live = null;
|
let live = null;
|
||||||
if (isRemote) {
|
if (isRemote) {
|
||||||
const r = await fetch(`${node.api_url}/sidecar/${rec.container_id}/status`, { signal: AbortSignal.timeout(2500) });
|
const r = await fetch(
|
||||||
|
`${node.api_url}/sidecar/${rec.container_id}/status`,
|
||||||
|
{ signal: AbortSignal.timeout(2500) }
|
||||||
|
);
|
||||||
if (r.ok) live = (await r.json()).live;
|
if (r.ok) live = (await r.json()).live;
|
||||||
} else {
|
} else {
|
||||||
const r = await fetch(`http://recorder-${rec.id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
|
const r = await fetch(
|
||||||
|
`http://recorder-${rec.id}:3001/capture/status`,
|
||||||
|
{ signal: AbortSignal.timeout(2000) }
|
||||||
|
);
|
||||||
if (r.ok) live = await r.json();
|
if (r.ok) live = await r.json();
|
||||||
}
|
}
|
||||||
if (live && live.signal) {
|
if (live && live.signal) {
|
||||||
base.signal = live.signal;
|
base.signal = live.signal;
|
||||||
base.framesReceived = live.framesReceived ?? null;
|
base.framesReceived = live.framesReceived ?? null;
|
||||||
base.currentFps = live.currentFps ?? null;
|
base.currentFps = live.currentFps ?? null;
|
||||||
} else { base.signal = 'connecting'; }
|
} else {
|
||||||
} catch (_) { base.signal = 'connecting'; }
|
base.signal = 'connecting';
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
base.signal = 'connecting';
|
||||||
|
}
|
||||||
return base;
|
return base;
|
||||||
})());
|
})());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.all(tasks);
|
const results = await Promise.all(tasks);
|
||||||
res.json(results);
|
res.json(results);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /devices/blackmagic – flatten every node's DeckLink cards for the
|
||||||
|
// recorder picker. Returns one entry per device with the host node info.
|
||||||
router.get('/devices/blackmagic', async (req, res, next) => {
|
router.get('/devices/blackmagic', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const r = await pool.query(
|
const r = await pool.query(
|
||||||
`SELECT id, hostname, ip_address, role, capabilities,
|
`SELECT id, hostname, ip_address, role, capabilities,
|
||||||
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
||||||
FROM cluster_nodes WHERE capabilities IS NOT NULL`
|
FROM cluster_nodes
|
||||||
|
WHERE capabilities IS NOT NULL`
|
||||||
);
|
);
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const row of r.rows) {
|
for (const row of r.rows) {
|
||||||
|
|
@ -231,98 +286,39 @@ router.get('/devices/blackmagic', async (req, res, next) => {
|
||||||
const bm = (row.capabilities && row.capabilities.blackmagic) || [];
|
const bm = (row.capabilities && row.capabilities.blackmagic) || [];
|
||||||
const model = (row.capabilities && row.capabilities.blackmagic_model) || null;
|
const model = (row.capabilities && row.capabilities.blackmagic_model) || null;
|
||||||
bm.forEach((d, idx) => {
|
bm.forEach((d, idx) => {
|
||||||
out.push({ node_id: row.id, hostname: row.hostname, ip_address: row.ip_address,
|
out.push({
|
||||||
role: row.role, online, model, index: d.index !== undefined ? d.index : idx, device: d.device });
|
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);
|
res.json(out);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/devices/deltacast', async (req, res, next) => {
|
// GET /:id/ping – probe the node's api_url/health endpoint directly
|
||||||
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) => {
|
router.get('/:id/ping', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const r = await pool.query('SELECT id, hostname, api_url FROM cluster_nodes WHERE id = $1', [req.params.id]);
|
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' });
|
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
|
||||||
|
|
||||||
const node = r.rows[0];
|
const node = r.rows[0];
|
||||||
if (!node.api_url) return res.json({ reachable: false, reason: 'no api_url registered' });
|
if (!node.api_url) return res.json({ reachable: false, reason: 'no api_url registered' });
|
||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
try {
|
try {
|
||||||
const upstream = await fetch(`${node.api_url}/health`, { signal: AbortSignal.timeout(4000) });
|
const upstream = await fetch(`${node.api_url}/health`, {
|
||||||
|
signal: AbortSignal.timeout(4000),
|
||||||
|
});
|
||||||
const latency_ms = Date.now() - start;
|
const latency_ms = Date.now() - start;
|
||||||
const body = await upstream.json().catch(() => ({}));
|
const body = await upstream.json().catch(() => ({}));
|
||||||
res.json({ reachable: upstream.ok, latency_ms, status: upstream.status, agent: body });
|
res.json({ reachable: upstream.ok, latency_ms, status: upstream.status, agent: body });
|
||||||
|
|
@ -332,44 +328,13 @@ router.get('/:id/ping', async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/metrics', async (req, res, next) => {
|
// DELETE /:id – deregister a node
|
||||||
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) => {
|
router.delete('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const r = await pool.query('DELETE FROM cluster_nodes WHERE id = $1 RETURNING id', [req.params.id]);
|
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' });
|
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,9 @@
|
||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { assertProjectAccess } from '../auth/authz.js';
|
|
||||||
|
|
||||||
const router = express.Router({ mergeParams: true });
|
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) {
|
function rowToJson(r) {
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
|
@ -63,9 +49,8 @@ router.post('/', async (req, res, next) => {
|
||||||
if (!body || !String(body).trim()) {
|
if (!body || !String(body).trim()) {
|
||||||
return res.status(400).json({ error: 'body is required' });
|
return res.status(400).json({ error: 'body is required' });
|
||||||
}
|
}
|
||||||
// Author is the authenticated user (requireAuth sets req.user for both
|
// Best-effort author lookup — pull from the session if AUTH_ENABLED is on.
|
||||||
// session and bearer auth, and the dev user when AUTH_ENABLED=false).
|
const userId = req.session?.userId || null;
|
||||||
const userId = req.user?.id || null;
|
|
||||||
|
|
||||||
const ins = await pool.query(
|
const ins = await pool.query(
|
||||||
`INSERT INTO asset_comments (asset_id, user_id, body, frame_ms)
|
`INSERT INTO asset_comments (asset_id, user_id, body, frame_ms)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import express from 'express';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { assertProjectAccess } from '../auth/authz.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -61,8 +60,6 @@ router.post('/youtube', async (req, res, next) => {
|
||||||
if (projCheck.rows.length === 0) {
|
if (projCheck.rows.length === 0) {
|
||||||
return res.status(404).json({ error: 'Project not found' });
|
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();
|
const assetId = uuidv4();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { assertProjectAccess } from '../auth/authz.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
// Note: jobs use BullMQ id format "<queueType>:<bullId>" (e.g. "conform:42"),
|
// Note: jobs use BullMQ id format "<queueType>:<bullId>" (e.g. "conform:42"),
|
||||||
|
|
@ -325,10 +324,6 @@ router.post('/conform', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conform writes back into a project — require edit on that project. Without
|
|
||||||
// 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 bullJob = await conformQueue.add('conform-task', {
|
||||||
edl,
|
edl,
|
||||||
projectId: project_id,
|
projectId: project_id,
|
||||||
|
|
|
||||||
|
|
@ -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,8 +1,6 @@
|
||||||
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 { validateUuid } from '../middleware/errors.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();
|
||||||
|
|
@ -18,29 +16,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 +51,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 +76,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 +122,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 +143,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,39 +1,14 @@
|
||||||
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 net from 'net';
|
||||||
import dgram from 'dgram';
|
import dgram from 'dgram';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { getS3Bucket } from '../s3/client.js';
|
import { getS3Bucket } from '../s3/client.js';
|
||||||
import { validateUuid } from '../middleware/errors.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();
|
||||||
|
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||||
// Every /:id recorder route is scoped to the recorder's project. The param
|
|
||||||
// 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.
|
// Base port for on-demand SDI sidecar containers on remote worker nodes.
|
||||||
// Device index 0 → 7438, index 1 → 7439, etc.
|
// Device index 0 → 7438, index 1 → 7439, etc.
|
||||||
|
|
@ -173,17 +148,6 @@ function pickRecorderFields(body) {
|
||||||
// parallel with a per-call timeout from `dockerApi`.
|
// 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).
|
|
||||||
// Recorders with a NULL project are admin-only and never appear for scoped
|
|
||||||
// users (accessibleProjectIds never yields a null id).
|
|
||||||
const access = await accessibleProjectIds(req.user);
|
|
||||||
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(`
|
const result = await pool.query(`
|
||||||
SELECT r.*, la.live_asset_id
|
SELECT r.*, la.live_asset_id
|
||||||
FROM recorders r
|
FROM recorders r
|
||||||
|
|
@ -197,9 +161,8 @@ router.get('/', async (req, res, next) => {
|
||||||
ORDER BY a.created_at DESC
|
ORDER BY a.created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) la ON TRUE
|
) la ON TRUE
|
||||||
${scopeClause}
|
|
||||||
ORDER BY r.created_at DESC
|
ORDER BY r.created_at DESC
|
||||||
`, params);
|
`);
|
||||||
const rows = result.rows;
|
const rows = result.rows;
|
||||||
|
|
||||||
// Only inspect containers for recorders that actually claim to be recording.
|
// Only inspect containers for recorders that actually claim to be recording.
|
||||||
|
|
@ -230,15 +193,10 @@ router.post('/', async (req, res, next) => {
|
||||||
.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
|
|
||||||
// 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.
|
// Defaults — written on insert so the DB row is always self-contained.
|
||||||
const defaults = {
|
const defaults = {
|
||||||
source_config: {},
|
source_config: {},
|
||||||
recording_codec: 'hevc_nvenc',
|
recording_codec: 'prores_hq',
|
||||||
recording_resolution: 'native',
|
recording_resolution: 'native',
|
||||||
recording_audio_codec: 'pcm_s24le',
|
recording_audio_codec: 'pcm_s24le',
|
||||||
recording_audio_channels: 2,
|
recording_audio_channels: 2,
|
||||||
|
|
@ -297,7 +255,7 @@ router.get('/:id', async (req, res, next) => {
|
||||||
|
|
||||||
// PATCH /:id - Edit recorder settings
|
// PATCH /:id - Edit recorder settings
|
||||||
// Blocked while recorder is actively recording to prevent config drift.
|
// Blocked while recorder is actively recording to prevent config drift.
|
||||||
router.patch('/:id', requireRecorderEdit, async (req, res, next) => {
|
router.patch('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
|
@ -336,7 +294,7 @@ router.patch('/:id', requireRecorderEdit, async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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;
|
||||||
|
|
||||||
|
|
@ -360,7 +318,6 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
||||||
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
|
// Growing-files mode is a global setting (settings table). When on, the
|
||||||
|
|
@ -379,21 +336,6 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
||||||
const customClipName = sanitizeClipName(req.body && req.body.clipName);
|
const customClipName = sanitizeClipName(req.body && req.body.clipName);
|
||||||
const clipName = customClipName || generateClipName(recorder.name);
|
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
|
// live-asset: create the asset row right now (status='live') so the
|
||||||
// library shows the recording while it is happening.
|
// library shows the recording while it is happening.
|
||||||
const assetIdLive = uuidv4();
|
const assetIdLive = uuidv4();
|
||||||
|
|
@ -404,7 +346,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
||||||
id, project_id, bin_id, filename, display_name, status, media_type,
|
id, project_id, bin_id, filename, display_name, status, media_type,
|
||||||
original_s3_key, created_at, updated_at
|
original_s3_key, created_at, updated_at
|
||||||
) VALUES ($1, $2, NULL, $3, $3, 'live', 'video', $4, NOW(), NOW())`,
|
) VALUES ($1, $2, NULL, $3, $3, 'live', 'video', $4, NOW(), NOW())`,
|
||||||
[assetIdLive, takeProjectId, clipName, `projects/${takeProjectId}/masters/${clipName}.${ext}`]
|
[assetIdLive, recorder.project_id, clipName, `projects/${recorder.project_id}/masters/${clipName}.${ext}`]
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[recorders] could not pre-create live asset:', e.message);
|
console.warn('[recorders] could not pre-create live asset:', e.message);
|
||||||
|
|
@ -449,21 +391,13 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
||||||
`PROXY_AUDIO_CHANNELS=${recorder.proxy_audio_channels ?? 2}`,
|
`PROXY_AUDIO_CHANNELS=${recorder.proxy_audio_channels ?? 2}`,
|
||||||
`PROXY_CONTAINER=${recorder.proxy_container || 'mp4'}`,
|
`PROXY_CONTAINER=${recorder.proxy_container || 'mp4'}`,
|
||||||
|
|
||||||
`PROJECT_ID=${takeProjectId}`,
|
`PROJECT_ID=${recorder.project_id}`,
|
||||||
`CLIP_NAME=${clipName}`,
|
`CLIP_NAME=${clipName}`,
|
||||||
`ASSET_ID=${assetIdLive}`,
|
`ASSET_ID=${assetIdLive}`,
|
||||||
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
|
|
||||||
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
|
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
|
||||||
`GROWING_PATH=/growing`,
|
`GROWING_PATH=/growing`,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Deltacast: pass port count so the capture container can enumerate
|
|
||||||
// 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,20 +410,8 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GPU-accelerated codecs require the NVIDIA container runtime on the node.
|
|
||||||
// hevc_nvenc / h264_nvenc are the only two we currently support; extend
|
|
||||||
// this list if av1_nvenc or others are added later.
|
|
||||||
const GPU_CODECS = ['hevc_nvenc', 'h264_nvenc'];
|
|
||||||
const useGpu = GPU_CODECS.includes(recorder.recording_codec);
|
|
||||||
|
|
||||||
// Determine whether to spawn locally or via a remote node-agent.
|
// Determine whether to spawn locally or via a remote node-agent.
|
||||||
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
|
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;
|
let containerId;
|
||||||
|
|
||||||
|
|
@ -499,7 +421,7 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
||||||
const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
|
const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ image: 'wild-dragon-capture:latest', env, capturePort, sourceType, useGpu }),
|
body: JSON.stringify({ image: 'wild-dragon-capture:latest', env, capturePort, sourceType }),
|
||||||
signal: AbortSignal.timeout(15000),
|
signal: AbortSignal.timeout(15000),
|
||||||
});
|
});
|
||||||
if (!sidecarRes.ok) {
|
if (!sidecarRes.ok) {
|
||||||
|
|
@ -521,39 +443,18 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
||||||
|
|
||||||
const hostBinds = ['/mnt/NVME/MAM/wild-dragon-live:/live'];
|
const hostBinds = ['/mnt/NVME/MAM/wild-dragon-live:/live'];
|
||||||
if (sourceType === 'sdi') hostBinds.push('/dev/blackmagic:/dev/blackmagic');
|
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');
|
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,
|
|
||||||
NetworkMode: dockerNetwork,
|
|
||||||
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
|
|
||||||
Binds: hostBinds,
|
|
||||||
...(useGpu && {
|
|
||||||
Runtime: 'nvidia',
|
|
||||||
DeviceRequests: [{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] }],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const containerConfig = {
|
const containerConfig = {
|
||||||
Image: 'wild-dragon-capture:latest',
|
Image: 'wild-dragon-capture:latest',
|
||||||
Env: localEnv,
|
Env: env,
|
||||||
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
|
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
|
||||||
HostConfig: localHostConfig,
|
HostConfig: {
|
||||||
|
Privileged: true,
|
||||||
|
NetworkMode: dockerNetwork,
|
||||||
|
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
|
||||||
|
Binds: hostBinds,
|
||||||
|
},
|
||||||
NetworkingConfig: {
|
NetworkingConfig: {
|
||||||
EndpointsConfig: {
|
EndpointsConfig: {
|
||||||
[dockerNetwork]: { Aliases: [alias] },
|
[dockerNetwork]: { Aliases: [alias] },
|
||||||
|
|
@ -600,7 +501,7 @@ 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;
|
||||||
|
|
||||||
|
|
@ -771,7 +672,7 @@ 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;
|
||||||
|
|
||||||
|
|
@ -938,37 +839,4 @@ function probeUdp(host, port) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 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;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import express from 'express';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { getSignedUrlForObject } from '../s3/client.js';
|
import { getSignedUrlForObject } from '../s3/client.js';
|
||||||
import { validateUuid } from '../middleware/errors.js';
|
import { validateUuid } from '../middleware/errors.js';
|
||||||
import { assertProjectAccess } from '../auth/authz.js';
|
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
|
|
||||||
const parseRedisUrl = (url) => {
|
const parseRedisUrl = (url) => {
|
||||||
|
|
@ -20,27 +19,7 @@ const conformQueue = new Queue('conform', {
|
||||||
});
|
});
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||||
// Scope every /:id sequence route to its project: validate the UUID, resolve
|
|
||||||
// project_id, assert the 'view' baseline; mutators escalate via requireSequenceEdit.
|
|
||||||
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 sequences WHERE id = $1', [req.params.id]);
|
|
||||||
if (rows.length === 0) return res.status(404).json({ error: 'Sequence not found' });
|
|
||||||
req.sequenceProjectId = rows[0].project_id;
|
|
||||||
await assertProjectAccess(req.user, req.sequenceProjectId, 'view');
|
|
||||||
next();
|
|
||||||
} catch (err) { next(err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
async function requireSequenceEdit(req, res, next) {
|
|
||||||
try {
|
|
||||||
await assertProjectAccess(req.user, req.sequenceProjectId, 'edit');
|
|
||||||
next();
|
|
||||||
} catch (err) { next(err); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Row mapper ────────────────────────────────────────────────────────────────
|
// ── Row mapper ────────────────────────────────────────────────────────────────
|
||||||
// node-postgres returns NUMERIC columns as strings. Coerce frame_rate to a
|
// node-postgres returns NUMERIC columns as strings. Coerce frame_rate to a
|
||||||
|
|
@ -145,7 +124,6 @@ router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { project_id } = req.query;
|
const { project_id } = req.query;
|
||||||
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
|
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
|
||||||
await assertProjectAccess(req.user, project_id, 'view');
|
|
||||||
const r = await pool.query(
|
const r = await pool.query(
|
||||||
`SELECT * FROM sequences WHERE project_id = $1 ORDER BY updated_at DESC`,
|
`SELECT * FROM sequences WHERE project_id = $1 ORDER BY updated_at DESC`,
|
||||||
[project_id]
|
[project_id]
|
||||||
|
|
@ -165,7 +143,6 @@ router.post('/', async (req, res, next) => {
|
||||||
height = 1080,
|
height = 1080,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
|
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
|
||||||
await assertProjectAccess(req.user, project_id, 'edit');
|
|
||||||
const r = await pool.query(
|
const r = await pool.query(
|
||||||
`INSERT INTO sequences (project_id, name, frame_rate, width, height)
|
`INSERT INTO sequences (project_id, name, frame_rate, width, height)
|
||||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||||
|
|
@ -211,7 +188,7 @@ router.get('/:id', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PUT /:id – update sequence metadata ──────────────────────────────────────
|
// ── PUT /:id – update sequence metadata ──────────────────────────────────────
|
||||||
router.put('/:id', requireSequenceEdit, async (req, res, next) => {
|
router.put('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { name, frame_rate, width, height } = req.body;
|
const { name, frame_rate, width, height } = req.body;
|
||||||
const updates = [];
|
const updates = [];
|
||||||
|
|
@ -234,7 +211,7 @@ router.put('/:id', requireSequenceEdit, async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── DELETE /:id ───────────────────────────────────────────────────────────────
|
// ── DELETE /:id ───────────────────────────────────────────────────────────────
|
||||||
router.delete('/:id', requireSequenceEdit, async (req, res, next) => {
|
router.delete('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const r = await pool.query(`DELETE FROM sequences WHERE id = $1`, [req.params.id]);
|
const r = await pool.query(`DELETE FROM sequences WHERE id = $1`, [req.params.id]);
|
||||||
if (!r.rowCount) return res.status(404).json({ error: 'Sequence not found' });
|
if (!r.rowCount) return res.status(404).json({ error: 'Sequence not found' });
|
||||||
|
|
@ -243,41 +220,25 @@ router.delete('/:id', requireSequenceEdit, async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PUT /:id/clips – full replace of clip array (single transaction) ──────────
|
// ── PUT /:id/clips – full replace of clip array (single transaction) ──────────
|
||||||
router.put('/:id/clips', requireSequenceEdit, async (req, res, next) => {
|
router.put('/:id/clips', async (req, res, next) => {
|
||||||
|
// Verify sequence exists first (before acquiring transaction client)
|
||||||
|
const seqCheck = await pool.query(`SELECT id FROM sequences WHERE id = $1`, [req.params.id]);
|
||||||
|
if (!seqCheck.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
||||||
|
|
||||||
const clips = Array.isArray(req.body) ? req.body : [];
|
const clips = Array.isArray(req.body) ? req.body : [];
|
||||||
let client;
|
for (const c of clips) {
|
||||||
|
if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' });
|
||||||
|
if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) ||
|
||||||
|
!Number.isFinite(Number(c.source_in_frames)) || !Number.isFinite(Number(c.source_out_frames))) {
|
||||||
|
return res.status(400).json({ error: 'Clip frame fields must be finite numbers' });
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(Number(c.track)) || Number(c.track) < 0) {
|
||||||
|
return res.status(400).json({ error: 'Clip track must be a non-negative integer' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
// Verify sequence exists first (before acquiring transaction client).
|
|
||||||
const seqCheck = await pool.query(`SELECT id FROM sequences WHERE id = $1`, [req.params.id]);
|
|
||||||
if (!seqCheck.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
|
||||||
|
|
||||||
for (const c of clips) {
|
|
||||||
if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' });
|
|
||||||
if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) ||
|
|
||||||
!Number.isFinite(Number(c.source_in_frames)) || !Number.isFinite(Number(c.source_out_frames))) {
|
|
||||||
return res.status(400).json({ error: 'Clip frame fields must be finite numbers' });
|
|
||||||
}
|
|
||||||
if (!Number.isInteger(Number(c.track)) || Number(c.track) < 0) {
|
|
||||||
return res.status(400).json({ error: 'Clip track must be a non-negative integer' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every referenced asset must belong to THIS sequence's project. Without this,
|
|
||||||
// a user with edit on the sequence could splice in assets from a project they
|
|
||||||
// can't access — and GET /:id would then hand back those assets' names and
|
|
||||||
// signed proxy URLs (cross-project leak).
|
|
||||||
const assetIds = [...new Set(clips.map(c => c.asset_id))];
|
|
||||||
if (assetIds.length) {
|
|
||||||
const owning = await pool.query(
|
|
||||||
`SELECT id FROM assets WHERE id = ANY($1::uuid[]) AND project_id = $2`,
|
|
||||||
[assetIds, req.sequenceProjectId]
|
|
||||||
);
|
|
||||||
if (owning.rows.length !== assetIds.length) {
|
|
||||||
return res.status(400).json({ error: 'All clip assets must belong to the sequence\'s project' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
client = await pool.connect();
|
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
await client.query(
|
await client.query(
|
||||||
`DELETE FROM sequence_clips WHERE sequence_id = $1`,
|
`DELETE FROM sequence_clips WHERE sequence_id = $1`,
|
||||||
|
|
@ -304,12 +265,10 @@ router.put('/:id/clips', requireSequenceEdit, async (req, res, next) => {
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
res.json({ ok: true, count: clips.length });
|
res.json({ ok: true, count: clips.length });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// client is only set once we've connected; a failure in the pre-transaction
|
await client.query('ROLLBACK');
|
||||||
// queries (existence/validation/ownership) has no transaction to roll back.
|
|
||||||
if (client) await client.query('ROLLBACK').catch(() => {});
|
|
||||||
next(e);
|
next(e);
|
||||||
} finally {
|
} finally {
|
||||||
if (client) client.release();
|
client.release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -341,7 +300,7 @@ router.post('/:id/export/edl', async (req, res, next) => {
|
||||||
// ── POST /:id/conform – conform sequence via FCP XML ─────────────────────────
|
// ── POST /:id/conform – conform sequence via FCP XML ─────────────────────────
|
||||||
// Accepts FCP XML content and encode settings from the Premiere plugin,
|
// Accepts FCP XML content and encode settings from the Premiere plugin,
|
||||||
// queues a conform job in BullMQ, and returns the job ID for polling.
|
// queues a conform job in BullMQ, and returns the job ID for polling.
|
||||||
router.post('/:id/conform', requireSequenceEdit, async (req, res, next) => {
|
router.post('/:id/conform', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]);
|
const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]);
|
||||||
if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import {
|
||||||
AbortMultipartUploadCommand,
|
AbortMultipartUploadCommand,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
import { getAmppConfig, ensureFolderPath } from '../ampp/client.js';
|
import { getAmppConfig, ensureFolderPath } from '../ampp/client.js';
|
||||||
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -139,24 +138,16 @@ function mediaTypeFromMime(mime = '') {
|
||||||
return 'document';
|
return 'document';
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/v1/upload - List in-progress uploads (#68). Scoped to projects the
|
// GET /api/v1/upload - List in-progress uploads (#68)
|
||||||
// caller can see — admins are unfiltered; a scoped viewer/editor only sees
|
|
||||||
// uploads for projects they have access to (no enumeration of other projects'
|
|
||||||
// in-flight filenames).
|
|
||||||
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(
|
||||||
let query = `SELECT id, filename, display_name, project_id, bin_id, status, created_at, updated_at
|
`SELECT id, filename, display_name, project_id, bin_id, status, created_at, updated_at
|
||||||
FROM assets
|
FROM assets
|
||||||
WHERE status = 'ingesting'`;
|
WHERE status = 'ingesting'
|
||||||
const params = [];
|
ORDER BY created_at DESC
|
||||||
if (!access.all) {
|
LIMIT 50`
|
||||||
if (access.ids.size === 0) return res.json([]);
|
);
|
||||||
query += ` AND project_id = ANY($1::uuid[])`;
|
|
||||||
params.push([...access.ids]);
|
|
||||||
}
|
|
||||||
query += ` ORDER BY created_at DESC LIMIT 50`;
|
|
||||||
const result = await pool.query(query, params);
|
|
||||||
res.json(result.rows);
|
res.json(result.rows);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
@ -172,17 +163,6 @@ router.post('/init', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uploading creates an asset under a project — require edit on that project.
|
|
||||||
// Without this, any logged-in user could write into any project.
|
|
||||||
await assertProjectAccess(req.user, projectId, 'edit');
|
|
||||||
if (binId) {
|
|
||||||
const bin = await pool.query('SELECT project_id FROM bins WHERE id = $1', [binId]);
|
|
||||||
if (bin.rows.length === 0) return res.status(400).json({ error: 'binId not found' });
|
|
||||||
if (bin.rows[0].project_id !== projectId) {
|
|
||||||
return res.status(400).json({ error: 'binId belongs to a different project' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetId = uuidv4();
|
const assetId = uuidv4();
|
||||||
const s3Key = `originals/${assetId}/${filename}`;
|
const s3Key = `originals/${assetId}/${filename}`;
|
||||||
const tagsArray = tags ? (Array.isArray(tags) ? tags : [tags]) : [];
|
const tagsArray = tags ? (Array.isArray(tags) ? tags : [tags]) : [];
|
||||||
|
|
@ -346,20 +326,6 @@ router.post('/simple', upload.single('file'), async (req, res, next) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same authz gate as /init.
|
|
||||||
await assertProjectAccess(req.user, projectId, 'edit');
|
|
||||||
if (binId) {
|
|
||||||
const bin = await pool.query('SELECT project_id FROM bins WHERE id = $1', [binId]);
|
|
||||||
if (bin.rows.length === 0) {
|
|
||||||
unlinkPart(tmpPath);
|
|
||||||
return res.status(400).json({ error: 'binId not found' });
|
|
||||||
}
|
|
||||||
if (bin.rows[0].project_id !== projectId) {
|
|
||||||
unlinkPart(tmpPath);
|
|
||||||
return res.status(400).json({ error: 'binId belongs to a different project' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetId = uuidv4();
|
const assetId = uuidv4();
|
||||||
const s3Key = `originals/${assetId}/${filename}`;
|
const s3Key = `originals/${assetId}/${filename}`;
|
||||||
const mimeType = contentType || req.file.mimetype;
|
const mimeType = contentType || req.file.mimetype;
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,10 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { hashPassword } from '../auth/passwords.js';
|
import { hashPassword } from '../auth/passwords.js';
|
||||||
import { DEV_USER_ID, requireAdmin } from '../middleware/auth.js';
|
import { DEV_USER_ID } from '../middleware/auth.js';
|
||||||
import { accessibleProjectIds } from '../auth/authz.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const MIN_PASSWORD_LEN = 12;
|
const MIN_PASSWORD_LEN = 12;
|
||||||
const ROLES = ['admin', 'editor', 'viewer'];
|
|
||||||
|
|
||||||
function bad(res, msg) { return res.status(400).json({ error: msg }); }
|
function bad(res, msg) { return res.status(400).json({ error: msg }); }
|
||||||
|
|
||||||
|
|
@ -16,7 +14,7 @@ function bad(res, msg) { return res.status(400).json({ error: msg }); }
|
||||||
router.get('/', async (_req, res, next) => {
|
router.get('/', async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT id, username, display_name, role, totp_enabled, last_login_at, created_at
|
`SELECT id, username, display_name, role, last_login_at, created_at
|
||||||
FROM users WHERE id <> $1 ORDER BY username`, [DEV_USER_ID]);
|
FROM users WHERE id <> $1 ORDER BY username`, [DEV_USER_ID]);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
|
|
@ -28,7 +26,6 @@ router.post('/', async (req, res, next) => {
|
||||||
const { username, password, display_name, role } = req.body || {};
|
const { username, password, display_name, role } = req.body || {};
|
||||||
if (!username || typeof username !== 'string') return bad(res, 'username required');
|
if (!username || typeof username !== 'string') return bad(res, 'username required');
|
||||||
if (!password || password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
|
if (!password || password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
|
||||||
if (role !== undefined && !ROLES.includes(role)) return bad(res, "role must be one of: " + ROLES.join(', '));
|
|
||||||
const hash = await hashPassword(password);
|
const hash = await hashPassword(password);
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO users (username, password_hash, display_name, role)
|
`INSERT INTO users (username, password_hash, display_name, role)
|
||||||
|
|
@ -79,10 +76,7 @@ router.patch('/:id', async (req, res, next) => {
|
||||||
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' });
|
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' });
|
||||||
const sets = []; const vals = [];
|
const sets = []; const vals = [];
|
||||||
if (typeof req.body?.display_name === 'string') { sets.push('display_name = $' + (sets.length + 1)); vals.push(req.body.display_name); }
|
if (typeof req.body?.display_name === 'string') { sets.push('display_name = $' + (sets.length + 1)); vals.push(req.body.display_name); }
|
||||||
if (typeof req.body?.role === 'string') {
|
if (typeof req.body?.role === 'string') { sets.push('role = $' + (sets.length + 1)); vals.push(req.body.role); }
|
||||||
if (!ROLES.includes(req.body.role)) return bad(res, "role must be one of: " + ROLES.join(', '));
|
|
||||||
sets.push('role = $' + (sets.length + 1)); vals.push(req.body.role);
|
|
||||||
}
|
|
||||||
if (typeof req.body?.password === 'string') {
|
if (typeof req.body?.password === 'string') {
|
||||||
if (req.body.password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
|
if (req.body.password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
|
||||||
sets.push('password_hash = $' + (sets.length + 1) + ', password_updated_at = NOW()');
|
sets.push('password_hash = $' + (sets.length + 1) + ', password_updated_at = NOW()');
|
||||||
|
|
@ -99,88 +93,4 @@ router.patch('/:id', async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id/access — effective per-project access for one user (admin only).
|
|
||||||
// Reuses authz.accessibleProjectIds (MAX over direct user grant + every group the
|
|
||||||
// user belongs to). `via` is 'direct' for a user grant, 'group:<name>' otherwise.
|
|
||||||
// When the effective level comes from several sources we report the direct grant
|
|
||||||
// if present, else the first contributing group.
|
|
||||||
router.get('/:id/access', requireAdmin, async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const { rows: urows } = await pool.query(
|
|
||||||
`SELECT id, role FROM users WHERE id = $1`, [req.params.id]);
|
|
||||||
if (urows.length === 0) return res.status(404).json({ error: 'user not found' });
|
|
||||||
const target = urows[0];
|
|
||||||
|
|
||||||
const { rows: groups } = await pool.query(
|
|
||||||
`SELECT g.id, g.name
|
|
||||||
FROM user_groups ug JOIN groups g ON g.id = ug.group_id
|
|
||||||
WHERE ug.user_id = $1 ORDER BY g.name`, [target.id]);
|
|
||||||
|
|
||||||
// Admins bypass scoping — every project at 'edit', via their role.
|
|
||||||
const access = await accessibleProjectIds(target);
|
|
||||||
if (access.all) {
|
|
||||||
const { rows: projects } = await pool.query(
|
|
||||||
`SELECT id, name FROM projects ORDER BY name`);
|
|
||||||
return res.json({
|
|
||||||
projects: projects.map(p => ({
|
|
||||||
project_id: p.id, project_name: p.name, level: 'edit', via: 'direct',
|
|
||||||
})),
|
|
||||||
groups,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ids = [...access.ids];
|
|
||||||
if (ids.length === 0) return res.json({ projects: [], groups });
|
|
||||||
|
|
||||||
// Resolve names + the source of each grant. groupNameById lets us label a
|
|
||||||
// group-sourced grant; a direct user grant always wins the `via` label.
|
|
||||||
const groupNameById = new Map(groups.map(g => [g.id, g.name]));
|
|
||||||
const { rows: grants } = await pool.query(
|
|
||||||
`SELECT pa.project_id, pa.subject_type, pa.subject_id, pa.level, p.name AS project_name
|
|
||||||
FROM project_access pa JOIN projects p ON p.id = pa.project_id
|
|
||||||
WHERE (pa.subject_type = 'user' AND pa.subject_id = $1)
|
|
||||||
OR (pa.subject_type = 'group' AND pa.subject_id IN (
|
|
||||||
SELECT group_id FROM user_groups WHERE user_id = $1
|
|
||||||
))`,
|
|
||||||
[target.id]);
|
|
||||||
|
|
||||||
const byProject = new Map();
|
|
||||||
for (const g of grants) {
|
|
||||||
const eff = access.levelByProject.get(g.project_id); // already the MAX
|
|
||||||
const via = g.subject_type === 'user'
|
|
||||||
? 'direct'
|
|
||||||
: 'group:' + (groupNameById.get(g.subject_id) || g.subject_id);
|
|
||||||
const prev = byProject.get(g.project_id);
|
|
||||||
// Keep a row only if it carries the effective level; prefer a direct grant
|
|
||||||
// when both a direct and a group grant hit the same level.
|
|
||||||
if (g.level === eff && (!prev || (prev.via !== 'direct' && via === 'direct'))) {
|
|
||||||
byProject.set(g.project_id, {
|
|
||||||
project_id: g.project_id, project_name: g.project_name, level: eff, via,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
projects: [...byProject.values()].sort((a, b) => a.project_name.localeCompare(b.project_name)),
|
|
||||||
groups,
|
|
||||||
});
|
|
||||||
} catch (err) { next(err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /:id/totp/disable — admin clears a locked-out user's 2FA WITHOUT their
|
|
||||||
// password (the self-service /auth/totp/disable needs the victim's own). Mirrors
|
|
||||||
// that handler's SQL but targets :id and skips the password check. Dev user blocked.
|
|
||||||
router.post('/:id/totp/disable', requireAdmin, async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' });
|
|
||||||
const { rowCount } = await pool.query(
|
|
||||||
`UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_last_counter = 0
|
|
||||||
WHERE id = $1 AND id <> $2`,
|
|
||||||
[req.params.id, DEV_USER_ID]);
|
|
||||||
if (rowCount === 0) return res.status(404).json({ error: 'user not found' });
|
|
||||||
await pool.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.params.id]);
|
|
||||||
res.status(204).end();
|
|
||||||
} catch (err) { next(err); }
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
|
||||||
import { S3Client, GetObjectCommand, DeleteObjectCommand, HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
|
import { S3Client, GetObjectCommand, DeleteObjectCommand, HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
|
||||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
import { Upload } from '@aws-sdk/lib-storage';
|
import { Upload } from '@aws-sdk/lib-storage';
|
||||||
|
|
@ -23,9 +22,6 @@ function buildClient(cfg) {
|
||||||
secretAccessKey: cfg.secretKey,
|
secretAccessKey: cfg.secretKey,
|
||||||
},
|
},
|
||||||
forcePathStyle: true,
|
forcePathStyle: true,
|
||||||
// Hard request/connection timeouts so a stalled RustFS GET can't hang the
|
|
||||||
// /video and /hls endpoints forever (the original browser-playback hang).
|
|
||||||
requestHandler: new NodeHttpHandler({ requestTimeout: 30_000, connectionTimeout: 10_000 }),
|
|
||||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,6 @@
|
||||||
|
|
||||||
import pool from './db/pool.js';
|
import pool from './db/pool.js';
|
||||||
import { syncToAmpp } from './routes/upload.js';
|
import { syncToAmpp } from './routes/upload.js';
|
||||||
import { restartChannel } from './routes/playout.js';
|
|
||||||
import { INTERNAL_TOKEN } from './middleware/auth.js';
|
|
||||||
|
|
||||||
const TICK_INTERVAL_MS = parseInt(process.env.SCHEDULER_TICK_MS || '15000', 10);
|
const TICK_INTERVAL_MS = parseInt(process.env.SCHEDULER_TICK_MS || '15000', 10);
|
||||||
const SELF_URL = process.env.MAM_API_SELF_URL || `http://127.0.0.1:${process.env.PORT || 3000}`;
|
const SELF_URL = process.env.MAM_API_SELF_URL || `http://127.0.0.1:${process.env.PORT || 3000}`;
|
||||||
|
|
@ -21,10 +19,7 @@ let _interval = null;
|
||||||
async function callSelf(path, method = 'POST') {
|
async function callSelf(path, method = 'POST') {
|
||||||
const res = await fetch(`${SELF_URL}${path}`, {
|
const res = await fetch(`${SELF_URL}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-internal-token': INTERNAL_TOKEN,
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(30000),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -34,7 +29,11 @@ async function callSelf(path, method = 'POST') {
|
||||||
return res.json().catch(() => ({}));
|
return res.json().catch(() => ({}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCHEDULER_LOCK_KEY = 8210301;
|
// Issue #103 — every mam-api replica runs the same tick on the same interval,
|
||||||
|
// so a multi-node deploy would double-fire recorder starts/stops. We guard
|
||||||
|
// the whole tick with a PG advisory lock (1 = scheduler) so exactly one
|
||||||
|
// replica processes a given interval. Pure-Postgres, no extra infra.
|
||||||
|
const SCHEDULER_LOCK_KEY = 8210301; // arbitrary, must be stable across replicas
|
||||||
|
|
||||||
async function tryAcquireSchedulerLock(client) {
|
async function tryAcquireSchedulerLock(client) {
|
||||||
const r = await client.query('SELECT pg_try_advisory_lock($1) AS got', [SCHEDULER_LOCK_KEY]);
|
const r = await client.query('SELECT pg_try_advisory_lock($1) AS got', [SCHEDULER_LOCK_KEY]);
|
||||||
|
|
@ -53,9 +52,14 @@ async function tick() {
|
||||||
try {
|
try {
|
||||||
haveLock = await tryAcquireSchedulerLock(client);
|
haveLock = await tryAcquireSchedulerLock(client);
|
||||||
if (!haveLock) {
|
if (!haveLock) {
|
||||||
|
// Another replica is processing this interval — bail silently.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1) Atomically claim pending schedules whose window has opened. The
|
||||||
|
// UPDATE...RETURNING flips status to 'running' in the same statement
|
||||||
|
// so even if another replica got past the lock (it can't, but
|
||||||
|
// belt-and-braces) each row can only be claimed once.
|
||||||
const dueStart = await client.query(
|
const dueStart = await client.query(
|
||||||
`UPDATE recorder_schedules
|
`UPDATE recorder_schedules
|
||||||
SET status = 'starting', updated_at = NOW()
|
SET status = 'starting', updated_at = NOW()
|
||||||
|
|
@ -88,6 +92,7 @@ async function tick() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2) Atomically claim running schedules whose window has closed.
|
||||||
const dueStop = await client.query(
|
const dueStop = await client.query(
|
||||||
`UPDATE recorder_schedules
|
`UPDATE recorder_schedules
|
||||||
SET status = 'stopping', updated_at = NOW()
|
SET status = 'stopping', updated_at = NOW()
|
||||||
|
|
@ -110,6 +115,7 @@ async function tick() {
|
||||||
console.log(`[scheduler] stopped schedule "${s.name}" on recorder ${s.recorder_id}`);
|
console.log(`[scheduler] stopped schedule "${s.name}" on recorder ${s.recorder_id}`);
|
||||||
await enqueueNextOccurrence(s, client);
|
await enqueueNextOccurrence(s, client);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Stop failed — flag as failed but don't keep trying forever.
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE recorder_schedules
|
`UPDATE recorder_schedules
|
||||||
SET status = 'failed', error_message = $2, updated_at = NOW()
|
SET status = 'failed', error_message = $2, updated_at = NOW()
|
||||||
|
|
@ -120,6 +126,7 @@ async function tick() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3) If a schedule was cancelled while running, stop the recorder.
|
||||||
const cancelledRunning = await client.query(
|
const cancelledRunning = await client.query(
|
||||||
`SELECT s.* FROM recorder_schedules s
|
`SELECT s.* FROM recorder_schedules s
|
||||||
JOIN recorders r ON r.id = s.recorder_id
|
JOIN recorders r ON r.id = s.recorder_id
|
||||||
|
|
@ -135,6 +142,9 @@ async function tick() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4) Mark stale live assets as 'error' (#66).
|
||||||
|
// If a capture container crashes without calling mark-empty/mark-complete,
|
||||||
|
// the asset row stays status='live' indefinitely. Timeout after 2 hours.
|
||||||
const LIVE_TIMEOUT_MINUTES = parseInt(process.env.LIVE_ASSET_TIMEOUT_MINUTES || '120', 10);
|
const LIVE_TIMEOUT_MINUTES = parseInt(process.env.LIVE_ASSET_TIMEOUT_MINUTES || '120', 10);
|
||||||
const staleResult = await client.query(
|
const staleResult = await client.query(
|
||||||
`UPDATE assets
|
`UPDATE assets
|
||||||
|
|
@ -151,6 +161,9 @@ async function tick() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5) AMPP sync retry (#77). Pick up any pending/failed rows whose
|
||||||
|
// next-attempt time has arrived and retry them. Cap per tick so we
|
||||||
|
// don't burn budget on a single rough interval.
|
||||||
const ampps = await client.query(
|
const ampps = await client.query(
|
||||||
`SELECT id, project_id, bin_id FROM assets
|
`SELECT id, project_id, bin_id FROM assets
|
||||||
WHERE ampp_sync_status IN ('pending', 'failed')
|
WHERE ampp_sync_status IN ('pending', 'failed')
|
||||||
|
|
@ -162,8 +175,6 @@ async function tick() {
|
||||||
for (const row of ampps.rows) {
|
for (const row of ampps.rows) {
|
||||||
await syncToAmpp(row.id, row.project_id, row.bin_id);
|
await syncToAmpp(row.id, row.project_id, row.bin_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await playoutHealthTick(client);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[scheduler] tick error:', err);
|
console.error('[scheduler] tick error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -190,73 +201,11 @@ async function enqueueNextOccurrence(schedule, client) {
|
||||||
console.log(`[scheduler] queued next "${schedule.name}" → ${start.toISOString()}`);
|
console.log(`[scheduler] queued next "${schedule.name}" → ${start.toISOString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Playout channel health + failover ────────────────────────────────────────
|
|
||||||
// Tick step 6. Reuses the same advisory lock so only one replica probes the
|
|
||||||
// sidecars. A missed probe is counted via last_heartbeat_at age: > 3 *
|
|
||||||
// TICK_INTERVAL means 3 consecutive misses.
|
|
||||||
//
|
|
||||||
// IMPORTANT: when last_heartbeat_at is NULL (channel just spawned, no
|
|
||||||
// successful tick yet), use updated_at as the grace anchor — otherwise the
|
|
||||||
// "0" fallback makes ageMs huge and the channel is instantly failover-killed
|
|
||||||
// before its first heartbeat can ever land.
|
|
||||||
async function playoutHealthTick(client) {
|
|
||||||
let channels;
|
|
||||||
try {
|
|
||||||
({ rows: channels } = await client.query(
|
|
||||||
`SELECT id, output_type, container_meta, node_id, last_heartbeat_at, updated_at, restart_count
|
|
||||||
FROM playout_channels WHERE status = 'running'`
|
|
||||||
));
|
|
||||||
} catch (err) {
|
|
||||||
if (err.code === '42P01') return;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TIMEOUT_MS = TICK_INTERVAL_MS * 3 + 5000;
|
|
||||||
for (const ch of channels) {
|
|
||||||
const sidecarUrl =
|
|
||||||
ch.container_meta && ch.container_meta.sidecar_url
|
|
||||||
? ch.container_meta.sidecar_url
|
|
||||||
: `http://playout-${ch.id}:3002`;
|
|
||||||
try {
|
|
||||||
const r = await fetch(`${sidecarUrl}/status`, { signal: AbortSignal.timeout(5000) });
|
|
||||||
if (!r.ok) throw new Error(`status HTTP ${r.status}`);
|
|
||||||
await client.query(
|
|
||||||
'UPDATE playout_channels SET last_heartbeat_at = NOW() WHERE id = $1', [ch.id]
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
const lastSeen = ch.last_heartbeat_at
|
|
||||||
? new Date(ch.last_heartbeat_at).getTime()
|
|
||||||
: new Date(ch.updated_at).getTime();
|
|
||||||
const ageMs = Date.now() - lastSeen;
|
|
||||||
if (ageMs < TIMEOUT_MS) continue;
|
|
||||||
|
|
||||||
if (ch.output_type === 'decklink') {
|
|
||||||
await client.query(
|
|
||||||
"UPDATE playout_channels SET status = 'error', error_message = $1 WHERE id = $2",
|
|
||||||
[`sidecar unreachable (${err.message}); decklink channels require manual recovery`, ch.id]
|
|
||||||
);
|
|
||||||
console.error(`[scheduler] decklink channel ${ch.id} unreachable — alert-only, no auto-failover`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(`[scheduler] failover: channel ${ch.id} unreachable (${err.message}), restart #${ch.restart_count + 1}`);
|
|
||||||
try {
|
|
||||||
const res = await restartChannel(ch.id);
|
|
||||||
if (res.restarted) {
|
|
||||||
console.log(`[scheduler] failover: channel ${ch.id} re-placed on node ${res.new_node_id}`);
|
|
||||||
} else {
|
|
||||||
console.error(`[scheduler] failover: channel ${ch.id} restart skipped — ${res.reason}`);
|
|
||||||
}
|
|
||||||
} catch (err2) {
|
|
||||||
console.error(`[scheduler] failover error for ${ch.id}: ${err2.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startSchedulerLoop() {
|
export function startSchedulerLoop() {
|
||||||
if (_interval) return;
|
if (_interval) return;
|
||||||
console.log(`[scheduler] tick loop started (interval=${TICK_INTERVAL_MS}ms)`);
|
console.log(`[scheduler] tick loop started (interval=${TICK_INTERVAL_MS}ms)`);
|
||||||
|
// Fire once on startup so a window that opened while the API was down
|
||||||
|
// doesn't have to wait a full interval.
|
||||||
setTimeout(() => tick().catch(() => {}), 2000);
|
setTimeout(() => tick().catch(() => {}), 2000);
|
||||||
_interval = setInterval(() => tick().catch(() => {}), TICK_INTERVAL_MS);
|
_interval = setInterval(() => tick().catch(() => {}), TICK_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
|
||||||
import {
|
|
||||||
isAdmin,
|
|
||||||
accessibleProjectIds,
|
|
||||||
projectLevel,
|
|
||||||
assertProjectAccess,
|
|
||||||
} from '../../src/auth/authz.js';
|
|
||||||
|
|
||||||
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
|
|
||||||
|
|
||||||
// ── isAdmin (pure, no DB) ───────────────────────────────────────────────────
|
|
||||||
test('isAdmin true only for role admin', () => {
|
|
||||||
assert.equal(isAdmin({ role: 'admin' }), true);
|
|
||||||
assert.equal(isAdmin({ role: 'editor' }), false);
|
|
||||||
assert.equal(isAdmin({ role: 'viewer' }), false);
|
|
||||||
assert.equal(isAdmin(null), false);
|
|
||||||
assert.equal(isAdmin(undefined), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Seed helpers shared across the DB-backed cases.
|
|
||||||
async function seed(pool) {
|
|
||||||
const proj = async (name) =>
|
|
||||||
(await pool.query(
|
|
||||||
`INSERT INTO projects (name, s3_prefix) VALUES ($1, $1) RETURNING id`, [name]
|
|
||||||
)).rows[0].id;
|
|
||||||
const user = async (username, role) =>
|
|
||||||
(await pool.query(
|
|
||||||
`INSERT INTO users (username, password_hash, role) VALUES ($1, 'x', $2) RETURNING id`,
|
|
||||||
[username, role]
|
|
||||||
)).rows[0].id;
|
|
||||||
const group = async (name) =>
|
|
||||||
(await pool.query(`INSERT INTO groups (name) VALUES ($1) RETURNING id`, [name])).rows[0].id;
|
|
||||||
const grantUser = (pid, uid, level) =>
|
|
||||||
pool.query(
|
|
||||||
`INSERT INTO project_access (project_id, subject_type, subject_id, level)
|
|
||||||
VALUES ($1, 'user', $2, $3)`, [pid, uid, level]);
|
|
||||||
const grantGroup = (pid, gid, level) =>
|
|
||||||
pool.query(
|
|
||||||
`INSERT INTO project_access (project_id, subject_type, subject_id, level)
|
|
||||||
VALUES ($1, 'group', $2, $3)`, [pid, gid, level]);
|
|
||||||
const addToGroup = (uid, gid) =>
|
|
||||||
pool.query(`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, [uid, gid]);
|
|
||||||
return { proj, user, group, grantUser, grantGroup, addToGroup };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('admin sees all projects, every project at edit', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
try {
|
|
||||||
const s = await seed(pool);
|
|
||||||
await s.proj('Alpha'); await s.proj('Beta');
|
|
||||||
const admin = { id: await s.user('adm', 'admin'), role: 'admin' };
|
|
||||||
|
|
||||||
const acc = await accessibleProjectIds(admin, pool);
|
|
||||||
assert.equal(acc.all, true);
|
|
||||||
assert.equal(await projectLevel(admin, '00000000-0000-4000-8000-000000000001', pool), 'edit');
|
|
||||||
await assertProjectAccess(admin, '00000000-0000-4000-8000-000000000001', 'edit', pool); // no throw
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('direct user grant scopes access and respects level', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
try {
|
|
||||||
const s = await seed(pool);
|
|
||||||
const alpha = await s.proj('Alpha');
|
|
||||||
const beta = await s.proj('Beta');
|
|
||||||
const u = { id: await s.user('bob', 'editor'), role: 'editor' };
|
|
||||||
await s.grantUser(alpha, u.id, 'view');
|
|
||||||
|
|
||||||
const acc = await accessibleProjectIds(u, pool);
|
|
||||||
assert.equal(acc.all, false);
|
|
||||||
assert.deepEqual([...acc.ids], [alpha]);
|
|
||||||
assert.equal(await projectLevel(u, alpha, pool), 'view');
|
|
||||||
assert.equal(await projectLevel(u, beta, pool), null);
|
|
||||||
|
|
||||||
await assertProjectAccess(u, alpha, 'view', pool); // ok
|
|
||||||
await assert.rejects(() => assertProjectAccess(u, alpha, 'edit', pool), e => e.status === 403);
|
|
||||||
await assert.rejects(() => assertProjectAccess(u, beta, 'view', pool), e => e.status === 403);
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('group grant flows through membership', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
try {
|
|
||||||
const s = await seed(pool);
|
|
||||||
const alpha = await s.proj('Alpha');
|
|
||||||
const u = { id: await s.user('carol', 'viewer'), role: 'viewer' };
|
|
||||||
const g = await s.group('broadcasters');
|
|
||||||
await s.addToGroup(u.id, g);
|
|
||||||
await s.grantGroup(alpha, g, 'edit');
|
|
||||||
|
|
||||||
assert.equal(await projectLevel(u, alpha, pool), 'edit');
|
|
||||||
const acc = await accessibleProjectIds(u, pool);
|
|
||||||
assert.deepEqual([...acc.ids], [alpha]);
|
|
||||||
await assertProjectAccess(u, alpha, 'edit', pool); // ok via group
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('effective level is the max of direct + group grants', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
try {
|
|
||||||
const s = await seed(pool);
|
|
||||||
const alpha = await s.proj('Alpha');
|
|
||||||
const u = { id: await s.user('dan', 'editor'), role: 'editor' };
|
|
||||||
const g = await s.group('team');
|
|
||||||
await s.addToGroup(u.id, g);
|
|
||||||
await s.grantUser(alpha, u.id, 'view'); // direct: view
|
|
||||||
await s.grantGroup(alpha, g, 'edit'); // group: edit → wins
|
|
||||||
|
|
||||||
assert.equal(await projectLevel(u, alpha, pool), 'edit');
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('user with no grants sees nothing', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
try {
|
|
||||||
const s = await seed(pool);
|
|
||||||
await s.proj('Alpha');
|
|
||||||
const u = { id: await s.user('eve', 'viewer'), role: 'viewer' };
|
|
||||||
|
|
||||||
const acc = await accessibleProjectIds(u, pool);
|
|
||||||
assert.equal(acc.ids.size, 0);
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
// Unit tests for the config-gating + domain helpers in google-oauth.js. The
|
|
||||||
// token-exchange / ID-token-verify path requires Google's servers and is covered
|
|
||||||
// by manual verification (see .env.example); here we lock down the pure logic
|
|
||||||
// that decides whether the feature is on and which domain is allowed.
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { isConfigured, allowedDomain } from '../../src/auth/google-oauth.js';
|
|
||||||
|
|
||||||
function withEnv(vars, fn) {
|
|
||||||
const saved = {};
|
|
||||||
for (const k of Object.keys(vars)) { saved[k] = process.env[k];
|
|
||||||
if (vars[k] === undefined) delete process.env[k]; else process.env[k] = vars[k]; }
|
|
||||||
try { return fn(); }
|
|
||||||
finally {
|
|
||||||
for (const k of Object.keys(vars)) {
|
|
||||||
if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test('isConfigured is false unless client id, secret, and redirect are all set', () => {
|
|
||||||
withEnv({ GOOGLE_CLIENT_ID: undefined, GOOGLE_CLIENT_SECRET: undefined, OAUTH_REDIRECT_URL: undefined }, () => {
|
|
||||||
assert.equal(isConfigured(), false);
|
|
||||||
});
|
|
||||||
withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: undefined, OAUTH_REDIRECT_URL: undefined }, () => {
|
|
||||||
assert.equal(isConfigured(), false);
|
|
||||||
});
|
|
||||||
withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: 'y', OAUTH_REDIRECT_URL: undefined }, () => {
|
|
||||||
assert.equal(isConfigured(), false);
|
|
||||||
});
|
|
||||||
withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: 'y', OAUTH_REDIRECT_URL: 'https://h/cb' }, () => {
|
|
||||||
assert.equal(isConfigured(), true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('allowedDomain normalizes and defaults to null', () => {
|
|
||||||
withEnv({ GOOGLE_ALLOWED_DOMAIN: undefined }, () => assert.equal(allowedDomain(), null));
|
|
||||||
withEnv({ GOOGLE_ALLOWED_DOMAIN: '' }, () => assert.equal(allowedDomain(), null));
|
|
||||||
withEnv({ GOOGLE_ALLOWED_DOMAIN: ' WildDragon.NET ' }, () => assert.equal(allowedDomain(), 'wilddragon.net'));
|
|
||||||
});
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
// MFA ticket binding tests — the second login step's tickets are bound to the
|
|
||||||
// issuing request's IP + User-Agent (hashed) so a stolen ticket replayed from
|
|
||||||
// a different origin can't complete the second factor.
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { issueTicket, redeemTicket } from '../../src/auth/mfa-tickets.js';
|
|
||||||
|
|
||||||
test('ticket round-trips when ip + userAgent match', () => {
|
|
||||||
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
|
|
||||||
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), 'user-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ticket rejects redemption from a different IP', () => {
|
|
||||||
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
|
|
||||||
assert.equal(redeemTicket(id, { ip: '9.9.9.9', userAgent: 'curl/8' }), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ticket rejects redemption with a different User-Agent', () => {
|
|
||||||
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
|
|
||||||
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'Mozilla/5.0' }), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ticket is single-use even on binding mismatch', () => {
|
|
||||||
// A wrong-binding probe must still burn the ticket — otherwise an attacker
|
|
||||||
// could try multiple IPs/UAs against the same ticket.
|
|
||||||
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
|
|
||||||
assert.equal(redeemTicket(id, { ip: '9.9.9.9', userAgent: 'curl/8' }), null);
|
|
||||||
// Same ticket with correct bindings now also fails — it was consumed.
|
|
||||||
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('redeemTicket returns null for missing or unknown id', () => {
|
|
||||||
assert.equal(redeemTicket(null), null);
|
|
||||||
assert.equal(redeemTicket(undefined), null);
|
|
||||||
assert.equal(redeemTicket(''), null);
|
|
||||||
assert.equal(redeemTicket('not-a-real-id', { ip: 'x', userAgent: 'y' }), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ticket is single-use on success', () => {
|
|
||||||
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
|
|
||||||
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), 'user-1');
|
|
||||||
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('issueTicket without bindings still works (back-compat / tests)', () => {
|
|
||||||
const id = issueTicket('user-1');
|
|
||||||
// No bindings on redeem either — both sides skip the check.
|
|
||||||
assert.equal(redeemTicket(id), 'user-1');
|
|
||||||
});
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import {
|
|
||||||
base32Encode, base32Decode, generateSecret, generateToken,
|
|
||||||
verifyToken, otpauthURI, generateRecoveryCodes,
|
|
||||||
} from '../../src/auth/totp.js';
|
|
||||||
|
|
||||||
// ── base32 round-trips ──────────────────────────────────────────────────────
|
|
||||||
test('base32 encode/decode round-trips arbitrary bytes', () => {
|
|
||||||
for (const s of ['', 'f', 'fo', 'foo', 'foob', 'fooba', 'foobar']) {
|
|
||||||
const buf = Buffer.from(s);
|
|
||||||
assert.deepEqual(base32Decode(base32Encode(buf)), buf);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── RFC 6238 Appendix B test vectors (SHA-1, 8 digits in the RFC; we use the
|
|
||||||
// low 6 here, so compare the last 6 digits of each published value). ──────────
|
|
||||||
// The RFC uses the ASCII secret "12345678901234567890". We base32-encode it and
|
|
||||||
// check the 6-digit code at each published timestamp.
|
|
||||||
test('matches RFC 6238 SHA-1 vectors (low 6 digits)', () => {
|
|
||||||
const secret = base32Encode(Buffer.from('12345678901234567890'));
|
|
||||||
// [unix seconds, full 8-digit code from the RFC] → expect last 6 digits.
|
|
||||||
const vectors = [
|
|
||||||
[59, '94287082'],
|
|
||||||
[1111111109, '07081804'],
|
|
||||||
[1111111111, '14050471'],
|
|
||||||
[1234567890, '89005924'],
|
|
||||||
[2000000000, '69279037'],
|
|
||||||
[20000000000, '65353130'],
|
|
||||||
];
|
|
||||||
for (const [secs, full8] of vectors) {
|
|
||||||
const got = generateToken(secret, secs * 1000);
|
|
||||||
assert.equal(got, full8.slice(-6), `t=${secs}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── verify with drift window ────────────────────────────────────────────────
|
|
||||||
// verifyToken returns the matched counter (truthy) or null (falsy).
|
|
||||||
test('verifyToken accepts the current code and ±1 step of drift', () => {
|
|
||||||
const secret = generateSecret();
|
|
||||||
const now = 1_700_000_000_000;
|
|
||||||
const code = generateToken(secret, now);
|
|
||||||
const baseCounter = Math.floor(now / 1000 / 30);
|
|
||||||
assert.equal(verifyToken(secret, code, now), baseCounter);
|
|
||||||
// 30s earlier / later still inside ±1 window — the *issued* code matches the
|
|
||||||
// baseCounter, but at now+30s we're in step baseCounter+1, so the issued
|
|
||||||
// code matches as drift = -1 step → returns baseCounter.
|
|
||||||
assert.equal(verifyToken(secret, code, now + 30_000), baseCounter);
|
|
||||||
assert.equal(verifyToken(secret, code, now - 30_000), baseCounter);
|
|
||||||
// 2 steps away → rejected.
|
|
||||||
assert.equal(verifyToken(secret, code, now + 90_000), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('verifyToken rejects malformed / empty input without throwing', () => {
|
|
||||||
const secret = generateSecret();
|
|
||||||
assert.equal(verifyToken(secret, ''), null);
|
|
||||||
assert.equal(verifyToken(secret, 'abcdef'), null);
|
|
||||||
assert.equal(verifyToken(secret, '12345'), null); // too short
|
|
||||||
assert.equal(verifyToken(secret, '1234567'), null); // too long
|
|
||||||
assert.equal(verifyToken('', '123456'), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('verifyToken tolerates spaces in the user-entered code', () => {
|
|
||||||
const secret = generateSecret();
|
|
||||||
const now = 1_700_000_000_000;
|
|
||||||
const code = generateToken(secret, now);
|
|
||||||
const expected = Math.floor(now / 1000 / 30);
|
|
||||||
assert.equal(verifyToken(secret, code.slice(0, 3) + ' ' + code.slice(3), now), expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('verifyToken returns the matched counter (for replay protection)', () => {
|
|
||||||
const secret = generateSecret();
|
|
||||||
const now = 1_700_000_000_000;
|
|
||||||
const code = generateToken(secret, now);
|
|
||||||
const counter = verifyToken(secret, code, now);
|
|
||||||
assert.ok(typeof counter === 'number' && counter > 0);
|
|
||||||
assert.equal(counter, Math.floor(now / 1000 / 30));
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── otpauth URI ─────────────────────────────────────────────────────────────
|
|
||||||
test('otpauthURI embeds secret, issuer, and account', () => {
|
|
||||||
const uri = otpauthURI('JBSWY3DPEHPK3PXP', 'alice', 'Dragonflight');
|
|
||||||
assert.match(uri, /^otpauth:\/\/totp\/Dragonflight%3Aalice\?/);
|
|
||||||
assert.match(uri, /secret=JBSWY3DPEHPK3PXP/);
|
|
||||||
assert.match(uri, /issuer=Dragonflight/);
|
|
||||||
assert.match(uri, /digits=6/);
|
|
||||||
assert.match(uri, /period=30/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── recovery codes ──────────────────────────────────────────────────────────
|
|
||||||
test('generateRecoveryCodes returns N distinct formatted codes', () => {
|
|
||||||
const codes = generateRecoveryCodes(10);
|
|
||||||
assert.equal(codes.length, 10);
|
|
||||||
assert.equal(new Set(codes).size, 10);
|
|
||||||
for (const c of codes) assert.match(c, /^[0-9a-f]{5}-[0-9a-f]{5}$/);
|
|
||||||
});
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
// Regression test: GET /api/v1/assets must be scoped to the caller's accessible
|
|
||||||
// projects. A pre-fix bug returned every asset across every project to any
|
|
||||||
// authenticated user, defeating RBAC v2.
|
|
||||||
//
|
|
||||||
// Like project-access.test.js, the assets router uses the singleton pool, so we
|
|
||||||
// point DATABASE_URL at TEST_DATABASE_URL and seed through the same pool.
|
|
||||||
// Skips when TEST_DATABASE_URL is unset.
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import express from 'express';
|
|
||||||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
|
||||||
|
|
||||||
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
|
|
||||||
|
|
||||||
async function appWithAssets(user) {
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
process.env.AUTH_ENABLED = 'true';
|
|
||||||
// Importing the assets router constructs BullMQ queues; they connect lazily,
|
|
||||||
// and the list route only touches Postgres, so no Redis is needed here.
|
|
||||||
const { default: assetsRouter } = await import('../../src/routes/assets.js?v=' + Date.now());
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use((req, _res, next) => { req.user = user; next(); });
|
|
||||||
app.use('/api/v1/assets', assetsRouter);
|
|
||||||
return new Promise(r => {
|
|
||||||
const srv = app.listen(0, '127.0.0.1', () =>
|
|
||||||
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seed(pool) {
|
|
||||||
const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id;
|
|
||||||
const alpha = await proj('Alpha');
|
|
||||||
const beta = await proj('Beta');
|
|
||||||
const asset = (pid, name) => pool.query(
|
|
||||||
`INSERT INTO assets (project_id, filename, display_name, media_type, status)
|
|
||||||
VALUES ($1, $2, $2, 'video', 'ready')`, [pid, name]);
|
|
||||||
await asset(alpha, 'a1'); await asset(alpha, 'a2'); await asset(beta, 'b1');
|
|
||||||
const admin = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('adm','x','admin') RETURNING id`)).rows[0].id, role: 'admin' };
|
|
||||||
const scoped = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
|
|
||||||
await pool.query(
|
|
||||||
`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`,
|
|
||||||
[alpha, scoped.id]);
|
|
||||||
return { alpha, beta, admin, scoped };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('GET /assets returns only assets in granted projects for scoped users', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
const { admin, scoped } = await seed(pool);
|
|
||||||
|
|
||||||
// Admin sees all three.
|
|
||||||
let a = await appWithAssets(admin);
|
|
||||||
let body = await (await fetch(a.baseUrl + '/api/v1/assets')).json();
|
|
||||||
assert.equal(body.assets.length, 3, 'admin should see every asset');
|
|
||||||
await a.close();
|
|
||||||
|
|
||||||
// Scoped viewer (granted only Alpha) sees the two Alpha assets, not Beta's.
|
|
||||||
let s = await appWithAssets(scoped);
|
|
||||||
body = await (await fetch(s.baseUrl + '/api/v1/assets')).json();
|
|
||||||
assert.equal(body.assets.length, 2, 'scoped user should see only granted-project assets');
|
|
||||||
assert.ok(body.assets.every(x => x.display_name !== 'b1'), 'must not leak ungranted-project asset');
|
|
||||||
await s.close();
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /assets returns nothing for a user with no grants', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
await seed(pool);
|
|
||||||
const nobody = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('no','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
|
|
||||||
const s = await appWithAssets(nobody);
|
|
||||||
const body = await (await fetch(s.baseUrl + '/api/v1/assets')).json();
|
|
||||||
assert.deepEqual(body, { assets: [], total: 0 });
|
|
||||||
await s.close();
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
// RBAC coverage for asset comments: the guard resolves the project via the
|
|
||||||
// asset, requiring view to read and edit to write. Also verifies the author id
|
|
||||||
// is recorded from req.user (regression for the old req.session.userId bug).
|
|
||||||
// Skips without TEST_DATABASE_URL.
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import express from 'express';
|
|
||||||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
|
||||||
|
|
||||||
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
|
|
||||||
|
|
||||||
async function appWithComments(user) {
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
process.env.AUTH_ENABLED = 'true';
|
|
||||||
const { default: commentsRouter } = await import('../../src/routes/comments.js?v=' + Date.now());
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use((req, _res, next) => { req.user = user; next(); });
|
|
||||||
// Mirror index.js mount so :assetId flows through (mergeParams in the router).
|
|
||||||
app.use('/api/v1/assets/:assetId/comments', commentsRouter);
|
|
||||||
return new Promise(r => {
|
|
||||||
const srv = app.listen(0, '127.0.0.1', () =>
|
|
||||||
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seed(pool) {
|
|
||||||
const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id;
|
|
||||||
const alpha = await proj('Alpha');
|
|
||||||
const beta = await proj('Beta');
|
|
||||||
const asset = async (pid, name) => (await pool.query(
|
|
||||||
`INSERT INTO assets (project_id, filename, display_name, media_type, status) VALUES ($1,$2,$2,'video','ready') RETURNING id`, [pid, name])).rows[0].id;
|
|
||||||
const assetA = await asset(alpha, 'a1');
|
|
||||||
const assetB = await asset(beta, 'b1');
|
|
||||||
const viewer = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
|
|
||||||
const editor = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('ed','x','editor') RETURNING id`)).rows[0].id, role: 'editor' };
|
|
||||||
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`, [alpha, viewer.id]);
|
|
||||||
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'edit')`, [alpha, editor.id]);
|
|
||||||
return { assetA, assetB, viewer, editor };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('comments require view to read and block ungranted assets', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
const { assetA, assetB, viewer } = await seed(pool);
|
|
||||||
const s = await appWithComments(viewer);
|
|
||||||
assert.equal((await fetch(s.baseUrl + '/api/v1/assets/' + assetA + '/comments')).status, 200);
|
|
||||||
assert.equal((await fetch(s.baseUrl + '/api/v1/assets/' + assetB + '/comments')).status, 403);
|
|
||||||
await s.close();
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('posting a comment requires edit and records the author id from req.user', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
const { assetA, viewer, editor } = await seed(pool);
|
|
||||||
|
|
||||||
// viewer (view-only) cannot post.
|
|
||||||
let s = await appWithComments(viewer);
|
|
||||||
let r = await fetch(s.baseUrl + '/api/v1/assets/' + assetA + '/comments', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: 'hi' }) });
|
|
||||||
assert.equal(r.status, 403);
|
|
||||||
await s.close();
|
|
||||||
|
|
||||||
// editor can post, and the author id is the editor (not null).
|
|
||||||
let e = await appWithComments(editor);
|
|
||||||
r = await fetch(e.baseUrl + '/api/v1/assets/' + assetA + '/comments', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: 'looks good' }) });
|
|
||||||
assert.equal(r.status, 201);
|
|
||||||
const created = await r.json();
|
|
||||||
assert.equal(created.user_id, editor.id, 'comment author must be req.user.id');
|
|
||||||
await e.close();
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
// Security regression test for resolveGoogleUser: a Google sign-in must NEVER
|
|
||||||
// adopt a pre-existing local account by matching email (that would be account
|
|
||||||
// takeover). It links only by google_sub, otherwise provisions a fresh viewer.
|
|
||||||
// Skips without TEST_DATABASE_URL.
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
|
||||||
import { hashPassword } from '../../src/auth/passwords.js';
|
|
||||||
|
|
||||||
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
|
|
||||||
|
|
||||||
async function loadResolve() {
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
return (await import('../../src/routes/auth.js?v=' + Date.now())).resolveGoogleUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('a Google login with an email matching a local admin does NOT take over that account', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
// Pre-existing local admin with a password and the same email the attacker controls.
|
|
||||||
const adminId = (await pool.query(
|
|
||||||
`INSERT INTO users (username, password_hash, role, email)
|
|
||||||
VALUES ('boss', $1, 'admin', 'boss@wilddragon.net') RETURNING id`,
|
|
||||||
[await hashPassword('a-real-password-12')])).rows[0].id;
|
|
||||||
|
|
||||||
const resolveGoogleUser = await loadResolve();
|
|
||||||
const user = await resolveGoogleUser({ sub: 'google-attacker-sub', email: 'boss@wilddragon.net', name: 'Not The Boss' });
|
|
||||||
|
|
||||||
// Must be a brand-new account, NOT the admin.
|
|
||||||
assert.notEqual(user.id, adminId, 'Google login must not resolve to the existing admin');
|
|
||||||
const row = (await pool.query(`SELECT role, google_sub FROM users WHERE id = $1`, [user.id])).rows[0];
|
|
||||||
assert.equal(row.role, 'viewer', 'auto-provisioned account must be a viewer');
|
|
||||||
assert.equal(row.google_sub, 'google-attacker-sub');
|
|
||||||
// The admin row is untouched (no google_sub linked onto it).
|
|
||||||
const admin = (await pool.query(`SELECT google_sub FROM users WHERE id = $1`, [adminId])).rows[0];
|
|
||||||
assert.equal(admin.google_sub, null, 'the existing admin must not have been linked');
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a returning Google user resolves to the same account by google_sub', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
const resolveGoogleUser = await loadResolve();
|
|
||||||
const first = await resolveGoogleUser({ sub: 'sub-123', email: 'alice@wilddragon.net', name: 'Alice' });
|
|
||||||
const second = await resolveGoogleUser({ sub: 'sub-123', email: 'alice@wilddragon.net', name: 'Alice' });
|
|
||||||
assert.equal(first.id, second.id, 'same google_sub must map to the same user');
|
|
||||||
const count = (await pool.query(`SELECT COUNT(*)::int AS n FROM users WHERE google_sub = 'sub-123'`)).rows[0].n;
|
|
||||||
assert.equal(count, 1, 'must not create a duplicate on second login');
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('username collisions get a numeric suffix', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('sam', 'x', 'viewer')`);
|
|
||||||
const resolveGoogleUser = await loadResolve();
|
|
||||||
const u = await resolveGoogleUser({ sub: 'sub-sam', email: 'sam@wilddragon.net', name: 'Sam' });
|
|
||||||
assert.match(u.username, /^sam\d+$/, 'colliding username should get a numeric suffix');
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
// Integration test for per-project RBAC: the grant-management API on the
|
|
||||||
// projects router + scoped enforcement on GET /projects and GET /projects/:id.
|
|
||||||
//
|
|
||||||
// NOTE: the routers use the singleton pool (src/db/pool.js), which reads
|
|
||||||
// DATABASE_URL. We point DATABASE_URL at the throwaway TEST_DATABASE_URL and
|
|
||||||
// seed through that same singleton pool so the router and the test share one
|
|
||||||
// database. Skips cleanly when TEST_DATABASE_URL is unset.
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import express from 'express';
|
|
||||||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
|
||||||
|
|
||||||
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
|
|
||||||
|
|
||||||
// Build an app that injects a chosen user as req.user (simulating requireAuth),
|
|
||||||
// then mounts the real projects router with the same admin gate index.js uses.
|
|
||||||
async function appWithProjects(user) {
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
process.env.AUTH_ENABLED = 'true';
|
|
||||||
const { default: projectsRouter } = await import('../../src/routes/projects.js?v=' + Date.now());
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use((req, _res, next) => { req.user = user; next(); });
|
|
||||||
app.use('/api/v1/projects', projectsRouter);
|
|
||||||
return new Promise(r => {
|
|
||||||
const srv = app.listen(0, '127.0.0.1', () =>
|
|
||||||
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedBaseline(pool) {
|
|
||||||
const alpha = (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ('Alpha','alpha') RETURNING id`)).rows[0].id;
|
|
||||||
const beta = (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ('Beta','beta') RETURNING id`)).rows[0].id;
|
|
||||||
const admin = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('adm','x','admin') RETURNING id`)).rows[0].id, role: 'admin' };
|
|
||||||
const scoped = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
|
|
||||||
return { alpha, beta, admin, scoped };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('admin grants a project, scoped user then sees only it; revoke removes access', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
const { alpha, beta, admin, scoped } = await seedBaseline(pool);
|
|
||||||
|
|
||||||
// Scoped viewer initially sees nothing.
|
|
||||||
let s = await appWithProjects(scoped);
|
|
||||||
let list = await (await fetch(s.baseUrl + '/api/v1/projects')).json();
|
|
||||||
assert.equal(list.length, 0, 'scoped user should see no projects before any grant');
|
|
||||||
// And cannot read Alpha directly.
|
|
||||||
let direct = await fetch(s.baseUrl + '/api/v1/projects/' + alpha);
|
|
||||||
assert.equal(direct.status, 403);
|
|
||||||
await s.close();
|
|
||||||
|
|
||||||
// Admin grants the scoped user 'view' on Alpha.
|
|
||||||
const a = await appWithProjects(admin);
|
|
||||||
const grant = await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ subject_type: 'user', subject_id: scoped.id, level: 'view' }),
|
|
||||||
});
|
|
||||||
assert.equal(grant.status, 201);
|
|
||||||
const grantList = await (await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access')).json();
|
|
||||||
assert.equal(grantList.length, 1);
|
|
||||||
assert.equal(grantList[0].subject_id, scoped.id);
|
|
||||||
await a.close();
|
|
||||||
|
|
||||||
// Scoped viewer now sees exactly Alpha (not Beta), can GET it, cannot PATCH (view-only).
|
|
||||||
s = await appWithProjects(scoped);
|
|
||||||
list = await (await fetch(s.baseUrl + '/api/v1/projects')).json();
|
|
||||||
assert.deepEqual(list.map(p => p.id), [alpha]);
|
|
||||||
assert.equal((await fetch(s.baseUrl + '/api/v1/projects/' + alpha)).status, 200);
|
|
||||||
assert.equal((await fetch(s.baseUrl + '/api/v1/projects/' + beta)).status, 403);
|
|
||||||
const patch = await fetch(s.baseUrl + '/api/v1/projects/' + alpha, {
|
|
||||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ description: 'hacked' }),
|
|
||||||
});
|
|
||||||
assert.equal(patch.status, 403, 'view-level grant must not allow edit');
|
|
||||||
await s.close();
|
|
||||||
|
|
||||||
// Admin revokes; scoped viewer goes back to seeing nothing.
|
|
||||||
const a2 = await appWithProjects(admin);
|
|
||||||
const del = await fetch(a2.baseUrl + '/api/v1/projects/' + alpha + '/access/user/' + scoped.id, { method: 'DELETE' });
|
|
||||||
assert.equal(del.status, 204);
|
|
||||||
await a2.close();
|
|
||||||
|
|
||||||
s = await appWithProjects(scoped);
|
|
||||||
list = await (await fetch(s.baseUrl + '/api/v1/projects')).json();
|
|
||||||
assert.equal(list.length, 0);
|
|
||||||
await s.close();
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-admin cannot reach the grant-management API', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
const { alpha, scoped } = await seedBaseline(pool);
|
|
||||||
const s = await appWithProjects(scoped);
|
|
||||||
// requireAdmin sits on the access sub-routes; a viewer is 403.
|
|
||||||
const r = await fetch(s.baseUrl + '/api/v1/projects/' + alpha + '/access');
|
|
||||||
assert.equal(r.status, 403);
|
|
||||||
await s.close();
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('edit-level grant allows PATCH', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
const { alpha, admin, scoped } = await seedBaseline(pool);
|
|
||||||
const a = await appWithProjects(admin);
|
|
||||||
await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ subject_type: 'user', subject_id: scoped.id, level: 'edit' }),
|
|
||||||
});
|
|
||||||
await a.close();
|
|
||||||
|
|
||||||
const s = await appWithProjects(scoped);
|
|
||||||
const patch = await fetch(s.baseUrl + '/api/v1/projects/' + alpha, {
|
|
||||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ description: 'updated by editor' }),
|
|
||||||
});
|
|
||||||
assert.equal(patch.status, 200);
|
|
||||||
await s.close();
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
// RBAC coverage for recorders: list is scoped to accessible projects, /:id
|
|
||||||
// asserts view, mutators assert edit, null-project recorders are admin-only.
|
|
||||||
// Same harness as assets-access.test.js — singleton pool on TEST_DATABASE_URL,
|
|
||||||
// req.user injected, router dynamic-imported. Skips without TEST_DATABASE_URL.
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import express from 'express';
|
|
||||||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
|
||||||
|
|
||||||
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
|
|
||||||
|
|
||||||
async function appWithRecorders(user) {
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
process.env.AUTH_ENABLED = 'true';
|
|
||||||
const { default: recordersRouter } = await import('../../src/routes/recorders.js?v=' + Date.now());
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use((req, _res, next) => { req.user = user; next(); });
|
|
||||||
app.use('/api/v1/recorders', recordersRouter);
|
|
||||||
return new Promise(r => {
|
|
||||||
const srv = app.listen(0, '127.0.0.1', () =>
|
|
||||||
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seed(pool) {
|
|
||||||
const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id;
|
|
||||||
const alpha = await proj('Alpha');
|
|
||||||
const beta = await proj('Beta');
|
|
||||||
const rec = async (pid, name) => (await pool.query(
|
|
||||||
`INSERT INTO recorders (name, source_type, project_id) VALUES ($1, 'srt', $2) RETURNING id`, [name, pid])).rows[0].id;
|
|
||||||
const recA = await rec(alpha, 'Cam A');
|
|
||||||
const recB = await rec(beta, 'Cam B');
|
|
||||||
const recNull = (await pool.query(
|
|
||||||
`INSERT INTO recorders (name, source_type, project_id) VALUES ('Unassigned','srt',NULL) RETURNING id`)).rows[0].id;
|
|
||||||
const admin = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('adm','x','admin') RETURNING id`)).rows[0].id, role: 'admin' };
|
|
||||||
const scoped = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('ed','x','editor') RETURNING id`)).rows[0].id, role: 'editor' };
|
|
||||||
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`, [alpha, scoped.id]);
|
|
||||||
return { alpha, beta, recA, recB, recNull, admin, scoped };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('recorders list is scoped; admin sees all incl. null-project, scoped sees only granted', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
const { recA, admin, scoped } = await seed(pool);
|
|
||||||
|
|
||||||
let a = await appWithRecorders(admin);
|
|
||||||
let list = await (await fetch(a.baseUrl + '/api/v1/recorders')).json();
|
|
||||||
assert.equal(list.length, 3, 'admin sees all three recorders');
|
|
||||||
await a.close();
|
|
||||||
|
|
||||||
let s = await appWithRecorders(scoped);
|
|
||||||
list = await (await fetch(s.baseUrl + '/api/v1/recorders')).json();
|
|
||||||
assert.deepEqual(list.map(r => r.id), [recA], 'scoped editor sees only the granted-project recorder');
|
|
||||||
await s.close();
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('recorder /:id enforces view; mutators enforce edit', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
const { recA, recB, recNull, scoped } = await seed(pool);
|
|
||||||
const s = await appWithRecorders(scoped);
|
|
||||||
|
|
||||||
// view-granted on Alpha: can GET recA, cannot GET recB (other project) or recNull (admin-only).
|
|
||||||
assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recA)).status, 200);
|
|
||||||
assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recB)).status, 403);
|
|
||||||
assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recNull)).status, 403);
|
|
||||||
|
|
||||||
// view-only grant cannot PATCH (edit) or start.
|
|
||||||
const patch = await fetch(s.baseUrl + '/api/v1/recorders/' + recA, {
|
|
||||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'x' }) });
|
|
||||||
assert.equal(patch.status, 403, 'view-level grant must not allow edit');
|
|
||||||
await s.close();
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('creating a recorder requires edit; null-project create is admin-only', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
const { alpha, admin, scoped } = await seed(pool);
|
|
||||||
|
|
||||||
// scoped editor has only 'view' on Alpha → create denied.
|
|
||||||
let s = await appWithRecorders(scoped);
|
|
||||||
let r = await fetch(s.baseUrl + '/api/v1/recorders', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: 'New', source_type: 'srt', project_id: alpha }) });
|
|
||||||
assert.equal(r.status, 403);
|
|
||||||
// null project → admin-only, still denied for the editor.
|
|
||||||
r = await fetch(s.baseUrl + '/api/v1/recorders', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: 'New2', source_type: 'srt' }) });
|
|
||||||
assert.equal(r.status, 403);
|
|
||||||
await s.close();
|
|
||||||
|
|
||||||
// admin can create in any project and with no project.
|
|
||||||
let a = await appWithRecorders(admin);
|
|
||||||
r = await fetch(a.baseUrl + '/api/v1/recorders', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: 'AdminRec', source_type: 'srt' }) });
|
|
||||||
assert.equal(r.status, 201);
|
|
||||||
await a.close();
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
// RBAC coverage for sequences: list/create assert on the query/body project,
|
|
||||||
// /:id asserts view, mutators assert edit. Skips without TEST_DATABASE_URL.
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import express from 'express';
|
|
||||||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
|
||||||
|
|
||||||
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
|
|
||||||
|
|
||||||
async function appWithSequences(user) {
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
process.env.AUTH_ENABLED = 'true';
|
|
||||||
const { default: sequencesRouter } = await import('../../src/routes/sequences.js?v=' + Date.now());
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use((req, _res, next) => { req.user = user; next(); });
|
|
||||||
app.use('/api/v1/sequences', sequencesRouter);
|
|
||||||
return new Promise(r => {
|
|
||||||
const srv = app.listen(0, '127.0.0.1', () =>
|
|
||||||
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seed(pool) {
|
|
||||||
const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id;
|
|
||||||
const alpha = await proj('Alpha');
|
|
||||||
const beta = await proj('Beta');
|
|
||||||
const seqB = (await pool.query(`INSERT INTO sequences (project_id, name) VALUES ($1,'B seq') RETURNING id`, [beta])).rows[0].id;
|
|
||||||
const viewer = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
|
|
||||||
const editor = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('ed','x','editor') RETURNING id`)).rows[0].id, role: 'editor' };
|
|
||||||
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`, [alpha, viewer.id]);
|
|
||||||
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'edit')`, [alpha, editor.id]);
|
|
||||||
return { alpha, beta, seqB, viewer, editor };
|
|
||||||
}
|
|
||||||
|
|
||||||
const asset = (pool, pid, name) => pool.query(
|
|
||||||
`INSERT INTO assets (project_id, filename, display_name, media_type, status) VALUES ($1,$2,$2,'video','ready') RETURNING id`,
|
|
||||||
[pid, name]).then(r => r.rows[0].id);
|
|
||||||
|
|
||||||
test('GET /sequences?project_id 403s on an ungranted project', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
const { alpha, beta, viewer } = await seed(pool);
|
|
||||||
const s = await appWithSequences(viewer);
|
|
||||||
assert.equal((await fetch(s.baseUrl + '/api/v1/sequences?project_id=' + alpha)).status, 200);
|
|
||||||
assert.equal((await fetch(s.baseUrl + '/api/v1/sequences?project_id=' + beta)).status, 403);
|
|
||||||
await s.close();
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /sequences requires edit; viewer denied, editor allowed; /:id view enforced', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
const { alpha, seqB, viewer, editor } = await seed(pool);
|
|
||||||
|
|
||||||
// viewer (view-only on Alpha) cannot create.
|
|
||||||
let s = await appWithSequences(viewer);
|
|
||||||
let r = await fetch(s.baseUrl + '/api/v1/sequences', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: alpha, name: 'X' }) });
|
|
||||||
assert.equal(r.status, 403);
|
|
||||||
// viewer cannot read a sequence in an ungranted project (Beta).
|
|
||||||
assert.equal((await fetch(s.baseUrl + '/api/v1/sequences/' + seqB)).status, 403);
|
|
||||||
await s.close();
|
|
||||||
|
|
||||||
// editor can create in Alpha and then PUT it.
|
|
||||||
let e = await appWithSequences(editor);
|
|
||||||
r = await fetch(e.baseUrl + '/api/v1/sequences', {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: alpha, name: 'Mine' }) });
|
|
||||||
assert.equal(r.status, 201);
|
|
||||||
const seqId = (await r.json()).id;
|
|
||||||
const put = await fetch(e.baseUrl + '/api/v1/sequences/' + seqId, {
|
|
||||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Renamed' }) });
|
|
||||||
assert.equal(put.status, 200);
|
|
||||||
await e.close();
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('PUT /:id/clips rejects assets from outside the sequence project (cross-project leak guard)', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
const { alpha, beta, editor } = await seed(pool);
|
|
||||||
const seqA = (await pool.query(`INSERT INTO sequences (project_id, name) VALUES ($1,'A seq') RETURNING id`, [alpha])).rows[0].id;
|
|
||||||
const assetA = await asset(pool, alpha, 'a-clip');
|
|
||||||
const assetB = await asset(pool, beta, 'b-clip'); // editor has NO access to project Beta
|
|
||||||
|
|
||||||
const e = await appWithSequences(editor);
|
|
||||||
const clip = (aid) => ({ asset_id: aid, track: 0, timeline_in_frames: 0, timeline_out_frames: 10, source_in_frames: 0, source_out_frames: 10 });
|
|
||||||
|
|
||||||
// Same-project asset is accepted.
|
|
||||||
let r = await fetch(e.baseUrl + '/api/v1/sequences/' + seqA + '/clips', {
|
|
||||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([clip(assetA)]) });
|
|
||||||
assert.equal(r.status, 200);
|
|
||||||
|
|
||||||
// Splicing in a Beta asset must be rejected — it would leak B's media via GET /:id.
|
|
||||||
r = await fetch(e.baseUrl + '/api/v1/sequences/' + seqA + '/clips', {
|
|
||||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([clip(assetA), clip(assetB)]) });
|
|
||||||
assert.equal(r.status, 400, 'foreign-project asset must be rejected');
|
|
||||||
await e.close();
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
// Integration test for the TOTP two-step login + recovery codes.
|
|
||||||
//
|
|
||||||
// Mounts the real auth router with a session store on the throwaway test DB.
|
|
||||||
// Drives: enroll (setup → enable) → logout → password login returns mfa_required
|
|
||||||
// → complete with a generated code → and the recovery-code single-use path.
|
|
||||||
// Skips when TEST_DATABASE_URL is unset.
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import express from 'express';
|
|
||||||
import session from 'express-session';
|
|
||||||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
|
||||||
import { hashPassword } from '../../src/auth/passwords.js';
|
|
||||||
import { generateToken } from '../../src/auth/totp.js';
|
|
||||||
|
|
||||||
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
|
|
||||||
|
|
||||||
async function appWithAuth(pool) {
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
process.env.AUTH_ENABLED = 'true';
|
|
||||||
process.env.SESSION_SECRET = 'test';
|
|
||||||
const ConnectPg = (await import('connect-pg-simple')).default(session);
|
|
||||||
const { default: authRouter } = await import('../../src/routes/auth.js?v=' + Date.now());
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(session({
|
|
||||||
store: new ConnectPg({ pool, tableName: 'sessions' }),
|
|
||||||
secret: 'test', name: 'dragonflight.sid',
|
|
||||||
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
|
|
||||||
rolling: false, resave: false, saveUninitialized: false,
|
|
||||||
}));
|
|
||||||
app.use('/api/v1/auth', authRouter);
|
|
||||||
return new Promise(r => {
|
|
||||||
const srv = app.listen(0, '127.0.0.1', () =>
|
|
||||||
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const J = (cookie, body) => ({
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...(cookie ? { cookie } : {}) },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loginPassword(baseUrl, username, password) {
|
|
||||||
const r = await fetch(baseUrl + '/api/v1/auth/login', J(null, { username, password }));
|
|
||||||
const cookie = (r.headers.get('set-cookie') || '').split(';')[0];
|
|
||||||
return { r, body: await r.json().catch(() => ({})), cookie };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('enable TOTP, then password login requires a second factor', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]);
|
|
||||||
const { baseUrl, close } = await appWithAuth(pool);
|
|
||||||
|
|
||||||
// 1. Password login (no TOTP yet) → 200 with a session cookie.
|
|
||||||
const first = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
|
|
||||||
assert.equal(first.r.status, 200);
|
|
||||||
assert.ok(!first.body.mfa_required);
|
|
||||||
const cookie = first.cookie;
|
|
||||||
|
|
||||||
// 2. Enroll: setup returns a secret; enable confirms with a live code.
|
|
||||||
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(cookie, {}))).json();
|
|
||||||
assert.match(setup.secret, /^[A-Z2-7]+$/);
|
|
||||||
const enableRes = await fetch(baseUrl + '/api/v1/auth/totp/enable', J(cookie, { code: generateToken(setup.secret) }));
|
|
||||||
assert.equal(enableRes.status, 200);
|
|
||||||
const enableBody = await enableRes.json();
|
|
||||||
assert.equal(enableBody.enabled, true);
|
|
||||||
assert.equal(enableBody.recovery_codes.length, 10);
|
|
||||||
|
|
||||||
// 3. Fresh password login now returns mfa_required + a ticket, NO session cookie.
|
|
||||||
const second = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
|
|
||||||
assert.equal(second.r.status, 200);
|
|
||||||
assert.equal(second.body.mfa_required, true);
|
|
||||||
assert.ok(second.body.ticket);
|
|
||||||
assert.ok(!second.cookie, 'no session cookie should be set before the second factor');
|
|
||||||
|
|
||||||
// 4. Wrong code → 401; the ticket is now spent.
|
|
||||||
const bad = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: second.body.ticket, code: '000000' }));
|
|
||||||
assert.equal(bad.status, 401);
|
|
||||||
|
|
||||||
// 5. New login + correct code → 200 with a session cookie.
|
|
||||||
const third = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
|
|
||||||
const ok = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: third.body.ticket, code: generateToken(setup.secret) }));
|
|
||||||
assert.equal(ok.status, 200);
|
|
||||||
assert.match(ok.headers.get('set-cookie') || '', /dragonflight\.sid=/);
|
|
||||||
|
|
||||||
await close();
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a recovery code logs in once and cannot be reused', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('bob', $1)`, [await hashPassword('correct-horse-battery')]);
|
|
||||||
const { baseUrl, close } = await appWithAuth(pool);
|
|
||||||
|
|
||||||
const first = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
|
|
||||||
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json();
|
|
||||||
const enableBody = await (await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) }))).json();
|
|
||||||
const recovery = enableBody.recovery_codes[0];
|
|
||||||
|
|
||||||
// Use a recovery code to complete a fresh login.
|
|
||||||
const login1 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
|
|
||||||
const use1 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login1.body.ticket, code: recovery }));
|
|
||||||
assert.equal(use1.status, 200, 'recovery code should complete login once');
|
|
||||||
|
|
||||||
// The same recovery code must NOT work a second time.
|
|
||||||
const login2 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
|
|
||||||
const use2 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login2.body.ticket, code: recovery }));
|
|
||||||
assert.equal(use2.status, 401, 'a spent recovery code must be rejected');
|
|
||||||
|
|
||||||
await close();
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('disable TOTP returns login to single-factor', { skip: SKIP }, async () => {
|
|
||||||
const pool = await setupTestDb();
|
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
||||||
try {
|
|
||||||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('carol', $1)`, [await hashPassword('correct-horse-battery')]);
|
|
||||||
const { baseUrl, close } = await appWithAuth(pool);
|
|
||||||
|
|
||||||
const first = await loginPassword(baseUrl, 'carol', 'correct-horse-battery');
|
|
||||||
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json();
|
|
||||||
await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) }));
|
|
||||||
|
|
||||||
// Disabling requires the password.
|
|
||||||
const wrongPw = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'nope' }));
|
|
||||||
assert.equal(wrongPw.status, 400);
|
|
||||||
const disabled = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'correct-horse-battery' }));
|
|
||||||
assert.equal(disabled.status, 204);
|
|
||||||
|
|
||||||
// Password login is single-factor again.
|
|
||||||
const relog = await loginPassword(baseUrl, 'carol', 'correct-horse-battery');
|
|
||||||
assert.equal(relog.r.status, 200);
|
|
||||||
assert.ok(!relog.body.mfa_required);
|
|
||||||
|
|
||||||
await close();
|
|
||||||
} finally { await pool.end(); }
|
|
||||||
});
|
|
||||||
|
|
@ -8,7 +8,7 @@ const NODE_ROLE = process.env.NODE_ROLE || 'worker';
|
||||||
const AGENT_PORT = parseInt(process.env.AGENT_PORT || '7436', 10);
|
const AGENT_PORT = parseInt(process.env.AGENT_PORT || '7436', 10);
|
||||||
const HEARTBEAT_MS = parseInt(process.env.HEARTBEAT_MS || '30000', 10);
|
const HEARTBEAT_MS = parseInt(process.env.HEARTBEAT_MS || '30000', 10);
|
||||||
const LIVE_DIR = process.env.LIVE_DIR || '/mnt/NVME/MAM/wild-dragon-live';
|
const LIVE_DIR = process.env.LIVE_DIR || '/mnt/NVME/MAM/wild-dragon-live';
|
||||||
const VERSION = '1.3.0';
|
const VERSION = '1.2.0';
|
||||||
|
|
||||||
// Pick the host's LAN IP. Inside a bridge-mode container,
|
// Pick the host's LAN IP. Inside a bridge-mode container,
|
||||||
// os.networkInterfaces() returns the container's docker-bridge IP (172.x),
|
// os.networkInterfaces() returns the container's docker-bridge IP (172.x),
|
||||||
|
|
@ -87,54 +87,19 @@ async function handleSidecarStart(body, res) {
|
||||||
env = [],
|
env = [],
|
||||||
capturePort = 3001,
|
capturePort = 3001,
|
||||||
sourceType = 'sdi',
|
sourceType = 'sdi',
|
||||||
// useGpu: true → attach NVIDIA runtime + NVIDIA_VISIBLE_DEVICES so the
|
|
||||||
// sidecar can call hevc_nvenc / h264_nvenc inside capture ffmpeg.
|
|
||||||
// Only set this when the recorder codec is GPU-accelerated; CPU codecs
|
|
||||||
// (ProRes, DNxHR, libx264) don't need it and it avoids a hard dep on the
|
|
||||||
// NVIDIA container runtime on nodes that have no GPU.
|
|
||||||
useGpu = false,
|
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
const binds = [`${LIVE_DIR}:/live`];
|
const binds = [`${LIVE_DIR}:/live`];
|
||||||
if (sourceType === 'sdi') binds.unshift('/dev/blackmagic:/dev/blackmagic');
|
if (sourceType === 'sdi') binds.unshift('/dev/blackmagic:/dev/blackmagic');
|
||||||
if (sourceType === 'deltacast') {
|
|
||||||
// Bind each /dev/deltacast* node that exists on the host into the container.
|
|
||||||
// If none exist the capture container falls back to test-card (lavfi) mode.
|
|
||||||
try {
|
|
||||||
const dcEntries = fs.readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n));
|
|
||||||
for (const d of dcEntries) binds.push(`/dev/${d}:/dev/${d}`);
|
|
||||||
} catch (_) { /* /dev always exists */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the sidecar environment, injecting NVIDIA vars when GPU is requested.
|
|
||||||
const sidecarEnv = [...env, `PORT=${capturePort}`];
|
|
||||||
if (useGpu) {
|
|
||||||
// NVIDIA_VISIBLE_DEVICES=all exposes every GPU on the host.
|
|
||||||
// For a single-GPU node (zampp2 / L4) this is equivalent to pinning GPU 0.
|
|
||||||
// When we later store per-recorder GPU affinity in the DB we can pass a
|
|
||||||
// specific UUID here instead.
|
|
||||||
sidecarEnv.push('NVIDIA_VISIBLE_DEVICES=all');
|
|
||||||
sidecarEnv.push('NVIDIA_DRIVER_CAPABILITIES=video,compute,utility');
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostConfig = {
|
|
||||||
NetworkMode: 'host',
|
|
||||||
Privileged: true,
|
|
||||||
Binds: binds,
|
|
||||||
};
|
|
||||||
if (useGpu) {
|
|
||||||
// Tell Docker to use the NVIDIA container runtime for this container.
|
|
||||||
// Equivalent to `docker run --gpus all` / `--runtime=nvidia`.
|
|
||||||
hostConfig.Runtime = 'nvidia';
|
|
||||||
hostConfig.DeviceRequests = [
|
|
||||||
{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const spec = {
|
const spec = {
|
||||||
Image: image,
|
Image: image,
|
||||||
Env: sidecarEnv,
|
Env: [...env, `PORT=${capturePort}`],
|
||||||
HostConfig: hostConfig,
|
HostConfig: {
|
||||||
|
NetworkMode: 'host',
|
||||||
|
Privileged: true,
|
||||||
|
Binds: binds,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const createRes = await dockerApi('POST', '/containers/create', spec);
|
const createRes = await dockerApi('POST', '/containers/create', spec);
|
||||||
|
|
@ -143,9 +108,6 @@ async function handleSidecarStart(body, res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerId = createRes.data.Id;
|
const containerId = createRes.data.Id;
|
||||||
const _u = (env.find(e => e.startsWith('MAM_API_URL=')) || '').slice(12);
|
|
||||||
const _tok = env.some(e => e.startsWith('MAM_API_TOKEN=') && e.length > 14);
|
|
||||||
console.log(`[sidecar-start] ${containerId} image=${image} src=${sourceType} MAM_API_URL=${_u} token=${_tok}`);
|
|
||||||
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
|
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
|
||||||
if (startRes.status !== 204) {
|
if (startRes.status !== 204) {
|
||||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
||||||
|
|
@ -158,40 +120,12 @@ async function handleSidecarStart(body, res) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchContainerLogs(containerId) {
|
|
||||||
return await new Promise((resolve) => {
|
|
||||||
const options = {
|
|
||||||
socketPath: '/var/run/docker.sock',
|
|
||||||
path: `/v1.43/containers/${containerId}/logs?stdout=1&stderr=1&tail=200`,
|
|
||||||
method: 'GET',
|
|
||||||
};
|
|
||||||
const req = http.request(options, res => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on('data', c => chunks.push(c));
|
|
||||||
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8').replace(/[\x00-\x08]/g, '')));
|
|
||||||
});
|
|
||||||
req.on('error', () => resolve('(log fetch failed)'));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSidecarStop(containerId, res) {
|
async function handleSidecarStop(containerId, res) {
|
||||||
try {
|
try {
|
||||||
console.log(`[sidecar-stop] stopping ${containerId} (grace 180s)...`);
|
await dockerApi('POST', `/containers/${containerId}/stop`).catch(() => {});
|
||||||
// Grace period must exceed the capture container's shutdown work
|
|
||||||
// (finalise ffmpeg session + register asset via callback). Default
|
|
||||||
// docker stop is only 10s, which SIGKILLs capture mid-finalise and
|
|
||||||
// loses the POST /assets callback -> asset stuck 'live', no jobs.
|
|
||||||
await dockerApi('POST', `/containers/${containerId}/stop?t=180`).catch(() => {});
|
|
||||||
// Dump the capture container's shutdown logs into our persistent log
|
|
||||||
// BEFORE removing it, so failed callbacks are diagnosable.
|
|
||||||
const logs = await fetchContainerLogs(containerId);
|
|
||||||
console.log(`[sidecar-stop] ==== capture logs for ${containerId} ====\n${logs}\n[sidecar-stop] ==== end logs ====`);
|
|
||||||
// Container has now exited gracefully (or hit the 180s cap); remove it.
|
|
||||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
||||||
jsonResponse(res, 200, { ok: true });
|
jsonResponse(res, 200, { ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[sidecar-stop] error: ${err.message}`);
|
|
||||||
jsonResponse(res, 500, { error: err.message });
|
jsonResponse(res, 500, { error: err.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -246,71 +180,6 @@ function sampleCpu() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// -- Live GPU utilization sampling -----------------------------------------
|
|
||||||
// Spawns a short-lived nvidia container via Docker API on each heartbeat call.
|
|
||||||
// Returns array of { index, util_pct, mem_used_mb, mem_total_mb } per GPU,
|
|
||||||
// or [] if no GPUs / nvidia runtime unavailable.
|
|
||||||
async function sampleGpuUtil() {
|
|
||||||
if (!_gpuCache || _gpuCache.length === 0) return [];
|
|
||||||
|
|
||||||
const QUERY = '--query-gpu=index,utilization.gpu,memory.used,memory.total';
|
|
||||||
const FMT = '--format=csv,noheader,nounits';
|
|
||||||
|
|
||||||
let containerId;
|
|
||||||
try {
|
|
||||||
const createRes = await dockerApi('POST', '/containers/create', {
|
|
||||||
Image: 'ubuntu:22.04',
|
|
||||||
Cmd: ['nvidia-smi', QUERY, FMT],
|
|
||||||
HostConfig: {
|
|
||||||
AutoRemove: false,
|
|
||||||
Runtime: 'nvidia',
|
|
||||||
DeviceRequests: [{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (createRes.status !== 201) return [];
|
|
||||||
containerId = createRes.data.Id;
|
|
||||||
|
|
||||||
await dockerApi('POST', `/containers/${containerId}/start`);
|
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
await new Promise(r => setTimeout(r, 400));
|
|
||||||
const inspect = await dockerApi('GET', `/containers/${containerId}/json`);
|
|
||||||
if (!inspect.data?.State?.Running) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const logRes = await new Promise((resolve, reject) => {
|
|
||||||
const options = {
|
|
||||||
socketPath: '/var/run/docker.sock',
|
|
||||||
path: `/v1.43/containers/${containerId}/logs?stdout=1&stderr=0`,
|
|
||||||
method: 'GET',
|
|
||||||
};
|
|
||||||
const req = http.request(options, res => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on('data', c => chunks.push(c));
|
|
||||||
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = logRes.replace(/[\x00-\x07].{7}/g, '').trim();
|
|
||||||
const lines = text.split('\n').filter(l => /^\d+,/.test(l.trim()));
|
|
||||||
|
|
||||||
return lines.map(line => {
|
|
||||||
const [idx, util, memUsed, memTotal] = line.split(',').map(s => parseInt(s.trim(), 10));
|
|
||||||
return { index: idx, util_pct: util, mem_used_mb: memUsed, mem_total_mb: memTotal };
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[gpu-util] sampling failed:', err.message);
|
|
||||||
return [];
|
|
||||||
} finally {
|
|
||||||
if (containerId) {
|
|
||||||
await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Hardware detection ────────────────────────────────────────────────────
|
// ── Hardware detection ────────────────────────────────────────────────────
|
||||||
// GPU_COUNT / BMD_COUNT env vars override filesystem detection when /dev isn't mapped
|
// GPU_COUNT / BMD_COUNT env vars override filesystem detection when /dev isn't mapped
|
||||||
// Cached GPU info from nvidia-smi — populated once at startup via Docker API.
|
// Cached GPU info from nvidia-smi — populated once at startup via Docker API.
|
||||||
|
|
@ -395,7 +264,7 @@ async function probeGpusViaSmi() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectHardware() {
|
function detectHardware() {
|
||||||
const capabilities = { gpus: [], blackmagic: [], deltacast: [] };
|
const capabilities = { gpus: [], blackmagic: [] };
|
||||||
|
|
||||||
// Issue #108 — previously GPU_COUNT short-circuited the entire detection
|
// Issue #108 — previously GPU_COUNT short-circuited the entire detection
|
||||||
// path, throwing away the nvidia-smi enrichment (model, memory, driver
|
// path, throwing away the nvidia-smi enrichment (model, memory, driver
|
||||||
|
|
@ -442,39 +311,12 @@ function detectHardware() {
|
||||||
// to render the correct card layout (Duo 2, Quad 2, Mini Recorder, ...).
|
// to render the correct card layout (Duo 2, Quad 2, Mini Recorder, ...).
|
||||||
if (process.env.BMD_MODEL) capabilities.blackmagic_model = process.env.BMD_MODEL;
|
if (process.env.BMD_MODEL) capabilities.blackmagic_model = process.env.BMD_MODEL;
|
||||||
|
|
||||||
// Deltacast SDI cards — enumerate /dev/deltacast* device nodes.
|
|
||||||
// DELTACAST_PORT_COUNT env overrides when devices aren't mapped (test/dev mode).
|
|
||||||
const dcOverride = parseInt(process.env.DELTACAST_PORT_COUNT || '-1', 10);
|
|
||||||
try {
|
|
||||||
const dcEntries = fs.readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n)).sort();
|
|
||||||
if (dcEntries.length > 0) {
|
|
||||||
capabilities.deltacast = dcEntries.map((d, i) => ({
|
|
||||||
device: `/dev/${d}`,
|
|
||||||
index: i,
|
|
||||||
present: true,
|
|
||||||
}));
|
|
||||||
} else if (dcOverride >= 0) {
|
|
||||||
// No device nodes but count is configured — test-card mode.
|
|
||||||
for (let i = 0; i < dcOverride; i++) {
|
|
||||||
capabilities.deltacast.push({ device: `/dev/deltacast${i}`, index: i, present: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
if (dcOverride >= 0) {
|
|
||||||
for (let i = 0; i < dcOverride; i++) {
|
|
||||||
capabilities.deltacast.push({ device: `/dev/deltacast${i}`, index: i, present: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (process.env.DELTACAST_MODEL) capabilities.deltacast_model = process.env.DELTACAST_MODEL;
|
|
||||||
|
|
||||||
return capabilities;
|
return capabilities;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Heartbeat ─────────────────────────────────────────────────────────────
|
// ── Heartbeat ─────────────────────────────────────────────────────────────
|
||||||
async function heartbeat() {
|
async function heartbeat() {
|
||||||
const cpu_usage = await sampleCpu();
|
const cpu_usage = await sampleCpu();
|
||||||
const gpu_util = await sampleGpuUtil();
|
|
||||||
const totalMem = os.totalmem();
|
const totalMem = os.totalmem();
|
||||||
const freeMem = os.freemem();
|
const freeMem = os.freemem();
|
||||||
const ip_address = getIp();
|
const ip_address = getIp();
|
||||||
|
|
@ -490,7 +332,6 @@ async function heartbeat() {
|
||||||
mem_used_mb: Math.round((totalMem - freeMem) / 1048576),
|
mem_used_mb: Math.round((totalMem - freeMem) / 1048576),
|
||||||
mem_total_mb: Math.round(totalMem / 1048576),
|
mem_total_mb: Math.round(totalMem / 1048576),
|
||||||
capabilities,
|
capabilities,
|
||||||
gpu_util,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
|
@ -506,10 +347,9 @@ async function heartbeat() {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const gpuStr = capabilities.gpus.length ? ` gpu=${capabilities.gpus.length}` : '';
|
const gpuStr = capabilities.gpus.length ? ` gpu=${capabilities.gpus.length}` : '';
|
||||||
const bmdStr = capabilities.blackmagic.length ? ` bmd=${capabilities.blackmagic.length}` : '';
|
const bmdStr = capabilities.blackmagic.length ? ` bmd=${capabilities.blackmagic.length}` : '';
|
||||||
const dcStr = capabilities.deltacast.length ? ` dc=${capabilities.deltacast.length}` : '';
|
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`[hb] ${payload.hostname} ip=${ip_address || '?'} cpu=${cpu_usage}% ` +
|
`[hb] ${payload.hostname} ip=${ip_address || '?'} cpu=${cpu_usage}% ` +
|
||||||
`mem=${payload.mem_used_mb}/${payload.mem_total_mb}MB${gpuStr}${bmdStr}${dcStr}\n`
|
`mem=${payload.mem_used_mb}/${payload.mem_total_mb}MB${gpuStr}${bmdStr}\n`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const txt = await res.text().catch(() => '');
|
const txt = await res.text().catch(() => '');
|
||||||
|
|
@ -527,22 +367,6 @@ probeGpusViaSmi().then(() => {
|
||||||
setInterval(heartbeat, HEARTBEAT_MS);
|
setInterval(heartbeat, HEARTBEAT_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Serve the local HLS live-preview files (written by the capture sidecar to
|
|
||||||
// LIVE_DIR) so the primary can reverse-proxy them to the browser. Read-only.
|
|
||||||
function serveLiveFile(pathname, res) {
|
|
||||||
const rel = decodeURIComponent(pathname.slice('/live/'.length));
|
|
||||||
if (!rel || rel.includes('..') || rel.startsWith('/')) { res.writeHead(403); return res.end(); }
|
|
||||||
const filePath = LIVE_DIR + '/' + rel;
|
|
||||||
fs.readFile(filePath, (err, data) => {
|
|
||||||
if (err) { res.writeHead(404); return res.end(); }
|
|
||||||
const ct = filePath.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl'
|
|
||||||
: filePath.endsWith('.ts') ? 'video/mp2t'
|
|
||||||
: 'application/octet-stream';
|
|
||||||
res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'no-cache', 'Access-Control-Allow-Origin': '*' });
|
|
||||||
res.end(data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── HTTP server ───────────────────────────────────────────────────────────
|
// ── HTTP server ───────────────────────────────────────────────────────────
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
const { pathname } = new URL(req.url, 'http://localhost');
|
const { pathname } = new URL(req.url, 'http://localhost');
|
||||||
|
|
@ -572,9 +396,6 @@ const server = http.createServer((req, res) => {
|
||||||
const id = pathname.slice('/sidecar/'.length, -'/status'.length);
|
const id = pathname.slice('/sidecar/'.length, -'/status'.length);
|
||||||
handleSidecarStatus(id, res);
|
handleSidecarStatus(id, res);
|
||||||
|
|
||||||
} else if (req.method === 'GET' && pathname.startsWith('/live/')) {
|
|
||||||
serveLiveFile(pathname, res);
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
res.end();
|
res.end();
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
# Wild Dragon Playout sidecar — CasparCG Server + Node AMCP control shim.
|
|
||||||
FROM ubuntu:22.04
|
|
||||||
|
|
||||||
ARG CASPAR_VERSION=2.4.0-stable
|
|
||||||
ARG CASPAR_URL=https://github.com/CasparCG/server/releases/download/v2.4.0-stable/casparcg-server-v2.4.0-stable-ubuntu22.zip
|
|
||||||
ARG NDI_SDK_URL=
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
|
|
||||||
# CEF (HTML producer) needs libnss3 + chromium runtime deps. Without these the
|
|
||||||
# server starts fine but SIGABRTs ~30s in when it lazy-inits CEF (NSS -8023).
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
ca-certificates curl unzip tar xz-utils gnupg \
|
|
||||||
xvfb libgl1-mesa-dri libglu1-mesa fonts-dejavu-core \
|
|
||||||
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
|
||||||
libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
|
|
||||||
libgbm1 libpango-1.0-0 libcairo2 libasound2 libatspi2.0-0 \
|
|
||||||
&& mkdir -p /etc/apt/keyrings \
|
|
||||||
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
|
|
||||||
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
|
||||||
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" \
|
|
||||||
> /etc/apt/sources.list.d/nodesource.list \
|
|
||||||
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /tmp/caspar
|
|
||||||
RUN set -eux; \
|
|
||||||
curl -fsSL "$CASPAR_URL" -o caspar.zip; \
|
|
||||||
unzip -q caspar.zip -d /opt; \
|
|
||||||
chmod +x /opt/casparcg_server/bin/casparcg /opt/casparcg_server/scanner 2>/dev/null || true; \
|
|
||||||
ls /opt/casparcg_server/; \
|
|
||||||
test -x /opt/casparcg_server/bin/casparcg; \
|
|
||||||
ln -sfn /opt/casparcg_server /opt/casparcg; \
|
|
||||||
echo "caspar binary: /opt/casparcg_server/bin/casparcg"; \
|
|
||||||
cd /; rm -rf /tmp/caspar
|
|
||||||
|
|
||||||
RUN if [ -n "$NDI_SDK_URL" ]; then \
|
|
||||||
mkdir -p /opt/ndi-lib && \
|
|
||||||
curl -fsSL "$NDI_SDK_URL" -o /tmp/ndi.tar.gz && \
|
|
||||||
tar xzf /tmp/ndi.tar.gz -C /tmp && \
|
|
||||||
find /tmp -name 'libndi*.so*' -exec cp -a {} /opt/ndi-lib/ \; && \
|
|
||||||
rm -f /tmp/ndi.tar.gz && ldconfig /opt/ndi-lib || true; \
|
|
||||||
fi
|
|
||||||
ENV NDI_RUNTIME_DIR_V6=/opt/ndi-lib
|
|
||||||
|
|
||||||
RUN mkdir -p /media
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm install --omit=dev
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
COPY casparcg.config /opt/casparcg/casparcg.config
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
|
||||||
RUN chmod +x /entrypoint.sh
|
|
||||||
|
|
||||||
EXPOSE 3002 5250
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<configuration>
|
|
||||||
<paths>
|
|
||||||
<media-path>/media/</media-path>
|
|
||||||
<log-path>/media/casparcg/log/</log-path>
|
|
||||||
<data-path>/media/casparcg/data/</data-path>
|
|
||||||
<template-path>/media/templates/</template-path>
|
|
||||||
</paths>
|
|
||||||
<channels>
|
|
||||||
<channel>
|
|
||||||
<video-mode>1080i5994</video-mode>
|
|
||||||
<consumers>
|
|
||||||
</consumers>
|
|
||||||
</channel>
|
|
||||||
</channels>
|
|
||||||
<controllers>
|
|
||||||
<tcp>
|
|
||||||
<port>5250</port>
|
|
||||||
<protocol>AMCP</protocol>
|
|
||||||
</tcp>
|
|
||||||
</controllers>
|
|
||||||
</configuration>
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [ -z "${DISPLAY:-}" ]; then
|
|
||||||
echo "[entrypoint] starting Xvfb on :99"
|
|
||||||
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
|
|
||||||
export DISPLAY=:99
|
|
||||||
for i in $(seq 1 20); do
|
|
||||||
[ -e /tmp/.X11-unix/X99 ] && break
|
|
||||||
sleep 0.25
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "${CHANNEL_ID:-}" ]; then
|
|
||||||
mkdir -p "/media/live/${CHANNEL_ID}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p /media/casparcg/log /media/casparcg/data /media/templates
|
|
||||||
|
|
||||||
# CEF (HTML producer) initialises an NSS database at /root/.pki/nssdb and
|
|
||||||
# Chrome caches under HOME. Pre-create writable dirs so CEF doesn't SIGABRT
|
|
||||||
# ~30s into the run when it first lazily inits.
|
|
||||||
mkdir -p /root/.pki/nssdb /root/.cache /tmp/cef-cache
|
|
||||||
chmod 700 /root/.pki/nssdb
|
|
||||||
export HOME=/root
|
|
||||||
|
|
||||||
# 2.4.x zip bundles its own .so files under lib/ — add to LD_LIBRARY_PATH.
|
|
||||||
export LD_LIBRARY_PATH="/opt/casparcg/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
|
||||||
|
|
||||||
cd /opt/casparcg
|
|
||||||
CASPAR_CFG=/opt/casparcg/casparcg.config
|
|
||||||
# 2.4.x: binary at bin/casparcg. 2.5.x: symlinked to casparcg at root.
|
|
||||||
if [ -x "./bin/casparcg" ]; then CASPAR_BIN="./bin/casparcg";
|
|
||||||
elif [ -x "./casparcg" ]; then CASPAR_BIN="./casparcg";
|
|
||||||
elif [ -x "./CasparCG Server" ]; then CASPAR_BIN="./CasparCG Server";
|
|
||||||
elif command -v casparcg >/dev/null; then CASPAR_BIN="casparcg";
|
|
||||||
else echo "[entrypoint] ERROR: casparcg binary not found"; exit 1; fi
|
|
||||||
echo "[entrypoint] launching CasparCG: $CASPAR_BIN $CASPAR_CFG"
|
|
||||||
"$CASPAR_BIN" "$CASPAR_CFG" &
|
|
||||||
CASPAR_PID=$!
|
|
||||||
|
|
||||||
term() {
|
|
||||||
echo "[entrypoint] terminating CasparCG ($CASPAR_PID)"
|
|
||||||
kill -TERM "$CASPAR_PID" 2>/dev/null || true
|
|
||||||
wait "$CASPAR_PID" 2>/dev/null || true
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
trap term SIGTERM SIGINT
|
|
||||||
|
|
||||||
cd /app
|
|
||||||
node src/index.js &
|
|
||||||
NODE_PID=$!
|
|
||||||
|
|
||||||
wait -n "$CASPAR_PID" "$NODE_PID"
|
|
||||||
term
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"name": "wild-dragon-playout",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Wild Dragon MAM playout sidecar — wraps a CasparCG Server instance and drives it over AMCP for master-control playout (SDI / NDI / SRT / RTMP).",
|
|
||||||
"type": "module",
|
|
||||||
"main": "src/index.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node src/index.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^4.18.0",
|
|
||||||
"cors": "^2.8.0",
|
|
||||||
"dotenv": "^16.4.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
import net from 'node:net';
|
|
||||||
|
|
||||||
// Minimal AMCP (Advanced Media Control Protocol) client for CasparCG.
|
|
||||||
//
|
|
||||||
// AMCP is a line-based TCP protocol: each command is a single CRLF-terminated
|
|
||||||
// line, and the server replies with a status line ("201 PLAY OK\r\n") optionally
|
|
||||||
// followed by data lines. We keep one persistent socket per CasparCG instance
|
|
||||||
// and serialize commands through a FIFO queue — CasparCG processes one command
|
|
||||||
// at a time per connection, so interleaving replies would otherwise be
|
|
||||||
// ambiguous.
|
|
||||||
//
|
|
||||||
// We only implement the subset the playout sidecar needs (PLAY / LOADBG / STOP /
|
|
||||||
// CLEAR / INFO / ADD / REMOVE). Responses are returned raw; callers parse the
|
|
||||||
// status code where they care.
|
|
||||||
|
|
||||||
const CRLF = '\r\n';
|
|
||||||
|
|
||||||
export class AmcpClient {
|
|
||||||
constructor({ host = '127.0.0.1', port = 5250 } = {}) {
|
|
||||||
this.host = host;
|
|
||||||
this.port = port;
|
|
||||||
this.socket = null;
|
|
||||||
this.connected = false;
|
|
||||||
this._buffer = '';
|
|
||||||
this._queue = []; // pending { command, resolve, reject, timer }
|
|
||||||
this._active = null; // command currently awaiting a reply
|
|
||||||
this._reconnectTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
if (this.socket) return;
|
|
||||||
const socket = net.createConnection({ host: this.host, port: this.port });
|
|
||||||
socket.setEncoding('utf8');
|
|
||||||
socket.setKeepAlive(true, 10000);
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
this.connected = true;
|
|
||||||
console.log(`[amcp] connected to ${this.host}:${this.port}`);
|
|
||||||
});
|
|
||||||
socket.on('data', (chunk) => this._onData(chunk));
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error(`[amcp] socket error: ${err.message}`);
|
|
||||||
});
|
|
||||||
socket.on('close', () => {
|
|
||||||
this.connected = false;
|
|
||||||
this.socket = null;
|
|
||||||
// Fail any in-flight + queued commands so callers don't hang.
|
|
||||||
const pending = this._active ? [this._active, ...this._queue] : [...this._queue];
|
|
||||||
this._active = null;
|
|
||||||
this._queue = [];
|
|
||||||
for (const p of pending) {
|
|
||||||
clearTimeout(p.timer);
|
|
||||||
p.reject(new Error('AMCP connection closed'));
|
|
||||||
}
|
|
||||||
this._scheduleReconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.socket = socket;
|
|
||||||
}
|
|
||||||
|
|
||||||
_scheduleReconnect() {
|
|
||||||
if (this._reconnectTimer) return;
|
|
||||||
this._reconnectTimer = setTimeout(() => {
|
|
||||||
this._reconnectTimer = null;
|
|
||||||
console.log('[amcp] reconnecting...');
|
|
||||||
this.connect();
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait until the socket is usable, up to timeoutMs.
|
|
||||||
async waitReady(timeoutMs = 30000) {
|
|
||||||
const deadline = Date.now() + timeoutMs;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (this.connected) return true;
|
|
||||||
if (!this.socket) this.connect();
|
|
||||||
await new Promise((r) => setTimeout(r, 250));
|
|
||||||
}
|
|
||||||
throw new Error('AMCP not ready within timeout');
|
|
||||||
}
|
|
||||||
|
|
||||||
_onData(chunk) {
|
|
||||||
this._buffer += chunk;
|
|
||||||
// A CasparCG reply is a status line, optionally followed by data lines.
|
|
||||||
// The simplest robust framing: a command's reply is complete when we see a
|
|
||||||
// status line AND (for 2-line "200" multi-line replies) the terminating
|
|
||||||
// blank line. For our command subset, single-status-line replies dominate;
|
|
||||||
// we treat a reply as complete at each newline and let the active command
|
|
||||||
// decide whether it has enough. To keep this correct for INFO (multi-line),
|
|
||||||
// we accumulate until the buffer ends with a known terminator.
|
|
||||||
if (!this._active) {
|
|
||||||
// Unsolicited data (e.g. connection banner) — discard.
|
|
||||||
this._buffer = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// CasparCG ends multi-line replies with CRLF on an empty line. Single-line
|
|
||||||
// replies (201/202/4xx/5xx) end with a single CRLF. Resolve when we have at
|
|
||||||
// least one complete line; for "200 ... OK" (list follows) wait for the
|
|
||||||
// blank-line terminator.
|
|
||||||
const firstLineEnd = this._buffer.indexOf(CRLF);
|
|
||||||
if (firstLineEnd === -1) return;
|
|
||||||
const statusLine = this._buffer.slice(0, firstLineEnd);
|
|
||||||
const code = parseInt(statusLine, 10);
|
|
||||||
|
|
||||||
if (code === 200) {
|
|
||||||
// Multi-line: data lines until an empty line.
|
|
||||||
const term = this._buffer.indexOf(CRLF + CRLF);
|
|
||||||
if (term === -1) return; // wait for more
|
|
||||||
const full = this._buffer.slice(0, term);
|
|
||||||
this._buffer = this._buffer.slice(term + 4);
|
|
||||||
this._finishActive(null, full);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code === 201 || code === 202) {
|
|
||||||
// 201: one data line follows the status line. 202: status only.
|
|
||||||
if (code === 201) {
|
|
||||||
const secondLineEnd = this._buffer.indexOf(CRLF, firstLineEnd + 2);
|
|
||||||
if (secondLineEnd === -1) return;
|
|
||||||
const full = this._buffer.slice(0, secondLineEnd);
|
|
||||||
this._buffer = this._buffer.slice(secondLineEnd + 2);
|
|
||||||
this._finishActive(null, full);
|
|
||||||
} else {
|
|
||||||
const full = this._buffer.slice(0, firstLineEnd);
|
|
||||||
this._buffer = this._buffer.slice(firstLineEnd + 2);
|
|
||||||
this._finishActive(null, full);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4xx / 5xx error, or any other single-line status.
|
|
||||||
const full = this._buffer.slice(0, firstLineEnd);
|
|
||||||
this._buffer = this._buffer.slice(firstLineEnd + 2);
|
|
||||||
if (code >= 400) this._finishActive(new Error(`AMCP error: ${full}`), full);
|
|
||||||
else this._finishActive(null, full);
|
|
||||||
}
|
|
||||||
|
|
||||||
_finishActive(err, data) {
|
|
||||||
const active = this._active;
|
|
||||||
this._active = null;
|
|
||||||
if (active) {
|
|
||||||
clearTimeout(active.timer);
|
|
||||||
if (err) active.reject(err);
|
|
||||||
else active.resolve(data);
|
|
||||||
}
|
|
||||||
this._pump();
|
|
||||||
}
|
|
||||||
|
|
||||||
_pump() {
|
|
||||||
if (this._active || this._queue.length === 0) return;
|
|
||||||
const next = this._queue.shift();
|
|
||||||
this._active = next;
|
|
||||||
try {
|
|
||||||
this.socket.write(next.command + CRLF);
|
|
||||||
} catch (err) {
|
|
||||||
this._active = null;
|
|
||||||
clearTimeout(next.timer);
|
|
||||||
next.reject(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send a single AMCP command and resolve with the raw reply string.
|
|
||||||
send(command, { timeoutMs = 15000 } = {}) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const entry = { command, resolve, reject, timer: null };
|
|
||||||
entry.timer = setTimeout(() => {
|
|
||||||
// Drop from queue if still pending; if active, detach so the next
|
|
||||||
// reply doesn't get misrouted.
|
|
||||||
if (this._active === entry) this._active = null;
|
|
||||||
else this._queue = this._queue.filter((e) => e !== entry);
|
|
||||||
reject(new Error(`AMCP command timed out: ${command}`));
|
|
||||||
}, timeoutMs);
|
|
||||||
this._queue.push(entry);
|
|
||||||
this._pump();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
|
|
||||||
if (this.socket) { try { this.socket.destroy(); } catch (_) {} this.socket = null; }
|
|
||||||
this.connected = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
import express from 'express';
|
|
||||||
import cors from 'cors';
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
import playoutManager from './playout-manager.js';
|
|
||||||
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const PORT = process.env.PORT || 3002;
|
|
||||||
|
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
app.get('/health', (req, res) => res.json({ status: 'ok' }));
|
|
||||||
|
|
||||||
// Start the channel's output consumer. Body: { outputType, outputConfig, videoFormat }
|
|
||||||
app.post('/channel/start', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const out = await playoutManager.startChannel(req.body || {});
|
|
||||||
res.json(out);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[playout] /channel/start error:', err.message);
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/channel/stop', async (req, res) => {
|
|
||||||
try { res.json(await playoutManager.stopChannel()); }
|
|
||||||
catch (err) { res.status(500).json({ error: err.message }); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load + start a playlist. Body: { items: [...], loop }
|
|
||||||
app.post('/playlist/load', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { items = [], loop = false } = req.body || {};
|
|
||||||
res.json(await playoutManager.loadPlaylist({ items, loop }));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[playout] /playlist/load error:', err.message);
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/transport/skip', async (req, res) => { try { res.json(await playoutManager.skip()); } catch (e) { res.status(500).json({ error: e.message }); } });
|
|
||||||
app.post('/transport/pause', async (req, res) => { try { res.json(await playoutManager.pause()); } catch (e) { res.status(500).json({ error: e.message }); } });
|
|
||||||
app.post('/transport/resume', async (req, res) => { try { res.json(await playoutManager.resume()); } catch (e) { res.status(500).json({ error: e.message }); } });
|
|
||||||
|
|
||||||
app.get('/status', (req, res) => res.json(playoutManager.getStatus()));
|
|
||||||
|
|
||||||
// Auto-start: when the sidecar is spawned by mam-api with channel env, bring up
|
|
||||||
// the output consumer immediately so the container is "on air idle" (black/slate)
|
|
||||||
// the moment it boots, mirroring the capture sidecar's bootstrap pattern.
|
|
||||||
async function bootstrap() {
|
|
||||||
const outputType = process.env.OUTPUT_TYPE;
|
|
||||||
if (!outputType) {
|
|
||||||
console.log('[bootstrap] no OUTPUT_TYPE — on-demand sidecar, waiting for /channel/start');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let outputConfig = {};
|
|
||||||
try { outputConfig = JSON.parse(process.env.OUTPUT_CONFIG || '{}'); }
|
|
||||||
catch (err) { console.error('[bootstrap] bad OUTPUT_CONFIG json:', err.message); }
|
|
||||||
const videoFormat = process.env.VIDEO_FORMAT || '1080i5994';
|
|
||||||
try {
|
|
||||||
await playoutManager.startChannel({ outputType, outputConfig, videoFormat });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[bootstrap] channel start failed:', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = app.listen(PORT, () => {
|
|
||||||
console.log(`Wild Dragon Playout Service listening on port ${PORT}`);
|
|
||||||
// Give CasparCG a moment to come up (started by the container entrypoint).
|
|
||||||
playoutManager.amcp.connect();
|
|
||||||
bootstrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
function shutdown(sig) {
|
|
||||||
console.log(`[playout] ${sig} — shutting down`);
|
|
||||||
playoutManager.stopChannel().catch(() => {}).finally(() => {
|
|
||||||
playoutManager.amcp.close();
|
|
||||||
server.close(() => process.exit(0));
|
|
||||||
setTimeout(() => process.exit(0), 5000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
||||||
|
|
@ -1,316 +0,0 @@
|
||||||
import { AmcpClient } from './amcp.js';
|
|
||||||
|
|
||||||
// Playout manager — owns one CasparCG channel's lifecycle inside this sidecar.
|
|
||||||
//
|
|
||||||
// One sidecar container == one CasparCG Server == one logical channel (channel
|
|
||||||
// index 1 in CasparCG terms). We add the output consumer (DeckLink / NDI / SRT
|
|
||||||
// / RTMP) at start, then walk a playlist by cueing the next clip on a background
|
|
||||||
// layer (LOADBG ... AUTO) so CasparCG performs a gapless transition at end of
|
|
||||||
// the current clip.
|
|
||||||
//
|
|
||||||
// Media is referenced by a path relative to CasparCG's configured media folder
|
|
||||||
// (/media inside the container). The mam-api stages assets from S3 to that
|
|
||||||
// shared volume and passes the resolved relative path on each item.
|
|
||||||
|
|
||||||
const CHANNEL = 1; // single CasparCG channel per sidecar
|
|
||||||
const FG_LAYER = 10; // foreground (on-air) layer
|
|
||||||
const MEDIA_ROOT = process.env.CASPAR_MEDIA_ROOT || '/media';
|
|
||||||
|
|
||||||
// Channel-id-derived HLS preview path. The mam-api proxies /live/<channel_id>/
|
|
||||||
// to this directory (shared media volume) so the UI's existing HLS player
|
|
||||||
// (capture's /live/<id> plumbing) works for playout monitors with zero new
|
|
||||||
// transport.
|
|
||||||
const CHANNEL_ID = process.env.CHANNEL_ID || '';
|
|
||||||
const HLS_DIR = CHANNEL_ID ? `${MEDIA_ROOT}/live/${CHANNEL_ID}` : '';
|
|
||||||
|
|
||||||
// CasparCG SEEK / LENGTH are in frames, not seconds. Capture standard is 59.94;
|
|
||||||
// SD/film modes need their own values. Default 60000/1001 matches both
|
|
||||||
// '1080p5994' and '1080i5994'.
|
|
||||||
function fpsFor(videoFormat) {
|
|
||||||
const f = String(videoFormat || '').toLowerCase();
|
|
||||||
if (f.endsWith('5994')) return 60000 / 1001;
|
|
||||||
if (f.endsWith('p60') || f.endsWith('i60')) return 60;
|
|
||||||
if (f.endsWith('p50') || f.endsWith('i50')) return 50;
|
|
||||||
if (f.endsWith('2997')) return 30000 / 1001;
|
|
||||||
if (f.endsWith('p30')) return 30;
|
|
||||||
if (f.endsWith('p25')) return 25;
|
|
||||||
if (f.endsWith('p24') || f.endsWith('2398')) return 24000 / 1001;
|
|
||||||
return 60000 / 1001; // safe default for the house standard
|
|
||||||
}
|
|
||||||
|
|
||||||
// CasparCG transition syntax fragments keyed by our item.transition value.
|
|
||||||
function transitionArgs(transition, ms, fps) {
|
|
||||||
if (!transition || transition === 'cut' || !ms) return '';
|
|
||||||
const frames = Math.max(1, Math.round((ms / 1000) * fps));
|
|
||||||
if (transition === 'mix') return ` MIX ${frames}`;
|
|
||||||
if (transition === 'wipe') return ` WIPE ${frames}`;
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Turn an absolute /media path (or a relative one) into the token CasparCG
|
|
||||||
// expects: a path relative to MEDIA_ROOT, without extension, forward-slashed.
|
|
||||||
// CasparCG resolves "subdir/clip" against its media folder + probes extensions.
|
|
||||||
function toCasparToken(mediaPath) {
|
|
||||||
let p = String(mediaPath || '');
|
|
||||||
if (p.startsWith(MEDIA_ROOT)) p = p.slice(MEDIA_ROOT.length);
|
|
||||||
p = p.replace(/^\/+/, '');
|
|
||||||
p = p.replace(/\.[^/.]+$/, ''); // strip extension
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PlayoutManager {
|
|
||||||
constructor() {
|
|
||||||
this.amcp = new AmcpClient({
|
|
||||||
host: process.env.CASPAR_HOST || '127.0.0.1',
|
|
||||||
port: parseInt(process.env.CASPAR_PORT || '5250', 10),
|
|
||||||
});
|
|
||||||
this.state = {
|
|
||||||
running: false,
|
|
||||||
outputType: null,
|
|
||||||
outputConfig: null,
|
|
||||||
videoFormat: null,
|
|
||||||
playlist: [], // resolved items in play order
|
|
||||||
currentIndex: -1,
|
|
||||||
loop: false,
|
|
||||||
currentClip: null,
|
|
||||||
startedAt: null,
|
|
||||||
lastError: null,
|
|
||||||
};
|
|
||||||
this._advanceTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _consumerCommand(outputType, cfg) {
|
|
||||||
// Returns the AMCP ADD argument string for the requested output target.
|
|
||||||
if (outputType === 'decklink') {
|
|
||||||
const dev = cfg.device_index || 1;
|
|
||||||
return `DECKLINK DEVICE ${dev} EMBEDDED_AUDIO`;
|
|
||||||
}
|
|
||||||
if (outputType === 'ndi') {
|
|
||||||
const name = cfg.ndi_name || 'DRAGONFLIGHT';
|
|
||||||
return `NDI NAME "${name}"`;
|
|
||||||
}
|
|
||||||
if (outputType === 'srt' || outputType === 'rtmp') {
|
|
||||||
// CasparCG 2.3 streams via the FFMPEG consumer, invoked with the STREAM
|
|
||||||
// keyword (FILE/STREAM are interchangeable aliases for it; the bare word
|
|
||||||
// "FFMPEG" is the PRODUCER and is NOT a valid consumer keyword). Args must
|
|
||||||
// use ffmpeg's -param:stream form (-codec:v, not -vcodec) or CasparCG
|
|
||||||
// rejects them. The channel feeds the consumer as RGBA, so a
|
|
||||||
// format=yuv420p filter is required before libx264.
|
|
||||||
const url = cfg.url || '';
|
|
||||||
if (outputType === 'srt') {
|
|
||||||
const latency = cfg.latency || 200;
|
|
||||||
const full = url.includes('latency=') ? url : `${url}${url.includes('?') ? '&' : '?'}latency=${latency}`;
|
|
||||||
return `STREAM "${full}" -format mpegts -codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 6M -codec:a aac -b:a 192k -filter:v format=yuv420p`;
|
|
||||||
}
|
|
||||||
const target = cfg.key ? `${url}/${cfg.key}` : url;
|
|
||||||
return `STREAM "${target}" -format flv -codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 6M -codec:a aac -b:a 192k -filter:v format=yuv420p`;
|
|
||||||
}
|
|
||||||
throw new Error(`Unknown output_type: ${outputType}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start the channel: bring up CasparCG's primary output consumer for the
|
|
||||||
// target, plus a second FFMPEG consumer writing HLS for the UI preview
|
|
||||||
// monitor (~4-6s lag, reuses capture's /live/<id> plumbing).
|
|
||||||
async startChannel({ outputType, outputConfig = {}, videoFormat = '1080p5994' }) {
|
|
||||||
await this.amcp.waitReady(30000);
|
|
||||||
|
|
||||||
// Set the channel video mode, then attach the output consumer.
|
|
||||||
try { await this.amcp.send(`SET ${CHANNEL} MODE ${videoFormat}`); }
|
|
||||||
catch (err) { console.warn(`[playout] SET MODE failed (continuing): ${err.message}`); }
|
|
||||||
|
|
||||||
const consumer = await this._consumerCommand(outputType, outputConfig);
|
|
||||||
await this.amcp.send(`ADD ${CHANNEL} ${consumer}`);
|
|
||||||
|
|
||||||
if (HLS_DIR) {
|
|
||||||
try {
|
|
||||||
await this._addHlsConsumer();
|
|
||||||
console.log(`[playout] HLS preview at ${HLS_DIR}/index.m3u8`);
|
|
||||||
} catch (err) {
|
|
||||||
// HLS preview is non-fatal — operators still get the on-air output.
|
|
||||||
console.warn(`[playout] HLS preview consumer failed: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state.running = true;
|
|
||||||
this.state.outputType = outputType;
|
|
||||||
this.state.outputConfig = outputConfig;
|
|
||||||
this.state.videoFormat = videoFormat;
|
|
||||||
this.state.fps = fpsFor(videoFormat);
|
|
||||||
this.state.startedAt = new Date().toISOString();
|
|
||||||
this.state.lastError = null;
|
|
||||||
console.log(`[playout] channel started output=${outputType} mode=${videoFormat} fps=${this.state.fps.toFixed(3)}`);
|
|
||||||
return this.getStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Low-bitrate HLS for the web UI preview. Segments land in the shared media
|
|
||||||
// volume; the mam-api serves /live/<channel_id>/* from there.
|
|
||||||
async _addHlsConsumer() {
|
|
||||||
// mkdir is done by the entrypoint; CasparCG's ffmpeg consumer creates the
|
|
||||||
// playlist on first segment. 2s segments / 6-window list keeps lag low
|
|
||||||
// without thrashing disk.
|
|
||||||
// FILE keyword (alias of the FFMPEG consumer) writing a segmented HLS
|
|
||||||
// playlist. Same arg rules as the STREAM consumer: -param:stream form and a
|
|
||||||
// format=yuv420p filter ahead of libx264 (channel output is RGBA).
|
|
||||||
const out = `${HLS_DIR}/index.m3u8`;
|
|
||||||
const args = [
|
|
||||||
`FILE "${out}"`,
|
|
||||||
'-format hls',
|
|
||||||
'-hls_time 2',
|
|
||||||
'-hls_list_size 6',
|
|
||||||
'-hls_flags delete_segments+append_list',
|
|
||||||
'-codec:v libx264 -preset:v veryfast -tune:v zerolatency -b:v 800k -maxrate 1M -bufsize 2M',
|
|
||||||
'-g 60 -keyint_min 60 -sc_threshold 0',
|
|
||||||
'-codec:a aac -b:a 96k',
|
|
||||||
'-filter:v format=yuv420p',
|
|
||||||
].join(' ');
|
|
||||||
await this.amcp.send(`ADD ${CHANNEL} ${args}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async stopChannel() {
|
|
||||||
this._clearAdvance();
|
|
||||||
try { await this.amcp.send(`STOP ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
|
|
||||||
try { await this.amcp.send(`CLEAR ${CHANNEL}`); } catch (_) {}
|
|
||||||
this.state.running = false;
|
|
||||||
this.state.playlist = [];
|
|
||||||
this.state.currentIndex = -1;
|
|
||||||
this.state.currentClip = null;
|
|
||||||
console.log('[playout] channel stopped');
|
|
||||||
return { stopped: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load a playlist (array of { id, asset_id, media_path, in_point, out_point,
|
|
||||||
// transition, transition_ms, clip_name }) and start playing from index 0.
|
|
||||||
async loadPlaylist({ items = [], loop = false }) {
|
|
||||||
this.state.playlist = items;
|
|
||||||
this.state.loop = !!loop;
|
|
||||||
this.state.currentIndex = -1;
|
|
||||||
if (items.length === 0) return this.getStatus();
|
|
||||||
await this._playIndex(0);
|
|
||||||
return this.getStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
async _playIndex(index) {
|
|
||||||
const item = this.state.playlist[index];
|
|
||||||
if (!item) return;
|
|
||||||
const fps = this.state.fps || fpsFor(this.state.videoFormat);
|
|
||||||
const token = toCasparToken(item.media_path);
|
|
||||||
const seek = item.in_point ? ` SEEK ${Math.round(item.in_point * fps)}` : '';
|
|
||||||
const length = (item.out_point && item.out_point > (item.in_point || 0))
|
|
||||||
? ` LENGTH ${Math.round((item.out_point - (item.in_point || 0)) * fps)}`
|
|
||||||
: '';
|
|
||||||
const trans = transitionArgs(item.transition, item.transition_ms, fps);
|
|
||||||
|
|
||||||
// PLAY puts the clip on the foreground layer immediately (first clip), with
|
|
||||||
// the configured transition. Subsequent clips are cued via LOADBG ... AUTO
|
|
||||||
// for a gapless hand-off; see _scheduleAdvance.
|
|
||||||
await this.amcp.send(`PLAY ${CHANNEL}-${FG_LAYER} "${token}"${seek}${length}${trans}`);
|
|
||||||
this.state.currentIndex = index;
|
|
||||||
this.state.currentClip = item.clip_name || token;
|
|
||||||
console.log(`[playout] PLAY [${index}] ${token}`);
|
|
||||||
this._reportAsRunStart(item);
|
|
||||||
this._scheduleAdvance(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Effective on-air duration of an item in milliseconds. Prefers an explicit
|
|
||||||
// in/out trim, else the asset's full duration. Returns null when unknown (no
|
|
||||||
// duration metadata + no out_point) so the caller can skip the timer.
|
|
||||||
_itemDurationMs(item) {
|
|
||||||
const inS = item.in_point || 0;
|
|
||||||
if (item.out_point && item.out_point > inS) return (item.out_point - inS) * 1000;
|
|
||||||
if (item.asset_duration_ms != null) return Math.max(0, item.asset_duration_ms - inS * 1000);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CasparCG's LOADBG ... AUTO swaps the cued background clip to foreground when
|
|
||||||
// the current clip ends, giving a gapless visual take. But CasparCG won't cue
|
|
||||||
// clip N+2 on its own and won't move OUR pointer / as-run bookkeeping. So we
|
|
||||||
// also arm a duration-based timer: when the current clip is due to end we
|
|
||||||
// advance currentIndex and cue the following clip. This keeps an arbitrary-
|
|
||||||
// length playlist walking, not just the first two items.
|
|
||||||
_scheduleAdvance(item) {
|
|
||||||
this._clearAdvance();
|
|
||||||
const next = this._nextIndex();
|
|
||||||
if (next === null) return; // end of a non-looping playlist
|
|
||||||
const nextItem = this.state.playlist[next];
|
|
||||||
const nextToken = toCasparToken(nextItem.media_path);
|
|
||||||
const fps = this.state.fps || fpsFor(this.state.videoFormat);
|
|
||||||
const trans = transitionArgs(nextItem.transition, nextItem.transition_ms, fps);
|
|
||||||
// Cue next on background with AUTO so CasparCG performs the gapless take.
|
|
||||||
this.amcp.send(`LOADBG ${CHANNEL}-${FG_LAYER} "${nextToken}" AUTO${trans}`)
|
|
||||||
.catch((err) => console.warn(`[playout] LOADBG failed: ${err.message}`));
|
|
||||||
|
|
||||||
// Arm the pointer-advance timer. Without duration metadata we can't time the
|
|
||||||
// hand-off; leave AUTO to take clip N+1 visually but log a warning since the
|
|
||||||
// pointer (and thus clip N+2 cueing) will stall.
|
|
||||||
const durMs = this._itemDurationMs(item);
|
|
||||||
if (durMs == null) {
|
|
||||||
console.warn(`[playout] no duration for clip [${this.state.currentIndex}] — pointer advance stalled after this clip`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._advanceTimer = setTimeout(() => {
|
|
||||||
this._advanceTimer = null;
|
|
||||||
// The AUTO take already happened in CasparCG; just move our pointer and
|
|
||||||
// cue the clip after next. _playIndex would re-PLAY and double-take, so we
|
|
||||||
// advance state directly and re-arm.
|
|
||||||
this.state.currentIndex = next;
|
|
||||||
this.state.currentClip = nextItem.clip_name || nextToken;
|
|
||||||
console.log(`[playout] advance -> [${next}] ${nextToken}`);
|
|
||||||
this._reportAsRunStart(nextItem);
|
|
||||||
this._scheduleAdvance(nextItem);
|
|
||||||
}, Math.max(250, durMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
_nextIndex() {
|
|
||||||
const n = this.state.currentIndex + 1;
|
|
||||||
if (n < this.state.playlist.length) return n;
|
|
||||||
if (this.state.loop && this.state.playlist.length > 0) return 0;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_clearAdvance() {
|
|
||||||
if (this._advanceTimer) { clearTimeout(this._advanceTimer); this._advanceTimer = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async skip() {
|
|
||||||
const next = this._nextIndex();
|
|
||||||
if (next === null) { await this.stopChannel(); return this.getStatus(); }
|
|
||||||
await this._playIndex(next);
|
|
||||||
return this.getStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
async pause() {
|
|
||||||
try { await this.amcp.send(`PAUSE ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
|
|
||||||
return this.getStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
async resume() {
|
|
||||||
try { await this.amcp.send(`RESUME ${CHANNEL}-${FG_LAYER}`); } catch (_) {}
|
|
||||||
return this.getStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
_reportAsRunStart(item) {
|
|
||||||
// The mam-api owns the as-run table; the sidecar just logs locally. The API
|
|
||||||
// polls /status and writes as-run rows on clip change. Keeping the DB write
|
|
||||||
// in the API avoids giving the sidecar a DB connection.
|
|
||||||
this.state.currentItemId = item.id || null;
|
|
||||||
this.state.currentItemStartedAt = new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatus() {
|
|
||||||
return {
|
|
||||||
running: this.state.running,
|
|
||||||
outputType: this.state.outputType,
|
|
||||||
videoFormat: this.state.videoFormat,
|
|
||||||
currentIndex: this.state.currentIndex,
|
|
||||||
currentClip: this.state.currentClip,
|
|
||||||
currentItemId: this.state.currentItemId || null,
|
|
||||||
currentItemStartedAt: this.state.currentItemStartedAt || null,
|
|
||||||
playlistLength: this.state.playlist.length,
|
|
||||||
loop: this.state.loop,
|
|
||||||
startedAt: this.state.startedAt,
|
|
||||||
lastError: this.state.lastError,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new PlayoutManager();
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// app.jsx - main shell
|
// app.jsx — main shell
|
||||||
|
|
||||||
const ACCENT = '#5B7CFA';
|
const ACCENT = '#5B7CFA';
|
||||||
|
|
||||||
|
|
@ -40,14 +40,6 @@ function App() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const navigate = (id) => { setOpenAsset(null); setRoute(id); };
|
const navigate = (id) => { setOpenAsset(null); setRoute(id); };
|
||||||
|
|
||||||
// Window-level nav event so deeply nested components (like the Tokens
|
|
||||||
// "see the parody" link) can route without prop drilling.
|
|
||||||
React.useEffect(() => {
|
|
||||||
const handler = (e) => { if (e && e.detail) navigate(e.detail); };
|
|
||||||
window.addEventListener('df:nav', handler);
|
|
||||||
return () => window.removeEventListener('df:nav', handler);
|
|
||||||
}, []);
|
|
||||||
const openProjectFromAnywhere = (p) => { setOpenAsset(null); setOpenProject(p); setRoute('library'); };
|
const openProjectFromAnywhere = (p) => { setOpenAsset(null); setOpenProject(p); setRoute('library'); };
|
||||||
|
|
||||||
const crumbs = React.useMemo(() => {
|
const crumbs = React.useMemo(() => {
|
||||||
|
|
@ -67,7 +59,7 @@ function App() {
|
||||||
schedule: ['Ingest', 'Schedule'],
|
schedule: ['Ingest', 'Schedule'],
|
||||||
youtube: ['Ingest', 'YouTube'],
|
youtube: ['Ingest', 'YouTube'],
|
||||||
capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
|
capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
|
||||||
jobs: ['Jobs'], editor: ['Editor'], playout: ['Operations', 'Playout'],
|
jobs: ['Jobs'], editor: ['Editor'],
|
||||||
users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
|
users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
|
||||||
containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
|
containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
|
||||||
settings: ['Admin', 'Settings'],
|
settings: ['Admin', 'Settings'],
|
||||||
|
|
@ -97,18 +89,11 @@ function App() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin-only destinations. Non-admins who reach one (deep link, keyboard
|
|
||||||
// router, stale tab) get bounced home instead of a broken/forbidden page.
|
|
||||||
// The API enforces the same rules — this is just UX.
|
|
||||||
const ADMIN_ROUTES = new Set(['users', 'containers', 'cluster', 'settings']);
|
|
||||||
const isAdmin = window.ZAMPP_DATA?.ME?.role === 'admin';
|
|
||||||
const effectiveRoute = (ADMIN_ROUTES.has(route) && !isAdmin) ? 'home' : route;
|
|
||||||
|
|
||||||
let content;
|
let content;
|
||||||
if (openAsset) {
|
if (openAsset) {
|
||||||
content = <AssetDetail asset={openAsset} onClose={() => setOpenAsset(null)} />;
|
content = <AssetDetail asset={openAsset} onClose={() => setOpenAsset(null)} />;
|
||||||
} else {
|
} else {
|
||||||
switch (effectiveRoute) {
|
switch (route) {
|
||||||
case 'home': content = <Home navigate={navigate} />; break;
|
case 'home': content = <Home navigate={navigate} />; break;
|
||||||
case 'dashboard': content = <Dashboard navigate={navigate} />; break;
|
case 'dashboard': content = <Dashboard navigate={navigate} />; break;
|
||||||
case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} onClearProject={() => setOpenProject(null)} />; break;
|
case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} onClearProject={() => setOpenProject(null)} />; break;
|
||||||
|
|
@ -120,10 +105,9 @@ function App() {
|
||||||
case 'capture': content = <Capture navigate={navigate} />; break;
|
case 'capture': content = <Capture navigate={navigate} />; break;
|
||||||
case 'monitors': content = <Monitors navigate={navigate} />; break;
|
case 'monitors': content = <Monitors navigate={navigate} />; break;
|
||||||
case 'jobs': content = <Jobs navigate={navigate} />; break;
|
case 'jobs': content = <Jobs navigate={navigate} />; break;
|
||||||
case 'playout': content = <Playout navigate={navigate} />; break;
|
case 'editor': content = <Editor />; break;
|
||||||
case 'users': content = <Users />; break;
|
case 'users': content = <Users />; break;
|
||||||
case 'tokens': content = <Tokens />; break;
|
case 'tokens': content = <Tokens />; break;
|
||||||
case 'billing': content = <TokensParody />; break;
|
|
||||||
case 'containers':content = <Containers />; break;
|
case 'containers':content = <Containers />; break;
|
||||||
case 'cluster': content = <Cluster />; break;
|
case 'cluster': content = <Cluster />; break;
|
||||||
case 'settings': content = <Settings />; break;
|
case 'settings': content = <Settings />; break;
|
||||||
|
|
@ -131,7 +115,7 @@ function App() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Home (launcher) suppresses the topbar - it's a full-bleed landing page.
|
// Home (launcher) suppresses the topbar — it's a full-bleed landing page.
|
||||||
const hideTopbar = !openAsset && route === 'home';
|
const hideTopbar = !openAsset && route === 'home';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
// auth-gate.jsx - owns the "logged in or not" state.
|
// auth-gate.jsx — owns the "logged in or not" state.
|
||||||
//
|
//
|
||||||
// The SPA boots into <AuthGate>, which calls GET /auth/me. On 401 it then
|
// The SPA boots into <AuthGate>, which calls GET /auth/me. On 401 it then
|
||||||
// calls GET /auth/setup-required and renders <SetupScreen> or <LoginScreen>
|
// calls GET /auth/setup-required and renders <SetupScreen> or <LoginScreen>
|
||||||
// (defined in screens-auth.jsx, Task 16). On 200 it renders the real <App>.
|
// (defined in screens-auth.jsx, Task 16). On 200 it renders the real <App>.
|
||||||
//
|
//
|
||||||
// This component is the SINGLE source of truth for the auth check - no other
|
// This component is the SINGLE source of truth for the auth check — no other
|
||||||
// component should redirect to a login page or wipe data on 401. Other code
|
// component should redirect to a login page or wipe data on 401. Other code
|
||||||
// surfaces auth failure by calling window.AuthGate.bounce(), which re-mounts
|
// surfaces auth failure by calling window.AuthGate.bounce(), which re-mounts
|
||||||
// the gate so the next /auth/me request decides what to do.
|
// the gate so the next /auth/me request decides what to do.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// data.jsx - API client; populates window.ZAMPP_DATA from real endpoints
|
// data.jsx — API client; populates window.ZAMPP_DATA from real endpoints
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1';
|
||||||
window.ZAMPP_API_PREFIX = API; // single source of truth (#115)
|
window.ZAMPP_API_PREFIX = API; // single source of truth (#115)
|
||||||
|
|
@ -22,28 +22,41 @@ window.ZAMPP_API_PREFIX = API; // single source of truth (#115)
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Premiere panel releases embedded in this deployment. Bumping the version
|
// Premiere panel releases embedded in this deployment. Bumping the version
|
||||||
// here is the single source of truth - both the Editor download buttons and
|
// here is the single source of truth — both the Editor download buttons and
|
||||||
// the Settings → Capture SDKs page read from this list (#125).
|
// the Settings → Capture SDKs page read from this list (#125).
|
||||||
//
|
|
||||||
// The panel is now a UXP plugin (.ccx), replacing the legacy CEP/ZXP panel.
|
|
||||||
// Each entry carries a `ccx` URL; the older `zxp`/`installer` fields are gone.
|
|
||||||
window.PREMIERE_RELEASES = [
|
window.PREMIERE_RELEASES = [
|
||||||
{
|
{
|
||||||
version: '2.2.2',
|
version: '2.2.2',
|
||||||
ccx: '/downloads/dragonflight-mam-2.2.2.ccx',
|
ccx: '/downloads/dragonflight-mam-2.2.2.ccx',
|
||||||
|
zxp: null,
|
||||||
installer: null,
|
installer: null,
|
||||||
notes: 'UXP plugin: one-click Export Timeline, library + conform + auto-relink, growing-file mount, runtime version chip. Replaces the legacy CEP/ZXP panel.',
|
notes: 'UXP panel — redesigned icon-rail UI, compact list view, single Export menu (Conform / Local Export), Upload to MAM',
|
||||||
latest: true,
|
latest: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: '1.2.0',
|
||||||
|
zxp: '/downloads/dragonflight-premiere-panel-1.2.0.zxp',
|
||||||
|
installer: null,
|
||||||
|
notes: 'Legacy CEP panel — design system refresh',
|
||||||
|
latest: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '1.0.1',
|
||||||
|
zxp: '/downloads/dragonflight-premiere-panel-1.0.1.zxp',
|
||||||
|
installer: '/downloads/dragonflight-premiere-panel-1.0.1-windows-setup.exe',
|
||||||
|
notes: 'Auto-relinking, growing-file support, batch trim',
|
||||||
|
latest: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: '1.0.0',
|
||||||
|
zxp: '/downloads/dragonflight-premiere-panel-1.0.0.zxp',
|
||||||
|
installer: '/downloads/dragonflight-premiere-panel-1.0.0-windows-setup.exe',
|
||||||
|
notes: 'Initial release',
|
||||||
|
latest: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
window.PREMIERE_LATEST = window.PREMIERE_RELEASES.find(r => r.latest) || window.PREMIERE_RELEASES[0];
|
window.PREMIERE_LATEST = window.PREMIERE_RELEASES.find(r => r.latest) || window.PREMIERE_RELEASES[0];
|
||||||
|
|
||||||
// Teams ISO workstation installer. Placeholder slot: the .exe is not in the
|
|
||||||
// repo yet, so `available` is false and the Downloads modal renders the row
|
|
||||||
// disabled with a "coming soon" note. Drop the file into public/downloads/
|
|
||||||
// and flip `available: true` (set `version`) to finish it.
|
|
||||||
window.TEAMS_ISO = { version: null, url: '/downloads/TeamsISO.exe', available: false };
|
|
||||||
|
|
||||||
window.ZAMPP_DATA = {
|
window.ZAMPP_DATA = {
|
||||||
PROJECTS: [],
|
PROJECTS: [],
|
||||||
ASSETS: [],
|
ASSETS: [],
|
||||||
|
|
@ -81,7 +94,7 @@ async function apiFetch(path, opts = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDuration(ms) {
|
function fmtDuration(ms) {
|
||||||
if (!ms) return '·';
|
if (!ms) return '—';
|
||||||
const s = Math.round(ms / 1000);
|
const s = Math.round(ms / 1000);
|
||||||
const h = Math.floor(s / 3600);
|
const h = Math.floor(s / 3600);
|
||||||
const m = Math.floor((s % 3600) / 60);
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
|
@ -91,7 +104,7 @@ function fmtDuration(ms) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtSize(bytes) {
|
function fmtSize(bytes) {
|
||||||
if (!bytes) return '·';
|
if (!bytes) return '—';
|
||||||
if (bytes >= 1e12) return (bytes / 1e12).toFixed(1) + ' TB';
|
if (bytes >= 1e12) return (bytes / 1e12).toFixed(1) + ' TB';
|
||||||
if (bytes >= 1e9) return (bytes / 1e9).toFixed(1) + ' GB';
|
if (bytes >= 1e9) return (bytes / 1e9).toFixed(1) + ' GB';
|
||||||
if (bytes >= 1e6) return Math.round(bytes / 1e6) + ' MB';
|
if (bytes >= 1e6) return Math.round(bytes / 1e6) + ' MB';
|
||||||
|
|
@ -99,7 +112,7 @@ function fmtSize(bytes) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtRelative(iso) {
|
function fmtRelative(iso) {
|
||||||
if (!iso) return '·';
|
if (!iso) return '—';
|
||||||
const diff = (Date.now() - new Date(iso)) / 1000;
|
const diff = (Date.now() - new Date(iso)) / 1000;
|
||||||
if (diff < 60) return 'just now';
|
if (diff < 60) return 'just now';
|
||||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||||
|
|
@ -117,7 +130,7 @@ function normalizeAsset(a, projectMap) {
|
||||||
type: a.media_type || 'video',
|
type: a.media_type || 'video',
|
||||||
duration: fmtDuration(a.duration_ms),
|
duration: fmtDuration(a.duration_ms),
|
||||||
size: fmtSize(a.file_size),
|
size: fmtSize(a.file_size),
|
||||||
res: a.resolution || '·',
|
res: a.resolution || '—',
|
||||||
updated: fmtRelative(a.updated_at),
|
updated: fmtRelative(a.updated_at),
|
||||||
project: (projectMap && projectMap[a.project_id]) || '',
|
project: (projectMap && projectMap[a.project_id]) || '',
|
||||||
comments: 0,
|
comments: 0,
|
||||||
|
|
@ -128,7 +141,7 @@ function normalizeAsset(a, projectMap) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRecorder(r) {
|
function normalizeRecorder(r) {
|
||||||
let elapsed = '·';
|
let elapsed = '—';
|
||||||
if (r.status === 'recording' && r.started_at) {
|
if (r.status === 'recording' && r.started_at) {
|
||||||
const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
|
const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
|
||||||
elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
|
elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
|
||||||
|
|
@ -138,13 +151,13 @@ function normalizeRecorder(r) {
|
||||||
const cfg = r.source_config || {};
|
const cfg = r.source_config || {};
|
||||||
return {
|
return {
|
||||||
...r,
|
...r,
|
||||||
source: r.source_type || '·',
|
source: r.source_type || '—',
|
||||||
url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '·',
|
url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '—',
|
||||||
codec: r.recording_codec || '·',
|
codec: r.recording_codec || '—',
|
||||||
res: r.recording_resolution || '·',
|
res: r.recording_resolution || '—',
|
||||||
node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
|
node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
|
||||||
elapsed,
|
elapsed,
|
||||||
bitrate: '·',
|
bitrate: '—',
|
||||||
health: 100,
|
health: 100,
|
||||||
audio: false,
|
audio: false,
|
||||||
};
|
};
|
||||||
|
|
@ -158,9 +171,9 @@ function normalizeJob(j) {
|
||||||
...j,
|
...j,
|
||||||
status: statusMap[j.status] || j.status,
|
status: statusMap[j.status] || j.status,
|
||||||
kind: kindMap[j.type] || j.type || 'Job',
|
kind: kindMap[j.type] || j.type || 'Job',
|
||||||
asset: j.asset_name || meta.filename || '·',
|
asset: j.asset_name || meta.filename || '—',
|
||||||
eta: '·',
|
eta: '—',
|
||||||
node: meta.node || '·',
|
node: meta.node || '—',
|
||||||
priority: meta.priority || 'normal',
|
priority: meta.priority || 'normal',
|
||||||
error: j.error || null,
|
error: j.error || null,
|
||||||
progress: j.progress || 0,
|
progress: j.progress || 0,
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -8,11 +8,10 @@ const ICONS = {
|
||||||
upload: <><path d="M12 16V4" /><path d="M6 10l6-6 6 6" /><path d="M4 20h16" /></>,
|
upload: <><path d="M12 16V4" /><path d="M6 10l6-6 6 6" /><path d="M4 20h16" /></>,
|
||||||
record: <><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M22 8l-6 4 6 4V8z" /></>,
|
record: <><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M22 8l-6 4 6 4V8z" /></>,
|
||||||
capture: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="4" /><circle cx="12" cy="12" r="1" /></>,
|
capture: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="4" /><circle cx="12" cy="12" r="1" /></>,
|
||||||
jobs: <><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1" fill="currentColor" /><circle cx="4" cy="12" r="1" fill="currentColor" /><circle cx="4" cy="18" r="1" fill="currentColor" /></>,
|
jobs: <><path d="M3 6h18" /><path d="M3 12h18" /><path d="M3 18h12" /></>,
|
||||||
editor: <><path d="M14.06 2.94l7 7-11 11H3v-7.06l11.06-10.94z" /><path d="M13 4l7 7" /></>,
|
editor: <><path d="M14.06 2.94l7 7-11 11H3v-7.06l11.06-10.94z" /><path d="M13 4l7 7" /></>,
|
||||||
users: <><circle cx="9" cy="8" r="4" /><path d="M2 21a7 7 0 0 1 14 0" /><circle cx="17" cy="6" r="3" /><path d="M22 18a5 5 0 0 0-7-4.5" /></>,
|
users: <><circle cx="9" cy="8" r="4" /><path d="M2 21a7 7 0 0 1 14 0" /><circle cx="17" cy="6" r="3" /><path d="M22 18a5 5 0 0 0-7-4.5" /></>,
|
||||||
token: <><circle cx="8" cy="15" r="4" /><path d="M10.85 12.15L19 4" /><path d="M18 5l3 3" /><path d="M15 8l3 3" /></>,
|
token: <><circle cx="8" cy="15" r="4" /><path d="M10.85 12.15L19 4" /><path d="M18 5l3 3" /><path d="M15 8l3 3" /></>,
|
||||||
dollar: <><line x1="12" y1="2" x2="12" y2="22" /><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></>,
|
|
||||||
container: <><rect x="3" y="6" width="18" height="12" rx="1" /><path d="M3 10h18" /><circle cx="7" cy="14" r="1" fill="currentColor" /><circle cx="11" cy="14" r="1" fill="currentColor" /></>,
|
container: <><rect x="3" y="6" width="18" height="12" rx="1" /><path d="M3 10h18" /><circle cx="7" cy="14" r="1" fill="currentColor" /><circle cx="11" cy="14" r="1" fill="currentColor" /></>,
|
||||||
cluster: <><circle cx="12" cy="5" r="2.5" /><circle cx="5" cy="19" r="2.5" /><circle cx="19" cy="19" r="2.5" /><path d="M12 7.5l-6.5 9M12 7.5l6.5 9M7.5 19h9" /></>,
|
cluster: <><circle cx="12" cy="5" r="2.5" /><circle cx="5" cy="19" r="2.5" /><circle cx="19" cy="19" r="2.5" /><path d="M12 7.5l-6.5 9M12 7.5l6.5 9M7.5 19h9" /></>,
|
||||||
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" /></>,
|
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z" /></>,
|
||||||
|
|
@ -38,14 +37,14 @@ const ICONS = {
|
||||||
x: <path d="M6 6l12 12M6 18L18 6" />,
|
x: <path d="M6 6l12 12M6 18L18 6" />,
|
||||||
filter: <path d="M3 5h18l-7 9v6l-4-2v-4L3 5z" />,
|
filter: <path d="M3 5h18l-7 9v6l-4-2v-4L3 5z" />,
|
||||||
sort: <><path d="M3 6h13M3 12h9M3 18h5" /><path d="M17 14l3 3 3-3M20 9v8" /></>,
|
sort: <><path d="M3 6h13M3 12h9M3 18h5" /><path d="M17 14l3 3 3-3M20 9v8" /></>,
|
||||||
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
|
grid: <><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></>,
|
||||||
list: <><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1" fill="currentColor" /><circle cx="4" cy="12" r="1" fill="currentColor" /><circle cx="4" cy="18" r="1" fill="currentColor" /></>,
|
list: <><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1" fill="currentColor" /><circle cx="4" cy="12" r="1" fill="currentColor" /><circle cx="4" cy="18" r="1" fill="currentColor" /></>,
|
||||||
comment: <path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z" />,
|
comment: <path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z" />,
|
||||||
clock: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>,
|
clock: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>,
|
||||||
layers: <><path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5M2 12l10 5 10-5" /></>,
|
layers: <><path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5M2 12l10 5 10-5" /></>,
|
||||||
gpu: <><rect x="3" y="7" width="18" height="10" rx="1" /><rect x="6" y="10" width="4" height="4" /><rect x="14" y="10" width="4" height="4" /><path d="M3 11H1M3 13H1M23 11h-2M23 13h-2" /></>,
|
gpu: <><rect x="3" y="7" width="18" height="10" rx="1" /><rect x="6" y="10" width="4" height="4" /><rect x="14" y="10" width="4" height="4" /><path d="M3 11H1M3 13H1M23 11h-2M23 13h-2" /></>,
|
||||||
cpu: <><rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" /><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3" /></>,
|
cpu: <><rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" /><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3" /></>,
|
||||||
hdd: <><ellipse cx="12" cy="6" rx="9" ry="3" /><path d="M3 6v12c0 1.66 4.03 3 9 3s9-1.34 9-3V6" /></>,
|
hdd: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="1" fill="currentColor" /></>,
|
||||||
sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></>,
|
sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></>,
|
||||||
moon: <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />,
|
moon: <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />,
|
||||||
signal: <><path d="M2 20h.01M7 20v-4M12 20v-8M17 20V8M22 20V4" /></>,
|
signal: <><path d="M2 20h.01M7 20v-4M12 20v-8M17 20V8M22 20V4" /></>,
|
||||||
|
|
@ -66,7 +65,7 @@ const ICONS = {
|
||||||
power: <><path d="M18.36 6.64a9 9 0 1 1-12.73 0" /><path d="M12 2v10" /></>,
|
power: <><path d="M18.36 6.64a9 9 0 1 1-12.73 0" /><path d="M12 2v10" /></>,
|
||||||
globe: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></>,
|
globe: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></>,
|
||||||
package: <><path d="M3 7l9-4 9 4M3 7v10l9 4 9-4V7M3 7l9 4 9-4M12 11v10" /></>,
|
package: <><path d="M3 7l9-4 9 4M3 7v10l9 4 9-4V7M3 7l9 4 9-4M12 11v10" /></>,
|
||||||
proxy: <><path d="M4 6h11M19 6h1M4 12h2M10 12h10M4 18h7M15 18h5" /><circle cx="17" cy="6" r="2" /><circle cx="8" cy="12" r="2" /><circle cx="13" cy="18" r="2" /></>,
|
proxy: <><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M9 12l3-3 3 3M12 9v8" /></>,
|
||||||
};
|
};
|
||||||
|
|
||||||
function Icon({ name, size = 16, className, style }) {
|
function Icon({ name, size = 16, className, style }) {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
<link rel="stylesheet" href="styles-rest.css" />
|
<link rel="stylesheet" href="styles-rest.css" />
|
||||||
<link rel="stylesheet" href="styles-modal.css" />
|
<link rel="stylesheet" href="styles-modal.css" />
|
||||||
<link rel="stylesheet" href="styles-fixes.css" />
|
<link rel="stylesheet" href="styles-fixes.css" />
|
||||||
<link rel="stylesheet" href="styles-playout.css" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
@ -36,7 +35,6 @@
|
||||||
<script src="dist/shell.js"></script>
|
<script src="dist/shell.js"></script>
|
||||||
<script src="dist/auth-gate.js"></script>
|
<script src="dist/auth-gate.js"></script>
|
||||||
<script src="dist/screens-auth.js"></script>
|
<script src="dist/screens-auth.js"></script>
|
||||||
<script src="dist/screens-resources.js"></script>
|
|
||||||
<script src="dist/screens-home.js"></script>
|
<script src="dist/screens-home.js"></script>
|
||||||
<script src="dist/screens-library.js"></script>
|
<script src="dist/screens-library.js"></script>
|
||||||
<script src="dist/screens-asset.js"></script>
|
<script src="dist/screens-asset.js"></script>
|
||||||
|
|
@ -48,7 +46,6 @@
|
||||||
<script src="js/bmd-card.js"></script>
|
<script src="js/bmd-card.js"></script>
|
||||||
<script src="dist/screens-editor.js"></script>
|
<script src="dist/screens-editor.js"></script>
|
||||||
<script src="dist/screens-admin.js"></script>
|
<script src="dist/screens-admin.js"></script>
|
||||||
<script src="dist/screens-playout.js"></script>
|
|
||||||
<script src="dist/modal-new-recorder.js"></script>
|
<script src="dist/modal-new-recorder.js"></script>
|
||||||
<script src="dist/app.js"></script>
|
<script src="dist/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -1,103 +1,4 @@
|
||||||
// modal-new-recorder.jsx - New Recorder dialog (SRT / RTMP / SDI / Deltacast)
|
// modal-new-recorder.jsx — New Recorder dialog (SRT / RTMP / SDI)
|
||||||
|
|
||||||
/**
|
|
||||||
* DevicePortPicker - groups a flat per-port API response by node_id and
|
|
||||||
* renders one button per actual port. Replaces the old code that iterated
|
|
||||||
* over entries and synthesised port counts, which caused duplicate groups.
|
|
||||||
*
|
|
||||||
* props:
|
|
||||||
* ports - flat array from /cluster/devices/blackmagic or /deltacast
|
|
||||||
* each entry: { node_id, hostname, model, index, device, present? }
|
|
||||||
* selectedIdx - currently selected device_index
|
|
||||||
* selectedNode - currently selected node_id
|
|
||||||
* onSelect(idx, nodeId)
|
|
||||||
* portLabel - e.g. "SDI" or "Port"
|
|
||||||
* showTestBadge - show TEST CARD badge when present===false
|
|
||||||
*/
|
|
||||||
function DevicePortPicker({ ports, selectedIdx, selectedNode, onSelect, portLabel = 'Port', showTestBadge = false }) {
|
|
||||||
// Group by node_id (stable - one group per physical node)
|
|
||||||
const groups = React.useMemo(() => {
|
|
||||||
const map = new Map();
|
|
||||||
for (const p of ports) {
|
|
||||||
const key = p.node_id || p.hostname || 'unknown';
|
|
||||||
if (!map.has(key)) map.set(key, { nodeId: p.node_id || p.hostname || '', hostname: p.hostname || key, model: p.model || '', ports: [] });
|
|
||||||
map.get(key).ports.push(p);
|
|
||||||
}
|
|
||||||
// Sort ports within each group by index
|
|
||||||
for (const g of map.values()) g.ports.sort((a, b) => a.index - b.index);
|
|
||||||
return Array.from(map.values());
|
|
||||||
}, [ports]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="sdi-port-mini">
|
|
||||||
{groups.map(group => (
|
|
||||||
<div key={group.nodeId} style={{ marginBottom: groups.length > 1 ? 12 : 4 }}>
|
|
||||||
{/* Node header: only show when multiple groups, or always for clarity */}
|
|
||||||
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-3)', textTransform: 'uppercase', padding: '0 0 6px' }}>
|
|
||||||
{group.model ? group.model.toUpperCase() + ' · ' : ''}{group.hostname}
|
|
||||||
</div>
|
|
||||||
{group.ports.map(port => {
|
|
||||||
const active = selectedIdx === port.index && selectedNode === group.nodeId;
|
|
||||||
return (
|
|
||||||
<button key={port.index}
|
|
||||||
className={`sdi-mini-port${active ? ' active' : ''}`}
|
|
||||||
onClick={() => onSelect(port.index, group.nodeId)}>
|
|
||||||
<span className="sdi-mini-radio"><span className="sdi-mini-radio-dot" /></span>
|
|
||||||
<span style={{ fontSize: 12, fontWeight: 600 }}>
|
|
||||||
{portLabel} {port.index + 1}
|
|
||||||
{showTestBadge && port.present === false && (
|
|
||||||
<span style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.06em', color: 'var(--accent)', background: 'var(--accent-soft)', borderRadius: 3, padding: '1px 5px', marginLeft: 7 }}>
|
|
||||||
TEST CARD
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-3)', marginLeft: 'auto', fontFamily: 'var(--font-mono)' }}>
|
|
||||||
{port.device ? port.device.split('/').pop() : `index ${port.index}`}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ManualDevicePicker - fallback when no devices detected. Lets the operator
|
|
||||||
* pick node + index from dropdowns.
|
|
||||||
*/
|
|
||||||
function ManualDevicePicker({ nodes, nodeId, deviceIdx, portLabel, portCount, onNodeChange, onIdxChange, emptyNote }) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '4px 0' }}>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 10 }}>
|
|
||||||
{emptyNote || `No ${portLabel} devices auto-detected. Configure manually:`}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
|
||||||
<div className="field">
|
|
||||||
<label className="field-label">Capture node</label>
|
|
||||||
<select className="field-input" value={nodeId}
|
|
||||||
onChange={e => onNodeChange(e.target.value)} style={{ appearance: 'auto' }}>
|
|
||||||
{nodes.length === 0
|
|
||||||
? <option value="">No cluster nodes</option>
|
|
||||||
: nodes.map(n => {
|
|
||||||
const id = n.id || n.hostname || n.name || '';
|
|
||||||
return <option key={id} value={id}>{n.hostname || n.name || id}</option>;
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label className="field-label">{portLabel} index</label>
|
|
||||||
<select className="field-input" value={deviceIdx}
|
|
||||||
onChange={e => onIdxChange(Number(e.target.value))} style={{ appearance: 'auto' }}>
|
|
||||||
{Array.from({ length: portCount }, (_, i) =>
|
|
||||||
<option key={i} value={i}>{portLabel} {i + 1} (index {i})</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProbeResult({ result }) {
|
function ProbeResult({ result }) {
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
|
@ -141,25 +42,9 @@ function NewRecorderModal({ open, onClose }) {
|
||||||
return n ? (n.id || n.hostname || '') : '';
|
return n ? (n.id || n.hostname || '') : '';
|
||||||
});
|
});
|
||||||
const [sdiDevices, setSdiDevices] = React.useState(null);
|
const [sdiDevices, setSdiDevices] = React.useState(null);
|
||||||
const [dcDeviceIdx, setDcDeviceIdx] = React.useState(0);
|
|
||||||
const [dcNodeId, setDcNodeId] = React.useState(() => {
|
|
||||||
const n = NODES[0];
|
|
||||||
return n ? (n.id || n.hostname || '') : '';
|
|
||||||
});
|
|
||||||
const [dcDevices, setDcDevices] = React.useState(null);
|
|
||||||
const [recTab, setRecTab] = React.useState('video');
|
const [recTab, setRecTab] = React.useState('video');
|
||||||
// All-Intra HEVC (NVENC) is the default master — GPU-encoded, growing-file
|
const [recCodec, setRecCodec] = React.useState('prores_hq');
|
||||||
// capable in fragmented MOV. ProRes stays selectable for 4:2:2 mezzanine.
|
const [recContainer, setRecContainer] = React.useState('mov');
|
||||||
const [recCodec, setRecCodec] = React.useState('hevc_nvenc');
|
|
||||||
// Custom target bitrate in Mbps for bitrate-controlled codecs (NVENC / x264 /
|
|
||||||
// x265 / DNxHD). ProRes ignores bitrate (quality is profile-driven).
|
|
||||||
const [recBitrate, setRecBitrate] = React.useState('60');
|
|
||||||
// Container is derived from the codec, not manually chosen: HEVC/ProRes/DNxHR
|
|
||||||
// → MOV (fragmented, growing-capable); H.264 → MP4.
|
|
||||||
const recContainer = (recCodec === 'h264' || recCodec === 'h264_nvenc' || recCodec === 'libx264') ? 'mp4' : 'mov';
|
|
||||||
// Codecs whose bitrate is operator-controlled (everything except ProRes).
|
|
||||||
const BITRATE_CODECS = new Set(['hevc_nvenc', 'h264_nvenc', 'libx264', 'libx265', 'dnxhd', 'dnxhr_hq']);
|
|
||||||
const codecUsesBitrate = BITRATE_CODECS.has(recCodec);
|
|
||||||
const [proxyOn, setProxyOn] = React.useState(true);
|
const [proxyOn, setProxyOn] = React.useState(true);
|
||||||
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
|
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
|
||||||
const [submitting, setSubmitting] = React.useState(false);
|
const [submitting, setSubmitting] = React.useState(false);
|
||||||
|
|
@ -174,13 +59,6 @@ function NewRecorderModal({ open, onClose }) {
|
||||||
.catch(() => setSdiDevices([]));
|
.catch(() => setSdiDevices([]));
|
||||||
}, [sourceType]);
|
}, [sourceType]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (sourceType !== 'DELTACAST' || dcDevices !== null) return;
|
|
||||||
window.ZAMPP_API.fetch('/cluster/devices/deltacast')
|
|
||||||
.then(d => setDcDevices(Array.isArray(d) ? d : []))
|
|
||||||
.catch(() => setDcDevices([]));
|
|
||||||
}, [sourceType]);
|
|
||||||
|
|
||||||
React.useEffect(() => { setProbeResult(null); }, [sourceType, srtUrl, rtmpUrl]);
|
React.useEffect(() => { setProbeResult(null); }, [sourceType, srtUrl, rtmpUrl]);
|
||||||
|
|
||||||
const handleProbe = () => {
|
const handleProbe = () => {
|
||||||
|
|
@ -197,7 +75,6 @@ function NewRecorderModal({ open, onClose }) {
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
if (!name.trim()) { setSubmitErr('Recorder name is required.'); return; }
|
if (!name.trim()) { setSubmitErr('Recorder name is required.'); return; }
|
||||||
if (sourceType === 'SDI' && !sdiNodeId) { setSubmitErr('Select a capture node for SDI.'); return; }
|
if (sourceType === 'SDI' && !sdiNodeId) { setSubmitErr('Select a capture node for SDI.'); return; }
|
||||||
if (sourceType === 'DELTACAST' && !dcNodeId) { setSubmitErr('Select a capture node for Deltacast.'); return; }
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setSubmitErr(null);
|
setSubmitErr(null);
|
||||||
|
|
||||||
|
|
@ -208,25 +85,14 @@ function NewRecorderModal({ open, onClose }) {
|
||||||
generate_proxy: proxyOn,
|
generate_proxy: proxyOn,
|
||||||
recording_codec: recCodec,
|
recording_codec: recCodec,
|
||||||
recording_container: recContainer,
|
recording_container: recContainer,
|
||||||
// Framerate + resolution are auto-detected from the source signal/stream.
|
|
||||||
recording_framerate: '', // empty = match source
|
|
||||||
recording_resolution: 'native',
|
|
||||||
};
|
};
|
||||||
// Custom bitrate only applies to bitrate-controlled codecs (ProRes ignores it).
|
|
||||||
if (codecUsesBitrate && recBitrate) {
|
|
||||||
body.recording_video_bitrate = `${recBitrate}M`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceType === 'SRT') {
|
if (sourceType === 'SRT') {
|
||||||
body.source_config = { url: srtUrl };
|
body.source_config = { url: srtUrl };
|
||||||
} else if (sourceType === 'RTMP') {
|
} else if (sourceType === 'RTMP') {
|
||||||
body.source_config = { url: rtmpUrl };
|
body.source_config = { url: rtmpUrl };
|
||||||
} else if (sourceType === 'DELTACAST') {
|
|
||||||
body.source_config = {};
|
|
||||||
body.device_index = dcDeviceIdx;
|
|
||||||
body.node_id = dcNodeId || undefined;
|
|
||||||
} else {
|
} else {
|
||||||
// SDI (DeckLink): device_index and node_id are top-level fields
|
// SDI: device_index and node_id are top-level fields
|
||||||
body.source_config = {};
|
body.source_config = {};
|
||||||
body.device_index = sdiDeviceIdx;
|
body.device_index = sdiDeviceIdx;
|
||||||
body.node_id = sdiNodeId || undefined;
|
body.node_id = sdiNodeId || undefined;
|
||||||
|
|
@ -267,10 +133,9 @@ function NewRecorderModal({ open, onClose }) {
|
||||||
<label className="field-label">Source type</label>
|
<label className="field-label">Source type</label>
|
||||||
<div className="source-type-grid">
|
<div className="source-type-grid">
|
||||||
{[
|
{[
|
||||||
{ id: 'SRT', label: 'SRT', desc: 'Secure Reliable Transport · pull caller', icon: 'signal' },
|
{ id: 'SRT', label: 'SRT', desc: 'Secure Reliable Transport — pull caller', icon: 'signal' },
|
||||||
{ id: 'RTMP', label: 'RTMP', desc: 'Real-Time Messaging Protocol', icon: 'globe' },
|
{ id: 'RTMP', label: 'RTMP', desc: 'Real-Time Messaging Protocol', icon: 'globe' },
|
||||||
{ id: 'SDI', label: 'SDI', desc: 'Blackmagic DeckLink hardware', icon: 'video' },
|
{ id: 'SDI', label: 'SDI', desc: 'Blackmagic DeckLink hardware', icon: 'video' },
|
||||||
{ id: 'DELTACAST', label: 'Deltacast', desc: 'Deltacast VideoMaster SDI', icon: 'video' },
|
|
||||||
].map(t => (
|
].map(t => (
|
||||||
<button key={t.id}
|
<button key={t.id}
|
||||||
className={`source-type-card ${sourceType === t.id ? 'active' : ''}`}
|
className={`source-type-card ${sourceType === t.id ? 'active' : ''}`}
|
||||||
|
|
@ -328,55 +193,54 @@ function NewRecorderModal({ open, onClose }) {
|
||||||
<div style={{ padding: '10px 0', color: 'var(--text-3)', fontSize: 12 }}>Detecting DeckLink devices…</div>
|
<div style={{ padding: '10px 0', color: 'var(--text-3)', fontSize: 12 }}>Detecting DeckLink devices…</div>
|
||||||
)}
|
)}
|
||||||
{sdiDevices !== null && sdiDevices.length > 0 && (
|
{sdiDevices !== null && sdiDevices.length > 0 && (
|
||||||
<DevicePortPicker
|
<div className="sdi-port-mini">
|
||||||
ports={sdiDevices}
|
{sdiDevices.map((dev, di) => (
|
||||||
selectedIdx={sdiDeviceIdx}
|
<div key={di} style={{ marginBottom: 8 }}>
|
||||||
selectedNode={sdiNodeId}
|
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-3)', textTransform: 'uppercase', padding: '4px 0 8px' }}>
|
||||||
onSelect={(idx, nodeId) => { setSdiDeviceIdx(idx); setSdiNodeId(nodeId); }}
|
{(dev.model || dev.device || 'DeckLink').toUpperCase()} · {dev.hostname}
|
||||||
portLabel="SDI"
|
</div>
|
||||||
/>
|
{Array.from({ length: dev.port_count || 4 }, (_, i) => i).map(idx => (
|
||||||
|
<button key={idx}
|
||||||
|
className={`sdi-mini-port ${sdiDeviceIdx === idx && sdiNodeId === (dev.node_id || dev.hostname || '') ? 'active' : ''}`}
|
||||||
|
onClick={() => { setSdiDeviceIdx(idx); setSdiNodeId(dev.node_id || dev.hostname || ''); }}>
|
||||||
|
<span className="sdi-mini-radio"><span className="sdi-mini-radio-dot" /></span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600 }}>SDI {idx + 1}</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-3)', marginLeft: 6 }}>index {idx}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{sdiDevices !== null && sdiDevices.length === 0 && (
|
{sdiDevices !== null && sdiDevices.length === 0 && (
|
||||||
<ManualDevicePicker
|
<div style={{ padding: '8px 0' }}>
|
||||||
nodes={NODES}
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 10 }}>
|
||||||
nodeId={sdiNodeId}
|
No DeckLink devices auto-detected. Configure manually:
|
||||||
deviceIdx={sdiDeviceIdx}
|
</div>
|
||||||
portLabel="SDI"
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||||
portCount={4}
|
<div className="field">
|
||||||
onNodeChange={setSdiNodeId}
|
<label className="field-label">Capture node</label>
|
||||||
onIdxChange={setSdiDeviceIdx}
|
<select className="field-input" value={sdiNodeId}
|
||||||
/>
|
onChange={e => setSdiNodeId(e.target.value)} style={{ appearance: 'auto' }}>
|
||||||
)}
|
{NODES.length === 0
|
||||||
</div>
|
? <option value="">No cluster nodes</option>
|
||||||
)}
|
: NODES.map(n => {
|
||||||
|
const id = n.id || n.hostname || n.name || '';
|
||||||
{sourceType === 'DELTACAST' && (
|
const label = n.hostname || n.name || id;
|
||||||
<div className="field">
|
return <option key={id} value={id}>{label}</option>;
|
||||||
<label className="field-label">Capture device</label>
|
})}
|
||||||
{dcDevices === null && (
|
</select>
|
||||||
<div style={{ padding: '10px 0', color: 'var(--text-3)', fontSize: 12 }}>Detecting Deltacast devices…</div>
|
</div>
|
||||||
)}
|
<div className="field">
|
||||||
{dcDevices !== null && dcDevices.length > 0 && (
|
<label className="field-label">Device index</label>
|
||||||
<DevicePortPicker
|
<select className="field-input" value={sdiDeviceIdx}
|
||||||
ports={dcDevices}
|
onChange={e => setSdiDeviceIdx(Number(e.target.value))} style={{ appearance: 'auto' }}>
|
||||||
selectedIdx={dcDeviceIdx}
|
{[0, 1, 2, 3].map(i =>
|
||||||
selectedNode={dcNodeId}
|
<option key={i} value={i}>SDI {i + 1} (index {i})</option>)}
|
||||||
onSelect={(idx, nodeId) => { setDcDeviceIdx(idx); setDcNodeId(nodeId); }}
|
</select>
|
||||||
portLabel="Port"
|
</div>
|
||||||
showTestBadge
|
</div>
|
||||||
/>
|
</div>
|
||||||
)}
|
|
||||||
{dcDevices !== null && dcDevices.length === 0 && (
|
|
||||||
<ManualDevicePicker
|
|
||||||
nodes={NODES}
|
|
||||||
nodeId={dcNodeId}
|
|
||||||
deviceIdx={dcDeviceIdx}
|
|
||||||
portLabel="Port"
|
|
||||||
portCount={8}
|
|
||||||
onNodeChange={setDcNodeId}
|
|
||||||
onIdxChange={setDcDeviceIdx}
|
|
||||||
emptyNote="No Deltacast devices detected. Configure manually (test-card mode):"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -399,48 +263,22 @@ function NewRecorderModal({ open, onClose }) {
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="field-label">Video codec</label>
|
<label className="field-label">Video codec</label>
|
||||||
<select className="field-input" value={recCodec} onChange={e => setRecCodec(e.target.value)} style={{ appearance: 'auto' }}>
|
<select className="field-input" value={recCodec} onChange={e => setRecCodec(e.target.value)} style={{ appearance: 'auto' }}>
|
||||||
<option value="hevc_nvenc">All-Intra HEVC (NVENC) — GPU, growing</option>
|
<option value="prores_4444xq">ProRes 4444 XQ</option>
|
||||||
<option value="h264_nvenc">H.264 (NVENC) — GPU</option>
|
<option value="prores_4444">ProRes 4444</option>
|
||||||
<option value="prores_hq">ProRes 422 HQ — 4:2:2 CPU</option>
|
<option value="prores_hq">ProRes 422 HQ</option>
|
||||||
<option value="prores">ProRes 422</option>
|
<option value="prores">ProRes 422</option>
|
||||||
<option value="prores_lt">ProRes 422 LT</option>
|
<option value="prores_lt">ProRes 422 LT</option>
|
||||||
<option value="prores_proxy">ProRes 422 Proxy</option>
|
<option value="prores_proxy">ProRes 422 Proxy</option>
|
||||||
|
<option value="libx264">H.264 (x264)</option>
|
||||||
|
<option value="libx265">H.265 / HEVC (x265)</option>
|
||||||
|
<option value="dnxhd">DNxHD 185x</option>
|
||||||
<option value="dnxhr_hq">DNxHR HQ</option>
|
<option value="dnxhr_hq">DNxHR HQ</option>
|
||||||
<option value="libx264">H.264 (x264, CPU)</option>
|
<option value="xdcam_hd422">XDCAM HD422</option>
|
||||||
<option value="libx265">H.265 (x265, CPU)</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{codecUsesBitrate ? (
|
<Field label="Resolution" value="Source (native)" select />
|
||||||
<div className="field">
|
<Field label="Color space" value="Rec. 709" select />
|
||||||
<label className="field-label">Target bitrate (Mbps)</label>
|
<Field label="Bit depth" value="10-bit" select />
|
||||||
<input
|
|
||||||
className="field-input"
|
|
||||||
type="number" min="1" max="400" step="1"
|
|
||||||
value={recBitrate}
|
|
||||||
onChange={e => setRecBitrate(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Field label="Bitrate" value="Quality-based (profile)" select />
|
|
||||||
)}
|
|
||||||
<Field label="Resolution" value="Auto — from source" select />
|
|
||||||
<Field label="Framerate" value="Auto — from source" select />
|
|
||||||
{/* #3: warn when the configured bitrate exceeds the probed source
|
|
||||||
bitrate — re-encoding above source adds storage, not quality. */}
|
|
||||||
{codecUsesBitrate && (() => {
|
|
||||||
const d = probeResult && probeResult.ok ? (probeResult.data || {}) : null;
|
|
||||||
const raw = d && (d.bitrate ?? d.bit_rate ?? d.video_bitrate ?? (d.video && d.video.bit_rate));
|
|
||||||
const srcMbps = raw ? (Number(raw) > 100000 ? Number(raw) / 1e6 : Number(raw)) : null;
|
|
||||||
const cfg = parseFloat(recBitrate);
|
|
||||||
if (srcMbps && cfg && cfg > srcMbps * 1.05) {
|
|
||||||
return (
|
|
||||||
<div style={{ gridColumn: '1 / -1', fontSize: 11.5, color: 'var(--warn, #d9a441)', border: '1px solid var(--warn, #d9a441)', borderRadius: 6, padding: '8px 10px', background: 'rgba(217,164,65,0.08)' }}>
|
|
||||||
⚠ Target {cfg} Mbps exceeds the source stream (~{srcMbps.toFixed(1)} Mbps). Encoding above the source bitrate increases file size without adding quality.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{recTab === 'audio' && (
|
{recTab === 'audio' && (
|
||||||
|
|
@ -453,8 +291,16 @@ function NewRecorderModal({ open, onClose }) {
|
||||||
)}
|
)}
|
||||||
{recTab === 'container' && (
|
{recTab === 'container' && (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||||
<Field label="Container" value={recContainer === 'mp4' ? 'MP4 (auto)' : 'Fragmented MOV (auto)'} select />
|
<div className="field">
|
||||||
<Field label="Growing-file" value={recContainer === 'mov' ? 'Supported (edit-while-record)' : 'No'} select />
|
<label className="field-label">Container</label>
|
||||||
|
<select className="field-input" value={recContainer} onChange={e => setRecContainer(e.target.value)} style={{ appearance: 'auto' }}>
|
||||||
|
<option value="mov">MOV (QuickTime)</option>
|
||||||
|
<option value="mxf">MXF (SMPTE)</option>
|
||||||
|
<option value="mkv">MKV (Matroska)</option>
|
||||||
|
<option value="mp4">MP4</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Field label="Segment" value="None (single file)" select />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -482,7 +328,7 @@ function NewRecorderModal({ open, onClose }) {
|
||||||
<span key={tag} className="mono" style={{ background: 'var(--bg-3)', borderRadius: 4, padding: '2px 8px', fontSize: 12 }}>{tag}</span>
|
<span key={tag} className="mono" style={{ background: 'var(--bg-3)', borderRadius: 4, padding: '2px 8px', fontSize: 12 }}>{tag}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>Fixed proxy profile. Not configurable.</div>
|
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>Fixed proxy profile — not configurable.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// screens-admin.jsx - Users, Tokens, Containers, Cluster (graph), Settings
|
// screens-admin.jsx — Users, Tokens, Containers, Cluster (graph), Settings
|
||||||
|
|
||||||
function _normalizeNode(n, x, y) {
|
function _normalizeNode(n, x, y) {
|
||||||
const cap = n.capabilities || {};
|
const cap = n.capabilities || {};
|
||||||
|
|
@ -29,9 +29,9 @@ function _normalizeNode(n, x, y) {
|
||||||
dbId: n.id,
|
dbId: n.id,
|
||||||
role: n.role || 'worker',
|
role: n.role || 'worker',
|
||||||
status: n.status || (n.online ? 'online' : 'offline'),
|
status: n.status || (n.online ? 'online' : 'offline'),
|
||||||
ip: n.ip_address || n.ip || '·',
|
ip: n.ip_address || n.ip || '—',
|
||||||
version: n.version || '·',
|
version: n.version || '—',
|
||||||
uptime: n.uptime || '·',
|
uptime: n.uptime || '—',
|
||||||
cpu: parseFloat(n.cpu_usage || n.cpu || n.cpu_percent || 0),
|
cpu: parseFloat(n.cpu_usage || n.cpu || n.cpu_percent || 0),
|
||||||
mem: Math.round(memUsedMb / 1024 * 10) / 10,
|
mem: Math.round(memUsedMb / 1024 * 10) / 10,
|
||||||
memTotal: Math.round(memTotalMb / 1024 * 10) / 10,
|
memTotal: Math.round(memTotalMb / 1024 * 10) / 10,
|
||||||
|
|
@ -230,7 +230,7 @@ function Users() {
|
||||||
{u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'}
|
{u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'}
|
||||||
</div>
|
</div>
|
||||||
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>
|
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>
|
||||||
{u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '·'}
|
{u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '—'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<button className="icon-btn" aria-label="User actions" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === u.id ? null : u.id); }}>
|
<button className="icon-btn" aria-label="User actions" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === u.id ? null : u.id); }}>
|
||||||
|
|
@ -258,7 +258,14 @@ function Users() {
|
||||||
{tab === 'groups' && <GroupsPanel groups={groups} users={users} onChange={refreshGroups} />}
|
{tab === 'groups' && <GroupsPanel groups={groups} users={users} onChange={refreshGroups} />}
|
||||||
|
|
||||||
{tab === 'policies' && (
|
{tab === 'policies' && (
|
||||||
<PoliciesPanel users={users} onChange={refreshUsers} />
|
<div className="panel" style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-3)' }}>
|
||||||
|
<Icon name="lock" size={24} />
|
||||||
|
<div style={{ marginTop: 10, fontWeight: 500, fontSize: 14, color: 'var(--text-2)' }}>Access policies</div>
|
||||||
|
<div style={{ fontSize: 12, marginTop: 4 }}>
|
||||||
|
Per-project and per-bin permissions are coming soon. For now, role-based access<br />
|
||||||
|
(admin / editor / viewer) is enforced API-wide.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showInvite && <InviteUserModal onCreated={onCreated} onClose={() => setShowInvite(false)} />}
|
{showInvite && <InviteUserModal onCreated={onCreated} onClose={() => setShowInvite(false)} />}
|
||||||
|
|
@ -278,204 +285,6 @@ function Users() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
|
||||||
// PoliciesPanel - interactive per-user permission matrix for the Policies tab.
|
|
||||||
// Keeps the access-model explainer as a small header, then renders one row per
|
|
||||||
// user with: inline role <select> (PATCH /users/:id), a 2FA badge driven by
|
|
||||||
// totp_enabled, an admin-only "Reset 2FA" action (POST /users/:id/totp/disable,
|
|
||||||
// 204), and an Access expander backed by GET /users/:id/access.
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
|
||||||
function PoliciesPanel({ users, onChange }) {
|
|
||||||
const [expandedId, setExpandedId] = React.useState(null);
|
|
||||||
const [err, setErr] = React.useState(null);
|
|
||||||
|
|
||||||
const changeRole = (u, newRole) => {
|
|
||||||
if (u.role === newRole) return;
|
|
||||||
setErr(null);
|
|
||||||
window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify({ role: newRole }) })
|
|
||||||
.then(() => onChange && onChange())
|
|
||||||
.catch(e => setErr('Role change failed: ' + (e.message || e)));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reset 2FA uses a raw fetch because ZAMPP_API.fetch throws on the 204 (no JSON
|
|
||||||
// body). Mirrors the disable() pattern in TotpSection.
|
|
||||||
const resetTotp = (u) => {
|
|
||||||
if (!confirm(`Reset two-factor for "${u.name}" (@${u.username})?\nThey will be able to sign in without a code until they re-enrol.`)) return;
|
|
||||||
setErr(null);
|
|
||||||
fetch('/api/v1/users/' + u.id + '/totp/disable', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: { 'X-Requested-With': 'dragonflight-ui' },
|
|
||||||
})
|
|
||||||
.then(r => {
|
|
||||||
if (r.status === 204) { onChange && onChange(); return; }
|
|
||||||
return r.json().catch(() => ({})).then(b => { throw new Error(b.error || ('Failed (' + r.status + ')')); });
|
|
||||||
})
|
|
||||||
.catch(e => setErr('Reset 2FA failed: ' + (e.message || e)));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Access-model explainer (kept from the old static tab, condensed) */}
|
|
||||||
<div className="panel" style={{ padding: '16px 20px', marginBottom: 12, color: 'var(--text-2)' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
|
||||||
<Icon name="lock" size={15} />
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 13.5 }}>Access model</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.6, maxWidth: 720 }}>
|
|
||||||
<strong style={{ color: 'var(--text-2)' }}>admin</strong> has full access to every project plus
|
|
||||||
user, group, cluster, and system administration. <strong style={{ color: 'var(--text-2)' }}>editor / viewer</strong> see
|
|
||||||
only the projects they're granted — a <em>view</em> grant is read-only, an <em>edit</em> grant
|
|
||||||
allows changes, and grants can target a user or a group. Edit per-project grants from the{' '}
|
|
||||||
<a href="#" onClick={e => { e.preventDefault(); window.dispatchEvent(new CustomEvent('df:nav', { detail: 'projects' })); }}
|
|
||||||
style={{ color: 'var(--accent-text)' }}>Projects</a> page; manage group membership on the Groups tab above.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginBottom: 8 }}>{err}</div>}
|
|
||||||
|
|
||||||
<div className="panel">
|
|
||||||
<div className="user-row head">
|
|
||||||
<div>User</div>
|
|
||||||
<div>Role</div>
|
|
||||||
<div>2FA</div>
|
|
||||||
<div>Access</div>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
{users.length === 0 && (
|
|
||||||
<div style={{ padding: '32px 0', textAlign: 'center', color: 'var(--text-3)' }}>No users found</div>
|
|
||||||
)}
|
|
||||||
{users.map(u => (
|
|
||||||
<UserPolicyRow key={u.id} user={u}
|
|
||||||
expanded={expandedId === u.id}
|
|
||||||
onToggle={() => setExpandedId(expandedId === u.id ? null : u.id)}
|
|
||||||
onChangeRole={changeRole}
|
|
||||||
onResetTotp={resetTotp} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserPolicyRow({ user: u, expanded, onToggle, onChangeRole, onResetTotp }) {
|
|
||||||
const [access, setAccess] = React.useState(null); // null = not loaded, {} once fetched
|
|
||||||
const [loading, setLoading] = React.useState(false);
|
|
||||||
const [accessErr, setAccessErr] = React.useState(null);
|
|
||||||
|
|
||||||
// Lazily fetch GET /users/:id/access the first time the row is expanded.
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!expanded || access !== null) return;
|
|
||||||
setLoading(true); setAccessErr(null);
|
|
||||||
window.ZAMPP_API.fetch('/users/' + u.id + '/access')
|
|
||||||
.then(d => setAccess(d || {}))
|
|
||||||
.catch(e => { setAccess({}); setAccessErr(e.message || 'Failed to load access'); })
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [expanded, access, u.id]);
|
|
||||||
|
|
||||||
const projects = (access && access.projects) || [];
|
|
||||||
const memberships = (access && (access.groups || access.memberships)) || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<div className="user-row" style={{ borderBottom: 'none' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<div className="avatar" style={{ width: 32, height: 32, fontSize: 11, background: avatarColor(u.initials || u.id) }}>{u.initials || '??'}</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 500, fontSize: 13 }}>{u.name}</div>
|
|
||||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>@{u.username}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<select value={u.role || 'viewer'}
|
|
||||||
onChange={e => onChangeRole(u, e.target.value)}
|
|
||||||
className="field-input"
|
|
||||||
style={{ width: 90, padding: '3px 6px', fontSize: 11.5, appearance: 'auto' }}>
|
|
||||||
<option value="admin">admin</option>
|
|
||||||
<option value="editor">editor</option>
|
|
||||||
<option value="viewer">viewer</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{u.totp_enabled
|
|
||||||
? <span className="badge success"><Icon name="key" size={10} /> 2FA on</span>
|
|
||||||
: <span className="badge neutral">2FA off</span>}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button className="btn ghost sm" onClick={onToggle}>
|
|
||||||
{expanded ? 'Hide' : 'View'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
||||||
{u.totp_enabled && (
|
|
||||||
<button className="btn ghost sm danger" onClick={() => onResetTotp(u)} title="Disable this user's two-factor">
|
|
||||||
<Icon name="key" size={11} />Reset 2FA
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded && (
|
|
||||||
<div style={{ padding: '0 16px 16px 16px', background: 'var(--bg-2)' }}>
|
|
||||||
{loading && <div style={{ padding: '12px 0', fontSize: 12.5, color: 'var(--text-3)' }}>Loading access…</div>}
|
|
||||||
{accessErr && <div style={{ padding: '8px 0', fontSize: 12, color: 'var(--danger)' }}>{accessErr}</div>}
|
|
||||||
{!loading && !accessErr && (u.role === 'admin') && (
|
|
||||||
<div style={{ padding: '12px 0', fontSize: 12.5, color: 'var(--text-3)', display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<Icon name="check" size={12} style={{ color: 'var(--success)' }} />
|
|
||||||
Admin — full access to every project.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!loading && !accessErr && u.role !== 'admin' && (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, paddingTop: 12 }}>
|
|
||||||
{/* Accessible projects */}
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, marginBottom: 8 }}>
|
|
||||||
Projects ({projects.length})
|
|
||||||
</div>
|
|
||||||
{projects.length === 0 && (
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-4)' }}>No project access granted.</div>
|
|
||||||
)}
|
|
||||||
{projects.map(p => {
|
|
||||||
// Backend `via` is 'direct' for a user grant, or 'group:<name>'
|
|
||||||
// when inherited from a group. Split the label off the prefix.
|
|
||||||
const via = p.via || 'direct';
|
|
||||||
const isGroup = via.indexOf('group') === 0;
|
|
||||||
const viaLabel = isGroup ? (via.indexOf(':') >= 0 ? via.slice(via.indexOf(':') + 1) : 'group') : 'direct';
|
|
||||||
return (
|
|
||||||
<div key={(p.project_id || p.id) + ':' + via}
|
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0', borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<span style={{ fontSize: 12.5, flex: 1 }}>{p.project_name || p.name || p.project_id || p.id}</span>
|
|
||||||
<span className={`badge ${(p.level === 'edit') ? 'accent' : 'neutral'}`}>{p.level || 'view'}</span>
|
|
||||||
<span className="badge neutral" title={isGroup ? 'Inherited from group ' + viaLabel : 'Granted directly'}>
|
|
||||||
<Icon name={isGroup ? 'users' : 'user'} size={9} /> {viaLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{/* Group memberships */}
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, marginBottom: 8 }}>
|
|
||||||
Groups ({memberships.length})
|
|
||||||
</div>
|
|
||||||
{memberships.length === 0 && (
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-4)' }}>Not a member of any group.</div>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
||||||
{memberships.map(g => (
|
|
||||||
<span key={g.id || g.group_id || g.name} className="badge neutral" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
|
||||||
<Icon name="users" size={9} />{g.name || g.group_name || g.group_id}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditUserModal({ user, onClose, onSaved }) {
|
function EditUserModal({ user, onClose, onSaved }) {
|
||||||
const [name, setName] = React.useState(user.display_name || user.name || '');
|
const [name, setName] = React.useState(user.display_name || user.name || '');
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
|
@ -520,7 +329,7 @@ function PasswordResetModal({ user, onClose, onSaved }) {
|
||||||
const [err, setErr] = React.useState(null);
|
const [err, setErr] = React.useState(null);
|
||||||
const [done, setDone] = React.useState(false);
|
const [done, setDone] = React.useState(false);
|
||||||
|
|
||||||
// #111 - guard async resolution / delayed onSaved against unmount.
|
// #111 — guard async resolution / delayed onSaved against unmount.
|
||||||
const mountedRef = React.useRef(true);
|
const mountedRef = React.useRef(true);
|
||||||
const savedTimerRef = React.useRef(null);
|
const savedTimerRef = React.useRef(null);
|
||||||
React.useEffect(() => () => {
|
React.useEffect(() => () => {
|
||||||
|
|
@ -672,7 +481,7 @@ function GroupsPanel({ groups, users, onChange }) {
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
{groups.length === 0 && !creating && (
|
{groups.length === 0 && !creating && (
|
||||||
<div style={{ padding: '32px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>
|
<div style={{ padding: '32px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>
|
||||||
No groups yet: click <em>New group</em> above to create one.
|
No groups yet — click <em>New group</em> above to create one.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{groups.map(g => {
|
{groups.map(g => {
|
||||||
|
|
@ -712,8 +521,8 @@ function GroupsPanel({ groups, users, onChange }) {
|
||||||
<select className="field-input" defaultValue=""
|
<select className="field-input" defaultValue=""
|
||||||
onChange={e => { addMember(g, e.target.value); e.target.value = ''; }}
|
onChange={e => { addMember(g, e.target.value); e.target.value = ''; }}
|
||||||
style={{ width: 200, padding: '4px 8px', fontSize: 12, appearance: 'auto' }}>
|
style={{ width: 200, padding: '4px 8px', fontSize: 12, appearance: 'auto' }}>
|
||||||
<option value="" disabled>Pick a user…</option>
|
<option value="" disabled>— Pick a user —</option>
|
||||||
{nonMembers.map(u => <option key={u.id} value={u.id}>@{u.username}: {u.name}</option>)}
|
{nonMembers.map(u => <option key={u.id} value={u.id}>@{u.username} — {u.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -727,25 +536,7 @@ function GroupsPanel({ groups, users, onChange }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Real Tokens admin page: wraps ApiTokensSection (defined further down) in a
|
|
||||||
// .page shell so it can be a top-level admin nav destination. The old parody
|
|
||||||
// pricing page lives below as TokensParody and is now routed at /billing in
|
|
||||||
// the Admin section.
|
|
||||||
function Tokens() {
|
function Tokens() {
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<div className="page-header">
|
|
||||||
<h1>Tokens</h1>
|
|
||||||
<span className="subtitle">API tokens for the Premiere panel, node-agents, and external integrations</span>
|
|
||||||
</div>
|
|
||||||
<div className="page-body">
|
|
||||||
<ApiTokensSection />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TokensParody() {
|
|
||||||
const [burned, setBurned] = React.useState(14340);
|
const [burned, setBurned] = React.useState(14340);
|
||||||
const [rate, setRate] = React.useState(2.4);
|
const [rate, setRate] = React.useState(2.4);
|
||||||
const [showCalc, setShowCalc] = React.useState(false);
|
const [showCalc, setShowCalc] = React.useState(false);
|
||||||
|
|
@ -791,7 +582,7 @@ function TokensParody() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const tiers = [
|
const tiers = [
|
||||||
{ name: "Starter", desc: "For \"evaluation only\": definitely not production", price: "$2,400", per: "/ month", tokens: "100k tokens", popular: false, color: "#6B7280" },
|
{ name: "Starter", desc: "For \"evaluation only\" — definitely not production", price: "$2,400", per: "/ month", tokens: "100k tokens", popular: false, color: "#6B7280" },
|
||||||
{ name: "Broadcast", desc: "Most teams. Most pain.", price: "$28,000", per: "/ month", tokens: "1.5M tokens", popular: true, color: "#5B7CFA" },
|
{ name: "Broadcast", desc: "Most teams. Most pain.", price: "$28,000", per: "/ month", tokens: "1.5M tokens", popular: true, color: "#5B7CFA" },
|
||||||
{ name: "Enterprise", desc: "If you have to ask, you can't afford it (but you'll ask anyway)", price: "$call us", per: "and bring lawyers", tokens: "∞ tokens", popular: false, color: "#B57CFA" },
|
{ name: "Enterprise", desc: "If you have to ask, you can't afford it (but you'll ask anyway)", price: "$call us", per: "and bring lawyers", tokens: "∞ tokens", popular: false, color: "#B57CFA" },
|
||||||
];
|
];
|
||||||
|
|
@ -799,7 +590,7 @@ function TokensParody() {
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Billing</h1>
|
<h1>Tokens</h1>
|
||||||
<span className="subtitle">Token-metered pricing parody · You actually pay <strong style={{ color: "var(--success)" }}>$0.00</strong></span>
|
<span className="subtitle">Token-metered pricing parody · You actually pay <strong style={{ color: "var(--success)" }}>$0.00</strong></span>
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
<span className="badge warning"><Icon name="alert" size={10} /> SATIRE</span>
|
<span className="badge warning"><Icon name="alert" size={10} /> SATIRE</span>
|
||||||
|
|
@ -852,7 +643,7 @@ function TokensParody() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="token-comparison">
|
<div className="token-comparison">
|
||||||
<div className="token-card-label" style={{ padding: "16px 16px 0" }}>HOURLY BURN: DRAGONFLIGHT vs. THE OTHER GUYS</div>
|
<div className="token-card-label" style={{ padding: "16px 16px 0" }}>HOURLY BURN — DRAGONFLIGHT vs. THE OTHER GUYS</div>
|
||||||
<div className="token-compare-chart">
|
<div className="token-compare-chart">
|
||||||
<ChartLine
|
<ChartLine
|
||||||
series={[
|
series={[
|
||||||
|
|
@ -908,7 +699,7 @@ function TokensParody() {
|
||||||
<div>
|
<div>
|
||||||
<strong>Disclaimer:</strong> No actual tokens were billed in the making of this page. Wild Dragon's broadcast platform
|
<strong>Disclaimer:</strong> No actual tokens were billed in the making of this page. Wild Dragon's broadcast platform
|
||||||
is self-hosted infrastructure. Any resemblance to per-API-call broadcast token economies, living or dead, is satirical
|
is self-hosted infrastructure. Any resemblance to per-API-call broadcast token economies, living or dead, is satirical
|
||||||
and protected as commentary. If you came here looking for actual API tokens, that page no longer exists: service
|
and protected as commentary. If you came here looking for actual API tokens, that page no longer exists — service
|
||||||
credentials are managed through the cluster's own JWT issuer.
|
credentials are managed through the cluster's own JWT issuer.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1007,7 +798,7 @@ function Containers() {
|
||||||
const [containers, setContainers] = React.useState(null);
|
const [containers, setContainers] = React.useState(null);
|
||||||
const [restartFlashState, setRestartFlashState] = React.useState(null);
|
const [restartFlashState, setRestartFlashState] = React.useState(null);
|
||||||
const [logsModalState, setLogsModalState] = React.useState(null);
|
const [logsModalState, setLogsModalState] = React.useState(null);
|
||||||
// #111 - guard restart-flash timers against unmount.
|
// #111 — guard restart-flash timers against unmount.
|
||||||
const mountedRef = React.useRef(true);
|
const mountedRef = React.useRef(true);
|
||||||
const flashTimerRef = React.useRef(null);
|
const flashTimerRef = React.useRef(null);
|
||||||
React.useEffect(() => () => {
|
React.useEffect(() => () => {
|
||||||
|
|
@ -1169,7 +960,7 @@ function Containers() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
// BmdCardPanel - capture-card section inside the Cluster node detail panel.
|
// BmdCardPanel — capture-card section inside the Cluster node detail panel.
|
||||||
// Shows port chips with live video-presence dots AND the BMD SVG card diagram.
|
// Shows port chips with live video-presence dots AND the BMD SVG card diagram.
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
function BmdCardPanel({ sel, portSignals }) {
|
function BmdCardPanel({ sel, portSignals }) {
|
||||||
|
|
@ -1204,7 +995,7 @@ function BmdCardPanel({ sel, portSignals }) {
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
|
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
<Icon name="video" size={11} />
|
<Icon name="video" size={11} />
|
||||||
Capture cards {sel.bmdCount > 0 ? `(${sel.bmdCount} port${sel.bmdCount !== 1 ? 's' : ''})` : '(none reported)'}
|
Capture cards {sel.bmdCount > 0 ? `(${sel.bmdCount} port${sel.bmdCount !== 1 ? 's' : ''})` : '— none reported'}
|
||||||
</div>
|
</div>
|
||||||
{sel.bmdPorts.length === 0 && (
|
{sel.bmdPorts.length === 0 && (
|
||||||
<div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No DeckLink cards detected on this node</div>
|
<div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No DeckLink cards detected on this node</div>
|
||||||
|
|
@ -1230,7 +1021,7 @@ function BmdCardPanel({ sel, portSignals }) {
|
||||||
const { label, color } = _signalChip(sig);
|
const { label, color } = _signalChip(sig);
|
||||||
const isReceiving = sig === 'receiving';
|
const isReceiving = sig === 'receiving';
|
||||||
return (
|
return (
|
||||||
<div key={p.index} title={sigEntry ? `${sigEntry.recorder_name || 'recorder'}: ${label}` : label}
|
<div key={p.index} title={sigEntry ? `${sigEntry.recorder_name || 'recorder'} — ${label}` : label}
|
||||||
style={{
|
style={{
|
||||||
display: "flex", alignItems: "center", gap: 5,
|
display: "flex", alignItems: "center", gap: 5,
|
||||||
fontSize: 10.5, fontFamily: "var(--font-mono)",
|
fontSize: 10.5, fontFamily: "var(--font-mono)",
|
||||||
|
|
@ -1279,7 +1070,7 @@ function _signalChip(sig) {
|
||||||
case 'error': return { label: 'ERROR', color: 'var(--danger)' };
|
case 'error': return { label: 'ERROR', color: 'var(--danger)' };
|
||||||
case 'idle': return { label: 'IDLE', color: 'var(--text-3)' };
|
case 'idle': return { label: 'IDLE', color: 'var(--text-3)' };
|
||||||
case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)' };
|
case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)' };
|
||||||
default: return { label: sig || '·', color: 'var(--text-4)' };
|
default: return { label: sig || '—', color: 'var(--text-4)' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1383,7 +1174,7 @@ function Cluster() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const removeNode = (node) => {
|
const removeNode = (node) => {
|
||||||
if (!window.confirm('Remove node ' + node.id + ' from the cluster?\nThis does not stop the machine: it only removes it from cluster membership.')) return;
|
if (!window.confirm('Remove node ' + node.id + ' from the cluster?\nThis does not stop the machine — it only removes it from cluster membership.')) return;
|
||||||
window.ZAMPP_API.fetch('/cluster/nodes/' + encodeURIComponent(node.dbId || node.id), { method: 'DELETE' })
|
window.ZAMPP_API.fetch('/cluster/nodes/' + encodeURIComponent(node.dbId || node.id), { method: 'DELETE' })
|
||||||
.then(() => refresh())
|
.then(() => refresh())
|
||||||
.catch(e => setAdviceModal({ title: 'Remove failed', lines: [e.message] }));
|
.catch(e => setAdviceModal({ title: 'Remove failed', lines: [e.message] }));
|
||||||
|
|
@ -1532,7 +1323,7 @@ function Cluster() {
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
|
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
<Icon name="gpu" size={11} />
|
<Icon name="gpu" size={11} />
|
||||||
GPUs {sel.gpuCount > 0 ? `(${sel.gpuCount})` : '(none reported)'}
|
GPUs {sel.gpuCount > 0 ? `(${sel.gpuCount})` : '— none reported'}
|
||||||
</div>
|
</div>
|
||||||
{sel.gpus.length === 0 && (
|
{sel.gpus.length === 0 && (
|
||||||
<div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No GPUs detected on this node</div>
|
<div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No GPUs detected on this node</div>
|
||||||
|
|
@ -1665,135 +1456,6 @@ function AccountSection() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two-factor (TOTP) enrollment + management. Reflects window.ZAMPP_DATA.ME.totp_enabled.
|
|
||||||
function TotpSection() {
|
|
||||||
const me = window.ZAMPP_DATA?.ME || {};
|
|
||||||
const [enabled, setEnabled] = React.useState(!!me.totp_enabled);
|
|
||||||
const [phase, setPhase] = React.useState('idle'); // idle | enrolling | recovery
|
|
||||||
const [enroll, setEnroll] = React.useState(null); // { secret, otpauth_uri, qr }
|
|
||||||
const [code, setCode] = React.useState('');
|
|
||||||
const [recovery, setRecovery] = React.useState(null); // string[]
|
|
||||||
const [disablePw, setDisablePw] = React.useState('');
|
|
||||||
const [showDisable, setShowDisable] = React.useState(false);
|
|
||||||
const [msg, setMsg] = React.useState(null);
|
|
||||||
const [busy, setBusy] = React.useState(false);
|
|
||||||
|
|
||||||
const api = (path, body) => fetch('/api/v1/auth' + path, {
|
|
||||||
method: 'POST', credentials: 'include',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
|
|
||||||
body: JSON.stringify(body || {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const startSetup = async () => {
|
|
||||||
setMsg(null); setBusy(true);
|
|
||||||
try {
|
|
||||||
const r = await api('/totp/setup');
|
|
||||||
if (r.status === 200) { setEnroll(await r.json()); setPhase('enrolling'); }
|
|
||||||
else setMsg({ kind: 'err', text: (await r.json().catch(() => ({}))).error || 'Setup failed' });
|
|
||||||
} finally { setBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmEnable = async () => {
|
|
||||||
setMsg(null); setBusy(true);
|
|
||||||
try {
|
|
||||||
const r = await api('/totp/enable', { code: code.trim() });
|
|
||||||
const body = await r.json().catch(() => ({}));
|
|
||||||
if (r.status === 200) {
|
|
||||||
setRecovery(body.recovery_codes || []); setPhase('recovery');
|
|
||||||
setEnabled(true); setCode('');
|
|
||||||
window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), totp_enabled: true };
|
|
||||||
} else setMsg({ kind: 'err', text: body.error || 'Could not enable' });
|
|
||||||
} finally { setBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const disable = async () => {
|
|
||||||
setMsg(null); setBusy(true);
|
|
||||||
try {
|
|
||||||
const r = await api('/totp/disable', { password: disablePw });
|
|
||||||
if (r.status === 204) {
|
|
||||||
setEnabled(false); setShowDisable(false); setDisablePw(''); setPhase('idle');
|
|
||||||
window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), totp_enabled: false };
|
|
||||||
setMsg({ kind: 'ok', text: 'Two-factor disabled' });
|
|
||||||
} else setMsg({ kind: 'err', text: (await r.json().catch(() => ({}))).error || 'Could not disable' });
|
|
||||||
} finally { setBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="panel" style={{ padding: 16, marginBottom: 16 }}>
|
|
||||||
<h3 style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 12 }}>Two-factor authentication</h3>
|
|
||||||
|
|
||||||
{/* Status line */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: phase === 'idle' ? 0 : 14 }}>
|
|
||||||
<span className={`badge ${enabled ? 'success' : 'neutral'}`}>{enabled ? 'Enabled' : 'Disabled'}</span>
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
||||||
{enabled ? 'An authenticator code is required at sign-in.' : 'Add a time-based code from an authenticator app.'}
|
|
||||||
</span>
|
|
||||||
<span style={{ flex: 1 }} />
|
|
||||||
{!enabled && phase === 'idle' && <button className="btn primary sm" disabled={busy} onClick={startSetup}>Set up</button>}
|
|
||||||
{enabled && phase !== 'recovery' && !showDisable && <button className="btn ghost sm danger" onClick={() => setShowDisable(true)}>Disable</button>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Enrolling: show QR / secret + code field */}
|
|
||||||
{phase === 'enrolling' && enroll && (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: 16, alignItems: 'start' }}>
|
|
||||||
<div>
|
|
||||||
{enroll.qr
|
|
||||||
? <img src={enroll.qr} alt="TOTP QR code" style={{ width: 160, height: 160, borderRadius: 6, background: '#fff', padding: 6 }} />
|
|
||||||
: <div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>Scan the secret below in your app.</div>}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>
|
|
||||||
Scan the QR with Google Authenticator, Authy, or 1Password — or enter this secret manually:
|
|
||||||
</div>
|
|
||||||
<div className="mono" style={{ fontSize: 12, background: 'var(--bg-2)', padding: '6px 10px', borderRadius: 5, wordBreak: 'break-all', marginBottom: 12 }}>{enroll.secret}</div>
|
|
||||||
<div className="field">
|
|
||||||
<label className="field-label">Enter the 6-digit code to confirm</label>
|
|
||||||
<input className="field-input mono" value={code} autoFocus
|
|
||||||
onChange={e => setCode(e.target.value)}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && code.trim()) confirmEnable(); }}
|
|
||||||
placeholder="123456" style={{ width: 140 }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: 10 }}>
|
|
||||||
<button className="btn primary sm" disabled={busy || !code.trim()} onClick={confirmEnable}>Enable</button>
|
|
||||||
<button className="btn ghost sm" style={{ marginLeft: 8 }} onClick={() => { setPhase('idle'); setEnroll(null); setCode(''); }}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recovery codes — shown exactly once */}
|
|
||||||
{phase === 'recovery' && recovery && (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>
|
|
||||||
Save these recovery codes somewhere safe. Each works once if you lose your authenticator. They won't be shown again.
|
|
||||||
</div>
|
|
||||||
<div className="mono" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, background: 'var(--bg-2)', padding: 12, borderRadius: 6, fontSize: 13, marginBottom: 10 }}>
|
|
||||||
{recovery.map(c => <div key={c}>{c}</div>)}
|
|
||||||
</div>
|
|
||||||
<button className="btn sm" onClick={() => navigator.clipboard && navigator.clipboard.writeText(recovery.join('\n'))}>Copy codes</button>
|
|
||||||
<button className="btn primary sm" style={{ marginLeft: 8 }} onClick={() => { setPhase('idle'); setRecovery(null); }}>Done</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Disable confirmation */}
|
|
||||||
{showDisable && (
|
|
||||||
<div style={{ marginTop: 12, display: 'grid', gridTemplateColumns: '160px 1fr auto auto', gap: 8, alignItems: 'end' }}>
|
|
||||||
<div className="field" style={{ marginBottom: 0, gridColumn: '1 / 3' }}>
|
|
||||||
<label className="field-label">Confirm your password to disable</label>
|
|
||||||
<input className="field-input" type="password" value={disablePw} autoComplete="current-password"
|
|
||||||
onChange={e => setDisablePw(e.target.value)}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && disablePw) disable(); }} />
|
|
||||||
</div>
|
|
||||||
<button className="btn danger sm" disabled={busy || !disablePw} onClick={disable}>Disable 2FA</button>
|
|
||||||
<button className="btn ghost sm" onClick={() => { setShowDisable(false); setDisablePw(''); }}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{msg && <div style={{ marginTop: 10, fontSize: 11.5, color: msg.kind === 'ok' ? 'var(--success)' : 'var(--danger)' }}>{msg.text}</div>}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ApiTokensSection() {
|
function ApiTokensSection() {
|
||||||
const [tokens, setTokens] = React.useState([]);
|
const [tokens, setTokens] = React.useState([]);
|
||||||
const [name, setName] = React.useState('');
|
const [name, setName] = React.useState('');
|
||||||
|
|
@ -1842,7 +1504,7 @@ function ApiTokensSection() {
|
||||||
{justCreated && (
|
{justCreated && (
|
||||||
<div style={{ marginBottom: 12, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}>
|
<div style={{ marginBottom: 12, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)', marginBottom: 6 }}>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)', marginBottom: 6 }}>
|
||||||
Save this token now: it will not be shown again
|
Save this token now — it will not be shown again
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-1)', wordBreak: 'break-all', marginBottom: 6 }}>{justCreated.token}</div>
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-1)', wordBreak: 'break-all', marginBottom: 6 }}>{justCreated.token}</div>
|
||||||
<button className="btn sm" onClick={() => navigator.clipboard.writeText(justCreated.token)}>Copy</button>
|
<button className="btn sm" onClick={() => navigator.clipboard.writeText(justCreated.token)}>Copy</button>
|
||||||
|
|
@ -1903,7 +1565,6 @@ function Settings() {
|
||||||
{section === 'account' && (
|
{section === 'account' && (
|
||||||
<>
|
<>
|
||||||
<AccountSection />
|
<AccountSection />
|
||||||
<TotpSection />
|
|
||||||
<ApiTokensSection />
|
<ApiTokensSection />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1919,7 +1580,7 @@ function Settings() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
// Storage - unified view: live mount/bucket health on top, then the two
|
// Storage — unified view: live mount/bucket health on top, then the two
|
||||||
// existing editors (S3 bucket + growing-files SMB landing zone) stacked.
|
// existing editors (S3 bucket + growing-files SMB landing zone) stacked.
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -1934,7 +1595,7 @@ function StorageSection() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(n) {
|
function formatBytes(n) {
|
||||||
if (n == null || isNaN(n)) return '·';
|
if (n == null || isNaN(n)) return '—';
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
let v = n, i = 0;
|
let v = n, i = 0;
|
||||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||||
|
|
@ -1967,7 +1628,7 @@ function MountHealthStrip() {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
load();
|
load();
|
||||||
// Light auto-refresh so free-space + reachability stay current while the
|
// Light auto-refresh so free-space + reachability stay current while the
|
||||||
// operator is on the page. 15s is plenty - these are diagnostic, not real-time.
|
// operator is on the page. 15s is plenty — these are diagnostic, not real-time.
|
||||||
const t = setInterval(load, 15_000);
|
const t = setInterval(load, 15_000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
@ -2017,9 +1678,9 @@ function MountHealthStrip() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
|
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
|
||||||
<span>Container</span><span className="mono">{g.container_path || '·'}</span>
|
<span>Container</span><span className="mono">{g.container_path || '—'}</span>
|
||||||
<span>Host</span><span className="mono">{g.host_path || '·'}</span>
|
<span>Host</span><span className="mono">{g.host_path || '—'}</span>
|
||||||
<span>SMB</span><span className="mono">{g.smb_url || '·'}</span>
|
<span>SMB</span><span className="mono">{g.smb_url || '—'}</span>
|
||||||
<span>Promote idle</span><span className="mono">{g.promote_after_seconds}s</span>
|
<span>Promote idle</span><span className="mono">{g.promote_after_seconds}s</span>
|
||||||
{g.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{g.error}</span></>}
|
{g.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{g.error}</span></>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2039,8 +1700,8 @@ function MountHealthStrip() {
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
|
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
|
||||||
<span>Endpoint</span><span className="mono">{s.endpoint || '(AWS default)'}</span>
|
<span>Endpoint</span><span className="mono">{s.endpoint || '(AWS default)'}</span>
|
||||||
<span>Bucket</span><span className="mono">{s.bucket || '·'}</span>
|
<span>Bucket</span><span className="mono">{s.bucket || '—'}</span>
|
||||||
<span>Region</span><span className="mono">{s.region || '·'}</span>
|
<span>Region</span><span className="mono">{s.region || '—'}</span>
|
||||||
{s.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{s.error}</span></>}
|
{s.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{s.error}</span></>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2106,7 +1767,7 @@ function S3SettingsCard() {
|
||||||
<SField label="Bucket"><input className="field-input mono" required value={s3.s3_bucket} onChange={e => setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" /></SField>
|
<SField label="Bucket"><input className="field-input mono" required value={s3.s3_bucket} onChange={e => setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" /></SField>
|
||||||
</div>
|
</div>
|
||||||
<SField label="Access key ID"><input className="field-input mono" required value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" autoComplete="off" /></SField>
|
<SField label="Access key ID"><input className="field-input mono" required value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" autoComplete="off" /></SField>
|
||||||
<SField label="Secret access key"><input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved: type to replace)' : 'Secret key'} autoComplete="new-password" /></SField>
|
<SField label="Secret access key"><input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} autoComplete="new-password" /></SField>
|
||||||
<SettingsMsg msg={msg} />
|
<SettingsMsg msg={msg} />
|
||||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||||
<button type="submit" className="btn primary sm" disabled={saving}>{saving ? 'Saving…' : 'Save & apply'}</button>
|
<button type="submit" className="btn primary sm" disabled={saving}>{saving ? 'Saving…' : 'Save & apply'}</button>
|
||||||
|
|
@ -2130,7 +1791,7 @@ function GpuSettingsCard() {
|
||||||
const save = () => {
|
const save = () => {
|
||||||
setSaving(true); setMsg(null);
|
setSaving(true); setMsg(null);
|
||||||
window.ZAMPP_API.fetch('/settings/transcoding', { method: 'PUT', body: JSON.stringify(cfg) })
|
window.ZAMPP_API.fetch('/settings/transcoding', { method: 'PUT', body: JSON.stringify(cfg) })
|
||||||
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved: new settings apply to the next proxy job.' }); })
|
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved — new settings apply to the next proxy job.' }); })
|
||||||
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
|
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2149,7 +1810,7 @@ function GpuSettingsCard() {
|
||||||
<SField label="Hardware acceleration">
|
<SField label="Hardware acceleration">
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
|
||||||
<input type="checkbox" checked={gpuEnabled} onChange={e => set('gpu_transcode_enabled', String(e.target.checked))} />
|
<input type="checkbox" checked={gpuEnabled} onChange={e => set('gpu_transcode_enabled', String(e.target.checked))} />
|
||||||
<span style={{ color: 'var(--text-2)' }}>Use GPU encoders (NVENC / VAAPI) when available: falls back to CPU on missing hardware</span>
|
<span style={{ color: 'var(--text-2)' }}>Use GPU encoders (NVENC / VAAPI) when available — falls back to CPU on missing hardware</span>
|
||||||
</label>
|
</label>
|
||||||
</SField>
|
</SField>
|
||||||
|
|
||||||
|
|
@ -2182,9 +1843,9 @@ function GpuSettingsCard() {
|
||||||
</SField>
|
</SField>
|
||||||
<SField label="Rate control">
|
<SField label="Rate control">
|
||||||
<select className="field-input" value={cfg.gpu_rc_mode || 'cbr'} onChange={e => set('gpu_rc_mode', e.target.value)} style={{ appearance: 'auto' }}>
|
<select className="field-input" value={cfg.gpu_rc_mode || 'cbr'} onChange={e => set('gpu_rc_mode', e.target.value)} style={{ appearance: 'auto' }}>
|
||||||
<option value="cbr">CBR: constant bitrate</option>
|
<option value="cbr">CBR — constant bitrate</option>
|
||||||
<option value="vbr">VBR: variable bitrate</option>
|
<option value="vbr">VBR — variable bitrate</option>
|
||||||
<option value="cqp">CQP / CRF: constant quality</option>
|
<option value="cqp">CQP / CRF — constant quality</option>
|
||||||
</select>
|
</select>
|
||||||
</SField>
|
</SField>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2280,13 +1941,13 @@ function SdiSettingsCard() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
// Capture SDK deployment - Blackmagic / AJA / Deltacast
|
// Capture SDK deployment — Blackmagic / AJA / Deltacast
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
const SDK_VENDORS = [
|
const SDK_VENDORS = [
|
||||||
{
|
{
|
||||||
id: 'blackmagic',
|
id: 'blackmagic',
|
||||||
name: 'Blackmagic DeckLink',
|
name: 'Blackmagic DeckLink',
|
||||||
sub: 'DeckLink SDK 16.x: required for SDI capture via DeckLink cards',
|
sub: 'DeckLink SDK 16.x — required for SDI capture via DeckLink cards',
|
||||||
expect: 'DeckLinkAPI.h, DeckLinkAPIDispatch.cpp, LinuxCOM.h, libDeckLinkAPI.so',
|
expect: 'DeckLinkAPI.h, DeckLinkAPIDispatch.cpp, LinuxCOM.h, libDeckLinkAPI.so',
|
||||||
docs: 'https://www.blackmagicdesign.com/developer/product/capture',
|
docs: 'https://www.blackmagicdesign.com/developer/product/capture',
|
||||||
buildHint: 'docker compose build --no-cache capture',
|
buildHint: 'docker compose build --no-cache capture',
|
||||||
|
|
@ -2295,24 +1956,24 @@ const SDK_VENDORS = [
|
||||||
{
|
{
|
||||||
id: 'aja',
|
id: 'aja',
|
||||||
name: 'AJA NTV2',
|
name: 'AJA NTV2',
|
||||||
sub: 'NTV2 SDK: for Kona / Io / U-Tap / T-Tap cards',
|
sub: 'NTV2 SDK — for Kona / Io / U-Tap / T-Tap cards',
|
||||||
expect: 'libajantv2.so, ntv2card.h, ntv2enums.h',
|
expect: 'libajantv2.so, ntv2card.h, ntv2enums.h',
|
||||||
docs: 'https://sdksupport.aja.com/',
|
docs: 'https://sdksupport.aja.com/',
|
||||||
buildHint: 'FFmpeg patch + Dockerfile update pending: files will be staged for the next capture image build',
|
buildHint: 'FFmpeg patch + Dockerfile update pending — files will be staged for the next capture image build',
|
||||||
status: 'staging-only',
|
status: 'staging-only',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'deltacast',
|
id: 'deltacast',
|
||||||
name: 'Deltacast VideoMaster',
|
name: 'Deltacast VideoMaster',
|
||||||
sub: 'VideoMasterHD SDK: for FLEX / DELTA-h4k2 / etc.',
|
sub: 'VideoMasterHD SDK — for FLEX / DELTA-h4k2 / etc.',
|
||||||
expect: 'VideoMasterHD_Core.h, libVideoMasterHD_Core.so',
|
expect: 'VideoMasterHD_Core.h, libVideoMasterHD_Core.so',
|
||||||
docs: 'https://www.deltacast.tv/products/sdk',
|
docs: 'https://www.deltacast.tv/products/sdk',
|
||||||
buildHint: 'FFmpeg patch + Dockerfile update pending: files will be staged for the next capture image build',
|
buildHint: 'FFmpeg patch + Dockerfile update pending — files will be staged for the next capture image build',
|
||||||
status: 'staging-only',
|
status: 'staging-only',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Premiere panel releases - single source of truth lives on `window.PREMIERE_RELEASES`
|
// Premiere panel releases — single source of truth lives on `window.PREMIERE_RELEASES`
|
||||||
// (see data.jsx). Local alias for readability.
|
// (see data.jsx). Local alias for readability.
|
||||||
const PREMIERE_RELEASES = window.PREMIERE_RELEASES;
|
const PREMIERE_RELEASES = window.PREMIERE_RELEASES;
|
||||||
|
|
||||||
|
|
@ -2327,7 +1988,7 @@ function SdkSettingsCard() {
|
||||||
React.useEffect(() => { load(); }, [load]);
|
React.useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsCard icon="video" title="Capture SDKs" sub="Vendor SDKs are licensed: upload them here so the capture container can build with hardware support"
|
<SettingsCard icon="video" title="Capture SDKs" sub="Vendor SDKs are licensed — upload them here so the capture container can build with hardware support"
|
||||||
tag={<span className="badge neutral">{SDK_VENDORS.length} vendors</span>}>
|
tag={<span className="badge neutral">{SDK_VENDORS.length} vendors</span>}>
|
||||||
|
|
||||||
{/* ── Premiere Panel download section ── */}
|
{/* ── Premiere Panel download section ── */}
|
||||||
|
|
@ -2336,9 +1997,8 @@ function SdkSettingsCard() {
|
||||||
Premiere Pro Panel
|
Premiere Pro Panel
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.55, marginBottom: 10 }}>
|
<div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.55, marginBottom: 10 }}>
|
||||||
The Dragonflight UXP plugin enables growing-file editing, batch trim, and one-click hi-res relink directly inside Premiere Pro.
|
The Dragonflight panel enables growing-file editing, conform, local export, and one-click hi-res relink directly inside Premiere Pro.
|
||||||
Install the <strong style={{ color: 'var(--text-2)' }}>.ccx</strong> via the <a href="https://developer.adobe.com/photoshop/uxp/2022/guides/devtool/installation/" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>Adobe UXP Developer Tool</a>,
|
Download the <strong style={{ color: 'var(--text-2)' }}>.ccx</strong> and double-click to install via Creative Cloud (Mac/Win), then restart Premiere.
|
||||||
or double-click it with Creative Cloud installed.
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{PREMIERE_RELEASES.map(r => (
|
{PREMIERE_RELEASES.map(r => (
|
||||||
|
|
@ -2350,15 +2010,15 @@ function SdkSettingsCard() {
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>{r.notes}</div>
|
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>{r.notes}</div>
|
||||||
</div>
|
</div>
|
||||||
{r.ccx && (
|
{r.ccx ? (
|
||||||
<a href={r.ccx} download style={{ textDecoration: 'none' }}>
|
<a href={r.ccx} download style={{ textDecoration: 'none' }}>
|
||||||
<button className="btn ghost sm">UXP (.ccx)</button>
|
<button className="btn ghost sm">.ccx</button>
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{r.installer && (
|
|
||||||
<a href={r.installer} download style={{ textDecoration: 'none' }}>
|
|
||||||
<button className="btn ghost sm">Win Installer</button>
|
|
||||||
</a>
|
</a>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{r.zxp && <a href={r.zxp} download style={{ textDecoration: 'none' }}><button className="btn ghost sm">ZXP</button></a>}
|
||||||
|
{r.installer && <a href={r.installer} download style={{ textDecoration: 'none' }}><button className="btn ghost sm">Win Installer</button></a>}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -2402,7 +2062,7 @@ function SdkVendorRow({ vendor, status, onDone }) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('archive', file);
|
fd.append('archive', file);
|
||||||
|
|
||||||
// Use XHR so we can report progress to the user - fetch's stream API is fiddly.
|
// Use XHR so we can report progress to the user — fetch's stream API is fiddly.
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', (window.ZAMPP_API_PREFIX || '/api/v1') + '/sdk/' + vendor.id);
|
xhr.open('POST', (window.ZAMPP_API_PREFIX || '/api/v1') + '/sdk/' + vendor.id);
|
||||||
|
|
@ -2418,7 +2078,7 @@ function SdkVendorRow({ vendor, status, onDone }) {
|
||||||
} else {
|
} else {
|
||||||
let txt = xhr.responseText;
|
let txt = xhr.responseText;
|
||||||
try { txt = JSON.parse(xhr.responseText).error || txt; } catch {}
|
try { txt = JSON.parse(xhr.responseText).error || txt; } catch {}
|
||||||
onDone(vendor.name + ': upload failed: ' + txt, false);
|
onDone(vendor.name + ': upload failed — ' + txt, false);
|
||||||
}
|
}
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
@ -2502,7 +2162,7 @@ function AmppSettingsCard() {
|
||||||
<input className="field-input mono" type="url" required value={cfg.ampp_base_url} onChange={e => setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" />
|
<input className="field-input mono" type="url" required value={cfg.ampp_base_url} onChange={e => setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" />
|
||||||
</SField>
|
</SField>
|
||||||
<SField label="API token">
|
<SField label="API token">
|
||||||
<input className="field-input mono" type="password" value={cfg.ampp_token} onChange={e => setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved: type to replace)' : 'AMPP API token'} autoComplete="new-password" />
|
<input className="field-input mono" type="password" value={cfg.ampp_token} onChange={e => setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved — type to replace)' : 'AMPP API token'} autoComplete="new-password" />
|
||||||
</SField>
|
</SField>
|
||||||
<SettingsMsg msg={msg} />
|
<SettingsMsg msg={msg} />
|
||||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// screens-asset.jsx - asset detail (Frame.io-style player, filmstrip, comments)
|
// screens-asset.jsx — asset detail (Frame.io-style player, filmstrip, comments)
|
||||||
|
|
||||||
// Simple gradient palette - replaces the missing thumbGrad function
|
// Simple gradient palette — replaces the missing thumbGrad function
|
||||||
const _FRAME_GRADIENTS = [
|
const _FRAME_GRADIENTS = [
|
||||||
'linear-gradient(135deg,#1a1f2e 0%,#2a3045 100%)',
|
'linear-gradient(135deg,#1a1f2e 0%,#2a3045 100%)',
|
||||||
'linear-gradient(135deg,#1e2030 0%,#2d2040 100%)',
|
'linear-gradient(135deg,#1e2030 0%,#2d2040 100%)',
|
||||||
|
|
@ -41,7 +41,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
// Player health: 'idle' | 'loading' | 'playing' | 'paused' | 'seeking' | 'waiting' | 'stalled' | 'error'
|
// Player health: 'idle' | 'loading' | 'playing' | 'paused' | 'seeking' | 'waiting' | 'stalled' | 'error'
|
||||||
const [playerState, setPlayerState] = React.useState('idle');
|
const [playerState, setPlayerState] = React.useState('idle');
|
||||||
const [playerError, setPlayerError] = React.useState(null);
|
const [playerError, setPlayerError] = React.useState(null);
|
||||||
// Array of {start, end} in milliseconds - populated from HTMLMediaElement.buffered
|
// Array of {start, end} in milliseconds — populated from HTMLMediaElement.buffered
|
||||||
const [buffered, setBuffered] = React.useState([]);
|
const [buffered, setBuffered] = React.useState([]);
|
||||||
// Wall-clock when waiting/stalled began (so we can show how long it's been hung)
|
// Wall-clock when waiting/stalled began (so we can show how long it's been hung)
|
||||||
const [stallStart, setStallStart] = React.useState(null);
|
const [stallStart, setStallStart] = React.useState(null);
|
||||||
|
|
@ -65,12 +65,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
setStreamLoading(true);
|
setStreamLoading(true);
|
||||||
window.ZAMPP_API.fetch('/assets/' + assetId + '/stream')
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/stream')
|
||||||
.then(function(r) {
|
.then(function(r) {
|
||||||
if (r && r.hls_url) {
|
if (r && r.url) {
|
||||||
// Prefer HLS for in-browser playback; `url` stays the MP4 proxy
|
|
||||||
// (used by the Premiere plugin importer + as a fallback).
|
|
||||||
setStreamUrl(r.hls_url);
|
|
||||||
setStreamType('hls');
|
|
||||||
} else if (r && r.url) {
|
|
||||||
setStreamUrl(r.url);
|
setStreamUrl(r.url);
|
||||||
setStreamType(r.type || 'mp4');
|
setStreamType(r.type || 'mp4');
|
||||||
} else if (r) {
|
} else if (r) {
|
||||||
|
|
@ -94,7 +89,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
}, [streamUrl, streamType]);
|
}, [streamUrl, streamType]);
|
||||||
|
|
||||||
// Fetch server-side filmstrip (pre-built by filmstrip worker via FFmpeg).
|
// Fetch server-side filmstrip (pre-built by filmstrip worker via FFmpeg).
|
||||||
// Falls back to nothing if not ready yet - user can right-click → Re-generate.
|
// Falls back to nothing if not ready yet — user can right-click → Re-generate.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!assetId) return;
|
if (!assetId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -120,7 +115,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
return function() { cancelled = true; };
|
return function() { cancelled = true; };
|
||||||
}, [assetId, filmstripKey]);
|
}, [assetId, filmstripKey]);
|
||||||
|
|
||||||
// Fake playback timer - only used when no real video stream
|
// Fake playback timer — only used when no real video stream
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!playing || totalMs <= 0 || streamUrl) return;
|
if (!playing || totalMs <= 0 || streamUrl) return;
|
||||||
const i = setInterval(function() {
|
const i = setInterval(function() {
|
||||||
|
|
@ -164,7 +159,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
return () => clearInterval(i);
|
return () => clearInterval(i);
|
||||||
}, [stallStart]);
|
}, [stallStart]);
|
||||||
|
|
||||||
// #143 - if the player is stalled within 250 ms of EOF for more than 1.2 s,
|
// #143 — if the player is stalled within 250 ms of EOF for more than 1.2 s,
|
||||||
// treat it as a clean end. Avoids the silent-freeze users hit when seeking
|
// treat it as a clean end. Avoids the silent-freeze users hit when seeking
|
||||||
// to the last instant of a clip.
|
// to the last instant of a clip.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -185,7 +180,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
}, [stallStart, totalMs, playerState]);
|
}, [stallStart, totalMs, playerState]);
|
||||||
|
|
||||||
const seek = function(ms) {
|
const seek = function(ms) {
|
||||||
// #143 - seeking exactly to `totalMs` parked the playhead one micro-sample
|
// #143 — seeking exactly to `totalMs` parked the playhead one micro-sample
|
||||||
// past the last decoded frame; the player then asked S3 for a range past
|
// past the last decoded frame; the player then asked S3 for a range past
|
||||||
// EOF and stalled silently. Pull the clamp back 50 ms so the final frames
|
// EOF and stalled silently. Pull the clamp back 50 ms so the final frames
|
||||||
// are reachable but the player never asks for bytes past the file size.
|
// are reachable but the player never asks for bytes past the file size.
|
||||||
|
|
@ -217,7 +212,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
.finally(function() { setDownloading(false); });
|
.finally(function() { setDownloading(false); });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Right-click style menu on the kebab icon - delete, copy ID.
|
// Right-click style menu on the kebab icon — delete, copy ID.
|
||||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||||
const moreBtnRef = React.useRef(null);
|
const moreBtnRef = React.useRef(null);
|
||||||
React.useEffect(function() {
|
React.useEffect(function() {
|
||||||
|
|
@ -263,7 +258,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
|
|
||||||
const regenFilmstrip = function() {
|
const regenFilmstrip = function() {
|
||||||
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=filmstrip', { method: 'POST' })
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=filmstrip', { method: 'POST' })
|
||||||
.then(function() { window.alert('Filmstrip job queued: it will appear automatically when ready.'); })
|
.then(function() { window.alert('Filmstrip job queued — it will appear automatically when ready.'); })
|
||||||
.catch(function(e) { window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error')); });
|
.catch(function(e) { window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error')); });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -482,7 +477,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
<span className="badge live">LIVE · REC</span>
|
<span className="badge live">LIVE · REC</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Player health badge: shows when waiting/stalled so the freeze is visible */}
|
{/* Player health badge — shows when waiting/stalled so the freeze is visible */}
|
||||||
{streamUrl && (playerState === 'waiting' || playerState === 'stalled' || playerState === 'seeking' || playerState === 'error') && (
|
{streamUrl && (playerState === 'waiting' || playerState === 'stalled' || playerState === 'seeking' || playerState === 'error') && (
|
||||||
<div style={{ position: "absolute", top: 12, right: 12, display: "flex", gap: 6, alignItems: "center" }}>
|
<div style={{ position: "absolute", top: 12, right: 12, display: "flex", gap: 6, alignItems: "center" }}>
|
||||||
<span className={'badge ' + (playerState === 'error' ? 'danger' : playerState === 'stalled' ? 'warning' : 'neutral')}>
|
<span className={'badge ' + (playerState === 'error' ? 'danger' : playerState === 'stalled' ? 'warning' : 'neutral')}>
|
||||||
|
|
@ -635,7 +630,7 @@ function PlaybackBar({ current, total, onSeek, comments, buffered }) {
|
||||||
const bufferedRanges = Array.isArray(buffered) ? buffered : [];
|
const bufferedRanges = Array.isArray(buffered) ? buffered : [];
|
||||||
return (
|
return (
|
||||||
<div className="playback-bar" ref={ref} onClick={handle}>
|
<div className="playback-bar" ref={ref} onClick={handle}>
|
||||||
{/* Buffered byte ranges: translucent grey segments showing what the browser has loaded */}
|
{/* Buffered byte ranges — translucent grey segments showing what the browser has loaded */}
|
||||||
{total > 0 && bufferedRanges.map((br, i) => {
|
{total > 0 && bufferedRanges.map((br, i) => {
|
||||||
const left = Math.max(0, (br.start / total) * 100);
|
const left = Math.max(0, (br.start / total) * 100);
|
||||||
const right = Math.min(100, (br.end / total) * 100);
|
const right = Math.min(100, (br.end / total) * 100);
|
||||||
|
|
@ -907,7 +902,7 @@ function FilesTab({ asset, filmFrames, filmstripLoading, streamUrl, reprocessing
|
||||||
<FileRow
|
<FileRow
|
||||||
label="Filmstrip"
|
label="Filmstrip"
|
||||||
present={hasFilmstrip}
|
present={hasFilmstrip}
|
||||||
path={hasFilmstrip ? (asset.filmstrip_s3_key || null) : filmstripLoading ? 'Fetching…' : 'Not generated yet: right-click filmstrip or click Re-generate'}
|
path={hasFilmstrip ? (asset.filmstrip_s3_key || null) : filmstripLoading ? 'Fetching…' : 'Not generated yet — right-click filmstrip or click Re-generate'}
|
||||||
icon="editor"
|
icon="editor"
|
||||||
actionLabel="Re-generate"
|
actionLabel="Re-generate"
|
||||||
onAction={onRegenFilmstrip}
|
onAction={onRegenFilmstrip}
|
||||||
|
|
@ -934,21 +929,21 @@ function FilesTab({ asset, filmFrames, filmstripLoading, streamUrl, reprocessing
|
||||||
function MetadataTab({ asset }) {
|
function MetadataTab({ asset }) {
|
||||||
var rows = [
|
var rows = [
|
||||||
{ k: "Filename", v: asset.name },
|
{ k: "Filename", v: asset.name },
|
||||||
{ k: "Duration", v: asset.duration || '·' },
|
{ k: "Duration", v: asset.duration || '—' },
|
||||||
{ k: "Resolution", v: asset.res || '·' },
|
{ k: "Resolution", v: asset.res || '—' },
|
||||||
{ k: "Codec", v: asset.codec || '·' },
|
{ k: "Codec", v: asset.codec || '—' },
|
||||||
{ k: "File size", v: asset.size || '·' },
|
{ k: "File size", v: asset.size || '—' },
|
||||||
{ k: "Status", v: asset.status || '·' },
|
{ k: "Status", v: asset.status || '—' },
|
||||||
{ k: "Updated", v: asset.updated || '·' },
|
{ k: "Updated", v: asset.updated || '—' },
|
||||||
{ k: "Project", v: asset.project || '·' },
|
{ k: "Project", v: asset.project || '—' },
|
||||||
];
|
];
|
||||||
var audioMeta = asset.audio_metadata;
|
var audioMeta = asset.audio_metadata;
|
||||||
if (audioMeta && Array.isArray(audioMeta) && audioMeta.length > 0) {
|
if (audioMeta && Array.isArray(audioMeta) && audioMeta.length > 0) {
|
||||||
rows.push({ k: "Audio tracks", v: audioMeta.length });
|
rows.push({ k: "Audio tracks", v: audioMeta.length });
|
||||||
audioMeta.forEach(function(tr, i) {
|
audioMeta.forEach(function(tr, i) {
|
||||||
var label = tr.title || ('Track ' + (tr.index != null ? tr.index : i + 1));
|
var label = tr.title || ('Track ' + (tr.index != null ? tr.index : i + 1));
|
||||||
var ch = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '·');
|
var ch = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '—');
|
||||||
var parts = [tr.codec || '·', ch, tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '·', tr.bit_depth ? tr.bit_depth + '-bit' : '·', tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '·'];
|
var parts = [tr.codec || '—', ch, tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '—', tr.bit_depth ? tr.bit_depth + '-bit' : '—', tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '—'];
|
||||||
if (tr.language) parts.push(tr.language);
|
if (tr.language) parts.push(tr.language);
|
||||||
rows.push({ k: " " + label, v: parts.join(' · ') });
|
rows.push({ k: " " + label, v: parts.join(' · ') });
|
||||||
});
|
});
|
||||||
|
|
@ -1111,13 +1106,13 @@ function AudioTab({ asset }) {
|
||||||
var st = trackState[i] || { muted: false, solo: false, volume: 100 };
|
var st = trackState[i] || { muted: false, solo: false, volume: 100 };
|
||||||
var isAudible = st.muted ? false : (anySolo ? st.solo : true);
|
var isAudible = st.muted ? false : (anySolo ? st.solo : true);
|
||||||
var color = _AUDIO_TRACK_COLORS[i % _AUDIO_TRACK_COLORS.length];
|
var color = _AUDIO_TRACK_COLORS[i % _AUDIO_TRACK_COLORS.length];
|
||||||
var chLabel = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '·');
|
var chLabel = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '—');
|
||||||
var trackName = tr.title || ('Track ' + (tr.index != null ? tr.index : i + 1));
|
var trackName = tr.title || ('Track ' + (tr.index != null ? tr.index : i + 1));
|
||||||
var langTag = tr.language ? <span className="badge neutral" style={{ marginLeft: 6 }}>{tr.language}</span> : null;
|
var langTag = tr.language ? <span className="badge neutral" style={{ marginLeft: 6 }}>{tr.language}</span> : null;
|
||||||
var codecLabel = tr.codec || '·';
|
var codecLabel = tr.codec || '—';
|
||||||
var srLabel = tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '·';
|
var srLabel = tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '—';
|
||||||
var bdLabel = tr.bit_depth ? tr.bit_depth + '-bit' : '·';
|
var bdLabel = tr.bit_depth ? tr.bit_depth + '-bit' : '—';
|
||||||
var brLabel = tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '·';
|
var brLabel = tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '—';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} className={'audio-track' + (isAudible ? '' : ' muted')}>
|
<div key={i} className={'audio-track' + (isAudible ? '' : ' muted')}>
|
||||||
|
|
@ -1192,7 +1187,7 @@ function AudioLevelMeter({ level, label, tall }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDuration(d) {
|
function parseDuration(d) {
|
||||||
if (!d || d === '·' || typeof d !== 'string') return 0;
|
if (!d || d === '—' || typeof d !== 'string') return 0;
|
||||||
const parts = d.split(':');
|
const parts = d.split(':');
|
||||||
if (parts.length < 2) return 0;
|
if (parts.length < 2) return 0;
|
||||||
const nums = parts.map(Number);
|
const nums = parts.map(Number);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// LoginScreen + SetupScreen - layout B from the auth brainstorm spec:
|
// LoginScreen + SetupScreen — layout B from the auth brainstorm spec:
|
||||||
// 22px wordmark + "WILD DRAGON BROADCAST" tagline above a --bg-1 card.
|
// 22px wordmark + "WILD DRAGON BROADCAST" tagline above a --bg-1 card.
|
||||||
// Matches DESIGN.md tokens; no decoration, dense, ops register.
|
// Matches DESIGN.md tokens; no decoration, dense, ops register.
|
||||||
|
|
||||||
|
|
@ -116,131 +116,27 @@
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Google sign-in availability + a friendly message for the callback's
|
|
||||||
// ?auth_error redirect (domain-not-allowed / generic google failure).
|
|
||||||
function useGoogleAndAuthError(setError) {
|
|
||||||
const [googleEnabled, setGoogleEnabled] = React.useState(false);
|
|
||||||
React.useEffect(() => {
|
|
||||||
fetch(API_BASE + '/auth/google/enabled', { credentials: 'include' })
|
|
||||||
.then(r => r.json()).then(d => setGoogleEnabled(!!d.enabled)).catch(() => {});
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
const e = params.get('auth_error');
|
|
||||||
if (e === 'domain') setError('That Google account is not in an allowed domain.');
|
|
||||||
else if (e === 'google') setError('Google sign-in failed. Please try again.');
|
|
||||||
if (e) {
|
|
||||||
// Clean the query string so a reload doesn't re-show the error.
|
|
||||||
const url = location.pathname + location.hash;
|
|
||||||
history.replaceState(null, '', url);
|
|
||||||
}
|
|
||||||
}, [setError]);
|
|
||||||
return googleEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
function GoogleButton() {
|
|
||||||
return (
|
|
||||||
<a href={API_BASE + '/auth/google'} style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
|
||||||
width: '100%', boxSizing: 'border-box', textDecoration: 'none',
|
|
||||||
background: 'var(--bg-3)', color: 'var(--text-1)',
|
|
||||||
border: '1px solid var(--border)', borderRadius: 4,
|
|
||||||
padding: '9px', fontSize: 13, fontWeight: 600, marginTop: 10,
|
|
||||||
}}>
|
|
||||||
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 700 }}>G</span>
|
|
||||||
Sign in with Google
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Divider() {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '14px 0 4px' }}>
|
|
||||||
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>or</span>
|
|
||||||
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoginScreen({ onDone }) {
|
function LoginScreen({ onDone }) {
|
||||||
const [username, setUsername] = React.useState('');
|
const [username, setUsername] = React.useState('');
|
||||||
const [password, setPassword] = React.useState('');
|
const [password, setPassword] = React.useState('');
|
||||||
const [error, setError] = React.useState('');
|
const [error, setError] = React.useState('');
|
||||||
const [busy, setBusy] = React.useState(false);
|
const [busy, setBusy] = React.useState(false);
|
||||||
// Second factor: when the server returns { mfa_required, ticket }, we switch
|
|
||||||
// to the code step instead of completing login. `ticket` may be a real value
|
|
||||||
// (password path) or the sentinel 'session' (Google path, where the ticket
|
|
||||||
// lives in the session cookie and is not exposed to JS).
|
|
||||||
const [ticket, setTicket] = React.useState(null);
|
|
||||||
const [code, setCode] = React.useState('');
|
|
||||||
const googleEnabled = useGoogleAndAuthError(setError);
|
|
||||||
|
|
||||||
// Google OAuth with TOTP redirects back to /?mfa=1; the ticket is in the
|
|
||||||
// session, so enter the code step without a body ticket.
|
|
||||||
React.useEffect(() => {
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
if (params.get('mfa') === '1') {
|
|
||||||
setTicket('session');
|
|
||||||
history.replaceState(null, '', location.pathname + location.hash);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
setError(''); setBusy(true);
|
setError(''); setBusy(true);
|
||||||
try {
|
try {
|
||||||
const r = await postJson('/auth/login', { username, password });
|
const r = await postJson('/auth/login', { username, password });
|
||||||
if (r.status === 200) {
|
if (r.status === 200) { onDone(); return; }
|
||||||
const body = await r.json().catch(() => ({}));
|
|
||||||
if (body.mfa_required) { setTicket(body.ticket); setBusy(false); return; }
|
|
||||||
onDone(); return;
|
|
||||||
}
|
|
||||||
const body = await r.json().catch(() => ({}));
|
const body = await r.json().catch(() => ({}));
|
||||||
setError(body.error || ('Login failed: ' + r.status));
|
setError(body.error || ('Login failed: ' + r.status));
|
||||||
} catch (e) { setError(e.message || 'Login failed'); }
|
} catch (e) { setError(e.message || 'Login failed'); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitCode = async () => {
|
|
||||||
setError(''); setBusy(true);
|
|
||||||
try {
|
|
||||||
// For the Google path the ticket is the session sentinel — send code only.
|
|
||||||
const payload = ticket === 'session' ? { code: code.trim() } : { ticket, code: code.trim() };
|
|
||||||
const r = await postJson('/auth/login/totp', payload);
|
|
||||||
if (r.status === 200) { onDone(); return; }
|
|
||||||
const body = await r.json().catch(() => ({}));
|
|
||||||
// An expired/used ticket means the user must start over.
|
|
||||||
if (r.status === 401 && /ticket/.test(body.error || '')) {
|
|
||||||
setTicket(null); setCode(''); setPassword('');
|
|
||||||
setError('Session timed out — please sign in again.');
|
|
||||||
} else {
|
|
||||||
setError(body.error || ('Verification failed: ' + r.status));
|
|
||||||
}
|
|
||||||
} catch (e) { setError(e.message || 'Verification failed'); }
|
|
||||||
finally { setBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
if (ticket) {
|
|
||||||
return (
|
|
||||||
<Screen>
|
|
||||||
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 14 }}>
|
|
||||||
Two-factor authentication
|
|
||||||
</div>
|
|
||||||
<ErrorRow text={error} />
|
|
||||||
<Field label="Authenticator code" value={code} onChange={setCode} autoComplete="one-time-code" autoFocus />
|
|
||||||
<div style={{ fontSize: 10.5, color: 'var(--text-3)', marginBottom: 12 }}>
|
|
||||||
Enter the 6-digit code from your authenticator app, or a recovery code.
|
|
||||||
</div>
|
|
||||||
<Button type="submit" disabled={busy || !code.trim()} onClick={submitCode}>Verify</Button>
|
|
||||||
</Screen>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<ErrorRow text={error} />
|
<ErrorRow text={error} />
|
||||||
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
|
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
|
||||||
<Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="current-password" />
|
<Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="current-password" />
|
||||||
<Button type="submit" disabled={busy || !username || !password} onClick={submit}>Sign in</Button>
|
<Button type="submit" disabled={busy || !username || !password} onClick={submit}>Sign in</Button>
|
||||||
{googleEnabled && <><Divider /><GoogleButton /></>}
|
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -267,7 +163,7 @@
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 14 }}>
|
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 14 }}>
|
||||||
First-run setup: create the first admin
|
First-run setup — create the first admin
|
||||||
</div>
|
</div>
|
||||||
<ErrorRow text={error} />
|
<ErrorRow text={error} />
|
||||||
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
|
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// screens-editor.jsx - NLE timeline editor
|
// screens-editor.jsx — NLE timeline editor
|
||||||
// Depends on: window.TC (timecode.js), window.Timeline (timeline.js), window.ZAMPP_API
|
// Depends on: window.TC (timecode.js), window.Timeline (timeline.js), window.ZAMPP_API
|
||||||
|
|
||||||
function _uid() { return 'ce_' + (++_uid._c || (_uid._c = 0)); }
|
function _uid() { return 'ce_' + (++_uid._c || (_uid._c = 0)); }
|
||||||
|
|
@ -378,23 +378,42 @@ function Editor() {
|
||||||
return (
|
return (
|
||||||
<div className="editor-shell" style={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<div className="editor-shell" style={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
|
||||||
{/* Beta banner: flat strip across top, no glassmorphism or gradient. */}
|
{/* ── COMING SOON bumper — overlays the entire editor ── */}
|
||||||
<div className="editor-beta-banner">
|
<div style={{
|
||||||
<Icon name="editor" size={14} />
|
position: 'absolute', inset: 0, zIndex: 100,
|
||||||
<div className="editor-beta-banner-body">
|
background: 'rgba(10, 12, 18, 0.92)',
|
||||||
<strong>NLE editor is in beta.</strong>
|
backdropFilter: 'blur(6px)',
|
||||||
<span> Use the Premiere Pro panel for frame-accurate editing and growing-file workflows.</span>
|
display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
gap: 20, pointerEvents: 'all',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 64, height: 64,
|
||||||
|
background: 'linear-gradient(135deg, var(--accent), hsl(250 80% 65%))',
|
||||||
|
borderRadius: 16,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: '0 0 40px rgba(91, 124, 250, 0.4)',
|
||||||
|
}}>
|
||||||
|
<Icon name="editor" size={30} />
|
||||||
</div>
|
</div>
|
||||||
<div className="editor-beta-banner-actions">
|
<div style={{ textAlign: 'center', maxWidth: 420 }}>
|
||||||
<a href={(window.PREMIERE_LATEST || {}).zxp || '#'} download className="btn primary sm">
|
<div style={{ fontSize: 26, fontWeight: 700, letterSpacing: '-0.02em', color: 'var(--text-primary)', marginBottom: 8 }}>
|
||||||
Download Premiere panel
|
NLE Editor — Coming Soon
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, color: 'var(--text-3)', lineHeight: 1.6 }}>
|
||||||
|
The browser-based timeline editor is under active development.
|
||||||
|
In the meantime, use the <strong style={{ color: 'var(--text-2)' }}>Premiere Pro panel</strong> for
|
||||||
|
frame-accurate editing and growing-file workflows — download it from
|
||||||
|
<strong style={{ color: 'var(--text-2)' }}> Settings → Capture SDKs</strong>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginTop: 4 }}>
|
||||||
|
<a href={(window.PREMIERE_LATEST || {}).ccx || (window.PREMIERE_LATEST || {}).zxp || '#'} download style={{ textDecoration: 'none' }}>
|
||||||
|
<button className="btn primary">Download Panel (.ccx)</button>
|
||||||
</a>
|
</a>
|
||||||
<a href={(window.PREMIERE_LATEST || {}).installer || '#'} download className="btn ghost sm">
|
</div>
|
||||||
Windows installer
|
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: -4 }}>
|
||||||
</a>
|
Dragonflight Premiere Panel v{(window.PREMIERE_LATEST || {}).version || '—'}
|
||||||
<span className="editor-beta-banner-version mono">
|
|
||||||
v{(window.PREMIERE_LATEST || {}).version || '·'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -697,7 +716,7 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
|
||||||
function EditorKeyboard({ onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSeq }) {
|
function EditorKeyboard({ onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSeq }) {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
function handler(e) {
|
function handler(e) {
|
||||||
// #116 - `document.activeElement` is null in some edge cases (iframe focus,
|
// #116 — `document.activeElement` is null in some edge cases (iframe focus,
|
||||||
// popovers, devtools-driven focus), and the previous code threw NPE here.
|
// popovers, devtools-driven focus), and the previous code threw NPE here.
|
||||||
const tag = (document.activeElement && document.activeElement.tagName) || '';
|
const tag = (document.activeElement && document.activeElement.tagName) || '';
|
||||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return;
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return;
|
||||||
|
|
|
||||||
|
|
@ -2,40 +2,31 @@
|
||||||
//
|
//
|
||||||
// Two routes share this file:
|
// Two routes share this file:
|
||||||
//
|
//
|
||||||
// • Home - the launcher. Big-button entry into each section of the MAM.
|
// • Home — the launcher. Big-button entry into each section of the MAM.
|
||||||
// Untouched in this rewrite.
|
// Untouched in this rewrite.
|
||||||
//
|
//
|
||||||
// • Dashboard - the operations view. Rebuilt as a control-room status
|
// • Dashboard — the operations view. Rebuilt as a control-room status
|
||||||
// board, not a SaaS analytics page. Sections render top-down by
|
// board, not a SaaS analytics page. Sections render top-down by
|
||||||
// operator priority:
|
// operator priority:
|
||||||
//
|
//
|
||||||
// 1. ON AIR - live recorder tiles, full-width
|
// 1. ON AIR — live recorder tiles, full-width
|
||||||
// 2. UP NEXT - single-row strip of next scheduled recordings
|
// 2. UP NEXT — single-row strip of next scheduled recordings
|
||||||
// 3. ATTENTION - conditional; only when something failed
|
// 3. ATTENTION — conditional; only when something failed
|
||||||
// 4. WORK + CLUSTER - two-column dense panels
|
// 4. WORK + CLUSTER — two-column dense panels
|
||||||
// 5. STATUS BAR - single mono-text line, bottom
|
// 5. STATUS BAR — single mono-text line, bottom
|
||||||
//
|
//
|
||||||
// Anything that would just say "all clear" is hidden, not rendered.
|
// Anything that would just say "all clear" is hidden, not rendered.
|
||||||
|
|
||||||
function Home({ navigate }) {
|
function Home({ navigate }) {
|
||||||
const [showDownloads, setShowDownloads] = React.useState(false);
|
|
||||||
|
|
||||||
// Pull live counts so the tile subtitles ("34 assets", "0 live", "3 running")
|
// Pull live counts so the tile subtitles ("34 assets", "0 live", "3 running")
|
||||||
// reflect what's actually in the DB right now, not a stale boot-time cache.
|
// reflect what's actually in the DB right now, not a stale boot-time cache.
|
||||||
const [cards, setCards] = React.useState({});
|
const [cards, setCards] = React.useState({});
|
||||||
// Playout has no /metrics/home card yet (and the playout schema may not be
|
|
||||||
// migrated on every install); fetch /playout/channels separately and degrade
|
|
||||||
// silently — the tile just shows "No channels" if the endpoint isn't there.
|
|
||||||
const [playoutChannels, setPlayoutChannels] = React.useState(null);
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const load = () => {
|
const load = () => {
|
||||||
window.ZAMPP_API.fetch('/metrics/home?hours=1')
|
window.ZAMPP_API.fetch('/metrics/home?hours=1')
|
||||||
.then(d => { if (!cancelled) setCards(d?.cards || {}); })
|
.then(d => { if (!cancelled) setCards(d?.cards || {}); })
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
window.ZAMPP_API.fetch('/playout/channels')
|
|
||||||
.then(d => { if (!cancelled) setPlayoutChannels(Array.isArray(d) ? d : []); })
|
|
||||||
.catch(() => { if (!cancelled) setPlayoutChannels([]); });
|
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
const t = setInterval(load, 30_000);
|
const t = setInterval(load, 30_000);
|
||||||
|
|
@ -71,27 +62,12 @@ function Home({ navigate }) {
|
||||||
desc: 'SDI · SRT · RTMP ingest. Start, stop, schedule.',
|
desc: 'SDI · SRT · RTMP ingest. Start, stop, schedule.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'playout',
|
id: 'editor',
|
||||||
label: 'Playout',
|
label: 'Editor',
|
||||||
icon: 'signal',
|
icon: 'editor',
|
||||||
tone: 'accent',
|
|
||||||
sub: (() => {
|
|
||||||
if (playoutChannels === null) return '·';
|
|
||||||
const total = playoutChannels.length;
|
|
||||||
const onAir = playoutChannels.filter(c => c.status === 'running').length;
|
|
||||||
if (total === 0) return 'No channels';
|
|
||||||
if (onAir > 0) return onAir + ' on air · ' + total + ' channel' + (total === 1 ? '' : 's');
|
|
||||||
return total + ' channel' + (total === 1 ? '' : 's');
|
|
||||||
})(),
|
|
||||||
desc: 'Master Control. SDI · NDI · SRT · RTMP playout, playlists, as-run.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '__downloads',
|
|
||||||
label: 'Downloads',
|
|
||||||
icon: 'download',
|
|
||||||
tone: 'purple',
|
tone: 'purple',
|
||||||
sub: 'Plugin · Teams ISO',
|
sub: 'Beta',
|
||||||
desc: 'Download the Premiere Pro UXP plugin and the Teams ISO installer.',
|
desc: 'Timeline editor with cross-clip preview and render queue.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'jobs',
|
id: 'jobs',
|
||||||
|
|
@ -115,19 +91,6 @@ function Home({ navigate }) {
|
||||||
|
|
||||||
const clusterHealthy = !nodesTotal || nodesOnline >= nodesTotal;
|
const clusterHealthy = !nodesTotal || nodesOnline >= nodesTotal;
|
||||||
|
|
||||||
// Activity strip (#153): live recorders + last-24h assets + alerts.
|
|
||||||
const liveRecorders = RECORDERS.filter(r => r.status === 'recording').slice(0, 4);
|
|
||||||
const recentAssets = (() => {
|
|
||||||
const dayAgo = Date.now() - 86400000;
|
|
||||||
return ASSETS
|
|
||||||
.filter(a => a.created_at && new Date(a.created_at).getTime() > dayAgo)
|
|
||||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
|
|
||||||
.slice(0, 6);
|
|
||||||
})();
|
|
||||||
const failedCount = JOBS.filter(j => j.status === 'failed').length;
|
|
||||||
const errCount = RECORDERS.filter(r => r.status === 'error').length;
|
|
||||||
const hasActivity = liveRecorders.length || recentAssets.length || failedCount || errCount;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="launcher">
|
<div className="launcher">
|
||||||
<div className="launcher-inner">
|
<div className="launcher-inner">
|
||||||
|
|
@ -140,10 +103,7 @@ function Home({ navigate }) {
|
||||||
/>
|
/>
|
||||||
<h1 className="launcher-wordmark">DRAGONFLIGHT</h1>
|
<h1 className="launcher-wordmark">DRAGONFLIGHT</h1>
|
||||||
<p className="launcher-tagline">
|
<p className="launcher-tagline">
|
||||||
Media Asset Management & Production Platform
|
Self-hosted broadcast media-asset management
|
||||||
</p>
|
|
||||||
<p className="launcher-tagline launcher-tagline-motto">
|
|
||||||
Let's create
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -152,7 +112,7 @@ function Home({ navigate }) {
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
className={'launcher-tile tone-' + t.tone}
|
className={'launcher-tile tone-' + t.tone}
|
||||||
onClick={() => t.id === '__downloads' ? setShowDownloads(true) : navigate(t.id)}
|
onClick={() => navigate(t.id)}
|
||||||
>
|
>
|
||||||
<span className="launcher-tile-icon">
|
<span className="launcher-tile-icon">
|
||||||
<Icon name={t.icon} size={26} />
|
<Icon name={t.icon} size={26} />
|
||||||
|
|
@ -171,7 +131,7 @@ function Home({ navigate }) {
|
||||||
onClick={() => navigate('dashboard')}
|
onClick={() => navigate('dashboard')}
|
||||||
>
|
>
|
||||||
<span className="launcher-tile-icon">
|
<span className="launcher-tile-icon">
|
||||||
<Icon name="layout" size={22} />
|
<Icon name="home" size={22} />
|
||||||
</span>
|
</span>
|
||||||
<span className="launcher-tile-label">Dashboard</span>
|
<span className="launcher-tile-label">Dashboard</span>
|
||||||
<span className="launcher-tile-sub">Operations view</span>
|
<span className="launcher-tile-sub">Operations view</span>
|
||||||
|
|
@ -184,71 +144,6 @@ function Home({ navigate }) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasActivity && (
|
|
||||||
<div className="launcher-activity">
|
|
||||||
{(failedCount > 0 || errCount > 0) && (
|
|
||||||
<div className="launcher-activity-strip alert">
|
|
||||||
<Icon name="alert" size={14} />
|
|
||||||
<span>
|
|
||||||
{errCount > 0 && <strong>{errCount} recorder{errCount === 1 ? '' : 's'} in error.</strong>}
|
|
||||||
{errCount > 0 && failedCount > 0 && ' '}
|
|
||||||
{failedCount > 0 && <strong>{failedCount} failed job{failedCount === 1 ? '' : 's'}.</strong>}
|
|
||||||
</span>
|
|
||||||
<button className="btn ghost sm" onClick={() => navigate('dashboard')}>Open Dashboard</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{liveRecorders.length > 0 && (
|
|
||||||
<div className="launcher-activity-section">
|
|
||||||
<div className="launcher-activity-head">
|
|
||||||
<span className="rec-dot" />
|
|
||||||
Recording now
|
|
||||||
<span className="muted">{liveRecorders.length} live</span>
|
|
||||||
<div style={{ flex: 1 }} />
|
|
||||||
<button className="btn ghost sm" onClick={() => navigate('monitors')}>Monitors</button>
|
|
||||||
</div>
|
|
||||||
<div className="launcher-activity-grid">
|
|
||||||
{liveRecorders.map(r => (
|
|
||||||
<button key={r.id} className="launcher-activity-item" onClick={() => navigate('recorders')}>
|
|
||||||
<span className="badge live">REC</span>
|
|
||||||
<span className="launcher-activity-item-name">{r.name}</span>
|
|
||||||
<span className="launcher-activity-item-meta mono">{r.source_type || 'sdi'}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{recentAssets.length > 0 && (
|
|
||||||
<div className="launcher-activity-section">
|
|
||||||
<div className="launcher-activity-head">
|
|
||||||
<Icon name="library" size={12} />
|
|
||||||
Last 24 hours
|
|
||||||
<span className="muted">{recentAssets.length} new asset{recentAssets.length === 1 ? '' : 's'}</span>
|
|
||||||
<div style={{ flex: 1 }} />
|
|
||||||
<button className="btn ghost sm" onClick={() => navigate('library')}>Library</button>
|
|
||||||
</div>
|
|
||||||
<div className="launcher-activity-grid">
|
|
||||||
{recentAssets.map(a => (
|
|
||||||
<button key={a.id} className="launcher-activity-item" onClick={() => navigate('library')}>
|
|
||||||
<Icon name={a.media_type === 'audio' ? 'audio' : 'video'} size={13} />
|
|
||||||
<span className="launcher-activity-item-name">{a.display_name || a.filename || 'untitled'}</span>
|
|
||||||
<span className="launcher-activity-item-meta mono">
|
|
||||||
{(() => {
|
|
||||||
const mins = Math.round((Date.now() - new Date(a.created_at)) / 60000);
|
|
||||||
if (mins < 60) return mins + 'm';
|
|
||||||
const h = Math.round(mins / 60);
|
|
||||||
return h + 'h';
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="launcher-status">
|
<div className="launcher-status">
|
||||||
<span className="launcher-status-pip">
|
<span className="launcher-status-pip">
|
||||||
<span
|
<span
|
||||||
|
|
@ -266,117 +161,18 @@ function Home({ navigate }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showDownloads && <DownloadsModal onClose={() => setShowDownloads(false)} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal listing all downloads: the Premiere Pro UXP plugin (.ccx, one per
|
|
||||||
// released version, sourced from window.PREMIERE_RELEASES written by the
|
|
||||||
// Settings → SDKs section in screens-admin.jsx) plus the Teams ISO installer
|
|
||||||
// (window.TEAMS_ISO; the .exe slot is wired but the file may still be pending).
|
|
||||||
function DownloadsModal({ onClose }) {
|
|
||||||
const teamsIso = window.TEAMS_ISO || {};
|
|
||||||
const releases = (window.PREMIERE_RELEASES || []).slice().sort((a, b) => {
|
|
||||||
// Newest first; fall back to lexicographic compare on version string.
|
|
||||||
const av = String(a.version || ''), bv = String(b.version || '');
|
|
||||||
return bv.localeCompare(av, undefined, { numeric: true });
|
|
||||||
});
|
|
||||||
const latest = window.PREMIERE_LATEST || releases[0] || null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-backdrop" onClick={onClose}>
|
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 560 }}>
|
|
||||||
<div className="modal-head">
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 15, fontWeight: 600 }}>Downloads</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>
|
|
||||||
The Premiere Pro (UXP) plugin and the Teams ISO installer. Install the .ccx per workstation via the Adobe UXP Developer Tool, or double-click it with Creative Cloud installed.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-body">
|
|
||||||
<div className="premiere-release">
|
|
||||||
<div className="premiere-release-head">
|
|
||||||
<span className="premiere-release-version mono">Teams ISO</span>
|
|
||||||
{teamsIso.version && (
|
|
||||||
<span className="premiere-release-date mono">v{teamsIso.version}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="premiere-release-notes">
|
|
||||||
Windows installer for the Teams ISO workstation build.
|
|
||||||
</div>
|
|
||||||
<div className="premiere-release-actions">
|
|
||||||
{teamsIso.available && teamsIso.url ? (
|
|
||||||
<a href={teamsIso.url} download className="btn primary sm">
|
|
||||||
<Icon name="download" />Teams ISO (.exe)
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="btn primary sm" aria-disabled="true" style={{ opacity: 0.5, pointerEvents: 'none' }}>
|
|
||||||
<Icon name="download" />Teams ISO (.exe)
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>coming soon — file pending</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{releases.length === 0 && (
|
|
||||||
<div style={{ padding: '24px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 12 }}>
|
|
||||||
No releases registered yet. Upload one from Settings → Capture SDKs.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{releases.map((rel, i) => (
|
|
||||||
<div key={rel.version || i} className="premiere-release">
|
|
||||||
<div className="premiere-release-head">
|
|
||||||
<span className="premiere-release-version mono">v{rel.version}</span>
|
|
||||||
{latest && latest.version === rel.version && (
|
|
||||||
<span className="badge accent">LATEST</span>
|
|
||||||
)}
|
|
||||||
{rel.released_at && (
|
|
||||||
<span className="premiere-release-date mono">
|
|
||||||
{new Date(rel.released_at).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{rel.notes && <div className="premiere-release-notes">{rel.notes}</div>}
|
|
||||||
<div className="premiere-release-actions">
|
|
||||||
{rel.ccx && (
|
|
||||||
<a href={rel.ccx} download className="btn primary sm">
|
|
||||||
<Icon name="download" />UXP plugin (.ccx)
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{rel.installer && (
|
|
||||||
<a href={rel.installer} download className="btn ghost sm">
|
|
||||||
<Icon name="download" />Windows installer
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-foot">
|
|
||||||
<div style={{ flex: 1, fontSize: 11.5, color: 'var(--text-3)' }}>
|
|
||||||
Need help installing? Use the Adobe Extension Manager or UPIA.
|
|
||||||
</div>
|
|
||||||
<button className="btn ghost" onClick={onClose}>Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// Dashboard - broadcast-ops control board
|
// Dashboard — broadcast-ops control board
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Dashboard({ navigate }) {
|
function Dashboard({ navigate }) {
|
||||||
const { RECORDERS, JOBS, NODES } = window.ZAMPP_DATA;
|
const { RECORDERS, JOBS, NODES } = window.ZAMPP_DATA;
|
||||||
|
|
||||||
// Live state - recompute every second so elapsed timers keep ticking.
|
// Live state — recompute every second so elapsed timers keep ticking.
|
||||||
const [tick, setTick] = React.useState(0);
|
const [tick, setTick] = React.useState(0);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const i = setInterval(() => setTick(t => t + 1), 1000);
|
const i = setInterval(() => setTick(t => t + 1), 1000);
|
||||||
|
|
@ -397,7 +193,7 @@ function Dashboard({ navigate }) {
|
||||||
return () => { cancelled = true; clearInterval(t); };
|
return () => { cancelled = true; clearInterval(t); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Refresh jobs frequently - this screen is the failed-job alert surface.
|
// Refresh jobs frequently — this screen is the failed-job alert surface.
|
||||||
const [jobs, setJobs] = React.useState(JOBS);
|
const [jobs, setJobs] = React.useState(JOBS);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -413,8 +209,8 @@ function Dashboard({ navigate }) {
|
||||||
...j,
|
...j,
|
||||||
status: statusMap[j.status] || j.status,
|
status: statusMap[j.status] || j.status,
|
||||||
kind: kindMap[j.type] || j.type || 'Job',
|
kind: kindMap[j.type] || j.type || 'Job',
|
||||||
asset: j.asset_name || meta.filename || '·',
|
asset: j.asset_name || meta.filename || '—',
|
||||||
node: meta.node || '·',
|
node: meta.node || '—',
|
||||||
error: j.error || null,
|
error: j.error || null,
|
||||||
progress: j.progress || 0,
|
progress: j.progress || 0,
|
||||||
};
|
};
|
||||||
|
|
@ -448,22 +244,6 @@ function Dashboard({ navigate }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page dash">
|
<div className="page dash">
|
||||||
<div className="page-header">
|
|
||||||
<h1>Dashboard</h1>
|
|
||||||
<span className="subtitle">Live operations: on-air recorders, jobs, cluster health</span>
|
|
||||||
<div className="spacer" />
|
|
||||||
{hasAttention && (
|
|
||||||
<span className="badge danger" title="Items need attention">
|
|
||||||
<Icon name="alert" size={10} />
|
|
||||||
{failedJobs.length + offlineNodes.length + erroredRecorders.length} alert{failedJobs.length + offlineNodes.length + erroredRecorders.length === 1 ? '' : 's'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="status-pip">
|
|
||||||
<span className="dot" style={{ background: offlineNodes.length === 0 ? 'var(--success)' : 'var(--warning)' }} />
|
|
||||||
<span>{onlineNodes}/{NODES.length || 0} nodes online</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ────────── ON AIR ────────── */}
|
{/* ────────── ON AIR ────────── */}
|
||||||
<section className="dash-section">
|
<section className="dash-section">
|
||||||
<DashSectionHead
|
<DashSectionHead
|
||||||
|
|
@ -545,7 +325,7 @@ function Dashboard({ navigate }) {
|
||||||
level="danger"
|
level="danger"
|
||||||
icon="alert"
|
icon="alert"
|
||||||
title={j.kind + ' failed'}
|
title={j.kind + ' failed'}
|
||||||
detail={(j.asset || '·') + (j.error ? ' · ' + j.error.slice(0, 100) : '')}
|
detail={(j.asset || '—') + (j.error ? ' · ' + j.error.slice(0, 100) : '')}
|
||||||
onClick={() => navigate('jobs')}
|
onClick={() => navigate('jobs')}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -623,18 +403,6 @@ function Dashboard({ navigate }) {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ────────── RESOURCES ────────── */}
|
|
||||||
<section className="dash-section">
|
|
||||||
<DashSectionHead title="Resources" />
|
|
||||||
{window.ClusterResources && <window.ClusterResources />}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ────────── RESOURCES ────────── */}
|
|
||||||
<section className="dash-section">
|
|
||||||
<DashSectionHead title="Resources" />
|
|
||||||
{window.ClusterResources && React.createElement(window.ClusterResources)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ────────── STATUS BAR (bottom) ────────── */}
|
{/* ────────── STATUS BAR (bottom) ────────── */}
|
||||||
<footer className="dash-statusbar">
|
<footer className="dash-statusbar">
|
||||||
<span className="dash-stat-pip" data-tone={liveRecorders.length > 0 ? 'live' : 'idle'}>
|
<span className="dash-stat-pip" data-tone={liveRecorders.length > 0 ? 'live' : 'idle'}>
|
||||||
|
|
@ -712,7 +480,7 @@ function OnAirTile({ recorder, onClick }) {
|
||||||
<div className="dash-onair-tile" onClick={onClick}>
|
<div className="dash-onair-tile" onClick={onClick}>
|
||||||
<div className="dash-onair-video">
|
<div className="dash-onair-video">
|
||||||
{recorder.live_asset_id
|
{recorder.live_asset_id
|
||||||
? <HlsPreview assetId={recorder.live_asset_id} recorderId={recorder.id} />
|
? <HlsPreview assetId={recorder.live_asset_id} />
|
||||||
: <FauxFrame />}
|
: <FauxFrame />}
|
||||||
<span className="dash-onair-rec-pip">
|
<span className="dash-onair-rec-pip">
|
||||||
<span className="dash-onair-rec-dot" />
|
<span className="dash-onair-rec-dot" />
|
||||||
|
|
@ -723,14 +491,14 @@ function OnAirTile({ recorder, onClick }) {
|
||||||
<div className="dash-onair-meta">
|
<div className="dash-onair-meta">
|
||||||
<div className="dash-onair-name">{recorder.name}</div>
|
<div className="dash-onair-name">{recorder.name}</div>
|
||||||
<div className="dash-onair-sub">
|
<div className="dash-onair-sub">
|
||||||
<span className="dash-onair-source">{recorder.source || '·'}</span>
|
<span className="dash-onair-source">{recorder.source || '—'}</span>
|
||||||
{recorder.res && recorder.res !== '·' && (
|
{recorder.res && recorder.res !== '—' && (
|
||||||
<>
|
<>
|
||||||
<span className="dash-onair-dot">·</span>
|
<span className="dash-onair-dot">·</span>
|
||||||
<span className="dash-onair-res">{recorder.res}</span>
|
<span className="dash-onair-res">{recorder.res}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{recorder.codec && recorder.codec !== '·' && (
|
{recorder.codec && recorder.codec !== '—' && (
|
||||||
<>
|
<>
|
||||||
<span className="dash-onair-dot">·</span>
|
<span className="dash-onair-dot">·</span>
|
||||||
<span className="dash-onair-codec">{recorder.codec}</span>
|
<span className="dash-onair-codec">{recorder.codec}</span>
|
||||||
|
|
@ -852,7 +620,7 @@ function DashClusterRow({ node }) {
|
||||||
</span>
|
</span>
|
||||||
<span className="dash-cluster-val">{Math.round(cpuPct)}%</span>
|
<span className="dash-cluster-val">{Math.round(cpuPct)}%</span>
|
||||||
</>
|
</>
|
||||||
) : <span className="dash-cluster-val muted">·</span>}
|
) : <span className="dash-cluster-val muted">—</span>}
|
||||||
</span>
|
</span>
|
||||||
<span className="dash-cluster-metric">
|
<span className="dash-cluster-metric">
|
||||||
{memPct != null ? (
|
{memPct != null ? (
|
||||||
|
|
@ -868,7 +636,7 @@ function DashClusterRow({ node }) {
|
||||||
</span>
|
</span>
|
||||||
<span className="dash-cluster-val">{memUsed < 1 ? Math.round(memUsed * 1024) + 'M' : memUsed.toFixed(1) + 'G'}</span>
|
<span className="dash-cluster-val">{memUsed < 1 ? Math.round(memUsed * 1024) + 'M' : memUsed.toFixed(1) + 'G'}</span>
|
||||||
</>
|
</>
|
||||||
) : <span className="dash-cluster-val muted">·</span>}
|
) : <span className="dash-cluster-val muted">—</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// screens-ingest.jsx - Upload, Recorders, Capture, Monitors
|
// screens-ingest.jsx — Upload, Recorders, Capture, Monitors
|
||||||
|
|
||||||
/* ===== Upload helpers ===== */
|
/* ===== Upload helpers ===== */
|
||||||
const _SIMPLE_MAX = 50 * 1024 * 1024; // 50 MB → simple upload
|
const _SIMPLE_MAX = 50 * 1024 * 1024; // 50 MB → simple upload
|
||||||
|
|
@ -38,7 +38,7 @@ async function _uploadFile(file, projectId, onProgress) {
|
||||||
(loaded, total) => onProgress(Math.round((loaded / total) * 100)));
|
(loaded, total) => onProgress(Math.round((loaded / total) * 100)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// - Multipart -
|
// — Multipart —
|
||||||
const init = await window.ZAMPP_API.fetch('/upload/init', {
|
const init = await window.ZAMPP_API.fetch('/upload/init', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ filename: file.name, fileSize: file.size, contentType: mime, projectId }),
|
body: JSON.stringify({ filename: file.name, fileSize: file.size, contentType: mime, projectId }),
|
||||||
|
|
@ -106,7 +106,7 @@ function Upload({ navigate }) {
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Upload</h1>
|
<h1>Upload</h1>
|
||||||
<span className="subtitle">Drop video, audio, or stills: we proxy and index automatically.</span>
|
<span className="subtitle">Drop video, audio, or stills — we proxy and index automatically.</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-body">
|
<div className="page-body">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 20 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 20 }}>
|
||||||
|
|
@ -227,32 +227,14 @@ function YouTubeImport({ navigate }) {
|
||||||
window.dispatchEvent(new CustomEvent('df:assets-changed'));
|
window.dispatchEvent(new CustomEvent('df:assets-changed'));
|
||||||
} else if (asset.status === 'error') {
|
} else if (asset.status === 'error') {
|
||||||
patch.status = 'error';
|
patch.status = 'error';
|
||||||
patch.error = patch.error || 'Import failed: check the Jobs screen for details.';
|
patch.error = patch.error || 'Import failed — check the Jobs screen for details.';
|
||||||
} else if (asset.status === 'processing') {
|
} else if (asset.status === 'processing') {
|
||||||
patch.status = 'processing';
|
patch.status = 'processing';
|
||||||
} else if (asset.status === 'ingesting') {
|
|
||||||
patch.status = 'downloading';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// While the import is still running, pull the live percentage from its
|
|
||||||
// BullMQ job so the bar reflects the actual yt-dlp download instead of
|
|
||||||
// sitting at 0 until the asset flips to ready. The worker emits 2..100
|
|
||||||
// across the download + S3 upload (job.updateProgress). If the job has
|
|
||||||
// already completed and been evicted, the fetch throws and we just fall
|
|
||||||
// back to the status-driven values above.
|
|
||||||
if (row.jobId && asset.status !== 'ready' && asset.status !== 'error') {
|
|
||||||
try {
|
|
||||||
const job = await window.ZAMPP_API.fetch('/jobs/' + row.jobId);
|
|
||||||
if (typeof job.progress === 'number') patch.progress = job.progress;
|
|
||||||
} catch { /* job evicted after completion — fall back to status */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(patch).length) updateRow(row.id, patch);
|
if (Object.keys(patch).length) updateRow(row.id, patch);
|
||||||
if (asset.status === 'ready' || asset.status === 'error') return;
|
if (asset.status === 'ready' || asset.status === 'error') return;
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
// Poll fairly briskly: the download phase is where the user wants to see
|
setTimeout(tick, 3000);
|
||||||
// the bar move, and a short clip can finish in a handful of seconds.
|
|
||||||
setTimeout(tick, 2000);
|
|
||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
return () => { stopped = true; };
|
return () => { stopped = true; };
|
||||||
|
|
@ -292,7 +274,7 @@ function YouTubeImport({ navigate }) {
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>YouTube</h1>
|
<h1>YouTube</h1>
|
||||||
<span className="subtitle">Paste a link: we download and import the best available MP4.</span>
|
<span className="subtitle">Paste a link — we download and import the best available MP4.</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-body">
|
<div className="page-body">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
|
||||||
|
|
@ -402,15 +384,13 @@ function YouTubeImport({ navigate }) {
|
||||||
and nginx.conf); we attach hls.js to a <video> when a recorder is
|
and nginx.conf); we attach hls.js to a <video> when a recorder is
|
||||||
actively recording and has a live asset.
|
actively recording and has a live asset.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
function HlsPreview({ assetId, recorderId, muted = true, controls = false, className }) {
|
function HlsPreview({ assetId, muted = true, controls = false, className }) {
|
||||||
const videoRef = React.useRef(null);
|
const videoRef = React.useRef(null);
|
||||||
const [err, setErr] = React.useState(null);
|
const [err, setErr] = React.useState(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!assetId || !videoRef.current) return;
|
if (!assetId || !videoRef.current) return;
|
||||||
const url = recorderId
|
const url = '/live/' + assetId + '/index.m3u8';
|
||||||
? '/api/v1/recorders/' + recorderId + '/live/' + assetId + '/index.m3u8'
|
|
||||||
: '/live/' + assetId + '/index.m3u8';
|
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
let retryTimer = 0;
|
let retryTimer = 0;
|
||||||
|
|
@ -491,7 +471,7 @@ function HlsPreview({ assetId, recorderId, muted = true, controls = false, class
|
||||||
|
|
||||||
/* ===== Recorders ===== */
|
/* ===== Recorders ===== */
|
||||||
function _normRecorder(r) {
|
function _normRecorder(r) {
|
||||||
let elapsed = '·';
|
let elapsed = '—';
|
||||||
if (r.status === 'recording' && r.started_at) {
|
if (r.status === 'recording' && r.started_at) {
|
||||||
const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
|
const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
|
||||||
elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
|
elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
|
||||||
|
|
@ -501,13 +481,13 @@ function _normRecorder(r) {
|
||||||
const cfg = r.source_config || {};
|
const cfg = r.source_config || {};
|
||||||
return {
|
return {
|
||||||
...r,
|
...r,
|
||||||
source: r.source_type || '·',
|
source: r.source_type || '—',
|
||||||
url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '·',
|
url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '—',
|
||||||
codec: r.recording_codec || '·',
|
codec: r.recording_codec || '—',
|
||||||
res: r.recording_resolution || '·',
|
res: r.recording_resolution || '—',
|
||||||
node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
|
node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
|
||||||
elapsed,
|
elapsed,
|
||||||
bitrate: '·',
|
bitrate: '—',
|
||||||
health: 100,
|
health: 100,
|
||||||
audio: false,
|
audio: false,
|
||||||
};
|
};
|
||||||
|
|
@ -524,7 +504,7 @@ function Recorders({ navigate, onNew }) {
|
||||||
setRecorders(norm);
|
setRecorders(norm);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
// apiFetch already redirects on 401 - don't log noise, interval
|
// apiFetch already redirects on 401 — don't log noise, interval
|
||||||
// will be cleared automatically when the component unmounts on redirect (#55)
|
// will be cleared automatically when the component unmounts on redirect (#55)
|
||||||
if (err && err.message && err.message.includes('Unauthenticated')) return;
|
if (err && err.message && err.message.includes('Unauthenticated')) return;
|
||||||
window.DF_LOG.warn('[recorders] poll error:', err?.message);
|
window.DF_LOG.warn('[recorders] poll error:', err?.message);
|
||||||
|
|
@ -579,21 +559,13 @@ function Recorders({ navigate, onNew }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
|
|
||||||
const [recorder, setRecorder] = React.useState(initialRecorder);
|
const [recorder, setRecorder] = React.useState(initialRecorder);
|
||||||
const [pending, setPending] = React.useState(false);
|
const [pending, setPending] = React.useState(false);
|
||||||
const [err, setErr] = React.useState(null);
|
const [err, setErr] = React.useState(null);
|
||||||
const [liveStatus, setLiveStatus] = React.useState(null);
|
const [liveStatus, setLiveStatus] = React.useState(null);
|
||||||
const [clipName, setClipName] = React.useState('');
|
const [clipName, setClipName] = React.useState('');
|
||||||
// Project override for this take. Defaults to the recorder's configured project.
|
|
||||||
const [takeProjectId, setTakeProjectId] = React.useState(initialRecorder.project_id || PROJECTS[0]?.id || '');
|
|
||||||
const isRec = recorder.status === 'recording';
|
const isRec = recorder.status === 'recording';
|
||||||
|
|
||||||
// Keep takeProjectId in sync if the recorder row changes (e.g. after a refresh).
|
|
||||||
React.useEffect(() => {
|
|
||||||
setTakeProjectId(initialRecorder.project_id || PROJECTS[0]?.id || '');
|
|
||||||
}, [initialRecorder.id]);
|
|
||||||
|
|
||||||
React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);
|
React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);
|
||||||
|
|
||||||
// Poll the status endpoint every 3s while recording for live feedback.
|
// Poll the status endpoint every 3s while recording for live feedback.
|
||||||
|
|
@ -620,8 +592,8 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
}, [liveStatus, recorder.elapsed]);
|
}, [liveStatus, recorder.elapsed]);
|
||||||
|
|
||||||
const displaySignal = liveStatus
|
const displaySignal = liveStatus
|
||||||
? (liveStatus.signal || '·')
|
? (liveStatus.signal || '—')
|
||||||
: (isRec ? 'connecting…' : '·');
|
: (isRec ? 'connecting…' : '—');
|
||||||
|
|
||||||
const signalColor = displaySignal === 'receiving' ? 'var(--success)'
|
const signalColor = displaySignal === 'receiving' ? 'var(--success)'
|
||||||
: displaySignal === 'stopped' ? 'var(--danger)'
|
: displaySignal === 'stopped' ? 'var(--danger)'
|
||||||
|
|
@ -633,18 +605,14 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
setPending(true);
|
setPending(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
|
setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
|
||||||
// Ship the operator-typed clip name and project override on start; stop has no body.
|
// Ship the operator-typed clip name on start; stop has no body.
|
||||||
const body = action === 'start'
|
const body = (action === 'start' && clipName.trim())
|
||||||
? JSON.stringify({
|
? JSON.stringify({ clipName: clipName.trim() })
|
||||||
...(clipName.trim() ? { clipName: clipName.trim() } : {}),
|
|
||||||
...(takeProjectId ? { projectId: takeProjectId } : {}),
|
|
||||||
})
|
|
||||||
: undefined;
|
: undefined;
|
||||||
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST', body })
|
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST', body })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setPending(false);
|
setPending(false);
|
||||||
// Clear the clip name on a successful stop so the next take starts fresh.
|
// Clear the input on a successful stop so the next take starts fresh.
|
||||||
// Leave takeProjectId as-is (operator likely wants the same project for the next take).
|
|
||||||
if (action === 'stop') setClipName('');
|
if (action === 'stop') setClipName('');
|
||||||
onRefresh();
|
onRefresh();
|
||||||
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
|
window.dispatchEvent(new CustomEvent('df:recorders-changed'));
|
||||||
|
|
@ -672,7 +640,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
<div className={'recorder-row ' + recorder.status}>
|
<div className={'recorder-row ' + recorder.status}>
|
||||||
<div className="recorder-preview">
|
<div className="recorder-preview">
|
||||||
{isRec && recorder.live_asset_id
|
{isRec && recorder.live_asset_id
|
||||||
? <HlsPreview assetId={recorder.live_asset_id} recorderId={recorder.id} />
|
? <HlsPreview assetId={recorder.live_asset_id} />
|
||||||
: isRec
|
: isRec
|
||||||
? <LiveStrip seed={recorder.id.length * 3} count={6} />
|
? <LiveStrip seed={recorder.id.length * 3} count={6} />
|
||||||
: <div className="recorder-empty"><Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} /></div>}
|
: <div className="recorder-empty"><Icon name={recorder.status === 'error' ? 'alert' : 'video'} size={20} style={{ opacity: 0.4 }} /></div>}
|
||||||
|
|
@ -716,31 +684,17 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
</div>
|
</div>
|
||||||
<div className="recorder-actions">
|
<div className="recorder-actions">
|
||||||
{!isRec && (
|
{!isRec && (
|
||||||
<>
|
<input
|
||||||
{PROJECTS.length > 0 && (
|
className="field-input"
|
||||||
<select
|
value={clipName}
|
||||||
className="field-input"
|
onChange={e => setClipName(e.target.value)}
|
||||||
value={takeProjectId}
|
placeholder="Clip name (optional)"
|
||||||
onChange={e => setTakeProjectId(e.target.value)}
|
disabled={pending}
|
||||||
disabled={pending}
|
maxLength={80}
|
||||||
style={{ width: 160, padding: '5px 8px', fontSize: 12, appearance: 'auto' }}
|
onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
|
||||||
title="Project clips go to"
|
style={{ width: 180, padding: '5px 8px', fontSize: 12 }}
|
||||||
>
|
title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
|
||||||
{PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
/>
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
className="field-input"
|
|
||||||
value={clipName}
|
|
||||||
onChange={e => setClipName(e.target.value)}
|
|
||||||
placeholder="Clip name (optional)"
|
|
||||||
disabled={pending}
|
|
||||||
maxLength={80}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
|
|
||||||
style={{ width: 160, padding: '5px 8px', fontSize: 12 }}
|
|
||||||
title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{isRec
|
{isRec
|
||||||
? <button className="btn danger sm" onClick={toggle} disabled={pending}>
|
? <button className="btn danger sm" onClick={toggle} disabled={pending}>
|
||||||
|
|
@ -771,7 +725,7 @@ function _captureSignalChip(sig) {
|
||||||
case 'error': return { label: 'ERROR', color: 'var(--danger)', pulse: false };
|
case 'error': return { label: 'ERROR', color: 'var(--danger)', pulse: false };
|
||||||
case 'idle': return { label: 'IDLE', color: 'var(--text-3)', pulse: false };
|
case 'idle': return { label: 'IDLE', color: 'var(--text-3)', pulse: false };
|
||||||
case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)', pulse: false };
|
case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)', pulse: false };
|
||||||
default: return { label: sig || '·', color: 'var(--text-4)', pulse: false };
|
default: return { label: sig || '—', color: 'var(--text-4)', pulse: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -783,7 +737,7 @@ function CapturePortChip({ port, sigEntry }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
title={sigEntry ? `${sigEntry.recorder_name || 'recorder'}: ${label}` : label}
|
title={sigEntry ? `${sigEntry.recorder_name || 'recorder'} — ${label}` : label}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
padding: '6px 12px', borderRadius: 5,
|
padding: '6px 12px', borderRadius: 5,
|
||||||
|
|
@ -1077,7 +1031,7 @@ function MonitorTile({ feed, seed }) {
|
||||||
return (
|
return (
|
||||||
<div className="monitor-tile">
|
<div className="monitor-tile">
|
||||||
{isLive && feed.live_asset_id
|
{isLive && feed.live_asset_id
|
||||||
? <HlsPreview assetId={feed.live_asset_id} recorderId={feed.id} />
|
? <HlsPreview assetId={feed.live_asset_id} />
|
||||||
: <FauxFrame />}
|
: <FauxFrame />}
|
||||||
{isLive && <div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />}
|
{isLive && <div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />}
|
||||||
<div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 6 }}>
|
<div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 6 }}>
|
||||||
|
|
@ -1094,7 +1048,7 @@ function MonitorTile({ feed, seed }) {
|
||||||
)}
|
)}
|
||||||
<div className="monitor-tile-label">
|
<div className="monitor-tile-label">
|
||||||
<span className="name">{feed.name}</span>
|
<span className="name">{feed.name}</span>
|
||||||
{feed.elapsed && feed.elapsed !== '·' && <span className="time mono">{feed.elapsed}</span>}
|
{feed.elapsed && feed.elapsed !== '—' && <span className="time mono">{feed.elapsed}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -1111,7 +1065,7 @@ const _STATUS_BADGE = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function _fmtWhen(iso) {
|
function _fmtWhen(iso) {
|
||||||
if (!iso) return '·';
|
if (!iso) return '—';
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
// Local-time, short, human; e.g. "May 22 · 7:30 PM"
|
// Local-time, short, human; e.g. "May 22 · 7:30 PM"
|
||||||
return d.toLocaleString(undefined, {
|
return d.toLocaleString(undefined, {
|
||||||
|
|
@ -1355,7 +1309,7 @@ function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, on
|
||||||
const d = drag;
|
const d = drag;
|
||||||
setDrag(null);
|
setDrag(null);
|
||||||
if (!d.moved) {
|
if (!d.moved) {
|
||||||
// Treat as a click - open the edit modal.
|
// Treat as a click — open the edit modal.
|
||||||
onClick(event);
|
onClick(event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1509,7 +1463,7 @@ function _RecorderGutter({ recorders, projects }) {
|
||||||
<span className={'epg-gutter-status ' + (isLive ? 'live' : isErr ? 'err' : 'idle')} />
|
<span className={'epg-gutter-status ' + (isLive ? 'live' : isErr ? 'err' : 'idle')} />
|
||||||
<div className="epg-gutter-meta">
|
<div className="epg-gutter-meta">
|
||||||
<div className="epg-gutter-name">{r.name}</div>
|
<div className="epg-gutter-name">{r.name}</div>
|
||||||
<div className="epg-gutter-sub mono">{(r.source_type || '·').toUpperCase()}{color ? ' · ' : ''}{color && <span className="epg-gutter-dot" style={{ background: color }} />}</div>
|
<div className="epg-gutter-sub mono">{(r.source_type || '—').toUpperCase()}{color ? ' · ' : ''}{color && <span className="epg-gutter-dot" style={{ background: color }} />}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -1537,7 +1491,7 @@ function Schedule({ navigate }) {
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Schedule data - pull everything once and filter client-side for the
|
// Schedule data — pull everything once and filter client-side for the
|
||||||
// active view. /schedules caps at 200 rows so this stays cheap.
|
// active view. /schedules caps at 200 rows so this stays cheap.
|
||||||
const apiFilter = view === 'list' ? listFilter : 'all';
|
const apiFilter = view === 'list' ? listFilter : 'all';
|
||||||
const load = React.useCallback(() => {
|
const load = React.useCallback(() => {
|
||||||
|
|
@ -1569,7 +1523,7 @@ function Schedule({ navigate }) {
|
||||||
|
|
||||||
const projects = window.ZAMPP_DATA?.PROJECTS || [];
|
const projects = window.ZAMPP_DATA?.PROJECTS || [];
|
||||||
|
|
||||||
// Pixels per hour - wider on Today (high-res operations view), tighter
|
// Pixels per hour — wider on Today (high-res operations view), tighter
|
||||||
// when the user is scanning Week-at-a-glance.
|
// when the user is scanning Week-at-a-glance.
|
||||||
const pph = view === 'week' ? 44 : 88;
|
const pph = view === 'week' ? 44 : 88;
|
||||||
|
|
||||||
|
|
@ -1624,7 +1578,7 @@ function Schedule({ navigate }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCtx = (s, ev) => setCtxMenu({ schedule: s, x: ev.clientX, y: ev.clientY });
|
const openCtx = (s, ev) => setCtxMenu({ schedule: s, x: ev.clientX, y: ev.clientY });
|
||||||
// Dismiss the context menu on any outside click - capture phase so a
|
// Dismiss the context menu on any outside click — capture phase so a
|
||||||
// click on a menu item still fires before the menu unmounts.
|
// click on a menu item still fires before the menu unmounts.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!ctxMenu) return;
|
if (!ctxMenu) return;
|
||||||
|
|
@ -1879,7 +1833,7 @@ function EditScheduleModal({ schedule, onClose, onSaved }) {
|
||||||
<label className="field-label">Recorder</label>
|
<label className="field-label">Recorder</label>
|
||||||
<input className="field-input mono" value={schedule.recorder_name || schedule.recorder_id} readOnly
|
<input className="field-input mono" value={schedule.recorder_name || schedule.recorder_id} readOnly
|
||||||
style={{ color: 'var(--text-3)' }} />
|
style={{ color: 'var(--text-3)' }} />
|
||||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>Recorder can't be reassigned: delete + recreate to change.</div>
|
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>Recorder can't be reassigned — delete + recreate to change.</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
|
|
@ -1948,11 +1902,11 @@ function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, default
|
||||||
const endD = new Date(form.end_at);
|
const endD = new Date(form.end_at);
|
||||||
if (endD <= startD) return setErr('End must be after start');
|
if (endD <= startD) return setErr('End must be after start');
|
||||||
|
|
||||||
// Warn (but allow) start times in the past - the scheduler tick will fire
|
// Warn (but allow) start times in the past — the scheduler tick will fire
|
||||||
// them immediately, which is occasionally what the operator wants
|
// them immediately, which is occasionally what the operator wants
|
||||||
// (e.g. "record the next 30 minutes starting now").
|
// (e.g. "record the next 30 minutes starting now").
|
||||||
if (startD < new Date(Date.now() - 60_000)) {
|
if (startD < new Date(Date.now() - 60_000)) {
|
||||||
if (!confirm('Start time is in the past: recorder will fire immediately when saved.\nContinue?')) return;
|
if (!confirm('Start time is in the past — recorder will fire immediately when saved.\nContinue?')) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Datetime-local inputs are in the browser's local zone; ship as ISO so
|
// Datetime-local inputs are in the browser's local zone; ship as ISO so
|
||||||
|
|
@ -1992,7 +1946,7 @@ function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, default
|
||||||
<select className="field-input" value={form.recorder_id}
|
<select className="field-input" value={form.recorder_id}
|
||||||
onChange={e => set('recorder_id', e.target.value)}
|
onChange={e => set('recorder_id', e.target.value)}
|
||||||
style={{ appearance: 'auto' }}>
|
style={{ appearance: 'auto' }}>
|
||||||
{recorders.length === 0 && <option value="">No recorders defined</option>}
|
{recorders.length === 0 && <option value="">— No recorders defined —</option>}
|
||||||
{recorders.map(r => (
|
{recorders.map(r => (
|
||||||
<option key={r.id} value={r.id}>
|
<option key={r.id} value={r.id}>
|
||||||
{r.name} · {r.source_type?.toUpperCase() || '?'}
|
{r.name} · {r.source_type?.toUpperCase() || '?'}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// screens-jobs.jsx
|
// screens-jobs.jsx
|
||||||
|
|
||||||
// Pick the most-meaningful timestamp + label for a job's current state.
|
// Pick the most-meaningful timestamp + label for a job's current state.
|
||||||
// Returns { label, iso } - caller renders "<label> <relative-time>" with
|
// Returns { label, iso } — caller renders "<label> <relative-time>" with
|
||||||
// the full ISO as a tooltip.
|
// the full ISO as a tooltip.
|
||||||
function _jobTimeFor(job) {
|
function _jobTimeFor(job) {
|
||||||
if (job.status === 'done' && job.completed_at) return { label: 'done', iso: job.completed_at };
|
if (job.status === 'done' && job.completed_at) return { label: 'done', iso: job.completed_at };
|
||||||
|
|
@ -21,7 +21,7 @@ function _fmtAbsolute(iso) {
|
||||||
} catch { return iso; }
|
} catch { return iso; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact clock for the inline jobs cell - "2:23 PM" if today,
|
// Compact clock for the inline jobs cell — "2:23 PM" if today,
|
||||||
// "May 22 · 2:23 PM" if a different day. Full datetime stays in the tooltip.
|
// "May 22 · 2:23 PM" if a different day. Full datetime stays in the tooltip.
|
||||||
function _fmtCompact(iso) {
|
function _fmtCompact(iso) {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
|
|
@ -51,9 +51,9 @@ function Jobs({ navigate }) {
|
||||||
...j,
|
...j,
|
||||||
status: statusMap[j.status] || j.status,
|
status: statusMap[j.status] || j.status,
|
||||||
kind: kindMap[j.type] || j.type || 'Job',
|
kind: kindMap[j.type] || j.type || 'Job',
|
||||||
asset: j.asset_name || meta.filename || '·',
|
asset: j.asset_name || meta.filename || '—',
|
||||||
eta: '·',
|
eta: '—',
|
||||||
node: meta.node || '·',
|
node: meta.node || '—',
|
||||||
priority: meta.priority || 'normal',
|
priority: meta.priority || 'normal',
|
||||||
error: j.error || null,
|
error: j.error || null,
|
||||||
progress: j.progress || 0,
|
progress: j.progress || 0,
|
||||||
|
|
@ -83,7 +83,7 @@ function Jobs({ navigate }) {
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
// One handler covers cancel (running) AND delete (queued / done / failed).
|
// One handler covers cancel (running) AND delete (queued / done / failed).
|
||||||
// BullMQ's job.remove() - what the API calls - works on any state, so a
|
// BullMQ's job.remove() — what the API calls — works on any state, so a
|
||||||
// stalled-active job (worker died mid-process, holding a concurrency slot)
|
// stalled-active job (worker died mid-process, holding a concurrency slot)
|
||||||
// gets yanked and the next queued job runs. mode just changes the prompt
|
// gets yanked and the next queued job runs. mode just changes the prompt
|
||||||
// copy so the operator knows what they're doing.
|
// copy so the operator knows what they're doing.
|
||||||
|
|
@ -98,7 +98,7 @@ function Jobs({ navigate }) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Retry every failed job at once. Useful after a transient infra issue
|
// Retry every failed job at once. Useful after a transient infra issue
|
||||||
// (S3 outage, hung worker) - one click per job is painful with 20+ failures.
|
// (S3 outage, hung worker) — one click per job is painful with 20+ failures.
|
||||||
const handleRetryAll = React.useCallback(() => {
|
const handleRetryAll = React.useCallback(() => {
|
||||||
const failedJobs = jobs.filter(j => j.status === 'failed');
|
const failedJobs = jobs.filter(j => j.status === 'failed');
|
||||||
if (failedJobs.length === 0) return;
|
if (failedJobs.length === 0) return;
|
||||||
|
|
@ -108,23 +108,6 @@ function Jobs({ navigate }) {
|
||||||
).then(refresh);
|
).then(refresh);
|
||||||
}, [jobs, refresh]);
|
}, [jobs, refresh]);
|
||||||
|
|
||||||
// Drop every failed job from the queue. The opposite of Retry all — used
|
|
||||||
// when a batch of jobs is unrecoverable (e.g. assets that were deleted
|
|
||||||
// mid-encode) and the operator just wants the queue cleared.
|
|
||||||
const handleCancelAll = React.useCallback(() => {
|
|
||||||
const failedJobs = jobs.filter(j => j.status === 'failed');
|
|
||||||
if (failedJobs.length === 0) return;
|
|
||||||
if (!window.confirm(`Remove all ${failedJobs.length} failed jobs from the queue?\nThis cannot be undone.`)) return;
|
|
||||||
Promise.allSettled(
|
|
||||||
failedJobs.map(j => window.ZAMPP_API.fetch('/jobs/' + j.id, { method: 'DELETE' }))
|
|
||||||
).then(() => {
|
|
||||||
// Optimistic local drop so the UI updates the instant the modal closes,
|
|
||||||
// not 5s later on the next poll tick.
|
|
||||||
setJobs(prev => prev.filter(j => j.status !== 'failed'));
|
|
||||||
refresh();
|
|
||||||
});
|
|
||||||
}, [jobs, refresh]);
|
|
||||||
|
|
||||||
const counts = {
|
const counts = {
|
||||||
all: jobs.length,
|
all: jobs.length,
|
||||||
running: jobs.filter(j => j.status === 'running').length,
|
running: jobs.filter(j => j.status === 'running').length,
|
||||||
|
|
@ -141,14 +124,9 @@ function Jobs({ navigate }) {
|
||||||
<span className="subtitle">Proxy generation, transcoding, and processing queue</span>
|
<span className="subtitle">Proxy generation, transcoding, and processing queue</span>
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
{counts.failed > 0 && (
|
{counts.failed > 0 && (
|
||||||
<>
|
<button className="btn ghost sm" onClick={handleRetryAll} title={`Retry all ${counts.failed} failed jobs`}>
|
||||||
<button className="btn ghost sm" onClick={handleRetryAll} title={`Retry all ${counts.failed} failed jobs`}>
|
<Icon name="refresh" />Retry all failed
|
||||||
<Icon name="refresh" />Retry all failed
|
</button>
|
||||||
</button>
|
|
||||||
<button className="btn ghost sm jobs-cancel-all" onClick={handleCancelAll} title={`Remove all ${counts.failed} failed jobs from the queue`}>
|
|
||||||
<Icon name="x" />Cancel all failed
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<button className="btn ghost sm" onClick={refresh}>
|
<button className="btn ghost sm" onClick={refresh}>
|
||||||
<Icon name="refresh" />Refresh
|
<Icon name="refresh" />Refresh
|
||||||
|
|
@ -170,18 +148,18 @@ function Jobs({ navigate }) {
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="label">Failed</div>
|
<div className="label">Failed</div>
|
||||||
<div className="value">{counts.failed}</div>
|
<div className="value">{counts.failed}</div>
|
||||||
<div className={'delta' + (counts.failed > 0 ? ' delta-warn' : '')}>
|
<div className="delta" style={{ color: counts.failed > 0 ? 'var(--warning)' : '' }}>
|
||||||
{counts.failed > 0 ? 'Needs attention' : 'All clear'}
|
{counts.failed > 0 ? 'Needs attention' : 'All clear'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="label">Total jobs</div>
|
<div className="label">Total jobs</div>
|
||||||
<div className="value">{counts.all}</div>
|
<div className="value">{counts.all}</div>
|
||||||
<div className="delta muted delta-tiny">Updated {Math.round((Date.now()-lastFetch)/1000)}s ago</div>
|
<div className="delta muted" style={{ fontSize: 10.5 }}>Updated {Math.round((Date.now()-lastFetch)/1000)}s ago</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tab-group jobs-tabs">
|
<div className="tab-group" style={{ marginTop: 20, width: 'fit-content' }}>
|
||||||
{[
|
{[
|
||||||
{ id: 'all', label: 'All · ' + counts.all },
|
{ id: 'all', label: 'All · ' + counts.all },
|
||||||
{ id: 'running', label: 'Running · ' + counts.running },
|
{ id: 'running', label: 'Running · ' + counts.running },
|
||||||
|
|
@ -193,12 +171,12 @@ function Jobs({ navigate }) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel jobs-panel">
|
<div className="panel" style={{ marginTop: 12 }}>
|
||||||
<div className="job-row head">
|
<div className="job-row head">
|
||||||
<div></div><div>Job</div><div>Asset</div><div>Node</div><div>Progress</div><div>Time</div><div>Priority</div><div></div>
|
<div></div><div>Job</div><div>Asset</div><div>Node</div><div>Progress</div><div>Time</div><div>Priority</div><div></div>
|
||||||
</div>
|
</div>
|
||||||
{filtered.length === 0
|
{filtered.length === 0
|
||||||
? <div className="jobs-empty">No jobs in this category.</div>
|
? <div style={{ padding: '24px', textAlign: 'center', color: 'var(--text-3)' }}>No jobs in this category.</div>
|
||||||
: filtered.map(j => <JobRow key={j.id} job={j} onRetry={handleRetry} onDelete={handleDelete} />)}
|
: filtered.map(j => <JobRow key={j.id} job={j} onRetry={handleRetry} onDelete={handleDelete} />)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -211,35 +189,36 @@ function JobRow({ job, onRetry, onDelete }) {
|
||||||
return (
|
return (
|
||||||
<div className="job-row">
|
<div className="job-row">
|
||||||
<div><StatusDot status={job.status} /></div>
|
<div><StatusDot status={job.status} /></div>
|
||||||
<div className="job-row-kind">
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<Icon name={iconMap[job.kind] || 'jobs'} size={13} className="job-row-kind-icon" />
|
<Icon name={iconMap[job.kind] || 'jobs'} size={13} style={{ color: 'var(--text-3)' }} />
|
||||||
<span className="job-row-kind-name">{job.kind}</span>
|
<span style={{ fontWeight: 500 }}>{job.kind}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="job-row-asset">{job.asset}</div>
|
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-2)' }}>{job.asset}</div>
|
||||||
<div className="mono job-row-node">{job.node}</div>
|
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{job.node}</div>
|
||||||
<div>
|
<div>
|
||||||
{job.status === 'running' && (
|
{job.status === 'running' && (
|
||||||
<div className="job-progress-wrap">
|
<div className="job-progress-wrap">
|
||||||
<div className="job-progress-bar"><div className="job-progress-fill" style={{ width: job.progress + '%' }} /></div>
|
<div className="job-progress-bar"><div className="job-progress-fill" style={{ width: job.progress + '%' }} /></div>
|
||||||
<span className="mono job-row-progress-pct">{Math.round(job.progress)}%</span>
|
<span className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', minWidth: 32, textAlign: 'right' }}>{Math.round(job.progress)}%</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{job.status === 'done' && <span className="badge success job-row-status-done"><Icon name="check" size={12} /> Complete</span>}
|
{job.status === 'done' && <span className="badge success" style={{ background: 'transparent', padding: 0 }}><Icon name="check" size={12} /> Complete</span>}
|
||||||
{job.status === 'queued' && <span className="job-row-status-queued">Waiting…</span>}
|
{job.status === 'queued' && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>Waiting…</span>}
|
||||||
{job.status === 'failed' && (
|
{job.status === 'failed' && (
|
||||||
<span title={job.error || 'Failed'} className="job-row-status-failed">
|
<span title={job.error || 'Failed'}
|
||||||
|
style={{ fontSize: 12, color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
||||||
<Icon name="alert" size={12} />
|
<Icon name="alert" size={12} />
|
||||||
<span className="job-row-status-failed-msg">
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
||||||
{(job.error || 'Failed').slice(0, 120)}
|
{(job.error || 'Failed').slice(0, 120)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mono job-row-time"
|
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||||
title={(() => { const t = _jobTimeFor(job); return t ? t.label + ' at ' + _fmtAbsolute(t.iso) : ''; })()}>
|
title={(() => { const t = _jobTimeFor(job); return t ? t.label + ' at ' + _fmtAbsolute(t.iso) : ''; })()}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const t = _jobTimeFor(job);
|
const t = _jobTimeFor(job);
|
||||||
if (!t) return '·';
|
if (!t) return '—';
|
||||||
// Terminal states (done/failed) anchor on the absolute clock so the
|
// Terminal states (done/failed) anchor on the absolute clock so the
|
||||||
// operator can correlate with logs; queued/running show relative
|
// operator can correlate with logs; queued/running show relative
|
||||||
// since it's a moving target.
|
// since it's a moving target.
|
||||||
|
|
@ -250,21 +229,21 @@ function JobRow({ job, onRetry, onDelete }) {
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div><span className={'badge ' + (job.priority === 'high' ? 'warning' : 'outline')}>{job.priority}</span></div>
|
<div><span className={'badge ' + (job.priority === 'high' ? 'warning' : 'outline')}>{job.priority}</span></div>
|
||||||
<div className="job-row-actions">
|
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
|
||||||
{job.status === 'failed' && (
|
{job.status === 'failed' && (
|
||||||
<button className="btn ghost sm" onClick={() => onRetry(job)}><Icon name="refresh" />Retry</button>
|
<button className="btn ghost sm" onClick={() => onRetry(job)}><Icon name="refresh" />Retry</button>
|
||||||
)}
|
)}
|
||||||
{job.status === 'running' && (
|
{job.status === 'running' && (
|
||||||
/* Cancel a stalled-active job: frees the BullMQ concurrency slot
|
/* Cancel a stalled-active job — frees the BullMQ concurrency slot
|
||||||
so anything queued behind it can run. The worker may finish in
|
so anything queued behind it can run. The worker may finish in
|
||||||
the background but its result is discarded. */
|
the background but its result is discarded. */
|
||||||
<button className="btn ghost sm job-row-cancel" onClick={() => onDelete(job, 'cancel')}
|
<button className="btn ghost sm" onClick={() => onDelete(job, 'cancel')}
|
||||||
title="Cancel this running job and free its queue slot">
|
style={{ color: 'var(--danger)' }} title="Cancel this running job and free its queue slot">
|
||||||
<Icon name="x" />Cancel
|
<Icon name="x" />Cancel
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(job.status === 'queued' || job.status === 'done' || job.status === 'failed') && (
|
{(job.status === 'queued' || job.status === 'done' || job.status === 'failed') && (
|
||||||
<button className="icon-btn" aria-label="Remove job from the queue" title="Remove job from the queue"
|
<button className="icon-btn" aria-label="Remove job from the queue" title="Remove job from the queue"
|
||||||
onClick={() => onDelete(job, 'delete')}><Icon name="x" /></button>
|
onClick={() => onDelete(job, 'delete')}><Icon name="x" /></button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
||||||
const [search, setSearch] = React.useState(window._dfPendingSearch || '');
|
const [search, setSearch] = React.useState(window._dfPendingSearch || '');
|
||||||
React.useEffect(() => { delete window._dfPendingSearch; }, []);
|
React.useEffect(() => { delete window._dfPendingSearch; }, []);
|
||||||
// Local state lets us re-render after delete / move-to-bin without forcing
|
// Local state lets us re-render after delete / move-to-bin without forcing
|
||||||
// a full app reload - keeps ZAMPP_DATA in sync as the cache of record.
|
// a full app reload — keeps ZAMPP_DATA in sync as the cache of record.
|
||||||
const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []);
|
const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []);
|
||||||
const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y }
|
const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y }
|
||||||
const [renamingAsset, setRenamingAsset] = React.useState(null);
|
const [renamingAsset, setRenamingAsset] = React.useState(null);
|
||||||
|
|
@ -65,7 +65,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
||||||
runDownload(asset);
|
runDownload(asset);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (_) { /* localStorage unavailable: show modal to be safe */ }
|
} catch (_) { /* localStorage unavailable — show modal to be safe */ }
|
||||||
setPendingDownload(asset);
|
setPendingDownload(asset);
|
||||||
};
|
};
|
||||||
const [creatingBin, setCreatingBin] = React.useState(false);
|
const [creatingBin, setCreatingBin] = React.useState(false);
|
||||||
|
|
@ -273,7 +273,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
||||||
)}
|
)}
|
||||||
{!creatingBin && BINS.length === 0 ? (
|
{!creatingBin && BINS.length === 0 ? (
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-3)', padding: '6px 8px', fontStyle: 'italic' }}>
|
<div style={{ fontSize: 11, color: 'var(--text-3)', padding: '6px 8px', fontStyle: 'italic' }}>
|
||||||
{openProject ? 'No bins yet: click + to create one.' : 'Open a project to manage bins.'}
|
{openProject ? 'No bins yet — click + to create one.' : 'Open a project to manage bins.'}
|
||||||
</div>
|
</div>
|
||||||
) : BINS.map(function(b) {
|
) : BINS.map(function(b) {
|
||||||
const isActive = selectedBinId === b.id;
|
const isActive = selectedBinId === b.id;
|
||||||
|
|
@ -307,7 +307,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
||||||
|
|
||||||
<div className="library-main">
|
<div className="library-main">
|
||||||
<div className="library-toolbar">
|
<div className="library-toolbar">
|
||||||
<h1 className="toolbar-title">{displayTitle}</h1>
|
<div className="toolbar-title">{displayTitle}</div>
|
||||||
<span className="count">· {assets.length} assets</span>
|
<span className="count">· {assets.length} assets</span>
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
<div className="search" style={{ width: 220 }}>
|
<div className="search" style={{ width: 220 }}>
|
||||||
|
|
@ -324,8 +324,8 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="tab-group">
|
<div className="tab-group">
|
||||||
<button className={view === 'grid' ? 'active' : ''} onClick={function() { setView('grid'); }} aria-label="Grid view" title="Grid view"><Icon name="grid" size={12} /></button>
|
<button className={view === 'grid' ? 'active' : ''} onClick={function() { setView('grid'); }}><Icon name="grid" size={12} /></button>
|
||||||
<button className={view === 'list' ? 'active' : ''} onClick={function() { setView('list'); }} aria-label="List view" title="List view"><Icon name="list" size={12} /></button>
|
<button className={view === 'list' ? 'active' : ''} onClick={function() { setView('list'); }}><Icon name="list" size={12} /></button>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn primary" onClick={function() { navigate('upload'); }}><Icon name="upload" />Upload</button>
|
<button className="btn primary" onClick={function() { navigate('upload'); }}><Icon name="upload" />Upload</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -362,7 +362,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sub">{a.duration}</div>
|
<div className="col-sub">{a.duration}</div>
|
||||||
<div className="col-sub">{a.res}</div>
|
<div className="col-sub">{a.res}</div>
|
||||||
<div className="col-sub">{a.codec || '·'}</div>
|
<div className="col-sub">{a.codec || '—'}</div>
|
||||||
<div className="col-sub">{a.size}</div>
|
<div className="col-sub">{a.size}</div>
|
||||||
<div className="col-sub">{a.updated}</div>
|
<div className="col-sub">{a.updated}</div>
|
||||||
<button className="icon-btn" aria-label="Asset actions" onClick={function(e) { e.stopPropagation(); openCtx(a, e); }}><Icon name="more" /></button>
|
<button className="icon-btn" aria-label="Asset actions" onClick={function(e) { e.stopPropagation(); openCtx(a, e); }}><Icon name="more" /></button>
|
||||||
|
|
@ -447,7 +447,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
||||||
// Hi-res download trigger shared by the card and the context menu. Resolves
|
// Hi-res download trigger shared by the card and the context menu. Resolves
|
||||||
// a presigned S3 URL via /assets/:id/hires (returns { url, filename, ext })
|
// a presigned S3 URL via /assets/:id/hires (returns { url, filename, ext })
|
||||||
// and clicks a hidden anchor so the browser does the download. The download
|
// and clicks a hidden anchor so the browser does the download. The download
|
||||||
// itself is direct S3 - never proxied through mam-api - so big files don't
|
// itself is direct S3 — never proxied through mam-api — so big files don't
|
||||||
// touch the API container.
|
// touch the API container.
|
||||||
function runDownload(asset) {
|
function runDownload(asset) {
|
||||||
if (!asset || !asset.id) return;
|
if (!asset || !asset.id) return;
|
||||||
|
|
@ -536,7 +536,7 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRen
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="ctx-empty">No bins: create one inside a project</div>
|
<div className="ctx-empty">No bins — create one inside a project</div>
|
||||||
)}
|
)}
|
||||||
<div className="ctx-divider" />
|
<div className="ctx-divider" />
|
||||||
<button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>
|
<button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>
|
||||||
|
|
@ -606,14 +606,14 @@ function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, drag
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Status badges and duration: inside the relative wrapper so
|
{/* Status badges and duration — inside the relative wrapper so
|
||||||
position:absolute is anchored to the thumbnail, not the card (#52) */}
|
position:absolute is anchored to the thumbnail, not the card (#52) */}
|
||||||
<div className="thumb-status">
|
<div className="thumb-status">
|
||||||
{asset.status === 'live' && <span className="badge live">LIVE</span>}
|
{asset.status === 'live' && <span className="badge live">LIVE</span>}
|
||||||
{asset.status === 'processing' && <span className="badge warning">Processing</span>}
|
{asset.status === 'processing' && <span className="badge warning">Processing</span>}
|
||||||
{asset.status === 'error' && <span className="badge danger">Error</span>}
|
{asset.status === 'error' && <span className="badge danger">Error</span>}
|
||||||
</div>
|
</div>
|
||||||
{/* Hi-res download trigger: only shown when the asset has an
|
{/* Hi-res download trigger — only shown when the asset has an
|
||||||
original_s3_key (everything queued through ingest / conform).
|
original_s3_key (everything queued through ingest / conform).
|
||||||
Hidden until card hover, lives in top-right of the thumb. */}
|
Hidden until card hover, lives in top-right of the thumb. */}
|
||||||
{asset.original_s3_key && onDownload && (
|
{asset.original_s3_key && onDownload && (
|
||||||
|
|
@ -625,7 +625,7 @@ function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, drag
|
||||||
<Icon name="download" size={12} />
|
<Icon name="download" size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(asset.type === 'video' || !asset.type) && asset.duration !== '·' && <div className="thumb-duration">{asset.duration}</div>}
|
{(asset.type === 'video' || !asset.type) && asset.duration !== '—' && <div className="thumb-duration">{asset.duration}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="meta">
|
<div className="meta">
|
||||||
<div className="name">{asset.name}</div>
|
<div className="name">{asset.name}</div>
|
||||||
|
|
|
||||||
|
|
@ -1,460 +0,0 @@
|
||||||
// screens-playout.jsx — Master Control (MCR) playout page.
|
|
||||||
//
|
|
||||||
// Operator workflow (Phase A — playlist player):
|
|
||||||
// 1. Create / pick a channel (output target: SRT / RTMP / NDI / DeckLink).
|
|
||||||
// 2. Start the channel → spawns the CasparCG sidecar, brings up the output.
|
|
||||||
// 3. Drag assets from the media bin into the playlist; reorder by dragging.
|
|
||||||
// Each item stages from S3 to the CasparCG /media volume in the background.
|
|
||||||
// 4. Hit PLAY → the engine walks the playlist gaplessly. PAUSE / SKIP / STOP
|
|
||||||
// transport. As-run log records what aired.
|
|
||||||
//
|
|
||||||
// Talks to /api/v1/playout via window.ZAMPP_API.fetch. Native HTML5 drag-drop,
|
|
||||||
// no extra library. Components are plain globals (esbuild bundle:false).
|
|
||||||
|
|
||||||
const PO_OUTPUTS = [
|
|
||||||
{ value: 'srt', label: 'SRT' },
|
|
||||||
{ value: 'rtmp', label: 'RTMP' },
|
|
||||||
{ value: 'ndi', label: 'NDI' },
|
|
||||||
{ value: 'decklink', label: 'SDI (DeckLink)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const PO_FORMATS = ['1080p5994', '1080i5994', '1080p2997', '720p5994', '1080i50', '1080p25'];
|
|
||||||
|
|
||||||
async function poFetch(path, opts) {
|
|
||||||
return window.ZAMPP_API.fetch('/playout' + path, opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Output-config sub-form (varies by output type) ───────────────────────────
|
|
||||||
function OutputConfigFields({ type, config, onChange }) {
|
|
||||||
const set = (k, v) => onChange({ ...config, [k]: v });
|
|
||||||
if (type === 'decklink') {
|
|
||||||
return (
|
|
||||||
<div className="field">
|
|
||||||
<label className="field-label">DeckLink device index</label>
|
|
||||||
<input className="field-input" type="number" min="1" value={config.device_index || 1}
|
|
||||||
onChange={e => set('device_index', parseInt(e.target.value, 10) || 1)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (type === 'ndi') {
|
|
||||||
return (
|
|
||||||
<div className="field">
|
|
||||||
<label className="field-label">NDI source name</label>
|
|
||||||
<input className="field-input" value={config.ndi_name || ''} placeholder="DRAGONFLIGHT CH1"
|
|
||||||
onChange={e => set('ndi_name', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// srt / rtmp
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<div className="field">
|
|
||||||
<label className="field-label">{type.toUpperCase()} URL</label>
|
|
||||||
<input className="field-input mono" value={config.url || ''}
|
|
||||||
placeholder={type === 'srt' ? 'srt://host:9000' : 'rtmp://host/live'}
|
|
||||||
onChange={e => set('url', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
{type === 'rtmp' && (
|
|
||||||
<div className="field">
|
|
||||||
<label className="field-label">Stream key</label>
|
|
||||||
<input className="field-input mono" value={config.key || ''}
|
|
||||||
onChange={e => set('key', e.target.value)} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{type === 'srt' && (
|
|
||||||
<div className="field">
|
|
||||||
<label className="field-label">Latency (ms)</label>
|
|
||||||
<input className="field-input" type="number" value={config.latency || 200}
|
|
||||||
onChange={e => set('latency', parseInt(e.target.value, 10) || 200)} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Channel create modal ─────────────────────────────────────────────────────
|
|
||||||
function ChannelCreate({ onClose, onCreated }) {
|
|
||||||
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
|
|
||||||
const [name, setName] = React.useState('');
|
|
||||||
const [outputType, setOutputType] = React.useState('srt');
|
|
||||||
const [config, setConfig] = React.useState({});
|
|
||||||
const [videoFormat, setVideoFormat] = React.useState('1080i5994');
|
|
||||||
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
|
|
||||||
const [busy, setBusy] = React.useState(false);
|
|
||||||
const [err, setErr] = React.useState(null);
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
setBusy(true); setErr(null);
|
|
||||||
try {
|
|
||||||
const ch = await poFetch('/channels', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
name, output_type: outputType, output_config: config,
|
|
||||||
video_format: videoFormat, project_id: projectId || null,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
onCreated(ch);
|
|
||||||
} catch (e) { setErr(e.message || 'Failed to create channel'); }
|
|
||||||
finally { setBusy(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-backdrop" onClick={onClose}>
|
|
||||||
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 460 }}>
|
|
||||||
<div className="modal-header"><h3>New Playout Channel</h3></div>
|
|
||||||
<div className="modal-body">
|
|
||||||
<div className="field">
|
|
||||||
<label className="field-label">Name</label>
|
|
||||||
<input className="field-input" value={name} autoFocus
|
|
||||||
onChange={e => setName(e.target.value)} placeholder="Channel 1" />
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label className="field-label">Output</label>
|
|
||||||
<select className="field-input" value={outputType}
|
|
||||||
onChange={e => { setOutputType(e.target.value); setConfig({}); }}>
|
|
||||||
{PO_OUTPUTS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<OutputConfigFields type={outputType} config={config} onChange={setConfig} />
|
|
||||||
<div className="field">
|
|
||||||
<label className="field-label">Video format</label>
|
|
||||||
<select className="field-input" value={videoFormat} onChange={e => setVideoFormat(e.target.value)}>
|
|
||||||
{PO_FORMATS.map(f => <option key={f} value={f}>{f}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label className="field-label">Project (RBAC scope)</label>
|
|
||||||
<select className="field-input" value={projectId} onChange={e => setProjectId(e.target.value)}>
|
|
||||||
<option value="">— admin only —</option>
|
|
||||||
{PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{err && <div className="alert error">{err}</div>}
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button className="btn ghost" onClick={onClose}>Cancel</button>
|
|
||||||
<button className="btn primary" disabled={busy || !name} onClick={submit}>
|
|
||||||
{busy ? 'Creating…' : 'Create'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Media bin: assets draggable into the playlist ────────────────────────────
|
|
||||||
function MediaBin({ projectId }) {
|
|
||||||
const ASSETS = (window.ZAMPP_DATA?.ASSETS || []).filter(a =>
|
|
||||||
!projectId || a.project_id === projectId);
|
|
||||||
const [q, setQ] = React.useState('');
|
|
||||||
const filtered = ASSETS.filter(a => !q || (a.name || '').toLowerCase().includes(q.toLowerCase()));
|
|
||||||
|
|
||||||
const onDragStart = (e, asset) => {
|
|
||||||
e.dataTransfer.setData('application/x-df-asset', JSON.stringify({ id: asset.id, name: asset.name }));
|
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="panel po-bin">
|
|
||||||
<div className="po-bin-head">
|
|
||||||
<span className="po-section-label">Media Bin</span>
|
|
||||||
<input className="field-input sm" placeholder="Filter…" value={q}
|
|
||||||
onChange={e => setQ(e.target.value)} style={{ maxWidth: 160 }} />
|
|
||||||
</div>
|
|
||||||
<div className="po-bin-list">
|
|
||||||
{filtered.length === 0 && <div className="muted" style={{ padding: 12 }}>No assets.</div>}
|
|
||||||
{filtered.map(a => (
|
|
||||||
<div key={a.id} className="po-bin-item" draggable
|
|
||||||
onDragStart={e => onDragStart(e, a)} title="Drag into the playlist">
|
|
||||||
<span className="po-bin-name">{a.name}</span>
|
|
||||||
<span className="mono muted" style={{ fontSize: 11 }}>{a.duration || ''}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const MEDIA_STATUS_BADGE = {
|
|
||||||
ready: 'success', staging: 'warn', pending: 'neutral', error: 'error',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Playlist: ordered, drag-drop reorder, drop-target for bin assets ─────────
|
|
||||||
function Playlist({ channel, playlistId, items, onReload }) {
|
|
||||||
const [dragIndex, setDragIndex] = React.useState(null);
|
|
||||||
|
|
||||||
const onItemDragStart = (e, index) => {
|
|
||||||
setDragIndex(index);
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
};
|
|
||||||
const onItemDragOver = (e) => { e.preventDefault(); };
|
|
||||||
const onItemDrop = async (e, index) => {
|
|
||||||
e.preventDefault();
|
|
||||||
// Asset dropped from the bin → append.
|
|
||||||
const assetRaw = e.dataTransfer.getData('application/x-df-asset');
|
|
||||||
if (assetRaw) {
|
|
||||||
const asset = JSON.parse(assetRaw);
|
|
||||||
await poFetch('/playlists/' + playlistId + '/items', {
|
|
||||||
method: 'POST', body: JSON.stringify({ asset_id: asset.id }),
|
|
||||||
});
|
|
||||||
onReload();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Reorder within the playlist.
|
|
||||||
if (dragIndex === null || dragIndex === index) return;
|
|
||||||
const order = items.map(i => i.id);
|
|
||||||
const [moved] = order.splice(dragIndex, 1);
|
|
||||||
order.splice(index, 0, moved);
|
|
||||||
setDragIndex(null);
|
|
||||||
await poFetch('/playlists/' + playlistId + '/reorder', {
|
|
||||||
method: 'PUT', body: JSON.stringify({ order }),
|
|
||||||
});
|
|
||||||
onReload();
|
|
||||||
};
|
|
||||||
// Dropping onto empty area appends.
|
|
||||||
const onContainerDrop = async (e) => {
|
|
||||||
const assetRaw = e.dataTransfer.getData('application/x-df-asset');
|
|
||||||
if (!assetRaw) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const asset = JSON.parse(assetRaw);
|
|
||||||
await poFetch('/playlists/' + playlistId + '/items', {
|
|
||||||
method: 'POST', body: JSON.stringify({ asset_id: asset.id }),
|
|
||||||
});
|
|
||||||
onReload();
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeItem = async (id) => {
|
|
||||||
await poFetch('/items/' + id, { method: 'DELETE' });
|
|
||||||
onReload();
|
|
||||||
};
|
|
||||||
const restage = async (id) => {
|
|
||||||
await poFetch('/items/' + id + '/stage', { method: 'POST' });
|
|
||||||
onReload();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="panel po-playlist" onDragOver={e => e.preventDefault()} onDrop={onContainerDrop}>
|
|
||||||
<div className="po-section-label" style={{ padding: '8px 12px' }}>Playlist</div>
|
|
||||||
{items.length === 0 && (
|
|
||||||
<div className="muted po-playlist-empty">Drag clips here to build the playlist.</div>
|
|
||||||
)}
|
|
||||||
{items.map((it, index) => (
|
|
||||||
<div key={it.id} className="po-pl-item" draggable
|
|
||||||
onDragStart={e => onItemDragStart(e, index)}
|
|
||||||
onDragOver={onItemDragOver}
|
|
||||||
onDrop={e => onItemDrop(e, index)}>
|
|
||||||
<span className="po-pl-index">{index + 1}</span>
|
|
||||||
<span className="po-pl-name">{it.clip_name || it.asset_id}</span>
|
|
||||||
<span className={'badge ' + (MEDIA_STATUS_BADGE[it.media_status] || 'neutral')}>
|
|
||||||
{it.media_status}
|
|
||||||
</span>
|
|
||||||
{it.media_status === 'error' && (
|
|
||||||
<button className="btn ghost xs" onClick={() => restage(it.id)}>Retry</button>
|
|
||||||
)}
|
|
||||||
<button className="btn ghost xs" onClick={() => removeItem(it.id)}>✕</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Transport bar ────────────────────────────────────────────────────────────
|
|
||||||
function Transport({ channel, playlistId, onStatus }) {
|
|
||||||
const [busy, setBusy] = React.useState(false);
|
|
||||||
const act = async (fn) => { setBusy(true); try { await fn(); } catch (e) { alert(e.message); } finally { setBusy(false); } };
|
|
||||||
|
|
||||||
const play = () => act(async () => {
|
|
||||||
const r = await poFetch('/channels/' + channel.id + '/play', {
|
|
||||||
method: 'POST', body: JSON.stringify({ playlist_id: playlistId }),
|
|
||||||
});
|
|
||||||
onStatus && onStatus(r);
|
|
||||||
});
|
|
||||||
const pause = () => act(() => poFetch('/channels/' + channel.id + '/pause', { method: 'POST' }));
|
|
||||||
const resume = () => act(() => poFetch('/channels/' + channel.id + '/resume', { method: 'POST' }));
|
|
||||||
const skip = () => act(() => poFetch('/channels/' + channel.id + '/skip', { method: 'POST' }));
|
|
||||||
const stopPb = () => act(() => poFetch('/channels/' + channel.id + '/stop-playback', { method: 'POST' }));
|
|
||||||
|
|
||||||
const live = channel.status === 'running';
|
|
||||||
return (
|
|
||||||
<div className="po-transport">
|
|
||||||
<button className="btn primary" disabled={!live || busy || !playlistId} onClick={play}>▶ Play</button>
|
|
||||||
<button className="btn ghost" disabled={!live || busy} onClick={pause}>⏸ Pause</button>
|
|
||||||
<button className="btn ghost" disabled={!live || busy} onClick={resume}>⏵ Resume</button>
|
|
||||||
<button className="btn ghost" disabled={!live || busy} onClick={skip}>⏭ Skip</button>
|
|
||||||
<button className="btn danger ghost" disabled={!live || busy} onClick={stopPb}>⏹ Stop</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Program monitor ──────────────────────────────────────────────────────────
|
|
||||||
function ProgramMonitor({ channel, engine }) {
|
|
||||||
const onAir = channel.status === 'running';
|
|
||||||
return (
|
|
||||||
<div className="po-monitor">
|
|
||||||
<div className="po-monitor-head">
|
|
||||||
<span className={'po-onair ' + (onAir ? 'live' : '')}>{onAir ? '● ON AIR' : '○ OFF'}</span>
|
|
||||||
<span className="mono muted">{channel.output_type?.toUpperCase()} · {channel.video_format}</span>
|
|
||||||
</div>
|
|
||||||
<div className="po-monitor-screen">
|
|
||||||
{engine && engine.currentClip
|
|
||||||
? <div className="po-monitor-clip">{engine.currentClip}</div>
|
|
||||||
: <div className="muted">{onAir ? 'Idle — no clip playing' : 'Channel stopped'}</div>}
|
|
||||||
</div>
|
|
||||||
{engine && (
|
|
||||||
<div className="po-monitor-foot mono muted">
|
|
||||||
clip {engine.currentIndex >= 0 ? engine.currentIndex + 1 : '–'} / {engine.playlistLength || 0}
|
|
||||||
{engine.loop ? ' · loop' : ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Channel detail (monitors + bin + playlist + transport) ───────────────────
|
|
||||||
function ChannelDetail({ channel, onChannelChange }) {
|
|
||||||
const [playlists, setPlaylists] = React.useState([]);
|
|
||||||
const [playlistId, setPlaylistId] = React.useState(null);
|
|
||||||
const [items, setItems] = React.useState([]);
|
|
||||||
const [engine, setEngine] = React.useState(null);
|
|
||||||
const [ch, setCh] = React.useState(channel);
|
|
||||||
|
|
||||||
React.useEffect(() => { setCh(channel); }, [channel.id]);
|
|
||||||
|
|
||||||
const loadPlaylists = React.useCallback(async () => {
|
|
||||||
const pls = await poFetch('/playlists?channel_id=' + channel.id);
|
|
||||||
setPlaylists(pls);
|
|
||||||
if (pls.length && !playlistId) setPlaylistId(pls[0].id);
|
|
||||||
if (!pls.length) {
|
|
||||||
// Auto-create a default playlist so the operator can start dragging.
|
|
||||||
const created = await poFetch('/playlists', {
|
|
||||||
method: 'POST', body: JSON.stringify({ channel_id: channel.id, name: 'Main' }),
|
|
||||||
});
|
|
||||||
setPlaylists([created]); setPlaylistId(created.id);
|
|
||||||
}
|
|
||||||
}, [channel.id]);
|
|
||||||
|
|
||||||
const loadItems = React.useCallback(async () => {
|
|
||||||
if (!playlistId) return;
|
|
||||||
const its = await poFetch('/playlists/' + playlistId + '/items');
|
|
||||||
setItems(its);
|
|
||||||
}, [playlistId]);
|
|
||||||
|
|
||||||
React.useEffect(() => { loadPlaylists(); }, [channel.id]);
|
|
||||||
React.useEffect(() => { loadItems(); }, [playlistId]);
|
|
||||||
|
|
||||||
// Poll engine status + item staging while live.
|
|
||||||
React.useEffect(() => {
|
|
||||||
let t;
|
|
||||||
const poll = async () => {
|
|
||||||
try {
|
|
||||||
const s = await poFetch('/channels/' + channel.id + '/status');
|
|
||||||
setEngine(s.engine || null);
|
|
||||||
} catch (_) {}
|
|
||||||
try { await loadItems(); } catch (_) {}
|
|
||||||
t = setTimeout(poll, 4000);
|
|
||||||
};
|
|
||||||
poll();
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, [channel.id, playlistId]);
|
|
||||||
|
|
||||||
const startChannel = async () => {
|
|
||||||
const updated = await poFetch('/channels/' + channel.id + '/start', { method: 'POST' });
|
|
||||||
setCh(updated); onChannelChange(updated);
|
|
||||||
};
|
|
||||||
const stopChannel = async () => {
|
|
||||||
const updated = await poFetch('/channels/' + channel.id + '/stop', { method: 'POST' });
|
|
||||||
setCh(updated); onChannelChange(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="po-detail">
|
|
||||||
<div className="po-detail-head">
|
|
||||||
<div>
|
|
||||||
<h3 style={{ margin: 0 }}>{ch.name}</h3>
|
|
||||||
<span className="mono muted">{ch.output_type?.toUpperCase()} · {ch.video_format} · {ch.status}</span>
|
|
||||||
</div>
|
|
||||||
<div className="po-detail-actions">
|
|
||||||
{ch.status === 'running'
|
|
||||||
? <button className="btn danger" onClick={stopChannel}>Stop channel</button>
|
|
||||||
: <button className="btn primary" onClick={startChannel}>Start channel</button>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{ch.error_message && <div className="alert error">{ch.error_message}</div>}
|
|
||||||
|
|
||||||
<div className="po-grid">
|
|
||||||
<ProgramMonitor channel={ch} engine={engine} />
|
|
||||||
<MediaBin projectId={ch.project_id} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Transport channel={ch} playlistId={playlistId} onStatus={() => loadItems()} />
|
|
||||||
|
|
||||||
{playlistId && (
|
|
||||||
<Playlist channel={ch} playlistId={playlistId} items={items} onReload={loadItems} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Top-level page ───────────────────────────────────────────────────────────
|
|
||||||
function Playout() {
|
|
||||||
const [channels, setChannels] = React.useState(null);
|
|
||||||
const [selectedId, setSelectedId] = React.useState(null);
|
|
||||||
const [showCreate, setShowCreate] = React.useState(false);
|
|
||||||
const [err, setErr] = React.useState(null);
|
|
||||||
|
|
||||||
const load = React.useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const list = await poFetch('/channels');
|
|
||||||
setChannels(list);
|
|
||||||
if (list.length && !selectedId) setSelectedId(list[0].id);
|
|
||||||
} catch (e) { setErr(e.message); setChannels([]); }
|
|
||||||
}, [selectedId]);
|
|
||||||
|
|
||||||
React.useEffect(() => { load(); }, []);
|
|
||||||
|
|
||||||
const selected = (channels || []).find(c => c.id === selectedId) || null;
|
|
||||||
const onChannelChange = (updated) => {
|
|
||||||
setChannels(cs => (cs || []).map(c => c.id === updated.id ? updated : c));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<div className="page-header">
|
|
||||||
<span className="title">Playout — Master Control</span>
|
|
||||||
<span className="subtitle">Schedule and play assets to SDI, NDI, SRT or RTMP.</span>
|
|
||||||
</div>
|
|
||||||
<div className="page-body po-page">
|
|
||||||
{err && <div className="alert error">{err}</div>}
|
|
||||||
<div className="po-channels-bar">
|
|
||||||
{(channels || []).map(c => (
|
|
||||||
<button key={c.id}
|
|
||||||
className={'po-chan-tab ' + (c.id === selectedId ? 'active' : '')}
|
|
||||||
onClick={() => setSelectedId(c.id)}>
|
|
||||||
<span className={'po-chan-dot ' + (c.status === 'running' ? 'live' : '')} />
|
|
||||||
{c.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button className="btn ghost sm" onClick={() => setShowCreate(true)}>+ Channel</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{channels === null && <div className="muted">Loading channels…</div>}
|
|
||||||
{channels !== null && channels.length === 0 && (
|
|
||||||
<div className="po-empty">
|
|
||||||
<p className="muted">No playout channels yet.</p>
|
|
||||||
<button className="btn primary" onClick={() => setShowCreate(true)}>Create your first channel</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selected && <ChannelDetail key={selected.id} channel={selected} onChannelChange={onChannelChange} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCreate && (
|
|
||||||
<ChannelCreate
|
|
||||||
onClose={() => setShowCreate(false)}
|
|
||||||
onCreated={(ch) => { setShowCreate(false); setChannels(cs => [...(cs || []), ch]); setSelectedId(ch.id); }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.Playout = Playout;
|
|
||||||
|
|
@ -49,9 +49,6 @@ function Projects({ onOpenProject, navigate }) {
|
||||||
const [showNew, setShowNew] = React.useState(false);
|
const [showNew, setShowNew] = React.useState(false);
|
||||||
const [menuFor, setMenuFor] = React.useState(null);
|
const [menuFor, setMenuFor] = React.useState(null);
|
||||||
const [renamingProject, setRenamingProject] = React.useState(null);
|
const [renamingProject, setRenamingProject] = React.useState(null);
|
||||||
const [accessProject, setAccessProject] = React.useState(null);
|
|
||||||
const isAdmin = window.ZAMPP_DATA?.ME?.role === 'admin';
|
|
||||||
const manageAccess = (p) => { setMenuFor(null); setAccessProject(p); };
|
|
||||||
|
|
||||||
const refresh = React.useCallback(() => {
|
const refresh = React.useCallback(() => {
|
||||||
window.ZAMPP_API.fetch('/projects')
|
window.ZAMPP_API.fetch('/projects')
|
||||||
|
|
@ -103,8 +100,8 @@ function Projects({ onOpenProject, navigate }) {
|
||||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search projects…" />
|
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search projects…" />
|
||||||
</div>
|
</div>
|
||||||
<div className="tab-group">
|
<div className="tab-group">
|
||||||
<button className={view === 'grid' ? 'active' : ''} onClick={() => setView('grid')} aria-label="Grid view" title="Grid view"><Icon name="grid" size={12} /></button>
|
<button className={view === 'grid' ? 'active' : ''} onClick={() => setView('grid')}><Icon name="grid" size={12} /></button>
|
||||||
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')} aria-label="List view" title="List view"><Icon name="list" size={12} /></button>
|
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')}><Icon name="list" size={12} /></button>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn primary" onClick={() => setShowNew(true)}><Icon name="plus" />New project</button>
|
<button className="btn primary" onClick={() => setShowNew(true)}><Icon name="plus" />New project</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -125,10 +122,8 @@ function Projects({ onOpenProject, navigate }) {
|
||||||
key={p.id}
|
key={p.id}
|
||||||
project={p}
|
project={p}
|
||||||
assets={ASSETS}
|
assets={ASSETS}
|
||||||
canManageAccess={isAdmin}
|
|
||||||
onOpen={() => onOpenProject(p)}
|
onOpen={() => onOpenProject(p)}
|
||||||
onRename={() => renameProject(p)}
|
onRename={() => renameProject(p)}
|
||||||
onManageAccess={() => manageAccess(p)}
|
|
||||||
onDelete={() => deleteProject(p)}
|
onDelete={() => deleteProject(p)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -145,15 +140,14 @@ function Projects({ onOpenProject, navigate }) {
|
||||||
<div>{p.name}</div>
|
<div>{p.name}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sub">{p.assets || 0}</div>
|
<div className="col-sub">{p.assets || 0}</div>
|
||||||
<div className="col-sub">·</div>
|
<div className="col-sub">—</div>
|
||||||
<div className="col-sub">{p.updated || '·'}</div>
|
<div className="col-sub">{p.updated || '—'}</div>
|
||||||
<div style={{ position: 'relative' }} onClick={e => e.stopPropagation()}>
|
<div style={{ position: 'relative' }} onClick={e => e.stopPropagation()}>
|
||||||
<button className="icon-btn" aria-label="Project actions" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === p.id ? null : p.id); }}><Icon name="more" /></button>
|
<button className="icon-btn" aria-label="Project actions" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === p.id ? null : p.id); }}><Icon name="more" /></button>
|
||||||
{menuFor === p.id && (
|
{menuFor === p.id && (
|
||||||
<div className="row-menu" onClick={e => e.stopPropagation()}>
|
<div className="row-menu" onClick={e => e.stopPropagation()}>
|
||||||
<button onClick={() => { setMenuFor(null); onOpenProject(p); }}><Icon name="library" size={11} />Open</button>
|
<button onClick={() => { setMenuFor(null); onOpenProject(p); }}><Icon name="library" size={11} />Open</button>
|
||||||
<button onClick={() => renameProject(p)}><Icon name="edit" size={11} />Rename…</button>
|
<button onClick={() => renameProject(p)}><Icon name="edit" size={11} />Rename…</button>
|
||||||
{isAdmin && <button onClick={() => manageAccess(p)}><Icon name="users" size={11} />Manage access…</button>}
|
|
||||||
<button className="danger" onClick={() => deleteProject(p)}><Icon name="trash" size={11} />Delete</button>
|
<button className="danger" onClick={() => deleteProject(p)}><Icon name="trash" size={11} />Delete</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -171,140 +165,6 @@ function Projects({ onOpenProject, navigate }) {
|
||||||
onSaved={() => { setRenamingProject(null); refresh(); }}
|
onSaved={() => { setRenamingProject(null); refresh(); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{accessProject && (
|
|
||||||
<ProjectAccessModal
|
|
||||||
project={accessProject}
|
|
||||||
onClose={() => setAccessProject(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin-only: grant/revoke per-project access to users and groups.
|
|
||||||
// Backed by GET/POST/DELETE /api/v1/projects/:id/access.
|
|
||||||
function ProjectAccessModal({ project, onClose }) {
|
|
||||||
const [grants, setGrants] = React.useState([]);
|
|
||||||
const [users, setUsers] = React.useState([]);
|
|
||||||
const [groups, setGroups] = React.useState([]);
|
|
||||||
const [loading, setLoading] = React.useState(true);
|
|
||||||
const [err, setErr] = React.useState(null);
|
|
||||||
|
|
||||||
// Add-grant form state.
|
|
||||||
const [subjType, setSubjType] = React.useState('user');
|
|
||||||
const [subjId, setSubjId] = React.useState('');
|
|
||||||
const [level, setLevel] = React.useState('view');
|
|
||||||
|
|
||||||
const loadGrants = React.useCallback(() => {
|
|
||||||
return window.ZAMPP_API.fetch('/projects/' + project.id + '/access')
|
|
||||||
.then(list => setGrants(list || []))
|
|
||||||
.catch(e => setErr(e.message));
|
|
||||||
}, [project.id]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setLoading(true);
|
|
||||||
Promise.all([
|
|
||||||
loadGrants(),
|
|
||||||
window.ZAMPP_API.fetch('/users').then(setUsers).catch(() => setUsers([])),
|
|
||||||
window.ZAMPP_API.fetch('/groups').then(setGroups).catch(() => setGroups([])),
|
|
||||||
]).finally(() => setLoading(false));
|
|
||||||
}, [loadGrants]);
|
|
||||||
|
|
||||||
const addGrant = () => {
|
|
||||||
if (!subjId) return;
|
|
||||||
setErr(null);
|
|
||||||
window.ZAMPP_API.fetch('/projects/' + project.id + '/access', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ subject_type: subjType, subject_id: subjId, level }),
|
|
||||||
})
|
|
||||||
.then(() => { setSubjId(''); return loadGrants(); })
|
|
||||||
.catch(e => setErr(e.message || 'Failed to add grant'));
|
|
||||||
};
|
|
||||||
|
|
||||||
const revoke = (g) => {
|
|
||||||
window.ZAMPP_API.fetch('/projects/' + project.id + '/access/' + g.subject_type + '/' + g.subject_id, { method: 'DELETE' })
|
|
||||||
.then(loadGrants)
|
|
||||||
.catch(e => setErr(e.message || 'Failed to revoke'));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Candidates for the picker — exclude subjects that already have a grant.
|
|
||||||
const grantedIds = new Set(grants.filter(g => g.subject_type === subjType).map(g => g.subject_id));
|
|
||||||
const candidates = (subjType === 'user' ? users : groups).filter(c => !grantedIds.has(c.id));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-backdrop" onClick={onClose}>
|
|
||||||
<div className="modal" style={{ width: 520 }} onClick={e => e.stopPropagation()}>
|
|
||||||
<div className="modal-head">
|
|
||||||
<div style={{ fontSize: 15, fontWeight: 600 }}>Manage access · {project.name}</div>
|
|
||||||
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
|
|
||||||
</div>
|
|
||||||
<div className="modal-body">
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 12 }}>
|
|
||||||
Admins always have full access. Grant specific users or groups view (read-only) or
|
|
||||||
edit (read-write) access to this project.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add-grant row */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr 90px auto', gap: 8, alignItems: 'end', marginBottom: 14 }}>
|
|
||||||
<div className="field" style={{ marginBottom: 0 }}>
|
|
||||||
<label className="field-label">Type</label>
|
|
||||||
<select className="field-input" value={subjType} style={{ appearance: 'auto' }}
|
|
||||||
onChange={e => { setSubjType(e.target.value); setSubjId(''); }}>
|
|
||||||
<option value="user">User</option>
|
|
||||||
<option value="group">Group</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="field" style={{ marginBottom: 0 }}>
|
|
||||||
<label className="field-label">{subjType === 'user' ? 'User' : 'Group'}</label>
|
|
||||||
<select className="field-input" value={subjId} style={{ appearance: 'auto' }}
|
|
||||||
onChange={e => setSubjId(e.target.value)}>
|
|
||||||
<option value="">Pick a {subjType}…</option>
|
|
||||||
{candidates.map(c => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{subjType === 'user' ? ('@' + c.username + (c.display_name ? ' · ' + c.display_name : '')) : c.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="field" style={{ marginBottom: 0 }}>
|
|
||||||
<label className="field-label">Level</label>
|
|
||||||
<select className="field-input" value={level} style={{ appearance: 'auto' }}
|
|
||||||
onChange={e => setLevel(e.target.value)}>
|
|
||||||
<option value="view">View</option>
|
|
||||||
<option value="edit">Edit</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button className="btn primary sm" onClick={addGrant} disabled={!subjId}>Add</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginBottom: 8 }}>{err}</div>}
|
|
||||||
|
|
||||||
{/* Existing grants */}
|
|
||||||
<div className="panel">
|
|
||||||
{loading && <div style={{ padding: 16, color: 'var(--text-3)', fontSize: 12.5 }}>Loading…</div>}
|
|
||||||
{!loading && grants.length === 0 && (
|
|
||||||
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>
|
|
||||||
No grants yet — only admins can see this project.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!loading && grants.map(g => (
|
|
||||||
<div key={g.subject_type + ':' + g.subject_id}
|
|
||||||
style={{ display: 'grid', gridTemplateColumns: '20px 1fr 70px 80px', gap: 10, alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<Icon name={g.subject_type === 'group' ? 'users' : 'user'} size={13} />
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{g.subject_name || '(deleted)'}</div>
|
|
||||||
{g.username && <div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>@{g.username}</div>}
|
|
||||||
</div>
|
|
||||||
<span className={`badge ${g.level === 'edit' ? 'accent' : 'neutral'}`}>{g.level}</span>
|
|
||||||
<button className="btn ghost sm danger" onClick={() => revoke(g)}>Revoke</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="modal-foot">
|
|
||||||
<button className="btn primary sm" onClick={onClose}>Done</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -346,11 +206,11 @@ function RenameProjectModal({ project, onClose, onSaved }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectCard({ project, assets, onOpen, onRename, onManageAccess, onDelete, canManageAccess }) {
|
function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
|
||||||
const ofProject = assets.filter(a => a.project_id === project.id);
|
const ofProject = assets.filter(a => a.project_id === project.id);
|
||||||
const thumbAssets = ofProject.slice(0, 4);
|
const thumbAssets = ofProject.slice(0, 4);
|
||||||
|
|
||||||
// Real status distribution - ready vs processing/live vs error.
|
// Real status distribution — ready vs processing/live vs error.
|
||||||
const total = ofProject.length || 1;
|
const total = ofProject.length || 1;
|
||||||
const ready = ofProject.filter(a => a.status === 'ready').length;
|
const ready = ofProject.filter(a => a.status === 'ready').length;
|
||||||
const inFlight = ofProject.filter(a => a.status === 'processing' || a.status === 'live').length;
|
const inFlight = ofProject.filter(a => a.status === 'processing' || a.status === 'live').length;
|
||||||
|
|
@ -399,7 +259,7 @@ function ProjectCard({ project, assets, onOpen, onRename, onManageAccess, onDele
|
||||||
<div className="project-meta">
|
<div className="project-meta">
|
||||||
<span>{ofProject.length} asset{ofProject.length === 1 ? '' : 's'}</span>
|
<span>{ofProject.length} asset{ofProject.length === 1 ? '' : 's'}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>updated {project.updated || '·'}</span>
|
<span>updated {project.updated || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
{ofProject.length > 0 ? (
|
{ofProject.length > 0 ? (
|
||||||
<div className="project-bar" title={`ready ${ready} · in-flight ${inFlight} · errored ${errored}`}>
|
<div className="project-bar" title={`ready ${ready} · in-flight ${inFlight} · errored ${errored}`}>
|
||||||
|
|
@ -415,7 +275,6 @@ function ProjectCard({ project, assets, onOpen, onRename, onManageAccess, onDele
|
||||||
<div className="row-menu" style={{ position: 'fixed', top: ctx.y, left: ctx.x, zIndex: 9999 }} onClick={e => e.stopPropagation()}>
|
<div className="row-menu" style={{ position: 'fixed', top: ctx.y, left: ctx.x, zIndex: 9999 }} onClick={e => e.stopPropagation()}>
|
||||||
<button onClick={() => { setCtx(null); onOpen(); }}><Icon name="library" size={11} />Open</button>
|
<button onClick={() => { setCtx(null); onOpen(); }}><Icon name="library" size={11} />Open</button>
|
||||||
<button onClick={() => { setCtx(null); onRename && onRename(); }}><Icon name="edit" size={11} />Rename…</button>
|
<button onClick={() => { setCtx(null); onRename && onRename(); }}><Icon name="edit" size={11} />Rename…</button>
|
||||||
{canManageAccess && <button onClick={() => { setCtx(null); onManageAccess && onManageAccess(); }}><Icon name="users" size={11} />Manage access…</button>}
|
|
||||||
<button className="danger" onClick={() => { setCtx(null); onDelete && onDelete(); }}><Icon name="trash" size={11} />Delete</button>
|
<button className="danger" onClick={() => { setCtx(null); onDelete && onDelete(); }}><Icon name="trash" size={11} />Delete</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -425,4 +284,3 @@ function ProjectCard({ project, assets, onOpen, onRename, onManageAccess, onDele
|
||||||
|
|
||||||
window.Projects = Projects;
|
window.Projects = Projects;
|
||||||
window.RenameProjectModal = RenameProjectModal;
|
window.RenameProjectModal = RenameProjectModal;
|
||||||
window.ProjectAccessModal = ProjectAccessModal;
|
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
// screens-resources.jsx
|
|
||||||
// Live CPU/RAM/GPU gauges for Dashboard. Polls /api/v1/cluster/metrics every 5s.
|
|
||||||
// Falls back to mock data when endpoint unavailable.
|
|
||||||
|
|
||||||
const RESOURCE_MOCK={nodes:[
|
|
||||||
{hostname:"zampp1",cpu_util_pct:42,ram_used_mb:14336,ram_total_mb:32768,
|
|
||||||
gpus:[{name:"RTX 3060",util_pct:67,memory_used_mb:5120,memory_total_mb:12288}]},
|
|
||||||
{hostname:"zampp2",cpu_util_pct:18,ram_used_mb:8192,ram_total_mb:32768,
|
|
||||||
gpus:[{name:"RTX 3060",util_pct:12,memory_used_mb:1024,memory_total_mb:12288}]},
|
|
||||||
]};
|
|
||||||
|
|
||||||
function useClusterMetrics(){
|
|
||||||
const [data,setData]=React.useState(null);
|
|
||||||
const [usingMock,setUsingMock]=React.useState(false);
|
|
||||||
React.useEffect(()=>{
|
|
||||||
let cancelled=false;
|
|
||||||
const load=()=>{
|
|
||||||
window.ZAMPP_API.fetch('/cluster/metrics')
|
|
||||||
.then(d=>{
|
|
||||||
if(cancelled)return;
|
|
||||||
if(d&&Array.isArray(d.nodes)&&d.nodes.length>0){
|
|
||||||
setData(d);setUsingMock(false);
|
|
||||||
}else{setData(RESOURCE_MOCK);setUsingMock(true);}
|
|
||||||
})
|
|
||||||
.catch(()=>{if(!cancelled){setData(RESOURCE_MOCK);setUsingMock(true);}});
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
const t=setInterval(load,5000);
|
|
||||||
return ()=>{cancelled=true;clearInterval(t);};
|
|
||||||
},[]);
|
|
||||||
return {data,usingMock};
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResBar({pct,color}){
|
|
||||||
const p=Math.min(100,Math.max(0,Math.round(pct||0)));
|
|
||||||
const c=color||(p>85?'var(--warning)':p>60?'var(--accent)':'var(--success)');
|
|
||||||
return (
|
|
||||||
<div className="res-bar-wrap">
|
|
||||||
<div className="res-bar"><div className="res-bar-fill" style={{width:p+'%',background:c}}/></div>
|
|
||||||
<span className="res-bar-pct">{p}%</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NodeResourceCard({node}){
|
|
||||||
const ramPct=node.ram_total_mb>0?(node.ram_used_mb/node.ram_total_mb)*100:0;
|
|
||||||
const ramUsed=(node.ram_used_mb/1024).toFixed(1);
|
|
||||||
const ramTotal=(node.ram_total_mb/1024).toFixed(0);
|
|
||||||
return (
|
|
||||||
<div className="panel res-node-card">
|
|
||||||
<div className="res-node-name"><span className="res-node-dot"/>{node.hostname}</div>
|
|
||||||
<div className="res-metric">
|
|
||||||
<div className="res-metric-label">CPU</div>
|
|
||||||
<ResBar pct={node.cpu_util_pct}/>
|
|
||||||
</div>
|
|
||||||
<div className="res-metric">
|
|
||||||
<div className="res-metric-label">RAM <span className="res-metric-sub">{ramUsed}/{ramTotal} GB</span></div>
|
|
||||||
<ResBar pct={ramPct} color={ramPct>85?'var(--warning)':'var(--text-2)'}/>
|
|
||||||
</div>
|
|
||||||
{(node.gpus||[]).map((gpu,i)=>{
|
|
||||||
const vramPct=gpu.memory_total_mb>0?(gpu.memory_used_mb/gpu.memory_total_mb)*100:0;
|
|
||||||
const vramUsed=(gpu.memory_used_mb/1024).toFixed(1);
|
|
||||||
const vramTotal=(gpu.memory_total_mb/1024).toFixed(0);
|
|
||||||
const lbl=(node.gpus||[]).length>1?'GPU '+(i+1):'GPU';
|
|
||||||
return (
|
|
||||||
<React.Fragment key={i}>
|
|
||||||
<div className="res-metric">
|
|
||||||
<div className="res-metric-label">{lbl} util</div>
|
|
||||||
<ResBar pct={gpu.util_pct}/>
|
|
||||||
</div>
|
|
||||||
<div className="res-metric">
|
|
||||||
<div className="res-metric-label">{lbl} VRAM <span className="res-metric-sub">{vramUsed}/{vramTotal} GB</span></div>
|
|
||||||
<ResBar pct={vramPct} color={vramPct>85?'var(--warning)':'var(--purple)'}/>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClusterResources(){
|
|
||||||
const {data,usingMock}=useClusterMetrics();
|
|
||||||
if(!data)return <div className="dash-panel-empty">Loading resource metrics...</div>;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{usingMock&&(
|
|
||||||
<div className="res-mock-note">⚠ Metrics API unavailable - showing mock data</div>
|
|
||||||
)}
|
|
||||||
<div className="res-nodes-grid">
|
|
||||||
{data.nodes.map(n=><NodeResourceCard key={n.hostname} node={n}/>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.ClusterResources=ClusterResources;
|
|
||||||
|
|
@ -1,62 +1,40 @@
|
||||||
// shell.jsx - app shell: sidebar nav, topbar, route container
|
// shell.jsx - app shell: sidebar nav, topbar, route container
|
||||||
|
|
||||||
// Sidebar IA: grouped sections. Renderer prints each section's label, then
|
const NAV_TREE = [
|
||||||
// its items. Items inside `children` of a `group:true` node still render as
|
{ id: "home", label: "Home", icon: "home" },
|
||||||
// the existing expandable submenu (only used for the Capture-SDK admin tools
|
{ id: "dashboard", label: "Dashboard", icon: "layout" },
|
||||||
// today, but kept general).
|
{ id: "library", label: "Library", icon: "library" },
|
||||||
const NAV_SECTIONS = [
|
{ id: "projects", label: "Projects", icon: "folder" },
|
||||||
{
|
{
|
||||||
label: "Workspace",
|
id: "ingest", label: "Ingest", icon: "upload", group: true,
|
||||||
items: [
|
children: [
|
||||||
{ id: "home", label: "Home", icon: "home" },
|
|
||||||
{ id: "dashboard", label: "Dashboard", icon: "layout" },
|
|
||||||
{ id: "projects", label: "Projects", icon: "folder" },
|
|
||||||
{ id: "library", label: "Library", icon: "library" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Ingest",
|
|
||||||
items: [
|
|
||||||
{ id: "upload", label: "Upload", icon: "upload" },
|
{ id: "upload", label: "Upload", icon: "upload" },
|
||||||
{ id: "youtube", label: "YouTube", icon: "download" },
|
{ id: "youtube", label: "YouTube", icon: "download" },
|
||||||
{ id: "recorders", label: "Recorders", icon: "record" },
|
{ id: "recorders", label: "Recorders", icon: "record" },
|
||||||
{ id: "schedule", label: "Schedule", icon: "clock" },
|
{ id: "schedule", label: "Schedule", icon: "jobs" },
|
||||||
|
{ id: "capture", label: "Capture", icon: "capture" },
|
||||||
{ id: "monitors", label: "Monitors", icon: "monitor" },
|
{ id: "monitors", label: "Monitors", icon: "monitor" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{ id: "jobs", label: "Jobs", icon: "jobs" },
|
||||||
label: "Operations",
|
{ id: "editor", label: "Editor", icon: "editor" },
|
||||||
items: [
|
|
||||||
{ id: "capture", label: "Capture", icon: "capture" },
|
|
||||||
{ id: "playout", label: "Playout", icon: "signal" },
|
|
||||||
{ id: "jobs", label: "Jobs", icon: "jobs" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Admin",
|
|
||||||
items: [
|
|
||||||
{ id: "users", label: "Users", icon: "users" },
|
|
||||||
{ id: "tokens", label: "Tokens", icon: "token" },
|
|
||||||
{ id: "billing", label: "Billing", icon: "dollar" },
|
|
||||||
{ id: "containers", label: "Containers", icon: "container" },
|
|
||||||
{ id: "cluster", label: "Cluster", icon: "cluster" },
|
|
||||||
{ id: "settings", label: "Settings", icon: "settings" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// No hidden routes currently; Billing (the satirical pricing page) lives in
|
const ADMIN_TREE = [
|
||||||
// the Admin section above. Real API token management is at /tokens.
|
{ id: "users", label: "Users", icon: "users" },
|
||||||
const NAV_HIDDEN = [];
|
{ id: "tokens", label: "Tokens", icon: "token" },
|
||||||
|
{ id: "containers", label: "Containers", icon: "container" },
|
||||||
|
{ id: "cluster", label: "Cluster", icon: "cluster" },
|
||||||
|
{ id: "settings", label: "Settings", icon: "settings" },
|
||||||
|
];
|
||||||
|
|
||||||
// Back-compat: NAV_TREE and ADMIN_TREE were used by other modules.
|
|
||||||
// NAV_FLAT is consumed by topbar search and the keyboard router.
|
|
||||||
const NAV_TREE = NAV_SECTIONS.slice(0, 3).flatMap(s => s.items);
|
|
||||||
const ADMIN_TREE = NAV_SECTIONS[3].items;
|
|
||||||
const NAV_FLAT = (() => {
|
const NAV_FLAT = (() => {
|
||||||
const out = [];
|
const out = [];
|
||||||
NAV_SECTIONS.forEach(s => s.items.forEach(n => out.push({ id: n.id, label: n.label, icon: n.icon })));
|
const visit = (arr) => arr.forEach(n => {
|
||||||
NAV_HIDDEN.forEach(n => out.push({ id: n.id, label: n.label, icon: n.icon }));
|
if (n.group && n.children) { visit(n.children); return; }
|
||||||
|
out.push({ id: n.id, label: n.label, icon: n.icon });
|
||||||
|
});
|
||||||
|
visit(NAV_TREE); visit(ADMIN_TREE);
|
||||||
return out;
|
return out;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
@ -102,11 +80,12 @@ function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup })
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
|
function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
|
||||||
const [openGroups, setOpenGroups] = React.useState(new Set([]));
|
const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"]));
|
||||||
const [jobsBadge, setJobsBadge] = React.useState(null);
|
const [jobsBadge, setJobsBadge] = React.useState(null);
|
||||||
|
const [captureBadge, setCaptureBadge] = React.useState(null);
|
||||||
|
|
||||||
// Live jobs count (#130): poll /jobs?status=active and render the result
|
// Live jobs count (#130) — poll /jobs/count for active jobs and render the
|
||||||
// as the sidebar badge on the Jobs item. Falls back to hidden on error.
|
// result as the sidebar badge. Falls back to hidden on error.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
|
|
@ -124,21 +103,43 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
|
||||||
return () => { cancelled = true; clearInterval(id); };
|
return () => { cancelled = true; clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// (Capture live-signal badge previously lived here; it now belongs in the
|
// Live DeckLink signal presence — poll every 5s, badge shows receiving port count.
|
||||||
// topbar status pip alongside the cluster pip. See issue #149.)
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const tick = () => {
|
||||||
|
window.ZAMPP_API.fetch('/cluster/devices/blackmagic/signal')
|
||||||
|
.then(entries => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const all = Array.isArray(entries) ? entries : [];
|
||||||
|
const live = all.filter(e => e.signal === 'receiving').length;
|
||||||
|
const total = all.length;
|
||||||
|
if (total === 0) { setCaptureBadge(null); return; }
|
||||||
|
setCaptureBadge(live > 0
|
||||||
|
? { kind: 'live', text: `${live}/${total}` }
|
||||||
|
: { kind: 'neutral', text: `0/${total}` });
|
||||||
|
})
|
||||||
|
.catch(() => setCaptureBadge(null));
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = setInterval(tick, 5000);
|
||||||
|
return () => { cancelled = true; clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Apply the live Jobs badge to the Operations section, and gate the Admin
|
// Apply live badges to nav items.
|
||||||
// section to admins only (RBAC v2). Non-admins never see Users/Cluster/etc.
|
const navTree = React.useMemo(
|
||||||
// This is UX only — the API enforces the same rules server-side.
|
() => NAV_TREE.map(n => {
|
||||||
const isAdmin = me?.role === 'admin';
|
if (n.id === 'jobs' && jobsBadge) return { ...n, badge: jobsBadge };
|
||||||
const sections = React.useMemo(
|
if (n.id === 'ingest' && n.children) {
|
||||||
() => NAV_SECTIONS
|
return {
|
||||||
.filter(sec => sec.label !== 'Admin' || isAdmin)
|
...n,
|
||||||
.map(sec => ({
|
children: n.children.map(c =>
|
||||||
...sec,
|
c.id === 'capture' && captureBadge ? { ...c, badge: captureBadge } : c
|
||||||
items: sec.items.map(n => (n.id === 'jobs' && jobsBadge) ? { ...n, badge: jobsBadge } : n),
|
),
|
||||||
})),
|
};
|
||||||
[jobsBadge, isAdmin]
|
}
|
||||||
|
return n;
|
||||||
|
}),
|
||||||
|
[jobsBadge, captureBadge]
|
||||||
);
|
);
|
||||||
const toggleGroup = (id) => {
|
const toggleGroup = (id) => {
|
||||||
setOpenGroups(prev => {
|
setOpenGroups(prev => {
|
||||||
|
|
@ -180,28 +181,34 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-scroll">
|
<div className="sidebar-scroll">
|
||||||
{sections.map((section, si) => (
|
{navTree.map(item => (
|
||||||
<React.Fragment key={section.label}>
|
<NavItem
|
||||||
{si > 0 && <div className="nav-section-label">{section.label}</div>}
|
key={item.id}
|
||||||
{section.items.map(item => (
|
item={item}
|
||||||
<NavItem
|
active={active}
|
||||||
key={item.id}
|
onSelect={onNavigate}
|
||||||
item={item}
|
openGroups={openGroups}
|
||||||
active={active}
|
toggleGroup={toggleGroup}
|
||||||
onSelect={onNavigate}
|
/>
|
||||||
openGroups={openGroups}
|
))}
|
||||||
toggleGroup={toggleGroup}
|
<div className="nav-section-label">Admin</div>
|
||||||
/>
|
{ADMIN_TREE.map(item => (
|
||||||
))}
|
<NavItem
|
||||||
</React.Fragment>
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
active={active}
|
||||||
|
onSelect={onNavigate}
|
||||||
|
openGroups={openGroups}
|
||||||
|
toggleGroup={toggleGroup}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
<div className="avatar">{me?.initials || '·'}</div>
|
<div className="avatar">{me?.initials || '—'}</div>
|
||||||
<div className="user-meta">
|
<div className="user-meta">
|
||||||
<div className="user-name">{me?.name || 'Not signed in'}</div>
|
<div className="user-name">{me?.name || 'Not signed in'}</div>
|
||||||
<div className="user-role" title={me?.synthetic ? 'AUTH_ENABLED=false: showing the OS user running the server' : ''}>
|
<div className="user-role" title={me?.synthetic ? 'AUTH_ENABLED=false — showing the OS user running the server' : ''}>
|
||||||
{me?.role || '·'}{me?.synthetic ? ' · auth off' : ''}
|
{me?.role || '—'}{me?.synthetic ? ' · auth off' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{me?.synthetic ? null : (
|
{me?.synthetic ? null : (
|
||||||
|
|
@ -258,7 +265,7 @@ function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) {
|
||||||
(D.ASSETS || []).forEach(a => {
|
(D.ASSETS || []).forEach(a => {
|
||||||
const hay = ((a.name || '') + ' ' + (a.project || '') + ' ' + (a.filename || '')).toLowerCase();
|
const hay = ((a.name || '') + ' ' + (a.project || '') + ' ' + (a.filename || '')).toLowerCase();
|
||||||
if (hay.includes(term)) {
|
if (hay.includes(term)) {
|
||||||
const sub = [a.project, a.res, a.duration].filter(x => x && x !== '·').join(' · ');
|
const sub = [a.project, a.res, a.duration].filter(x => x && x !== '—').join(' · ');
|
||||||
out.push({ kind: 'asset', icon: assetSearchIcon(a), label: a.name, sub, item: a });
|
out.push({ kind: 'asset', icon: assetSearchIcon(a), label: a.name, sub, item: a });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -392,7 +399,7 @@ function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject, onToggleSidebar }) {
|
function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject, onToggleSidebar }) {
|
||||||
// Light cluster ping - the badge in the topbar should reflect reality,
|
// Light cluster ping — the badge in the topbar should reflect reality,
|
||||||
// not just look reassuring. /metrics/home returns cluster online/total.
|
// not just look reassuring. /metrics/home returns cluster online/total.
|
||||||
const [clusterHealthy, setClusterHealthy] = React.useState(true);
|
const [clusterHealthy, setClusterHealthy] = React.useState(true);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -471,6 +478,5 @@ window.Sidebar = Sidebar;
|
||||||
window.Topbar = Topbar;
|
window.Topbar = Topbar;
|
||||||
window.NAV_TREE = NAV_TREE;
|
window.NAV_TREE = NAV_TREE;
|
||||||
window.NAV_FLAT = NAV_FLAT;
|
window.NAV_FLAT = NAV_FLAT;
|
||||||
window.NAV_SECTIONS = NAV_SECTIONS;
|
|
||||||
window.Field = Field;
|
window.Field = Field;
|
||||||
window.GlobalSearch = GlobalSearch;
|
window.GlobalSearch = GlobalSearch;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/* ========== Asset detail ========== */
|
/* ========== Asset detail ========== */
|
||||||
.asset-detail {
|
.asset-detail {
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
flex: 1; /* parent is `.main` (flex col) - fill remaining vertical space */
|
flex: 1; /* parent is `.main` (flex col) — fill remaining vertical space */
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
background: var(--bg-0);
|
background: var(--bg-0);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -60,11 +60,13 @@
|
||||||
background: rgba(0,0,0,0.55);
|
background: rgba(0,0,0,0.55);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
.player-tc {
|
.player-tc {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 12px; bottom: 12px;
|
right: 12px; bottom: 12px;
|
||||||
background: rgba(0,0,0,0.6);
|
background: rgba(0,0,0,0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@
|
||||||
.activity-text .target { word-break: break-word; }
|
.activity-text .target { word-break: break-word; }
|
||||||
|
|
||||||
.asset-card .meta .sub { overflow: hidden; min-width: 0; flex-wrap: wrap; }
|
.asset-card .meta .sub { overflow: hidden; min-width: 0; flex-wrap: wrap; }
|
||||||
/* #52 - duration mono badge in the meta row had no shrink behaviour, so on
|
/* #52 — duration mono badge in the meta row had no shrink behaviour, so on
|
||||||
narrow cards it overlapped the project text. Force the duration column to
|
narrow cards it overlapped the project text. Force the duration column to
|
||||||
never overflow and let the project label ellipsize. */
|
never overflow and let the project label ellipsize. */
|
||||||
.asset-card .meta .sub > .duration { flex-shrink: 0; margin-left: auto; }
|
.asset-card .meta .sub > .duration { flex-shrink: 0; margin-left: auto; }
|
||||||
|
|
@ -76,7 +76,7 @@
|
||||||
.dash-sparkline { z-index: 0; }
|
.dash-sparkline { z-index: 0; }
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Search bar polish - give it a real container so it doesn't
|
Search bar polish — give it a real container so it doesn't
|
||||||
read as floating text on the topbar background.
|
read as floating text on the topbar background.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.topbar .search,
|
.topbar .search,
|
||||||
|
|
@ -123,7 +123,7 @@
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Library-local "Filter assets" search - same container treatment,
|
/* Library-local "Filter assets" search — same container treatment,
|
||||||
keep its compact width. */
|
keep its compact width. */
|
||||||
.library-toolbar .search {
|
.library-toolbar .search {
|
||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
|
|
@ -165,7 +165,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Right-click context menu - pop it forward off the page so it
|
Right-click context menu — pop it forward off the page so it
|
||||||
reads as a menu, not a floating list.
|
reads as a menu, not a floating list.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.ctx-menu {
|
.ctx-menu {
|
||||||
|
|
@ -225,7 +225,7 @@
|
||||||
}
|
}
|
||||||
.ctx-menu button.danger:hover:not(:disabled) svg { color: var(--danger); }
|
.ctx-menu button.danger:hover:not(:disabled) svg { color: var(--danger); }
|
||||||
|
|
||||||
/* Row-popover menu (Users page etc.) - match the same polish so the
|
/* Row-popover menu (Users page etc.) — match the same polish so the
|
||||||
app feels consistent. */
|
app feels consistent. */
|
||||||
.row-menu {
|
.row-menu {
|
||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
|
|
@ -240,7 +240,7 @@
|
||||||
.row-menu button.danger:hover { background: var(--danger-soft); color: var(--danger); }
|
.row-menu button.danger:hover { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Sidebar brand logo - replace the gradient "D" tile with the
|
Sidebar brand logo — replace the gradient "D" tile with the
|
||||||
actual dragon-coiled-D logo. mix-blend-mode: screen drops the
|
actual dragon-coiled-D logo. mix-blend-mode: screen drops the
|
||||||
light-gray PNG background so only the black silhouette + blue
|
light-gray PNG background so only the black silhouette + blue
|
||||||
flame remain over the dark sidebar.
|
flame remain over the dark sidebar.
|
||||||
|
|
@ -260,7 +260,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Launcher home - full-bleed landing page with the logo as hero
|
Launcher home — full-bleed landing page with the logo as hero
|
||||||
and big section tiles.
|
and big section tiles.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.launcher {
|
.launcher {
|
||||||
|
|
@ -297,7 +297,7 @@
|
||||||
width: 180px;
|
width: 180px;
|
||||||
height: 180px;
|
height: 180px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
/* Convert to white - same approach as .brand-logo. */
|
/* Convert to white — same approach as .brand-logo. */
|
||||||
filter:
|
filter:
|
||||||
brightness(0) invert(1)
|
brightness(0) invert(1)
|
||||||
drop-shadow(0 0 24px rgba(91, 124, 250, 0.28))
|
drop-shadow(0 0 24px rgba(91, 124, 250, 0.28))
|
||||||
|
|
@ -324,14 +324,6 @@
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.launcher-tagline-motto {
|
|
||||||
margin-top: 6px;
|
|
||||||
color: var(--accent);
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 15px;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.launcher-grid {
|
.launcher-grid {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -443,7 +435,7 @@
|
||||||
color: var(--tile-icon-fg, var(--accent-text));
|
color: var(--tile-icon-fg, var(--accent-text));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tone variants - colour the icon tile + halo, leave the body text
|
/* Tone variants — colour the icon tile + halo, leave the body text
|
||||||
neutral so the tile reads as a button, not a banner. */
|
neutral so the tile reads as a button, not a banner. */
|
||||||
.launcher-tile.tone-accent {
|
.launcher-tile.tone-accent {
|
||||||
--tile-tint: rgba(91, 124, 250, 0.18);
|
--tile-tint: rgba(91, 124, 250, 0.18);
|
||||||
|
|
@ -523,7 +515,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Recorder row - signal indicator with a pulsing dot when
|
Recorder row — signal indicator with a pulsing dot when
|
||||||
actually receiving frames. Closes part of #2.
|
actually receiving frames. Closes part of #2.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.signal-val {
|
.signal-val {
|
||||||
|
|
@ -547,7 +539,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
BMD card diagram - rendered inside the Cluster node panel.
|
BMD card diagram — rendered inside the Cluster node panel.
|
||||||
The SVG is generated by bmd-card.js; styles live here so
|
The SVG is generated by bmd-card.js; styles live here so
|
||||||
they inherit the app CSS custom properties at render time.
|
they inherit the app CSS custom properties at render time.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
@ -686,84 +678,3 @@
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Resource utilization cards (screens-resources.jsx) ── */
|
|
||||||
.res-nodes-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.res-node-card {
|
|
||||||
padding: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.res-node-name {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-1);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
.res-node-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--success);
|
|
||||||
box-shadow: 0 0 0 3px var(--success-soft);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.res-metric {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.res-metric-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-3);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.res-metric-sub {
|
|
||||||
color: var(--text-4);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
.res-bar-wrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.res-bar {
|
|
||||||
flex: 1;
|
|
||||||
height: 6px;
|
|
||||||
background: var(--bg-4);
|
|
||||||
border-radius: 99px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.res-bar-fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 99px;
|
|
||||||
transition: width 0.6s ease;
|
|
||||||
}
|
|
||||||
.res-bar-pct {
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
color: var(--text-3);
|
|
||||||
min-width: 32px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.res-mock-note {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--warning);
|
|
||||||
background: var(--warning-soft);
|
|
||||||
border-radius: var(--r-sm);
|
|
||||||
padding: 6px 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
/* Playout / Master Control (MCR) page styles. */
|
|
||||||
|
|
||||||
.po-page { display: flex; flex-direction: column; gap: 14px; }
|
|
||||||
|
|
||||||
/* Channel tab bar */
|
|
||||||
.po-channels-bar {
|
|
||||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
|
||||||
padding-bottom: 10px; border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.po-chan-tab {
|
|
||||||
display: inline-flex; align-items: center; gap: 7px;
|
|
||||||
padding: 6px 12px; border-radius: 8px;
|
|
||||||
background: var(--bg-2); border: 1px solid var(--border);
|
|
||||||
color: var(--text-2); font-size: 13px; cursor: pointer;
|
|
||||||
}
|
|
||||||
.po-chan-tab:hover { background: var(--bg-3); color: var(--text-1); }
|
|
||||||
.po-chan-tab.active { background: var(--accent-soft); color: var(--accent-text); border-color: var(--accent-soft-2); }
|
|
||||||
.po-chan-dot {
|
|
||||||
width: 8px; height: 8px; border-radius: 50%;
|
|
||||||
background: var(--text-3);
|
|
||||||
}
|
|
||||||
.po-chan-dot.live { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); }
|
|
||||||
|
|
||||||
.po-empty { text-align: center; padding: 48px 0; display: flex; flex-direction: column; gap: 12px; align-items: center; }
|
|
||||||
|
|
||||||
/* Channel detail */
|
|
||||||
.po-detail { display: flex; flex-direction: column; gap: 14px; }
|
|
||||||
.po-detail-head { display: flex; justify-content: space-between; align-items: flex-start; }
|
|
||||||
.po-detail-actions { display: flex; gap: 8px; }
|
|
||||||
|
|
||||||
.po-grid {
|
|
||||||
display: grid; grid-template-columns: 1.4fr 1fr; gap: 14px;
|
|
||||||
}
|
|
||||||
@media (max-width: 900px) { .po-grid { grid-template-columns: 1fr; } }
|
|
||||||
|
|
||||||
.po-section-label {
|
|
||||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
|
|
||||||
color: var(--text-3); font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Program monitor */
|
|
||||||
.po-monitor {
|
|
||||||
background: var(--bg-1); border: 1px solid var(--border); border-radius: 12px;
|
|
||||||
display: flex; flex-direction: column; overflow: hidden;
|
|
||||||
}
|
|
||||||
.po-monitor-head {
|
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
|
||||||
padding: 10px 12px; border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.po-onair { font-size: 12px; font-weight: 700; color: var(--text-3); letter-spacing: 0.04em; }
|
|
||||||
.po-onair.live { color: var(--danger); }
|
|
||||||
.po-monitor-screen {
|
|
||||||
flex: 1; min-height: 220px; background: #000;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
color: var(--text-2);
|
|
||||||
}
|
|
||||||
.po-monitor-clip { font-family: var(--font-mono); font-size: 14px; color: var(--text-1); }
|
|
||||||
.po-monitor-foot { padding: 8px 12px; border-top: 1px solid var(--border); font-size: 11px; }
|
|
||||||
|
|
||||||
/* Media bin */
|
|
||||||
.po-bin {
|
|
||||||
display: flex; flex-direction: column; min-height: 260px; max-height: 360px;
|
|
||||||
border-radius: 12px; overflow: hidden;
|
|
||||||
}
|
|
||||||
.po-bin-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
|
||||||
.po-bin-list { overflow-y: auto; flex: 1; }
|
|
||||||
.po-bin-item {
|
|
||||||
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
|
||||||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
|
||||||
cursor: grab; user-select: none;
|
|
||||||
}
|
|
||||||
.po-bin-item:hover { background: var(--bg-3); }
|
|
||||||
.po-bin-item:active { cursor: grabbing; }
|
|
||||||
.po-bin-name { font-size: 13px; color: var(--text-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
|
|
||||||
/* Transport */
|
|
||||||
.po-transport {
|
|
||||||
display: flex; gap: 8px; flex-wrap: wrap;
|
|
||||||
padding: 12px; background: var(--bg-1); border: 1px solid var(--border); border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Playlist */
|
|
||||||
.po-playlist {
|
|
||||||
border-radius: 12px; overflow: hidden;
|
|
||||||
min-height: 120px;
|
|
||||||
}
|
|
||||||
.po-playlist-empty { padding: 28px 12px; text-align: center; }
|
|
||||||
.po-pl-item {
|
|
||||||
display: flex; align-items: center; gap: 10px;
|
|
||||||
padding: 9px 12px; border-bottom: 1px solid var(--border);
|
|
||||||
cursor: grab; user-select: none;
|
|
||||||
}
|
|
||||||
.po-pl-item:hover { background: var(--bg-3); }
|
|
||||||
.po-pl-item:active { cursor: grabbing; }
|
|
||||||
.po-pl-index {
|
|
||||||
width: 22px; text-align: center; font-family: var(--font-mono);
|
|
||||||
font-size: 12px; color: var(--text-3);
|
|
||||||
}
|
|
||||||
.po-pl-name { flex: 1; font-size: 13px; color: var(--text-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
|
|
||||||
/* Small button variants reused */
|
|
||||||
.btn.xs { padding: 2px 8px; font-size: 11px; }
|
|
||||||
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
|
||||||
.field-input.sm { padding: 5px 8px; font-size: 12px; }
|
|
||||||
|
|
@ -361,7 +361,7 @@
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.monitor-tile.audio { background: var(--bg-1); }
|
.monitor-tile.audio { background: linear-gradient(135deg, hsl(180 30% 12%), hsl(200 25% 6%)); }
|
||||||
.monitor-tile-label {
|
.monitor-tile-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0; left: 0; right: 0;
|
bottom: 0; left: 0; right: 0;
|
||||||
|
|
@ -391,7 +391,7 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
/* status · Job · Asset · Node · Progress · Time · Priority · Actions
|
/* status · Job · Asset · Node · Progress · Time · Priority · Actions
|
||||||
Time needs room for "done May 22 · 2:23 PM · 6h ago"; Progress hosts
|
Time needs room for "done May 22 · 2:23 PM · 6h ago"; Progress hosts
|
||||||
the bar + percent; Node is just "primary" or "-" so it can be tight. */
|
the bar + percent; Node is just "primary" or "—" so it can be tight. */
|
||||||
grid-template-columns: 20px 110px 1fr 60px 240px 240px 70px 90px;
|
grid-template-columns: 20px 110px 1fr 60px 240px 240px 70px 90px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
@ -420,7 +420,7 @@
|
||||||
}
|
}
|
||||||
.job-progress-fill {
|
.job-progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--accent);
|
background: linear-gradient(90deg, var(--accent), #7C9EFF);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 2s linear infinite;
|
animation: shimmer 2s linear infinite;
|
||||||
transition: width 300ms;
|
transition: width 300ms;
|
||||||
|
|
@ -429,7 +429,7 @@
|
||||||
/* ========== Editor ========== */
|
/* ========== Editor ========== */
|
||||||
.editor-shell {
|
.editor-shell {
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
flex: 1; /* parent is `.main` (flex col) - fill remaining vertical space */
|
flex: 1; /* parent is `.main` (flex col) — fill remaining vertical space */
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
background: var(--bg-0);
|
background: var(--bg-0);
|
||||||
}
|
}
|
||||||
|
|
@ -627,7 +627,7 @@
|
||||||
so the gutter (left) and ruler (top) stay sticky during scroll. */
|
so the gutter (left) and ruler (top) stay sticky during scroll. */
|
||||||
.epg-page {
|
.epg-page {
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
flex: 1; /* parent is `.main` (flex col) - fill remaining vertical space (#132) */
|
flex: 1; /* parent is `.main` (flex col) — fill remaining vertical space (#132) */
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
--epg-pph: 88px; /* pixels per hour, overridden inline per view */
|
--epg-pph: 88px; /* pixels per hour, overridden inline per view */
|
||||||
--epg-row-h: 60px;
|
--epg-row-h: 60px;
|
||||||
|
|
@ -786,7 +786,7 @@
|
||||||
grid-row: 2; grid-column: 2;
|
grid-row: 2; grid-column: 2;
|
||||||
position: relative;
|
position: relative;
|
||||||
background:
|
background:
|
||||||
/* hour-band rhythm - alternating subtle stripe every other hour */
|
/* hour-band rhythm — alternating subtle stripe every other hour */
|
||||||
repeating-linear-gradient(
|
repeating-linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
transparent 0,
|
transparent 0,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: white;
|
color: white;
|
||||||
background: rgba(0,0,0,0.7);
|
background: rgba(0,0,0,0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
|
@ -43,7 +44,7 @@
|
||||||
display: flex; gap: 4px;
|
display: flex; gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hi-res download button - top-right corner of an asset thumbnail.
|
/* Hi-res download button — top-right corner of an asset thumbnail.
|
||||||
Hidden by default, revealed on card hover or button focus. Avoids
|
Hidden by default, revealed on card hover or button focus. Avoids
|
||||||
crowding the resting-state thumb (issue #145). */
|
crowding the resting-state thumb (issue #145). */
|
||||||
.thumb-download-btn {
|
.thumb-download-btn {
|
||||||
|
|
@ -60,6 +61,7 @@
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
transition: opacity 80ms ease-out, transform 120ms ease-out, background 80ms;
|
transition: opacity 80ms ease-out, transform 120ms ease-out, background 80ms;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
.asset-card:hover .thumb-download-btn,
|
.asset-card:hover .thumb-download-btn,
|
||||||
.thumb-download-btn:focus-visible {
|
.thumb-download-btn:focus-visible {
|
||||||
|
|
@ -136,7 +138,7 @@
|
||||||
50% { opacity: 0.6; }
|
50% { opacity: 0.6; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== Dashboard stats - dense row, not hero-metric cards ========== */
|
/* ========== Dashboard stats — dense row, not hero-metric cards ========== */
|
||||||
.dash-stat-row {
|
.dash-stat-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
|
@ -203,7 +205,7 @@
|
||||||
}
|
}
|
||||||
.dash-stat-sub.up { color: var(--success); }
|
.dash-stat-sub.up { color: var(--success); }
|
||||||
|
|
||||||
/* Sparkline sits in its own row at the bottom - no absolute positioning */
|
/* Sparkline sits in its own row at the bottom — no absolute positioning */
|
||||||
.dash-sparkline {
|
.dash-sparkline {
|
||||||
height: 24px;
|
height: 24px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
|
@ -248,6 +250,7 @@
|
||||||
background: rgba(0,0,0,0.6);
|
background: rgba(0,0,0,0.6);
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
@ -270,6 +273,7 @@
|
||||||
background: rgba(0,0,0,0.6);
|
background: rgba(0,0,0,0.6);
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
.live-feed-tile-badge {
|
.live-feed-tile-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -289,6 +293,7 @@
|
||||||
background: rgba(0,0,0,0.5);
|
background: rgba(0,0,0,0.5);
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
.live-feed-project-dot {
|
.live-feed-project-dot {
|
||||||
width: 6px; height: 6px;
|
width: 6px; height: 6px;
|
||||||
|
|
@ -613,6 +618,7 @@
|
||||||
background: rgba(0,0,0,0.65);
|
background: rgba(0,0,0,0.65);
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
.dash-onair-meta {
|
.dash-onair-meta {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
|
|
@ -1079,8 +1085,6 @@
|
||||||
.library-toolbar .toolbar-title {
|
.library-toolbar .toolbar-title {
|
||||||
font-size: 14px; font-weight: 600;
|
font-size: 14px; font-weight: 600;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
margin: 0;
|
|
||||||
line-height: inherit;
|
|
||||||
}
|
}
|
||||||
.library-toolbar .count { color: var(--text-3); font-size: 12.5px; }
|
.library-toolbar .count { color: var(--text-3); font-size: 12.5px; }
|
||||||
|
|
||||||
|
|
@ -1199,180 +1203,3 @@
|
||||||
}
|
}
|
||||||
.list-row .name { font-weight: 500; }
|
.list-row .name { font-weight: 500; }
|
||||||
.list-row .col-sub { color: var(--text-3); font-family: var(--font-mono); font-size: 11.5px; }
|
.list-row .col-sub { color: var(--text-3); font-family: var(--font-mono); font-size: 11.5px; }
|
||||||
|
|
||||||
/* Editor beta banner: flat strip on top of editor, replacing the old
|
|
||||||
glassmorphism + gradient + glow bumper. No blur, no gradients, no glow. */
|
|
||||||
.editor-beta-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: var(--accent-soft);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
color: var(--text-2);
|
|
||||||
font-size: 12.5px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.editor-beta-banner > svg { color: var(--accent-text); flex-shrink: 0; }
|
|
||||||
.editor-beta-banner-body { flex: 1; min-width: 0; }
|
|
||||||
.editor-beta-banner-body strong { color: var(--text-1); font-weight: 600; margin-right: 4px; }
|
|
||||||
.editor-beta-banner-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
|
||||||
.editor-beta-banner-actions a { text-decoration: none; }
|
|
||||||
.editor-beta-banner-version { font-size: 11px; color: var(--text-3); padding-left: 4px; }
|
|
||||||
|
|
||||||
/* Home activity strip (issue #153). Sits below the launcher grid and shows
|
|
||||||
real activity: live recorders, last-24h assets, attention alerts. */
|
|
||||||
.launcher-activity {
|
|
||||||
margin-top: 28px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
max-width: 880px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.launcher-activity-strip.alert {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(255,91,91,0.08);
|
|
||||||
border: 1px solid var(--danger);
|
|
||||||
color: var(--text-2);
|
|
||||||
font-size: 12.5px;
|
|
||||||
}
|
|
||||||
.launcher-activity-strip.alert > svg { color: var(--danger); flex-shrink: 0; }
|
|
||||||
.launcher-activity-strip.alert strong { color: var(--text-1); font-weight: 600; }
|
|
||||||
.launcher-activity-strip.alert > span { flex: 1; }
|
|
||||||
.launcher-activity-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.launcher-activity-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 10.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-3);
|
|
||||||
}
|
|
||||||
.launcher-activity-head .muted { color: var(--text-4); font-weight: 500; letter-spacing: 0; text-transform: none; font-size: 11px; }
|
|
||||||
.launcher-activity-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.launcher-activity-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
background: var(--bg-1);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12.5px;
|
|
||||||
color: var(--text-2);
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 80ms, border-color 80ms;
|
|
||||||
}
|
|
||||||
.launcher-activity-item:hover { background: var(--bg-2); border-color: var(--border-strong); }
|
|
||||||
.launcher-activity-item > svg { color: var(--text-3); flex-shrink: 0; }
|
|
||||||
.launcher-activity-item-name {
|
|
||||||
flex: 1; min-width: 0;
|
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
||||||
color: var(--text-1); font-weight: 500;
|
|
||||||
}
|
|
||||||
.launcher-activity-item-meta {
|
|
||||||
font-size: 10.5px; color: var(--text-3);
|
|
||||||
text-transform: uppercase; letter-spacing: 0.04em;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Jobs screen: classes extracted from per-row inline styles (issue #148).
|
|
||||||
Cuts 487 rendered inline styles to roughly zero. */
|
|
||||||
.jobs-tabs { margin-top: 20px; width: fit-content; }
|
|
||||||
.jobs-panel { margin-top: 12px; }
|
|
||||||
.jobs-empty { padding: 24px; text-align: center; color: var(--text-3); }
|
|
||||||
|
|
||||||
.stat-card .delta.delta-warn { color: var(--warning); }
|
|
||||||
.stat-card .delta.delta-tiny { font-size: 10.5px; }
|
|
||||||
|
|
||||||
.job-row .job-row-kind { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.job-row .job-row-kind-icon { color: var(--text-3); }
|
|
||||||
.job-row .job-row-kind-name { font-weight: 500; }
|
|
||||||
.job-row .job-row-asset {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--text-2);
|
|
||||||
}
|
|
||||||
.job-row .job-row-node { font-size: 11.5px; color: var(--text-3); }
|
|
||||||
.job-row .job-row-progress-pct {
|
|
||||||
font-size: 10.5px;
|
|
||||||
color: var(--text-3);
|
|
||||||
min-width: 32px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.job-row .job-row-status-done { background: transparent; padding: 0; }
|
|
||||||
.job-row .job-row-status-queued { font-size: 12px; color: var(--text-3); }
|
|
||||||
.job-row .job-row-status-failed {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--danger);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.job-row .job-row-status-failed-msg {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.job-row .job-row-time {
|
|
||||||
font-size: 11.5px;
|
|
||||||
color: var(--text-3);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.job-row .job-row-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
.job-row .job-row-cancel { color: var(--danger); }
|
|
||||||
|
|
||||||
/* Premiere panel download modal (rows for each released version) */
|
|
||||||
.premiere-release {
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 12px 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.premiere-release:last-child { border-bottom: 0; }
|
|
||||||
.premiere-release-head {
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
}
|
|
||||||
.premiere-release-version {
|
|
||||||
font-size: 13px; font-weight: 600; color: var(--text-1);
|
|
||||||
}
|
|
||||||
.premiere-release-date {
|
|
||||||
font-size: 11px; color: var(--text-3);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
.premiere-release-notes {
|
|
||||||
font-size: 12px; color: var(--text-2); line-height: 1.5;
|
|
||||||
}
|
|
||||||
.premiere-release-actions {
|
|
||||||
display: flex; gap: 8px; margin-top: 4px;
|
|
||||||
}
|
|
||||||
.premiere-release-actions a { text-decoration: none; }
|
|
||||||
|
|
||||||
/* Tint Cancel-all-failed button to signal destructive action without
|
|
||||||
making it loud — same pattern as the per-row Cancel. */
|
|
||||||
.jobs-cancel-all { color: var(--danger); }
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
/* text */
|
/* text */
|
||||||
--text-1: #F2F3F6;
|
--text-1: #F2F3F6;
|
||||||
--text-2: #A8AEBC;
|
--text-2: #A8AEBC;
|
||||||
--text-3: #8B92A0; /* WCAG AA (#133) - was #6B7280, 4.06:1 vs --bg-0; now ~7.5:1 */
|
--text-3: #8B92A0; /* WCAG AA (#133) — was #6B7280, 4.06:1 vs --bg-0; now ~7.5:1 */
|
||||||
--text-4: #6B7280;
|
--text-4: #6B7280;
|
||||||
|
|
||||||
/* accent (blue, frame.io-ish) */
|
/* accent (blue, frame.io-ish) */
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
# yt-dlp powers the YouTube importer; python3 is its runtime dep.
|
# yt-dlp powers the YouTube importer; python3 is its runtime dep.
|
||||||
# Not from apk: the packaged yt-dlp goes stale and YouTube breaks old versions.
|
RUN apk add --no-cache ffmpeg yt-dlp python3
|
||||||
# Pull the latest python-zipapp release (runs on musl via system python3) so a
|
|
||||||
# rebuild always refreshes it. /usr/local/bin precedes /usr/bin on PATH.
|
|
||||||
RUN apk add --no-cache ffmpeg python3 curl \
|
|
||||||
&& curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp \
|
|
||||||
-o /usr/local/bin/yt-dlp \
|
|
||||||
&& chmod a+rx /usr/local/bin/yt-dlp
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
|
||||||
|
|
@ -9,20 +9,11 @@
|
||||||
FROM nvcr.io/nvidia/cuda:12.3.1-base-ubuntu22.04
|
FROM nvcr.io/nvidia/cuda:12.3.1-base-ubuntu22.04
|
||||||
|
|
||||||
# Install Node.js 20, ffmpeg (Ubuntu's ffmpeg includes h264_nvenc/hevc_nvenc),
|
# Install Node.js 20, ffmpeg (Ubuntu's ffmpeg includes h264_nvenc/hevc_nvenc),
|
||||||
# and yt-dlp for the YouTube importer.
|
# and yt-dlp (+ python3 runtime) for the YouTube importer.
|
||||||
#
|
|
||||||
# yt-dlp is NOT installed from apt: Ubuntu 22.04's package is pinned to a 2022
|
|
||||||
# release, which YouTube has long since broken (extraction fails). yt-dlp must
|
|
||||||
# track YouTube's frequent changes, so we pull the latest self-contained
|
|
||||||
# release binary at build time. /usr/local/bin precedes /usr/bin on PATH, so
|
|
||||||
# `yt-dlp` resolves to this one. Rebuild the worker image to refresh it.
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl ca-certificates ffmpeg python3 \
|
curl ca-certificates ffmpeg yt-dlp python3 \
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
&& curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux \
|
|
||||||
-o /usr/local/bin/yt-dlp \
|
|
||||||
&& chmod a+rx /usr/local/bin/yt-dlp \
|
|
||||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ import { filmstripWorker } from './workers/filmstrip.js';
|
||||||
import { conformWorker } from './workers/conform.js';
|
import { conformWorker } from './workers/conform.js';
|
||||||
import { youtubeImportWorker, proxyQueue as youtubeProxyQueue } from './workers/youtube-import.js';
|
import { youtubeImportWorker, proxyQueue as youtubeProxyQueue } from './workers/youtube-import.js';
|
||||||
import { trimWorker } from './workers/trimWorker.js';
|
import { trimWorker } from './workers/trimWorker.js';
|
||||||
import { hlsWorker } from './workers/hls.js';
|
|
||||||
import { playoutStageWorker } from './workers/playout-stage.js';
|
|
||||||
import { startPromotionWorker } from './workers/promotion.js';
|
import { startPromotionWorker } from './workers/promotion.js';
|
||||||
|
|
||||||
const parseRedisUrl = (url) => {
|
const parseRedisUrl = (url) => {
|
||||||
|
|
@ -21,17 +19,8 @@ const parseRedisUrl = (url) => {
|
||||||
|
|
||||||
const redisOptions = parseRedisUrl(process.env.REDIS_URL || 'redis://localhost:6379');
|
const redisOptions = parseRedisUrl(process.env.REDIS_URL || 'redis://localhost:6379');
|
||||||
|
|
||||||
// Human-readable node/GPU label stamped onto each job so the Jobs UI can show
|
|
||||||
// which worker ran it (UI reads metadata.node). Set per container via env.
|
|
||||||
const WORKER_LABEL = process.env.WORKER_LABEL || process.env.HOSTNAME || 'worker';
|
|
||||||
|
|
||||||
const createWorker = (queueName, handler, overrides = {}) => {
|
const createWorker = (queueName, handler, overrides = {}) => {
|
||||||
// Stamp node attribution into the job data on pickup (persists to Redis).
|
const worker = new Worker(queueName, handler, {
|
||||||
const wrapped = async (job) => {
|
|
||||||
try { await job.updateData({ ...job.data, node: WORKER_LABEL }); } catch (_) {}
|
|
||||||
return handler(job);
|
|
||||||
};
|
|
||||||
const worker = new Worker(queueName, wrapped, {
|
|
||||||
connection: redisOptions,
|
connection: redisOptions,
|
||||||
// Stall detection: if a worker dies mid-job, BullMQ moves it back to wait
|
// Stall detection: if a worker dies mid-job, BullMQ moves it back to wait
|
||||||
// after stalledInterval. Without this a crashed run sits in active forever.
|
// after stalledInterval. Without this a crashed run sits in active forever.
|
||||||
|
|
@ -73,33 +62,18 @@ const FILMSTRIP_CONCURRENCY = parseInt(process.env.FILMSTRIP_CONCURRENCY || '2
|
||||||
const CONFORM_CONCURRENCY = parseInt(process.env.CONFORM_CONCURRENCY || '1', 10);
|
const CONFORM_CONCURRENCY = parseInt(process.env.CONFORM_CONCURRENCY || '1', 10);
|
||||||
const TRIM_CONCURRENCY = parseInt(process.env.TRIM_CONCURRENCY || '4', 10);
|
const TRIM_CONCURRENCY = parseInt(process.env.TRIM_CONCURRENCY || '4', 10);
|
||||||
|
|
||||||
// Capability routing: a worker only subscribes to the queues named in
|
|
||||||
// WORKER_QUEUES (comma-separated). Unset = all queues (back-compat). This lets
|
|
||||||
// us pin heavy encodes (proxy/conform/trim) to strong GPUs and light jobs
|
|
||||||
// (thumbnail/filmstrip) to weak ones, each container bound to one GPU.
|
|
||||||
const _wq = (process.env.WORKER_QUEUES || '').trim();
|
|
||||||
const _enabled = _wq ? new Set(_wq.split(',').map(x => x.trim()).filter(Boolean)) : null;
|
|
||||||
const want = (q) => !_enabled || _enabled.has(q);
|
|
||||||
|
|
||||||
const workers = [
|
const workers = [
|
||||||
want('proxy') && createWorker('proxy', proxyWorker, { concurrency: PROXY_CONCURRENCY }),
|
createWorker('proxy', proxyWorker, { concurrency: PROXY_CONCURRENCY }),
|
||||||
want('thumbnail') && createWorker('thumbnail', thumbnailWorker, { concurrency: THUMBNAIL_CONCURRENCY }),
|
createWorker('thumbnail', thumbnailWorker, { concurrency: THUMBNAIL_CONCURRENCY }),
|
||||||
want('filmstrip') && createWorker('filmstrip', filmstripWorker, { concurrency: FILMSTRIP_CONCURRENCY }),
|
createWorker('filmstrip', filmstripWorker, { concurrency: FILMSTRIP_CONCURRENCY }),
|
||||||
want('conform') && createWorker('conform', conformWorker, { concurrency: CONFORM_CONCURRENCY }),
|
createWorker('conform', conformWorker, { concurrency: CONFORM_CONCURRENCY }),
|
||||||
want('trim') && createWorker('trim', trimWorker, { concurrency: TRIM_CONCURRENCY }),
|
createWorker('trim', trimWorker, { concurrency: TRIM_CONCURRENCY }),
|
||||||
// HLS backfill remux is a light stream-copy. Run it wherever proxy runs so
|
createWorker('import', youtubeImportWorker, {
|
||||||
// existing proxy nodes pick up reprocess?type=hls jobs without an env change.
|
|
||||||
(want('proxy') || want('hls')) && createWorker('hls', hlsWorker, { concurrency: 2 }),
|
|
||||||
want('import') && createWorker('import', youtubeImportWorker, {
|
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
lockDuration: 10 * 60 * 1000,
|
lockDuration: 10 * 60 * 1000,
|
||||||
lockRenewTime: 60000,
|
lockRenewTime: 60000,
|
||||||
}),
|
}),
|
||||||
// playout-stage = S3 → /media volume + EBU R128 loudnorm. CPU/IO-bound;
|
];
|
||||||
// colocate with workers that already have ffmpeg + the media mount.
|
|
||||||
want('playout-stage') && createWorker('playout-stage', playoutStageWorker, { concurrency: 1 }),
|
|
||||||
].filter(Boolean);
|
|
||||||
console.log(`WORKER_QUEUES=${_wq || '(all)'}`);
|
|
||||||
|
|
||||||
// Filmstrip queue singleton — used by thumbnail worker to enqueue filmstrip jobs
|
// Filmstrip queue singleton — used by thumbnail worker to enqueue filmstrip jobs
|
||||||
export const filmstripQueue = new Queue('filmstrip', { connection: redisOptions });
|
export const filmstripQueue = new Queue('filmstrip', { connection: redisOptions });
|
||||||
|
|
@ -108,11 +82,7 @@ console.log(`Concurrency: proxy=${PROXY_CONCURRENCY} thumbnail=${THUMBNAIL_CONCU
|
||||||
|
|
||||||
// BUG FIX #4: startPromotionWorker() now returns a shutdown function that
|
// BUG FIX #4: startPromotionWorker() now returns a shutdown function that
|
||||||
// clears the poll intervals and closes the promotion proxyQueue singleton.
|
// clears the poll intervals and closes the promotion proxyQueue singleton.
|
||||||
// Promotion (growing-files -> S3) is a polling SCAN, not a queue consumer.
|
const stopPromotionWorker = startPromotionWorker();
|
||||||
// With multiple worker containers it must run on exactly one, or every node
|
|
||||||
// races the same files. Gate behind RUN_PROMOTION (set true on a single worker).
|
|
||||||
const stopPromotionWorker = (process.env.RUN_PROMOTION === 'true') ? startPromotionWorker() : null;
|
|
||||||
if (process.env.RUN_PROMOTION === 'true') console.log('[promotion] scanner ENABLED on this worker');
|
|
||||||
|
|
||||||
console.log('Wild Dragon Worker Service started');
|
console.log('Wild Dragon Worker Service started');
|
||||||
console.log(`Redis: ${redisOptions.host}:${redisOptions.port}`);
|
console.log(`Redis: ${redisOptions.host}:${redisOptions.port}`);
|
||||||
|
|
@ -137,7 +107,7 @@ process.on('SIGTERM', async () => {
|
||||||
youtubeProxyQueue.close().catch(() => {}),
|
youtubeProxyQueue.close().catch(() => {}),
|
||||||
filmstripQueue.close().catch(() => {}),
|
filmstripQueue.close().catch(() => {}),
|
||||||
// BUG FIX #4: Stop the promotion worker intervals and close its proxyQueue
|
// BUG FIX #4: Stop the promotion worker intervals and close its proxyQueue
|
||||||
stopPromotionWorker ? stopPromotionWorker() : Promise.resolve(),
|
stopPromotionWorker(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log('All workers and queues closed');
|
console.log('All workers and queues closed');
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import { join } from 'path';
|
|
||||||
import { tmpdir } from 'os';
|
|
||||||
import { mkdtemp, readdir, rm, unlink } from 'fs/promises';
|
|
||||||
import { Queue } from 'bullmq';
|
|
||||||
import { query } from '../db/client.js';
|
|
||||||
import { downloadFromS3, uploadToS3 } from '../s3/client.js';
|
|
||||||
import { segmentToHls } from '../ffmpeg/executor.js';
|
|
||||||
|
|
||||||
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
|
|
||||||
|
|
||||||
const parseRedisUrl = (url) => {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
return { host: parsed.hostname, port: parseInt(parsed.port, 10) };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remux a local, already-browser-compatible MP4 (H.264/AAC, as the proxy
|
|
||||||
// worker produces) into an fMP4 HLS rendition and upload it to hls/<assetId>/.
|
|
||||||
// This is a stream COPY (no re-encode) — it costs seconds, not minutes.
|
|
||||||
//
|
|
||||||
// HLS is served to the browser as whole-file GETs through mam-api, which
|
|
||||||
// sidesteps the RustFS ranged-GET bug that the MP4 /video path has to stitch
|
|
||||||
// around. The MP4 proxy is kept for the Premiere panel + downloads.
|
|
||||||
//
|
|
||||||
// Returns the playlist S3 key and sets assets.hls_s3_key.
|
|
||||||
export async function remuxToHls(localMp4Path, assetId) {
|
|
||||||
const workDir = await mkdtemp(join(tmpdir(), `hls-${assetId}-`));
|
|
||||||
try {
|
|
||||||
// Produces playlist.m3u8 + init.mp4 + segment_NNNNN.m4s in workDir.
|
|
||||||
await segmentToHls(localMp4Path, workDir);
|
|
||||||
|
|
||||||
const prefix = `hls/${assetId}`;
|
|
||||||
const files = await readdir(workDir);
|
|
||||||
if (!files.includes('playlist.m3u8')) {
|
|
||||||
throw new Error('segmentToHls produced no playlist.m3u8');
|
|
||||||
}
|
|
||||||
for (const f of files) {
|
|
||||||
await uploadToS3(S3_BUCKET, `${prefix}/${f}`, join(workDir, f));
|
|
||||||
}
|
|
||||||
|
|
||||||
const playlistKey = `${prefix}/playlist.m3u8`;
|
|
||||||
await query(
|
|
||||||
'UPDATE assets SET hls_s3_key = $1, updated_at = NOW() WHERE id = $2',
|
|
||||||
[playlistKey, assetId]
|
|
||||||
);
|
|
||||||
return playlistKey;
|
|
||||||
} finally {
|
|
||||||
await rm(workDir, { recursive: true, force: true }).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backfill worker: remux an EXISTING proxy MP4 into HLS for assets that
|
|
||||||
// predate the proxy-worker HLS step. Enqueued by POST /assets/:id/reprocess?type=hls.
|
|
||||||
export const hlsWorker = async (job) => {
|
|
||||||
const { assetId, proxyKey } = job.data;
|
|
||||||
if (!proxyKey) throw new Error('hls job requires proxyKey');
|
|
||||||
const tmpPath = join(tmpdir(), `hls-src-${job.id}.mp4`);
|
|
||||||
try {
|
|
||||||
await job.updateProgress(10);
|
|
||||||
console.log(`[hls] Downloading proxy ${proxyKey} for asset ${assetId}`);
|
|
||||||
await downloadFromS3(S3_BUCKET, proxyKey, tmpPath);
|
|
||||||
await job.updateProgress(40);
|
|
||||||
const key = await remuxToHls(tmpPath, assetId);
|
|
||||||
console.log(`[hls] Asset ${assetId} HLS rendition complete → ${key}`);
|
|
||||||
await job.updateProgress(100);
|
|
||||||
return { assetId, hlsKey: key };
|
|
||||||
} finally {
|
|
||||||
await unlink(tmpPath).catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const hlsQueue = new Queue('hls', {
|
|
||||||
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
|
|
||||||
});
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
import { join, extname } from 'path';
|
|
||||||
import { mkdir, stat, rename, unlink } from 'fs/promises';
|
|
||||||
import { spawn } from 'child_process';
|
|
||||||
import { query } from '../db/client.js';
|
|
||||||
import { downloadFromS3 } from '../s3/client.js';
|
|
||||||
|
|
||||||
// Playout media staging — copy an asset from S3 into the shared CasparCG media
|
|
||||||
// volume so a playout channel can play it. CasparCG plays from a local folder
|
|
||||||
// (/media), not from S3, so every playlist item must be staged to 'ready'
|
|
||||||
// before it can go on air. See docs/superpowers/specs/2026-05-30-playout-mcr-design.md §4.
|
|
||||||
//
|
|
||||||
// Two passes:
|
|
||||||
// 1. download from S3 to /media/playout/<assetId><ext>.raw
|
|
||||||
// 2. ffmpeg loudnorm (EBU R128, target I=-23 LUFS, TP=-1 dBTP, LRA=11) to the
|
|
||||||
// final path, then atomic rename. Skipped when items.audio_normalized=true.
|
|
||||||
//
|
|
||||||
// The media volume is mounted into BOTH this worker and the playout sidecars at
|
|
||||||
// the same path (PLAYOUT_MEDIA_DIR, default /media). We stage under a per-asset
|
|
||||||
// filename so re-staging is idempotent and multiple items referencing the same
|
|
||||||
// asset share one file.
|
|
||||||
|
|
||||||
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
|
|
||||||
const MEDIA_DIR = process.env.PLAYOUT_MEDIA_DIR || '/media';
|
|
||||||
|
|
||||||
async function fileExists(p) {
|
|
||||||
try { const s = await stat(p); return s.size > 0; } catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two-pass loudnorm: pass 1 measures, pass 2 applies linear normalization with
|
|
||||||
// the measured values. Linear mode preserves dynamics at the cost of accuracy
|
|
||||||
// vs the target — fine for broadcast playout where transparent levels matter
|
|
||||||
// more than hitting -23 LUFS to the decibel.
|
|
||||||
function runFfmpeg(args) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
||||||
let stderr = '';
|
|
||||||
proc.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
||||||
proc.on('error', reject);
|
|
||||||
proc.on('close', (code) => {
|
|
||||||
if (code === 0) resolve(stderr);
|
|
||||||
else reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(-500)}`));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function measureLoudness(inputPath) {
|
|
||||||
// -23 / -1 / 11 are the EBU R128 broadcast targets; loudnorm prints a JSON
|
|
||||||
// block to stderr after the analysis pass which feeds pass 2's measured_*
|
|
||||||
// params.
|
|
||||||
const stderr = await runFfmpeg([
|
|
||||||
'-hide_banner', '-nostats', '-i', inputPath,
|
|
||||||
'-af', 'loudnorm=I=-23:TP=-1:LRA=11:print_format=json',
|
|
||||||
'-f', 'null', '-',
|
|
||||||
]);
|
|
||||||
const match = stderr.match(/\{[\s\S]*?"input_i"[\s\S]*?\}/);
|
|
||||||
if (!match) throw new Error('loudnorm pass 1 produced no JSON');
|
|
||||||
return JSON.parse(match[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyLoudnorm(inputPath, outputPath, m) {
|
|
||||||
// Pass 2: linear normalization using pass 1's measurements. -c:v copy keeps
|
|
||||||
// the video stream intact so we only re-encode audio (target AAC stereo, the
|
|
||||||
// common-denominator CasparCG ffmpeg producer accepts).
|
|
||||||
await runFfmpeg([
|
|
||||||
'-hide_banner', '-nostats', '-y', '-i', inputPath,
|
|
||||||
'-af', `loudnorm=I=-23:TP=-1:LRA=11:measured_I=${m.input_i}:measured_TP=${m.input_tp}:measured_LRA=${m.input_lra}:measured_thresh=${m.input_thresh}:offset=${m.target_offset}:linear=true:print_format=summary`,
|
|
||||||
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000',
|
|
||||||
outputPath,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function playoutStageWorker(job) {
|
|
||||||
const { itemId, assetId } = job.data;
|
|
||||||
if (!itemId || !assetId) throw new Error('playout-stage requires itemId + assetId');
|
|
||||||
|
|
||||||
await query("UPDATE playout_items SET media_status = 'staging', updated_at = NOW() WHERE id = $1", [itemId]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const a = await query(
|
|
||||||
'SELECT id, filename, original_s3_key, proxy_s3_key FROM assets WHERE id = $1', [assetId]);
|
|
||||||
if (a.rows.length === 0) throw new Error(`asset ${assetId} not found`);
|
|
||||||
const asset = a.rows[0];
|
|
||||||
|
|
||||||
// Prefer the master for air quality; fall back to proxy if no master key.
|
|
||||||
const s3Key = asset.original_s3_key || asset.proxy_s3_key;
|
|
||||||
if (!s3Key) throw new Error(`asset ${assetId} has no S3 media key to stage`);
|
|
||||||
|
|
||||||
const ext = extname(s3Key) || extname(asset.filename || '') || '.mp4';
|
|
||||||
// Stable per-asset path under the media volume; CasparCG resolves the token
|
|
||||||
// "playout/<assetId>" against MEDIA_DIR.
|
|
||||||
const relDir = 'playout';
|
|
||||||
const fileName = `${assetId}${ext}`;
|
|
||||||
const absDir = join(MEDIA_DIR, relDir);
|
|
||||||
const absPath = join(absDir, fileName);
|
|
||||||
const mediaPath = join(MEDIA_DIR, relDir, fileName);
|
|
||||||
|
|
||||||
await mkdir(absDir, { recursive: true });
|
|
||||||
|
|
||||||
// Skip the whole pipeline when the final file already exists from a prior
|
|
||||||
// stage of the same asset. The audio_normalized flag is per-item so a
|
|
||||||
// second item referencing the same staged file gets flipped to true below.
|
|
||||||
const itemRow = await query('SELECT audio_normalized FROM playout_items WHERE id = $1', [itemId]);
|
|
||||||
const alreadyNormalized = itemRow.rows[0]?.audio_normalized === true;
|
|
||||||
|
|
||||||
if (!(await fileExists(absPath))) {
|
|
||||||
const rawPath = `${absPath}.raw${ext}`;
|
|
||||||
console.log(`[playout-stage] downloading ${s3Key} -> ${rawPath}`);
|
|
||||||
await downloadFromS3(S3_BUCKET, s3Key, rawPath);
|
|
||||||
|
|
||||||
if (alreadyNormalized) {
|
|
||||||
// Asset was previously normalized for another item — keep the bytes
|
|
||||||
// as-is. Atomic rename so CasparCG never sees a partial file.
|
|
||||||
await rename(rawPath, absPath);
|
|
||||||
} else {
|
|
||||||
console.log(`[playout-stage] loudnorm pass 1: ${rawPath}`);
|
|
||||||
const measured = await measureLoudness(rawPath);
|
|
||||||
const tmpOut = `${absPath}.tmp${ext}`;
|
|
||||||
console.log(`[playout-stage] loudnorm pass 2: I=${measured.input_i} TP=${measured.input_tp} -> ${tmpOut}`);
|
|
||||||
await applyLoudnorm(rawPath, tmpOut, measured);
|
|
||||||
await rename(tmpOut, absPath);
|
|
||||||
await unlink(rawPath).catch(() => {});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log(`[playout-stage] already staged: ${absPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await query(
|
|
||||||
"UPDATE playout_items SET media_status = 'ready', media_path = $1, audio_normalized = TRUE, updated_at = NOW() WHERE id = $2",
|
|
||||||
[mediaPath, itemId]);
|
|
||||||
console.log(`[playout-stage] item ${itemId} ready at ${mediaPath}`);
|
|
||||||
return { itemId, mediaPath };
|
|
||||||
} catch (err) {
|
|
||||||
await query("UPDATE playout_items SET media_status = 'error', updated_at = NOW() WHERE id = $1", [itemId])
|
|
||||||
.catch(() => {});
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { Queue } from 'bullmq';
|
||||||
import { query } from '../db/client.js';
|
import { query } from '../db/client.js';
|
||||||
import { downloadFromS3, uploadToS3 } from '../s3/client.js';
|
import { downloadFromS3, uploadToS3 } from '../s3/client.js';
|
||||||
import { transcodeVideo, transcodeImage, getMediaInfo, isHwCodec } from '../ffmpeg/executor.js';
|
import { transcodeVideo, transcodeImage, getMediaInfo, isHwCodec } from '../ffmpeg/executor.js';
|
||||||
import { remuxToHls } from './hls.js';
|
|
||||||
|
|
||||||
// Read the global proxy-encoder settings from the DB. These are written by
|
// Read the global proxy-encoder settings from the DB. These are written by
|
||||||
// Settings → Proxy encoding in the GUI. Falls back to libx264 defaults if
|
// Settings → Proxy encoding in the GUI. Falls back to libx264 defaults if
|
||||||
|
|
@ -224,19 +223,6 @@ export const proxyWorker = async (job) => {
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate the HLS rendition from the proxy we just wrote. The file is
|
|
||||||
// still on local disk, so this is a fast stream-copy remux (no download,
|
|
||||||
// no re-encode). Best-effort: HLS is the preferred browser playback path,
|
|
||||||
// but the MP4 /video fallback still works if this fails, so never fail the
|
|
||||||
// proxy job over it.
|
|
||||||
await job.updateProgress(80);
|
|
||||||
try {
|
|
||||||
const hlsKey = await remuxToHls(outputPath, assetId);
|
|
||||||
console.log(`[proxy] HLS rendition generated for ${assetId} → ${hlsKey}`);
|
|
||||||
} catch (hlsErr) {
|
|
||||||
console.warn(`[proxy] HLS generation failed for ${assetId} (non-fatal): ${hlsErr.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now proxy exists in S3 — safe to queue thumbnail generation
|
// Now proxy exists in S3 — safe to queue thumbnail generation
|
||||||
const thumbnailKey = `thumbnails/${assetId}.jpg`;
|
const thumbnailKey = `thumbnails/${assetId}.jpg`;
|
||||||
await thumbnailQueue.add('generate', {
|
await thumbnailQueue.add('generate', {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue