Compare commits
64 commits
redesign/p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bc460025d | |||
| 3578c7b4e9 | |||
| cddcc9a29e | |||
| 0e844c0fc3 | |||
| 551af09dc7 | |||
| 4d6a999665 | |||
| f971d57bb9 | |||
| 7ab70948a0 | |||
| 13bbd4216e | |||
| fcd8e8dd2e | |||
| 67ac007706 | |||
| b4f2fb12ff | |||
| aa7f836493 | |||
| c2409bd037 | |||
| 42064acefa | |||
| 2e2b091653 | |||
|
|
c502d4a16f | ||
|
|
9d098e9778 | ||
|
|
02631f7b96 | ||
|
|
9436434599 | ||
|
|
f837e57969 | ||
|
|
ca71e47035 | ||
|
|
34352e3299 | ||
|
|
d505a488ac | ||
|
|
793011b78b | ||
|
|
5538683d78 | ||
|
|
d62af34e98 | ||
|
|
209f9fda52 | ||
|
|
29187a90df | ||
|
|
512267159a | ||
|
|
72fc608d8a | ||
|
|
3fe7d6bba2 | ||
|
|
2615143c6d | ||
|
|
0c3a4b625f | ||
|
|
fff0828d79 | ||
|
|
ec026195eb | ||
| 9d6bbf8112 | |||
| b449ef0ce3 | |||
| 39ef551489 | |||
| 8f26f1bd9a | |||
| a7ef0397e1 | |||
| cf1fe136d0 | |||
| 0818f15498 | |||
| 4473427515 | |||
| 9b47250388 | |||
| 8ea750f5df | |||
| a28dc43ed5 | |||
| 35fd9c0253 | |||
| 0ee0cb91ef | |||
| 9210b41589 | |||
| f2542bc929 | |||
| 0f6c715a30 | |||
| fdec2e307d | |||
| 92b460f503 | |||
| 500599a955 | |||
| 634f1842bd | |||
| 453103aee6 | |||
| 6f64b55824 | |||
| 303f12e0f9 | |||
| 342b56af35 | |||
| f54c49d2dc | |||
| 888ca65045 | |||
| b6f5b9b407 | |||
| 354731a363 |
111 changed files with 9539 additions and 1498 deletions
34
.env.example
34
.env.example
|
|
@ -25,6 +25,13 @@ 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.
|
||||||
|
|
@ -36,3 +43,30 @@ 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,6 +90,16 @@ 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:
|
||||||
|
|
|
||||||
101
WORK_LOG_PLAYOUT.md
Normal file
101
WORK_LOG_PLAYOUT.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Playout / Master Control — Implementation Work Log
|
||||||
|
|
||||||
|
**Branch:** `feat/playout-mcr` (off `main`)
|
||||||
|
**Started:** 2026-05-30
|
||||||
|
**Status:** Code complete, awaiting runtime validation
|
||||||
|
|
||||||
|
Tracks the build of the playout (MCR) subsystem against the design at
|
||||||
|
`docs/superpowers/specs/2026-05-30-playout-mcr-design.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit sequence
|
||||||
|
|
||||||
|
| # | Commit | Scope |
|
||||||
|
|---|--------|-------|
|
||||||
|
| 1 | `docs(playout)` | Design spec, §7 questions answered |
|
||||||
|
| 2 | `feat(mam-api): migration 029` | Six tables, failover columns, audio_normalized flag |
|
||||||
|
| 3 | `feat(worker): playout-stage` | S3 → /media + EBU R128 loudnorm + index.js wiring |
|
||||||
|
| 4 | `feat(playout): sidecar` | CasparCG image + AMCP shim, HLS preview consumer, fps-aware frame math |
|
||||||
|
| 5 | `feat(mam-api): /playout control plane + auto-failover` | Routes + scheduler health tick + restartChannel helper |
|
||||||
|
| 6 | `feat(web-ui): MCR page` | screens-playout, styles, app/shell/index.html wiring |
|
||||||
|
| 7 | `build(playout): compose wiring + .env knobs` | /media volume, queue addition, build-only service |
|
||||||
|
| 8 | `docs(playout): work log` | This file |
|
||||||
|
|
||||||
|
## Resolved §7 decisions (2026-05-30)
|
||||||
|
|
||||||
|
- **Audio loudness:** pre-normalize at stage time. ffmpeg `loudnorm` two-pass
|
||||||
|
(I=-23 LUFS, TP=-1 dBTP, LRA=11), linear mode preserves dynamics. Output
|
||||||
|
AAC 192k @ 48 kHz, video stream copied. Per-item `audio_normalized` flag
|
||||||
|
so re-stages of the same asset skip the pass.
|
||||||
|
- **Frame rate:** `1080p5994` default (was `1080i5994`). Per-channel
|
||||||
|
override allowed via `video_format`. `fpsFor(videoFormat)` helper in
|
||||||
|
the sidecar drives SEEK / LENGTH / transition-frames math.
|
||||||
|
- **Preview latency:** HLS v1. CasparCG runs a second FFMPEG consumer
|
||||||
|
alongside the primary output, writing `/media/live/<channel_id>/index.m3u8`
|
||||||
|
(~600 kbps, 2s segments, 6-window list). Web UI plays via the existing
|
||||||
|
HLS plumbing.
|
||||||
|
- **Failover:** auto-restart on healthy node for NDI/SRT/RTMP. Alert-only
|
||||||
|
for DeckLink (device-index pinning makes blind re-placement risky).
|
||||||
|
Scheduler tick (PG advisory lock, same lock as recorder schedules) polls
|
||||||
|
sidecar `/status`; ~3 missed checks → `restartChannel(id)` picks the most
|
||||||
|
recently-seen-online other node, bumps `restart_count`, calls `/start`.
|
||||||
|
|
||||||
|
## Architecture notes
|
||||||
|
|
||||||
|
**Sidecar model.** One CasparCG container per channel. Spawned by mam-api
|
||||||
|
via local Docker socket (primary node) or remote node-agent
|
||||||
|
`/sidecar/start`. Tracked in `playout_sidecars` plus `playout_channels.container_id`.
|
||||||
|
Killed on `/stop` or by `restartChannel` during failover.
|
||||||
|
|
||||||
|
**Media flow.**
|
||||||
|
```
|
||||||
|
S3 master/proxy → playout-stage worker → /media/playout/<assetId>.<ext>
|
||||||
|
(loudnormed, AAC@-23 LUFS)
|
||||||
|
↓
|
||||||
|
CasparCG channel #1
|
||||||
|
↓
|
||||||
|
primary consumer HLS consumer
|
||||||
|
(DeckLink/NDI/ ↓
|
||||||
|
SRT/RTMP) /media/live/<ch_id>/*.m3u8
|
||||||
|
```
|
||||||
|
|
||||||
|
**Port contention.** `assertDeckLinkFree()` blocks starting a SDI channel
|
||||||
|
when a recorder or another channel on the same node+device_index is active.
|
||||||
|
|
||||||
|
**Failover scope.** NDI/SRT/RTMP have no hardware tie, so any healthy
|
||||||
|
cluster_node is eligible. DeckLink channels surface an alert in the UI
|
||||||
|
(`status='error'` + `error_message`) and require operator intervention.
|
||||||
|
|
||||||
|
## Testing checklist
|
||||||
|
|
||||||
|
- [ ] Apply migration 029 on dev DB
|
||||||
|
- [ ] Build playout image: `docker compose --profile build-only build playout`
|
||||||
|
- [ ] Build web-ui (`screens-playout` joins the esbuild list automatically)
|
||||||
|
- [ ] Create channel via POST /api/v1/playout/channels (SRT first, no HW)
|
||||||
|
- [ ] Stage 2-3 assets to a playlist, verify loudnorm metadata in stderr
|
||||||
|
- [ ] Start channel → sidecar container appears in `docker ps`
|
||||||
|
- [ ] AMCP smoke: `telnet <host> 5250`, `VERSION`, `INFO`
|
||||||
|
- [ ] Play playlist; verify HLS at /media/live/<id>/index.m3u8
|
||||||
|
- [ ] Skip / pause / resume / stop
|
||||||
|
- [ ] As-run log: GET /api/v1/playout/channels/:id/asrun
|
||||||
|
- [ ] Kill sidecar container → scheduler should restart on another node
|
||||||
|
within ~3 ticks (~45s), restart_count increments
|
||||||
|
- [ ] DeckLink channel kill: status flips to 'error', NO restart attempt
|
||||||
|
- [ ] Try starting a decklink channel on a device_index already held by a
|
||||||
|
recorder → 409
|
||||||
|
- [ ] MCR UI smoke: nav entry visible, page renders, drag-drop adds items,
|
||||||
|
transport buttons hit the API
|
||||||
|
|
||||||
|
## Known gaps (deferred)
|
||||||
|
|
||||||
|
- No WebRTC preview (HLS-only v1 — 4-6s lag, fine for confidence monitor).
|
||||||
|
- No graphics/CG overlay layer in Phase A (templates land in Phase B).
|
||||||
|
- No Phase B scheduler / 24/7 wall-clock channel (schema is in place,
|
||||||
|
scheduler tick is not).
|
||||||
|
- No multi-channel grid view (one channel at a time per page).
|
||||||
|
- No timecode / remaining-duration overlay (would need CasparCG INFO poll).
|
||||||
|
- No audio level meters on the UI.
|
||||||
|
- `restartChannel` updates DB state and triggers `/start`; if the new node
|
||||||
|
also fails repeatedly, there's no exponential backoff yet — bounded only
|
||||||
|
by the manual stop button.
|
||||||
|
|
@ -58,6 +58,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -99,6 +100,31 @@ 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,6 +40,7 @@ services:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- /mnt/NVME/MAM/wild-dragon-live:/live
|
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||||
- /mnt/NVME/MAM/wild-dragon-growing:/growing
|
- /mnt/NVME/MAM/wild-dragon-growing:/growing
|
||||||
|
- /mnt/NVME/MAM/wild-dragon-media:/media
|
||||||
- /mnt/NVME/MAM/sdk:/sdk
|
- /mnt/NVME/MAM/sdk:/sdk
|
||||||
- /dev/shm:/dev/shm
|
- /dev/shm:/dev/shm
|
||||||
- /run/dbus:/run/dbus
|
- /run/dbus:/run/dbus
|
||||||
|
|
@ -60,6 +61,9 @@ 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:
|
||||||
|
|
@ -94,8 +98,15 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- wild-dragon
|
- wild-dragon
|
||||||
|
|
||||||
worker:
|
# ── GPU worker pool (capability-routed) ──────────────────────────────
|
||||||
build: ./services/worker
|
# worker-p4: HEAVY tier (proxy/conform/trim) on the Tesla P4 (NVENC).
|
||||||
|
# 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
|
||||||
|
|
@ -108,8 +119,59 @@ 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
|
||||||
|
|
||||||
|
|
@ -119,12 +181,22 @@ services:
|
||||||
- "${PORT_WEB_UI:-7434}:80"
|
- "${PORT_WEB_UI:-7434}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/NVME/MAM/wild-dragon-live:/live
|
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||||
|
- /mnt/NVME/MAM/wild-dragon-media:/media:ro
|
||||||
- /dev/shm:/dev/shm
|
- /dev/shm:/dev/shm
|
||||||
- /run/dbus:/run/dbus
|
- /run/dbus:/run/dbus
|
||||||
- /run/systemd:/run/systemd
|
- /run/systemd:/run/systemd
|
||||||
networks:
|
networks:
|
||||||
- wild-dragon
|
- wild-dragon
|
||||||
|
|
||||||
|
# Build-only: the CasparCG sidecar image. mam-api spawns these on-demand per
|
||||||
|
# channel (one container per playout channel), so this service is never up'd —
|
||||||
|
# it exists so `docker compose build playout` produces the image the API tags
|
||||||
|
# via PLAYOUT_IMAGE. Profile excludes it from default `up`.
|
||||||
|
playout:
|
||||||
|
profiles: ["build-only"]
|
||||||
|
build: ./services/playout
|
||||||
|
image: wild-dragon-playout:latest
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
|
|
||||||
116
docs/WORK_LOG_2026_05.md
Normal file
116
docs/WORK_LOG_2026_05.md
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
# 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
|
||||||
197
docs/design/2026-05-29-all-intra-hevc-ingest.md
Normal file
197
docs/design/2026-05-29-all-intra-hevc-ingest.md
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
# 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.
|
||||||
84
docs/superpowers/specs/2026-05-29-hls-vod-playback-design.md
Normal file
84
docs/superpowers/specs/2026-05-29-hls-vod-playback-design.md
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# 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).
|
||||||
235
docs/superpowers/specs/2026-05-30-playout-mcr-design.md
Normal file
235
docs/superpowers/specs/2026-05-30-playout-mcr-design.md
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
# Wild Dragon MAM — Playout / Master Control (MCR)
|
||||||
|
|
||||||
|
**Date:** 2026-05-30 (revised 2026-05-30 — §7 closed)
|
||||||
|
**Status:** APPROVED — implementation in progress (code drafted but uncommitted; see WORK_LOG_PLAYOUT.md)
|
||||||
|
**Author:** Zac + Claude
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resolved Decisions
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
|----------|----------|
|
||||||
|
| Playout engine | **CasparCG Server** (orchestrated via AMCP), not ffmpeg-native |
|
||||||
|
| Channel count | **Multi-channel from start** — N independent channels, placed across cluster nodes by capability (mirrors recorders) |
|
||||||
|
| Scheduling model | **Phased** — Phase A: on-demand playlist player; Phase B: 24/7 continuous channel |
|
||||||
|
| Output targets | SDI (DeckLink), NDI, SRT, RTMP — all via CasparCG consumers |
|
||||||
|
| Media source | Assets live in **S3**; must be staged to a CasparCG-local media volume before play (see §4) |
|
||||||
|
| CasparCG packaging | **Build our own image** (like `capture/build-with-decklink.sh`) — GL context via GPU passthrough or Xvfb; NDI + DeckLink SDKs fetched at build time (not redistributable) |
|
||||||
|
| Master codec playability | Zac confirms current masters **play fine in CasparCG** — no transcode-for-playout step; staging is a plain S3→/media copy. Validate on hardware but do not gate on it |
|
||||||
|
| Management UI | **Single Dragonflight `playout.html` GUI** drives everything via AMCP; operator never touches CasparCG directly |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Playout adds **master-control-room** capability to Dragonflight: take library assets, arrange them on a timeline / playlist, and play them out continuously to a broadcast output — SDI via DeckLink, or stream via SRT / RTMP / NDI. Drag-and-drop scheduling, a program/preview monitor, and as-run logging.
|
||||||
|
|
||||||
|
This is the **mirror image** of the existing capture path. Capture is `input → ffmpeg encode → S3`. Playout is `S3 asset → engine → output`. We reuse three things wholesale:
|
||||||
|
|
||||||
|
1. **Cluster node + capability model** — nodes already advertise DeckLink/Deltacast/GPU in `cluster_nodes.capabilities`; channels are placed on nodes that have a free output port, exactly as recorders claim input ports.
|
||||||
|
2. **Sidecar orchestration** — mam-api spawns containers via the local Docker socket or the remote `node-agent /sidecar/start`. A CasparCG channel is just a different sidecar image.
|
||||||
|
3. **Scheduler tick + PG advisory lock** — `src/scheduler.js` already runs a single-leader tick over a schedule table. Phase B's wall-clock channel reuses this pattern.
|
||||||
|
|
||||||
|
### Why CasparCG over ffmpeg-native
|
||||||
|
|
||||||
|
The capture stack proves we can drive ffmpeg + DeckLink. But playout's hard part is **gapless, frame-accurate, clean transitions between clips** — every clip boundary in an ffmpeg-per-clip model is a black flash unless we engineer a concat-feeder. CasparCG solves this natively: a channel is a persistent output with a playlist, hard/mix/wipe transitions, layered graphics/logo (CG), and DeckLink/NDI/SRT/RTMP consumers built in. We orchestrate it over **AMCP** (its TCP control protocol) instead of reinventing a feeder. Trade: a new dependency + container image, and media must be on a CasparCG-visible disk (§4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Data Model
|
||||||
|
|
||||||
|
New migration `029-playout.sql`. Five tables.
|
||||||
|
|
||||||
|
### 1.1 `playout_channels`
|
||||||
|
A logical output. One channel → one engine instance → one output target.
|
||||||
|
|
||||||
|
```
|
||||||
|
id uuid pk
|
||||||
|
name text -- "Channel 1", "Pop-up SDI"
|
||||||
|
node_id uuid -> cluster_nodes(id) -- where the engine runs (null = primary)
|
||||||
|
output_type text -- 'decklink' | 'ndi' | 'srt' | 'rtmp'
|
||||||
|
output_config jsonb -- { device_index } | { ndi_name } | { url, latency } | { url, key }
|
||||||
|
video_format text -- '1080i5994' | '1080p5994' | '720p5994' ...
|
||||||
|
status text -- 'stopped' | 'starting' | 'running' | 'error'
|
||||||
|
container_id text -- running CasparCG sidecar
|
||||||
|
project_id uuid -> projects(id) -- RBAC scoping (nullable = admin-only)
|
||||||
|
created_at / updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
`output_type` + `output_config` map straight to a CasparCG consumer:
|
||||||
|
- `decklink` → `ADD <ch> DECKLINK <device> ...`
|
||||||
|
- `ndi` → `ADD <ch> NDI ...`
|
||||||
|
- `srt`/`rtmp` → `ADD <ch> FFMPEG <url> -f mpegts ...` (CasparCG 2.3+ ffmpeg consumer)
|
||||||
|
|
||||||
|
### 1.2 `playout_playlists`
|
||||||
|
An ordered list of items bound to a channel. Phase A's primary object.
|
||||||
|
|
||||||
|
```
|
||||||
|
id, channel_id -> playout_channels(id)
|
||||||
|
name, loop boolean, created_at / updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 `playout_items`
|
||||||
|
One entry on a playlist OR one entry on the 24/7 timeline.
|
||||||
|
|
||||||
|
```
|
||||||
|
id
|
||||||
|
playlist_id uuid -> playout_playlists(id) -- Phase A
|
||||||
|
asset_id uuid -> assets(id)
|
||||||
|
sort_order int -- position in playlist (Phase A)
|
||||||
|
scheduled_at timestamptz -- wall-clock start (Phase B, null in A)
|
||||||
|
in_point numeric -- seconds, trim head (reuse subclip in/out from editor)
|
||||||
|
out_point numeric -- seconds, trim tail
|
||||||
|
transition text -- 'cut' | 'mix' | 'wipe'
|
||||||
|
transition_ms int
|
||||||
|
graphics jsonb -- optional CG/template overlay (Phase B+)
|
||||||
|
media_status text -- 'pending' | 'staging' | 'ready' | 'error' (see §4)
|
||||||
|
media_path text -- resolved path inside the CasparCG media volume
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 `playout_schedule` (Phase B)
|
||||||
|
Day-ahead, wall-clock-bound timeline rows. Same shape as `playout_items` but `scheduled_at` is authoritative and the scheduler tick (§5) drives transitions. Phase A can ignore this table.
|
||||||
|
|
||||||
|
### 1.5 `playout_as_run`
|
||||||
|
Append-only log: what actually played, when, for how long. Compliance / billing.
|
||||||
|
|
||||||
|
```
|
||||||
|
id, channel_id, asset_id, item_id
|
||||||
|
started_at, ended_at, duration_s, result -- 'played' | 'skipped' | 'error'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Services & Components
|
||||||
|
|
||||||
|
### 2.1 New sidecar: `services/playout/` (CasparCG wrapper)
|
||||||
|
A thin container: **CasparCG Server** + a small Node control shim exposing HTTP, the same way `capture` wraps ffmpeg.
|
||||||
|
|
||||||
|
- Base image: official/community `casparcg/server` (Linux build with DeckLink + NDI + FFmpeg producers/consumers).
|
||||||
|
- Node shim (`src/index.js`): opens an AMCP TCP socket to local CasparCG, exposes:
|
||||||
|
- `POST /channel/start` → `ADD <ch> <consumer>` for the channel's output target
|
||||||
|
- `POST /play` → `PLAY <ch>-<layer> <media> [transition]`
|
||||||
|
- `POST /loadbg` + `/play` → preview/cue then take (preview monitor)
|
||||||
|
- `POST /stop`, `GET /status` → `INFO <ch>` (current clip, position, fps)
|
||||||
|
- playlist load → translate `playout_items` rows into a sequence of AMCP `LOADBG`/`PLAY` calls, advancing on `OnTransition` / end-of-clip events.
|
||||||
|
- Mirrors capture's status-polling contract so the UI monitor reuses existing plumbing.
|
||||||
|
|
||||||
|
### 2.2 mam-api: `src/routes/playout.js`
|
||||||
|
CRUD + control, RBAC-scoped via the existing `assertProjectAccess` helper (channels carry `project_id`).
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /playout/channels list (project-filtered)
|
||||||
|
POST /playout/channels create (edit on project)
|
||||||
|
POST /playout/channels/:id/start|stop spawn/kill CasparCG sidecar
|
||||||
|
GET /playout/channels/:id/status proxy engine INFO
|
||||||
|
POST /playout/channels/:id/play|pause|skip transport control
|
||||||
|
GET/POST/PUT/DELETE /playout/playlists... playlist + item CRUD, reorder
|
||||||
|
POST /playout/items/:id/stage kick S3→media-volume staging (§4)
|
||||||
|
GET /playout/channels/:id/asrun as-run log
|
||||||
|
```
|
||||||
|
|
||||||
|
Channel start/stop reuses `resolveNodeTarget()` + the Docker-socket / `node-agent /sidecar/start` split already in `recorders.js`. **Refactor opportunity:** lift that sidecar-spawn logic out of `recorders.js` into `src/orchestration/sidecar.js` so both recorders and playout share it (keep this small — only what both need).
|
||||||
|
|
||||||
|
### 2.3 web-ui: `playout.html` + `public/playout.jsx`
|
||||||
|
New MCR page. Layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ PREVIEW ───────────┬─ PROGRAM (on air) ──────┐
|
||||||
|
│ [cued clip] │ [live output] ● ON AIR │
|
||||||
|
│ TC / duration │ TC / remaining │
|
||||||
|
│ [CUE] [TAKE] │ [PLAY][PAUSE][SKIP][STOP]│
|
||||||
|
├─ MEDIA BIN ─────────┴──────────────────────────┤
|
||||||
|
│ (draggable asset list, reuse asset browser) │
|
||||||
|
├─ PLAYLIST / TIMELINE ──────────────────────────┤
|
||||||
|
│ ▸ clip A ──▸ clip B ──▸ clip C (drag-drop) │ Phase A: ordered list
|
||||||
|
│ └ 24h time grid w/ now-bar │ Phase B: time-of-day grid
|
||||||
|
└────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Drag-drop: reuse whatever the NLE editor timeline uses (check `editor.jsx`); assets drag from the bin into the playlist/grid.
|
||||||
|
- API via existing `ZAMPP_API.fetch` wrapper.
|
||||||
|
- Program monitor: HLS preview of the output — CasparCG can emit a second low-bitrate FFmpeg consumer to HLS, reusing the `/live/<id>` HLS plumbing capture already uses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Channel placement & ports
|
||||||
|
|
||||||
|
A DeckLink port is exclusive — same constraint capture already handles. A node's DeckLink port can be an **input (recorder)** or an **output (playout channel)**, never both at once. So:
|
||||||
|
|
||||||
|
- Extend the capability/port-claim check: when starting a channel on `output_type=decklink`, verify the target node has that device index free (no active recorder, no active channel).
|
||||||
|
- NDI / SRT / RTMP outputs have no hardware contention → can stack many per node (GPU/CPU-bound only).
|
||||||
|
- Surface a unified "device map" (extend the existing cluster DeckLink-status endpoint) showing each port's role: idle / recording / playing-out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Media staging (the S3 ⇄ CasparCG gap)
|
||||||
|
|
||||||
|
**The crux.** Assets live in S3 (`original_s3_key` / `proxy_s3_key`). CasparCG plays from a **local media folder**. Options:
|
||||||
|
|
||||||
|
- **A — Pre-stage to a shared media volume (recommended).** Before a clip can go on air, download/symlink it from S3 to a CasparCG-visible volume (`/media`), set `playout_items.media_status='ready'` + `media_path`. A new BullMQ `playout-stage` job (reuses the worker pattern) does the pull. UI shows per-item readiness; TAKE is blocked until `ready`. Mirrors the growing-file SMB share already mounted for capture.
|
||||||
|
- **B — Stream from S3 via presigned URL.** CasparCG FFmpeg producer plays an HTTPS presigned URL directly. Zero staging, but seeking/trim and gapless transitions over network are fragile for broadcast. Acceptable as a fallback for SRT/RTMP, risky for SDI.
|
||||||
|
|
||||||
|
**Decision:** Phase A uses **A** (stage proxies for preview, masters for air) with **B** available as a per-channel "low-latency / no-stage" toggle. Zac confirms the current masters play fine in CasparCG, so staging is a **plain S3→/media copy — no transcode-for-playout step**. (Validate on hardware during implementation, but the model does not assume a transcode stage.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Scheduling
|
||||||
|
|
||||||
|
### Phase A — playlist player
|
||||||
|
No wall clock. Operator builds a `playout_playlists` row, drags items in, hits PLAY. The playout sidecar walks `playout_items` by `sort_order`, cueing the next clip during the current one (`LOADBG`) and taking it at end-of-clip with the configured transition. `loop` repeats. As-run logged per item.
|
||||||
|
|
||||||
|
### Phase B — 24/7 continuous channel
|
||||||
|
Wall-clock timeline in `playout_schedule`. Reuse `src/scheduler.js`:
|
||||||
|
- Add a second tick (or extend the existing one) under the **same PG advisory lock pattern** — exactly-one-leader, so a multi-replica deploy doesn't double-fire.
|
||||||
|
- Tick responsibilities: stage upcoming items (look-ahead window), verify the on-air item matches the schedule, **fill gaps** (loop a filler/slate asset when the timeline has a hole — a channel must never go black), roll the day forward.
|
||||||
|
- As-run becomes the compliance record.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Phasing / Milestones
|
||||||
|
|
||||||
|
**Phase A — Playlist playout MVP**
|
||||||
|
1. Migration `029-playout.sql` (channels, playlists, items, as-run).
|
||||||
|
2. `services/playout/` sidecar: CasparCG image + AMCP control shim, single output target (start with **SRT or NDI** — no hardware needed for dev; DeckLink behind hardware check).
|
||||||
|
3. mam-api `routes/playout.js` — channel + playlist CRUD, start/stop, transport, RBAC.
|
||||||
|
4. `playout-stage` BullMQ job (S3 → /media).
|
||||||
|
5. web-ui `playout.html` — bin + drag-drop ordered playlist + program/preview monitors + transport.
|
||||||
|
6. DeckLink output on real hardware; port-contention check vs recorders.
|
||||||
|
|
||||||
|
**Phase B — 24/7 continuous channel**
|
||||||
|
7. `playout_schedule` + time-of-day grid UI.
|
||||||
|
8. Scheduler tick (advisory-locked) — look-ahead staging, gap-fill/slate, day-roll.
|
||||||
|
9. As-run reporting view.
|
||||||
|
10. Graphics/CG overlay (logo bug, lower-thirds) via CasparCG templates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Open Questions (for review)
|
||||||
|
|
||||||
|
**Resolved (2026-05-30):**
|
||||||
|
- ~~CasparCG packaging~~ → **build our own image.** Fetch DeckLink + NDI SDKs at build time (not redistributable — same as capture's DeckLink build). GL context for the mixer comes from GPU passthrough on a real node, or **Xvfb** (virtual framebuffer) where there's no display — community images run `--privileged` + X11 socket. Pin the NDI SDK version to what the server expects (`.so` version mismatch is the common docker failure).
|
||||||
|
- ~~Master codec playability~~ → Zac confirms masters **play fine**; no transcode-for-playout. Staging = plain S3→/media copy.
|
||||||
|
- ~~Management GUI~~ → **single Dragonflight `playout.html`** drives everything via AMCP; operator never touches CasparCG.
|
||||||
|
- ~~Audio loudness~~ → **pre-normalize at stage time** (Zac, 2026-05-30). `playout-stage` job runs ffmpeg `loudnorm` (EBU R128, target −23 LUFS, true-peak −1 dBTP) once, on the S3→/media copy. Output is the cached version CasparCG plays. Staging is no longer a pure copy — staging cost ≈ realtime CPU per clip on first stage; results are reusable across channels. Override (`media_status='ready'` + raw copy) available for clips already mastered to spec.
|
||||||
|
- ~~Frame rate~~ → **`1080p5994`** default for new channels (Zac, 2026-05-30). Progressive 1080 @ 59.94 fps. Per-channel override allowed (`video_format` column). Streaming-friendly (SRT/RTMP/NDI) and current SDI gear accepts it; matches capture's 59.94 cadence.
|
||||||
|
- ~~Preview latency~~ → **HLS v1** (Zac, 2026-05-30). Reuse capture's `/live/<id>` HLS plumbing. CasparCG emits a second low-bitrate FFmpeg consumer to HLS. ~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,4 +1,10 @@
|
||||||
# ── Stage 1: Build FFmpeg with DeckLink support ─────────────────────────────
|
# ── Stage 1: Build FFmpeg with DeckLink + NVENC (HEVC/H264) support ─────────
|
||||||
|
# All-Intra HEVC NVENC is the master codec for growing-file ingest (see
|
||||||
|
# docs/design/2026-05-29-all-intra-hevc-ingest.md). This stage gets the
|
||||||
|
# nv-codec-headers (header-only, no driver / no full CUDA toolkit needed)
|
||||||
|
# so ffmpeg's configure can light up hevc_nvenc / h264_nvenc / cuvid.
|
||||||
|
# At runtime, /dev/nvidia* + the host driver libs (via the NVIDIA Container
|
||||||
|
# Toolkit) supply the actual encoder.
|
||||||
FROM debian:bookworm AS ffmpeg-builder
|
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 \
|
||||||
|
|
@ -13,6 +19,11 @@ 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
|
||||||
|
|
||||||
|
|
@ -20,8 +31,15 @@ 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 \
|
||||||
|
|
@ -32,13 +50,20 @@ RUN ./configure \
|
||||||
--enable-libsrt \
|
--enable-libsrt \
|
||||||
--enable-libzmq \
|
--enable-libzmq \
|
||||||
--enable-decklink \
|
--enable-decklink \
|
||||||
--extra-cflags="-I/decklink-sdk" \
|
--enable-ffnvcodec \
|
||||||
|
--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
|
||||||
|
|
||||||
|
|
@ -58,6 +83,11 @@ 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,7 +28,32 @@ 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' },
|
||||||
hevc_nvenc: { args: ['-c:v', 'hevc_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' },
|
// All-Intra HEVC on NVENC — the growing-file master codec.
|
||||||
|
// Goal: every frame an IDR (all-intra), so a still-growing file is decodable
|
||||||
|
// to its last complete frame — the prerequisite for edit-while-record.
|
||||||
|
//
|
||||||
|
// NVENC will NOT accept `-g 1`: InitializeEncoder enforces
|
||||||
|
// "GopLength > numBFrames + 1", so with -bf 0 the minimum GOP is 2 and -g 1
|
||||||
|
// is rejected with EINVAL (validated on the L4, driver 595). The working
|
||||||
|
// recipe for true all-intra is therefore:
|
||||||
|
// -bf 0 no B-frames
|
||||||
|
// -g 600 large GOP just to satisfy the init check
|
||||||
|
// -forced-idr 1 forced keyframes are emitted as IDR
|
||||||
|
// -force_key_frames expr:1 force a keyframe on EVERY frame
|
||||||
|
// → ffprobe confirms pict_type = I for all frames.
|
||||||
|
//
|
||||||
|
// Container: fragmented MOV (+frag_keyframe+empty_moov+default_base_moof),
|
||||||
|
// NOT MXF — this ffmpeg's MXF muxer rejects HEVC ("Operation not permitted").
|
||||||
|
// The frag-MOV index is not deferred to EOF, so the file stays readable while
|
||||||
|
// growing. (See docs/design/2026-05-29-all-intra-hevc-ingest.md §8.)
|
||||||
|
//
|
||||||
|
// -profile:v main10 / p010le: 10-bit 4:2:0 — the closest NVENC HEVC can get
|
||||||
|
// to ProRes 4:2:2 10-bit. If strict 4:2:2 is required, use prores_hq (CPU).
|
||||||
|
hevc_nvenc: {
|
||||||
|
args: ['-c:v', 'hevc_nvenc', '-preset', 'p4', '-rc', 'vbr', '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1', '-profile:v', 'main10'],
|
||||||
|
bitrateControl: true,
|
||||||
|
pixFmt: 'p010le',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUDIO_CODECS = {
|
const AUDIO_CODECS = {
|
||||||
|
|
@ -128,25 +153,81 @@ 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)').
|
// FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo 2 (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 -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
const out = execSync('ffmpeg -hide_banner -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]) deckLinkName = names[idx];
|
if (names[idx]) {
|
||||||
else deckLinkName = `DeckLink Duo (${idx + 1})`;
|
deckLinkName = names[idx];
|
||||||
} catch (_) {
|
console.log(`[capture] DeckLink index ${idx} → "${deckLinkName}" (from ${names.length} detected: ${names.join(', ')})`);
|
||||||
deckLinkName = `DeckLink Duo (${parseInt(device, 10) + 1})`;
|
} else {
|
||||||
|
// Fallback: cannot determine model name without enumeration.
|
||||||
|
// Log a warning — operator should check the detected device list.
|
||||||
|
console.warn(`[capture] DeckLink index ${idx} out of range (detected ${names.length} devices: ${names.join(', ')}). Falling back to index-only input — capture may fail.`);
|
||||||
|
deckLinkName = `DeckLink (${idx})`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[capture] ffmpeg -sources decklink failed: ${err.message}. Using index ${device} directly.`);
|
||||||
|
// Pass the numeric index directly; some ffmpeg builds accept it.
|
||||||
|
deckLinkName = String(device);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -210,12 +291,16 @@ 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); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network sources cannot be opened by two FFmpeg processes simultaneously
|
// DeckLink hardware does NOT support concurrent capture from the same port.
|
||||||
// (one socket = one consumer). For SRT/RTMP the BullMQ worker generates
|
// Opening a second ffmpeg process on the same DeckLink input while the first
|
||||||
// the proxy after the recording stops.
|
// is already capturing causes "Cannot Autodetect input stream or No signal"
|
||||||
const proxyKey = (sourceType === 'sdi' && proxyEnabled)
|
// on the second process — making the proxy empty and potentially crashing the
|
||||||
? `projects/${projectId}/proxies/${clipName}.${proxyExt}`
|
// container before the hires upload completes.
|
||||||
: 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();
|
||||||
|
|
||||||
|
|
@ -241,12 +326,38 @@ 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'];
|
||||||
|
|
||||||
const hiresProcess = spawn('ffmpeg', [
|
// For SDI we cannot open the DeckLink device a second time for a preview
|
||||||
|
// tee, so the live HLS preview is produced as a SECOND OUTPUT of the hires
|
||||||
|
// ffmpeg: one decklink read -> yadif -> split -> [ProRes/S3] + [H.264/HLS].
|
||||||
|
let sdiHlsDir = null;
|
||||||
|
let hiresArgs;
|
||||||
|
if (sourceType === 'sdi' && this._assetIdForHls) {
|
||||||
|
const fsMod = await import('node:fs');
|
||||||
|
sdiHlsDir = '/live/' + this._assetIdForHls;
|
||||||
|
try { fsMod.mkdirSync(sdiHlsDir, { recursive: true }); } catch (_) {}
|
||||||
|
hiresArgs = [
|
||||||
...inputArgs,
|
...inputArgs,
|
||||||
...sdiFilterArgs,
|
'-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,
|
...hiresCodecArgs,
|
||||||
hiresOutput,
|
hiresOutput,
|
||||||
], { stdio: hiresStdio });
|
// 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 })
|
||||||
|
|
@ -298,39 +409,8 @@ class CaptureManager {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// SDI only: spawn a second ffmpeg for the proxy.
|
// Proxy is generated after stop by the BullMQ worker (same as SRT/RTMP).
|
||||||
// DeckLink cards allow concurrent reads; network sockets do not.
|
// DeckLink hardware does not support two concurrent readers on the same port.
|
||||||
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,6 +9,7 @@ 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());
|
||||||
|
|
@ -128,17 +129,33 @@ 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' },
|
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
|
||||||
});
|
});
|
||||||
} 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' },
|
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
projectId: completed.projectId,
|
projectId: completed.projectId,
|
||||||
binId: completed.binId,
|
binId: completed.binId,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
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';
|
||||||
|
|
@ -95,8 +96,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.
|
||||||
// Format: [decklink @ ...] "DeckLink Quad 2" (input #0)
|
// DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)"
|
||||||
const lines = output.split('\n');
|
const lines = output.split('\n');
|
||||||
let deviceIndex = 0;
|
let deviceIndex = 0;
|
||||||
|
|
||||||
|
|
@ -118,6 +119,57 @@ 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
|
||||||
|
|
@ -150,6 +202,28 @@ 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,7 +22,9 @@
|
||||||
"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"
|
||||||
|
|
|
||||||
90
services/mam-api/src/auth/authz.js
Normal file
90
services/mam-api/src/auth/authz.js
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
services/mam-api/src/auth/google-oauth.js
Normal file
90
services/mam-api/src/auth/google-oauth.js
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
58
services/mam-api/src/auth/mfa-tickets.js
Normal file
58
services/mam-api/src/auth/mfa-tickets.js
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
118
services/mam-api/src/auth/totp.js
Normal file
118
services/mam-api/src/auth/totp.js
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- 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 $$;
|
||||||
5
services/mam-api/src/db/migrations/025-hls-key.sql
Normal file
5
services/mam-api/src/db/migrations/025-hls-key.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- 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;
|
||||||
30
services/mam-api/src/db/migrations/026-project-access.sql
Normal file
30
services/mam-api/src/db/migrations/026-project-access.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- 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);
|
||||||
20
services/mam-api/src/db/migrations/027-totp-2fa.sql
Normal file
20
services/mam-api/src/db/migrations/027-totp-2fa.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
-- 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);
|
||||||
13
services/mam-api/src/db/migrations/028-google-oauth.sql
Normal file
13
services/mam-api/src/db/migrations/028-google-oauth.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- 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;
|
||||||
165
services/mam-api/src/db/migrations/029-playout.sql
Normal file
165
services/mam-api/src/db/migrations/029-playout.sql
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
-- Migration 029 — Playout / Master Control (MCR).
|
||||||
|
--
|
||||||
|
-- Adds a broadcast playout subsystem: take library assets, arrange them on a
|
||||||
|
-- playlist (Phase A) or a wall-clock timeline (Phase B), and play them out
|
||||||
|
-- continuously to SDI (DeckLink) / NDI / SRT / RTMP via a CasparCG sidecar.
|
||||||
|
--
|
||||||
|
-- This is the mirror of the capture path (input -> ffmpeg -> S3). A channel is
|
||||||
|
-- placed on a cluster node by capability the same way recorders claim input
|
||||||
|
-- ports; the engine container is spawned via the same Docker-socket /
|
||||||
|
-- node-agent orchestration. See docs/superpowers/specs/2026-05-30-playout-mcr-design.md.
|
||||||
|
--
|
||||||
|
-- Tables:
|
||||||
|
-- playout_channels — a logical output (one channel -> one CasparCG instance -> one target)
|
||||||
|
-- playout_playlists — an ordered list of items bound to a channel (Phase A)
|
||||||
|
-- playout_items — one clip on a playlist OR one row on the timeline
|
||||||
|
-- playout_sidecars — running CasparCG sidecar registry (one per channel; health-checked)
|
||||||
|
-- playout_schedule — wall-clock day-ahead rows (Phase B; unused in A)
|
||||||
|
-- playout_as_run — append-only log of what actually played (compliance)
|
||||||
|
|
||||||
|
-- ── Channels ───────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS playout_channels (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
node_id UUID REFERENCES cluster_nodes(id) ON DELETE SET NULL,
|
||||||
|
output_type TEXT NOT NULL DEFAULT 'srt',
|
||||||
|
-- output_config is consumer-shape-specific:
|
||||||
|
-- decklink: { "device_index": 1 }
|
||||||
|
-- ndi: { "ndi_name": "DRAGONFLIGHT CH1" }
|
||||||
|
-- srt: { "url": "srt://host:9000", "latency": 200 }
|
||||||
|
-- rtmp: { "url": "rtmp://host/live", "key": "streamkey" }
|
||||||
|
output_config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
-- 1080p59.94 is the house standard (matches capture cadence, streaming-friendly,
|
||||||
|
-- accepted by current SDI gear). Per-channel override allowed.
|
||||||
|
video_format TEXT NOT NULL DEFAULT '1080p5994',
|
||||||
|
status TEXT NOT NULL DEFAULT 'stopped',
|
||||||
|
container_id TEXT,
|
||||||
|
-- For remote channels the node-agent reports the reachable host:port of the
|
||||||
|
-- sidecar HTTP shim; stored here so the API can proxy transport calls.
|
||||||
|
container_meta JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
error_message TEXT,
|
||||||
|
-- Failover bookkeeping. Scheduler tick health-checks the sidecar; on N missed
|
||||||
|
-- checks the channel is re-placed on a healthy node (auto for ndi/srt/rtmp,
|
||||||
|
-- alert-only for decklink — device-index pinning makes re-placement non-trivial).
|
||||||
|
restart_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_restart_at TIMESTAMPTZ,
|
||||||
|
last_heartbeat_at TIMESTAMPTZ,
|
||||||
|
-- RBAC scoping: a NULL project_id resolves to admin-only (authz.js), the same
|
||||||
|
-- convention recorders use for unassigned resources.
|
||||||
|
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CHECK (output_type IN ('decklink','ndi','srt','rtmp')),
|
||||||
|
CHECK (status IN ('stopped','starting','running','error'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_channels_node ON playout_channels (node_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_channels_project ON playout_channels (project_id);
|
||||||
|
|
||||||
|
-- ── Playlists ──────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS playout_playlists (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
loop BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_playlists_channel ON playout_playlists (channel_id);
|
||||||
|
|
||||||
|
-- ── Items ──────────────────────────────────────────────────────────────────
|
||||||
|
-- One entry on a playlist (Phase A, ordered by sort_order) OR one entry on the
|
||||||
|
-- timeline (Phase B, ordered by scheduled_at). in/out points reuse the editor's
|
||||||
|
-- subclip trim model (seconds). media_status tracks the S3 -> /media staging
|
||||||
|
-- (see playout-stage worker job); a clip cannot go on air until 'ready'.
|
||||||
|
CREATE TABLE IF NOT EXISTS playout_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
playlist_id UUID REFERENCES playout_playlists(id) ON DELETE CASCADE,
|
||||||
|
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
scheduled_at TIMESTAMPTZ,
|
||||||
|
in_point NUMERIC,
|
||||||
|
out_point NUMERIC,
|
||||||
|
transition TEXT NOT NULL DEFAULT 'cut',
|
||||||
|
transition_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
graphics JSONB,
|
||||||
|
media_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
media_path TEXT,
|
||||||
|
-- Set when playout-stage has run loudnorm (EBU R128, -23 LUFS / -1 dBTP) on
|
||||||
|
-- the staged file. Re-stages skip the loudnorm pass when true.
|
||||||
|
audio_normalized BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CHECK (transition IN ('cut','mix','wipe')),
|
||||||
|
CHECK (media_status IN ('pending','staging','ready','error'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_items_playlist ON playout_items (playlist_id, sort_order);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_items_asset ON playout_items (asset_id);
|
||||||
|
|
||||||
|
-- ── Sidecars ───────────────────────────────────────────────────────────────
|
||||||
|
-- Running CasparCG container registry, one row per running channel. The
|
||||||
|
-- scheduler tick (src/scheduler.js) pings each sidecar's /status endpoint and
|
||||||
|
-- updates last_heartbeat_at; missed checks trigger the failover path in
|
||||||
|
-- routes/playout.js.
|
||||||
|
CREATE TABLE IF NOT EXISTS playout_sidecars (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
|
||||||
|
node_id UUID REFERENCES cluster_nodes(id) ON DELETE SET NULL,
|
||||||
|
container_id TEXT NOT NULL,
|
||||||
|
sidecar_url TEXT, -- http://host:port for the shim
|
||||||
|
amcp_port INTEGER, -- in-container AMCP port (default 5250)
|
||||||
|
status TEXT NOT NULL DEFAULT 'running',
|
||||||
|
last_heartbeat_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CHECK (status IN ('starting','running','error','stopped'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_playout_sidecars_channel ON playout_sidecars (channel_id)
|
||||||
|
WHERE status IN ('starting','running');
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_sidecars_status ON playout_sidecars (status);
|
||||||
|
|
||||||
|
-- ── Schedule (Phase B) ─────────────────────────────────────────────────────
|
||||||
|
-- Wall-clock day-ahead timeline. The scheduler tick (src/scheduler.js, under
|
||||||
|
-- the existing PG advisory lock) drives transitions and gap-fill. Unused by the
|
||||||
|
-- Phase A playlist player but created now so the schema is stable.
|
||||||
|
CREATE TABLE IF NOT EXISTS playout_schedule (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
|
||||||
|
asset_id UUID REFERENCES assets(id) ON DELETE SET NULL,
|
||||||
|
scheduled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
in_point NUMERIC,
|
||||||
|
out_point NUMERIC,
|
||||||
|
transition TEXT NOT NULL DEFAULT 'cut',
|
||||||
|
transition_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_filler BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'scheduled',
|
||||||
|
media_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
media_path TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CHECK (transition IN ('cut','mix','wipe')),
|
||||||
|
CHECK (status IN ('scheduled','playing','played','skipped','error')),
|
||||||
|
CHECK (media_status IN ('pending','staging','ready','error'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_schedule_channel_time ON playout_schedule (channel_id, scheduled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_schedule_status ON playout_schedule (status, scheduled_at);
|
||||||
|
|
||||||
|
-- ── As-run log ─────────────────────────────────────────────────────────────
|
||||||
|
-- Append-only record of what actually went to air. Never updated after insert.
|
||||||
|
CREATE TABLE IF NOT EXISTS playout_as_run (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
channel_id UUID NOT NULL REFERENCES playout_channels(id) ON DELETE CASCADE,
|
||||||
|
asset_id UUID REFERENCES assets(id) ON DELETE SET NULL,
|
||||||
|
item_id UUID,
|
||||||
|
clip_name TEXT,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
duration_s NUMERIC,
|
||||||
|
result TEXT NOT NULL DEFAULT 'played',
|
||||||
|
CHECK (result IN ('played','skipped','error'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_playout_as_run_channel ON playout_as_run (channel_id, started_at DESC);
|
||||||
9
services/mam-api/src/db/migrations/030-totp-replay.sql
Normal file
9
services/mam-api/src/db/migrations/030-totp-replay.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- 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;
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Migration 031 — Add last_seen_at to cluster_nodes
|
||||||
|
--
|
||||||
|
-- Playout failover (routes/playout.js restartChannel) queries cluster_nodes.last_seen_at
|
||||||
|
-- to find healthy nodes for channel re-placement. Column was missing from original
|
||||||
|
-- cluster schema; heartbeat endpoint updates it via /cluster/heartbeat.
|
||||||
|
|
||||||
|
ALTER TABLE cluster_nodes ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Backfill existing nodes to NOW() so they're immediately eligible for failover
|
||||||
|
UPDATE cluster_nodes SET last_seen_at = NOW() WHERE last_seen_at IS NULL;
|
||||||
|
|
@ -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');
|
CREATE TYPE source_type AS ENUM ('sdi', 'srt', 'rtmp', 'deltacast');
|
||||||
|
|
||||||
-- 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 } from './middleware/auth.js';
|
import { requireAuth, requireUiHeader, requireAdmin } 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,6 +22,7 @@ import jobsRouter from './routes/jobs.js';
|
||||||
import captureRouter from './routes/capture.js';
|
import captureRouter from './routes/capture.js';
|
||||||
import uploadRouter from './routes/upload.js';
|
import uploadRouter from './routes/upload.js';
|
||||||
import recordersRouter from './routes/recorders.js';
|
import recordersRouter from './routes/recorders.js';
|
||||||
|
import playoutRouter from './routes/playout.js';
|
||||||
import settingsRouter from './routes/settings.js';
|
import settingsRouter from './routes/settings.js';
|
||||||
import amppRouter from './routes/ampp.js';
|
import amppRouter from './routes/ampp.js';
|
||||||
import groupsRouter from './routes/groups.js';
|
import groupsRouter from './routes/groups.js';
|
||||||
|
|
@ -40,18 +41,12 @@ import { startCleanupLoop } from './tasks/cleanupTempSegments.js';
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
// ── Middleware ────────────────────────────────────────────────────────────────
|
|
||||||
// Tightened CORS — once cookies carry authority, `origin: true` would let
|
|
||||||
// any site forge requests with the cookie. Drive the allowlist from env.
|
|
||||||
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
|
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
|
||||||
.split(',').map(s => s.trim()).filter(Boolean);
|
.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: (origin, cb) => {
|
origin: (origin, cb) => {
|
||||||
// No Origin header (same-origin or curl) — allow.
|
|
||||||
if (!origin) return cb(null, true);
|
if (!origin) return cb(null, true);
|
||||||
if (allowedOrigins.length === 0 || allowedOrigins.includes(origin)) return cb(null, true);
|
if (allowedOrigins.length === 0 || allowedOrigins.includes(origin)) return cb(null, true);
|
||||||
// Reject cleanly: omit the Allow-Origin header so the browser surfaces
|
|
||||||
// a real CORS error instead of a 500 from a thrown Error in the callback.
|
|
||||||
console.warn('[cors] rejected origin:', origin);
|
console.warn('[cors] rejected origin:', origin);
|
||||||
return cb(null, false);
|
return cb(null, false);
|
||||||
},
|
},
|
||||||
|
|
@ -59,14 +54,8 @@ app.use(cors({
|
||||||
}));
|
}));
|
||||||
app.use(express.json({ limit: '50mb' }));
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
|
||||||
// Trust the reverse proxy only when explicitly told to (production HTTPS).
|
|
||||||
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);
|
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);
|
||||||
|
|
||||||
// HSTS — once a browser has seen this header over HTTPS for dragonflight.live,
|
|
||||||
// it auto-upgrades every future http:// request to https:// before hitting the
|
|
||||||
// wire. Cookies are Secure-only (below) and the CORS allowlist rejects HTTP,
|
|
||||||
// so without HSTS a user who lands on http:// silently can't log in.
|
|
||||||
// Only emit on actual HTTPS responses; req.secure honors trust proxy + X-Forwarded-Proto.
|
|
||||||
if (process.env.AUTH_ENABLED === 'true') {
|
if (process.env.AUTH_ENABLED === 'true') {
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (req.secure) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
if (req.secure) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||||
|
|
@ -74,17 +63,13 @@ if (process.env.AUTH_ENABLED === 'true') {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hard-fail when production-mode auth has no stable session secret. Without
|
|
||||||
// this, express-session falls back to an in-memory random secret which
|
|
||||||
// invalidates every session on restart and breaks multi-node deployments.
|
|
||||||
if (process.env.AUTH_ENABLED === 'true' && !process.env.SESSION_SECRET) {
|
if (process.env.AUTH_ENABLED === 'true' && !process.env.SESSION_SECRET) {
|
||||||
console.error('[fatal] SESSION_SECRET is required when AUTH_ENABLED=true');
|
console.error('[fatal] SESSION_SECRET is required when AUTH_ENABLED=true');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session — actually wired this time. See specs/2026-05-27-auth-system-design.md.
|
|
||||||
app.use(session({
|
app.use(session({
|
||||||
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 /* seconds = 15 min */ }),
|
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 }),
|
||||||
secret: process.env.SESSION_SECRET,
|
secret: process.env.SESSION_SECRET,
|
||||||
name: 'dragonflight.sid',
|
name: 'dragonflight.sid',
|
||||||
cookie: {
|
cookie: {
|
||||||
|
|
@ -94,31 +79,26 @@ app.use(session({
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: 8 * 3600 * 1000,
|
maxAge: 8 * 3600 * 1000,
|
||||||
},
|
},
|
||||||
rolling: false, // sliding renewal handled in requireAuth so idle + absolute can be enforced separately
|
rolling: false,
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Health ────────────────────────────────────────────────────────────────────
|
|
||||||
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
||||||
|
|
||||||
// ── Auth gate ─────────────────────────────────────────────────────────────────
|
const UNAUTH_PATHS = new Set([
|
||||||
// req.path is relative to the /api/v1 mount, so /auth/login NOT /api/v1/auth/login.
|
'/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required',
|
||||||
const UNAUTH_PATHS = new Set(['/auth/login', '/auth/setup', '/auth/setup-required']);
|
'/auth/google', '/auth/google/callback', '/auth/google/enabled',
|
||||||
// node-agent now authenticates /cluster/heartbeat with a bound api_token
|
]);
|
||||||
// (migration 019 + bound_hostname on the token). requireAuth handles the
|
|
||||||
// bearer lookup and sets req.tokenBoundHostname; the heartbeat handler in
|
|
||||||
// routes/cluster.js verifies body.hostname matches that binding.
|
|
||||||
app.use('/api/v1', requireUiHeader);
|
app.use('/api/v1', requireUiHeader);
|
||||||
app.use('/api/v1', (req, res, next) => {
|
app.use('/api/v1', (req, res, next) => {
|
||||||
if (UNAUTH_PATHS.has(req.path)) return next();
|
if (UNAUTH_PATHS.has(req.path)) return next();
|
||||||
return requireAuth(req, res, next);
|
return requireAuth(req, res, next);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── API Routes ────────────────────────────────────────────────────────────────
|
|
||||||
app.use('/api/v1/auth', authRouter);
|
app.use('/api/v1/auth', authRouter);
|
||||||
app.use('/api/v1/auth/users', usersRouter);
|
app.use('/api/v1/auth/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/users', requireAdmin, usersRouter);
|
||||||
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
|
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
|
||||||
app.use('/api/v1/assets', assetsRouter);
|
app.use('/api/v1/assets', assetsRouter);
|
||||||
app.use('/api/v1/projects', projectsRouter);
|
app.use('/api/v1/projects', projectsRouter);
|
||||||
|
|
@ -127,9 +107,10 @@ 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', groupsRouter);
|
app.use('/api/v1/groups', requireAdmin, 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);
|
||||||
|
|
@ -140,21 +121,14 @@ app.use('/api/v1/assets/:assetId/comments', commentsRouter);
|
||||||
app.use('/api/v1/imports', importsRouter);
|
app.use('/api/v1/imports', importsRouter);
|
||||||
app.use('/api/v1/storage', storageRouter);
|
app.use('/api/v1/storage', storageRouter);
|
||||||
|
|
||||||
// ── Error handler ─────────────────────────────────────────────────────────────
|
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
// ── Start ────────────────────────────────────────────────────────────────────
|
|
||||||
import { readdirSync, readFileSync } from 'node:fs';
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
const __dirnameMig = dirname(fileURLToPath(import.meta.url));
|
const __dirnameMig = dirname(fileURLToPath(import.meta.url));
|
||||||
async function runMigrations() {
|
async function runMigrations() {
|
||||||
// Issue #107 — previously the loop swallowed errors and let the server boot
|
|
||||||
// on a half-migrated schema. Now: track applied migrations in a table, run
|
|
||||||
// every pending one inside a transaction, and exit non-zero on failure so
|
|
||||||
// the orchestrator restarts (and so an operator notices) instead of serving
|
|
||||||
// 500s for the next month.
|
|
||||||
const dir = join(__dirnameMig, 'db', 'migrations');
|
const dir = join(__dirnameMig, 'db', 'migrations');
|
||||||
let files = [];
|
let files = [];
|
||||||
try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
|
try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
|
||||||
|
|
@ -167,7 +141,6 @@ async function runMigrations() {
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Allow forcing a re-run via env when iterating locally.
|
|
||||||
const force = process.env.MIGRATIONS_FORCE === '1';
|
const force = process.env.MIGRATIONS_FORCE === '1';
|
||||||
const allowFailures = process.env.MIGRATIONS_ALLOW_FAILURES === '1';
|
const allowFailures = process.env.MIGRATIONS_ALLOW_FAILURES === '1';
|
||||||
|
|
||||||
|
|
@ -193,7 +166,6 @@ async function runMigrations() {
|
||||||
console.error('[migration] FAILED ' + f + ': ' + err.message);
|
console.error('[migration] FAILED ' + f + ': ' + err.message);
|
||||||
client.release();
|
client.release();
|
||||||
if (allowFailures) continue;
|
if (allowFailures) continue;
|
||||||
// Hard fail — better to crash now than serve traffic on a broken schema.
|
|
||||||
console.error('[migration] aborting startup. Set MIGRATIONS_ALLOW_FAILURES=1 to override.');
|
console.error('[migration] aborting startup. Set MIGRATIONS_ALLOW_FAILURES=1 to override.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
@ -202,13 +174,9 @@ async function runMigrations() {
|
||||||
}
|
}
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
|
||||||
// Load S3 config from DB so any settings saved via the Settings page override env vars
|
|
||||||
await loadS3ConfigFromDb();
|
await loadS3ConfigFromDb();
|
||||||
|
|
||||||
// ── Cluster self-heartbeat ────────────────────────────────────────────────────
|
|
||||||
function getLocalIp() {
|
function getLocalIp() {
|
||||||
// Prefer an explicit override — useful when running inside Docker where
|
|
||||||
// os.networkInterfaces() returns container bridge IPs, not the host LAN IP.
|
|
||||||
if (process.env.NODE_IP) return process.env.NODE_IP;
|
if (process.env.NODE_IP) return process.env.NODE_IP;
|
||||||
|
|
||||||
const ifaces = os.networkInterfaces();
|
const ifaces = os.networkInterfaces();
|
||||||
|
|
@ -220,9 +188,6 @@ function getLocalIp() {
|
||||||
return '127.0.0.1';
|
return '127.0.0.1';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect NVIDIA GPUs available to this container via nvidia-smi.
|
|
||||||
// Returns an array like [{ index: 0, name: 'Tesla P4', memory_mb: 7680 }, ...]
|
|
||||||
// or an empty array if nvidia-smi is unavailable or no GPUs found.
|
|
||||||
function detectGpus() {
|
function detectGpus() {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
exec(
|
exec(
|
||||||
|
|
@ -244,6 +209,10 @@ function detectGpus() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Primary mam-api node self-registers in cluster_nodes every 30s. Must write
|
||||||
|
// BOTH last_seen (legacy column) and last_seen_at (added by mig 031, used by
|
||||||
|
// playout failover) — otherwise the primary appears stale to the failover
|
||||||
|
// query and channels get re-placed off it incorrectly.
|
||||||
async function selfHeartbeat() {
|
async function selfHeartbeat() {
|
||||||
const load = os.loadavg()[0];
|
const load = os.loadavg()[0];
|
||||||
const total = os.totalmem();
|
const total = os.totalmem();
|
||||||
|
|
@ -255,14 +224,15 @@ async function selfHeartbeat() {
|
||||||
pool.query(
|
pool.query(
|
||||||
`INSERT INTO cluster_nodes
|
`INSERT INTO cluster_nodes
|
||||||
(hostname, ip_address, role, version, api_url,
|
(hostname, ip_address, role, version, api_url,
|
||||||
cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen)
|
cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen, last_seen_at)
|
||||||
VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW())
|
VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW(),NOW())
|
||||||
ON CONFLICT (hostname) DO UPDATE SET
|
ON CONFLICT (hostname) DO UPDATE SET
|
||||||
ip_address = EXCLUDED.ip_address,
|
ip_address = EXCLUDED.ip_address,
|
||||||
cpu_usage = EXCLUDED.cpu_usage,
|
cpu_usage = EXCLUDED.cpu_usage,
|
||||||
mem_used_mb = EXCLUDED.mem_used_mb,
|
mem_used_mb = EXCLUDED.mem_used_mb,
|
||||||
mem_total_mb = EXCLUDED.mem_total_mb,
|
mem_total_mb = EXCLUDED.mem_total_mb,
|
||||||
capabilities = EXCLUDED.capabilities,
|
capabilities = EXCLUDED.capabilities,
|
||||||
|
last_seen_at = NOW(),
|
||||||
last_seen = NOW()`,
|
last_seen = NOW()`,
|
||||||
[
|
[
|
||||||
process.env.NODE_HOSTNAME || os.hostname(),
|
process.env.NODE_HOSTNAME || os.hostname(),
|
||||||
|
|
@ -287,39 +257,26 @@ const server = app.listen(PORT, () => {
|
||||||
if (process.env.AUTH_ENABLED === 'true' && process.env.TRUST_PROXY !== 'true') {
|
if (process.env.AUTH_ENABLED === 'true' && process.env.TRUST_PROXY !== 'true') {
|
||||||
console.warn('[auth] WARNING: AUTH_ENABLED=true but TRUST_PROXY=false — req.ip will be the proxy IP, login rate-limit will throttle all clients together. Set TRUST_PROXY=true when behind nginx/HTTPS.');
|
console.warn('[auth] WARNING: AUTH_ENABLED=true but TRUST_PROXY=false — req.ip will be the proxy IP, login rate-limit will throttle all clients together. Set TRUST_PROXY=true when behind nginx/HTTPS.');
|
||||||
}
|
}
|
||||||
// Boot the recorder scheduler tick loop after the HTTP server is live so
|
|
||||||
// the loop's self-calls to /recorders/:id/start|stop reach a ready socket.
|
|
||||||
startSchedulerLoop();
|
startSchedulerLoop();
|
||||||
|
|
||||||
// Boot the temp-segment cleanup loop (runs hourly).
|
|
||||||
startCleanupLoop();
|
startCleanupLoop();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Issue #100 — graceful shutdown. Without this, `docker stop` (SIGTERM) killed
|
|
||||||
// the process mid-scheduler-tick, leaving Redis connections and Docker
|
|
||||||
// sockets dangling and producing partial DB writes. Now: stop the scheduler,
|
|
||||||
// finish in-flight HTTP requests, close PG/Redis pools, and exit cleanly
|
|
||||||
// (or hard-exit after 25 s if something is stuck).
|
|
||||||
let _shuttingDown = false;
|
let _shuttingDown = false;
|
||||||
async function gracefulShutdown(signal) {
|
async function gracefulShutdown(signal) {
|
||||||
if (_shuttingDown) return;
|
if (_shuttingDown) return;
|
||||||
_shuttingDown = true;
|
_shuttingDown = true;
|
||||||
console.log(`[shutdown] received ${signal} — closing gracefully…`);
|
console.log(`[shutdown] received ${signal} — closing gracefully…`);
|
||||||
|
|
||||||
// Stop accepting new requests + wind down the scheduler tick.
|
|
||||||
try { stopSchedulerLoop(); } catch (_) {}
|
try { stopSchedulerLoop(); } catch (_) {}
|
||||||
|
|
||||||
// Force-exit watchdog so a hung connection can't keep us alive forever.
|
|
||||||
const killSwitch = setTimeout(() => {
|
const killSwitch = setTimeout(() => {
|
||||||
console.error('[shutdown] forced exit after 25s timeout');
|
console.error('[shutdown] forced exit after 25s timeout');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}, 25_000);
|
}, 25_000);
|
||||||
killSwitch.unref();
|
killSwitch.unref();
|
||||||
|
|
||||||
// Stop the HTTP server (waits for in-flight requests to finish).
|
|
||||||
await new Promise(resolve => server.close(resolve));
|
await new Promise(resolve => server.close(resolve));
|
||||||
|
|
||||||
// Close DB pool + S3 client + any other resources. Best-effort.
|
|
||||||
try { await pool.end(); } catch (e) { console.warn('[shutdown] pool.end:', e.message); }
|
try { await pool.end(); } catch (e) { console.warn('[shutdown] pool.end:', e.message); }
|
||||||
|
|
||||||
console.log('[shutdown] clean exit');
|
console.log('[shutdown] clean exit');
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,29 @@
|
||||||
|
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';
|
||||||
export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)' };
|
// role 'admin' so dev mode (AUTH_ENABLED=false) keeps full access through the
|
||||||
|
// RBAC v2 gates — matches migration 023's seeded dev row.
|
||||||
|
export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)', role: 'admin' };
|
||||||
|
|
||||||
const ABSOLUTE_MS = 8 * 3600 * 1000;
|
const ABSOLUTE_MS = 8 * 3600 * 1000;
|
||||||
const IDLE_MS = 1 * 3600 * 1000;
|
const IDLE_MS = 1 * 3600 * 1000;
|
||||||
|
|
@ -18,11 +37,18 @@ 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 FROM users WHERE id = $1`, [id]);
|
`SELECT id, username, display_name, role, totp_enabled 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;
|
||||||
|
|
@ -73,6 +99,14 @@ 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.
|
||||||
|
|
@ -88,6 +122,8 @@ const CSRF_EXEMPT_PATHS = new Set(['/cluster/heartbeat']);
|
||||||
|
|
||||||
export function requireUiHeader(req, res, next) {
|
export function requireUiHeader(req, res, next) {
|
||||||
if (!MUTATING.has(req.method)) return next();
|
if (!MUTATING.has(req.method)) return next();
|
||||||
|
// Internal loopback self-call (scheduler) — not a browser, can't be drive-by'd.
|
||||||
|
if (isInternalCall(req)) return next();
|
||||||
// Bearer-authed requests (Premiere panel, scripts) are exempt — they're not
|
// Bearer-authed requests (Premiere panel, scripts) are exempt — they're not
|
||||||
// browsers and can't be drive-by'd from another origin.
|
// browsers and can't be drive-by'd from another origin.
|
||||||
if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next();
|
if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next();
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,36 @@ 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) => {
|
||||||
|
|
@ -33,6 +60,10 @@ 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 {
|
||||||
|
|
@ -62,6 +93,15 @@ 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'`;
|
||||||
|
|
@ -128,6 +168,9 @@ 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)' });
|
||||||
|
|
@ -216,8 +259,8 @@ router.post('/', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /cleanup-live
|
// POST /cleanup-live — cross-project maintenance, admin only.
|
||||||
router.post('/cleanup-live', async (req, res, next) => {
|
router.post('/cleanup-live', requireAdmin, 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(
|
||||||
|
|
@ -230,8 +273,8 @@ router.post('/cleanup-live', async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /cleanup-live-orphans
|
// POST /cleanup-live-orphans — cross-project maintenance, admin only.
|
||||||
router.post('/cleanup-live-orphans', async (_req, res, next) => {
|
router.post('/cleanup-live-orphans', requireAdmin, async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const liveRoot = process.env.LIVE_DIR || '/live';
|
const liveRoot = process.env.LIVE_DIR || '/live';
|
||||||
let entries;
|
let entries;
|
||||||
|
|
@ -273,10 +316,22 @@ router.get('/:id', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /:id
|
// PATCH /:id
|
||||||
router.patch('/:id', async (req, res, next) => {
|
router.patch('/:id', requireAssetEdit, 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); }
|
||||||
|
|
@ -295,13 +350,32 @@ router.patch('/:id', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/copy
|
// POST /:id/copy
|
||||||
router.post('/:id/copy', async (req, res, next) => {
|
router.post('/:id/copy', requireAssetEdit, 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
|
||||||
|
|
@ -316,8 +390,8 @@ router.post('/:id/copy', 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, projectId || src.project_id,
|
newId, destProjectId,
|
||||||
binId === undefined ? src.bin_id : (binId || null),
|
destBinId,
|
||||||
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,
|
||||||
|
|
@ -342,7 +416,7 @@ router.post('/:id/copy', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/mark-empty
|
// POST /:id/mark-empty
|
||||||
router.post('/:id/mark-empty', async (req, res, next) => {
|
router.post('/:id/mark-empty', requireAssetEdit, 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
|
||||||
|
|
@ -373,8 +447,66 @@ router.post('/:id/mark-empty', 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', async (req, res, next) => {
|
router.post('/:id/generate-proxy', requireAssetEdit, 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]);
|
||||||
|
|
@ -390,8 +522,8 @@ router.post('/:id/generate-proxy', async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /backfill-proxies
|
// POST /backfill-proxies — cross-project maintenance, admin only.
|
||||||
router.post('/backfill-proxies', async (_req, res, next) => {
|
router.post('/backfill-proxies', requireAdmin, 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
|
||||||
|
|
@ -415,12 +547,12 @@ router.post('/backfill-proxies', 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', async (req, res, next) => {
|
router.post('/:id/reprocess', requireAssetEdit, 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'].includes(type)) {
|
if (!['proxy', 'thumbnail', 'filmstrip', 'hls'].includes(type)) {
|
||||||
return res.status(400).json({ error: 'type must be "proxy", "thumbnail", or "filmstrip"' });
|
return res.status(400).json({ error: 'type must be "proxy", "thumbnail", "filmstrip", or "hls"' });
|
||||||
}
|
}
|
||||||
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' });
|
||||||
|
|
@ -443,6 +575,12 @@ router.post('/:id/reprocess', 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); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -460,7 +598,7 @@ router.get('/:id/filmstrip', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/retry
|
// POST /:id/retry
|
||||||
router.post('/:id/retry', async (req, res, next) => {
|
router.post('/:id/retry', requireAssetEdit, 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]);
|
||||||
|
|
@ -479,7 +617,7 @@ router.post('/:id/retry', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /:id
|
// DELETE /:id
|
||||||
router.delete('/:id', async (req, res, next) => {
|
router.delete('/:id', requireAssetEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { hard } = req.query;
|
const { hard } = req.query;
|
||||||
|
|
@ -527,6 +665,20 @@ 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))
|
||||||
|
|
@ -538,6 +690,42 @@ 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 {
|
||||||
|
|
@ -790,6 +978,15 @@ 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,6 +3,14 @@ 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';
|
||||||
|
|
||||||
|
|
@ -76,7 +84,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 FROM users WHERE username = $1 AND id <> $2`,
|
`SELECT id, username, display_name, password_hash, totp_enabled 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) {
|
||||||
|
|
@ -93,6 +101,29 @@ 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
|
||||||
|
// a short-lived ticket the client redeems via /login/totp with a code.
|
||||||
|
// Crucially: do NOT clear the per-IP failure counter here. If we did, each
|
||||||
|
// /login retry would reset the backoff and let an attacker brute the 6-digit
|
||||||
|
// TOTP space (10^6) with no per-attempt delay. The counter is cleared
|
||||||
|
// inside establishSession() once MFA has actually passed.
|
||||||
|
if (user.totp_enabled) {
|
||||||
|
return res.json({
|
||||||
|
mfa_required: true,
|
||||||
|
ticket: issueTicket(user.id, { ip, userAgent: req.get('user-agent') }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await establishSession(req, user, ip);
|
||||||
|
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write the session and wait for it to persist before responding. Extracted so
|
||||||
|
// both the password-only and the MFA-completion paths share one implementation.
|
||||||
|
// Clears the per-IP failure counter only here — after every required factor has
|
||||||
|
// actually been proven (password [+ TOTP if enabled, or OAuth + TOTP]).
|
||||||
|
async function establishSession(req, user, ip) {
|
||||||
req.session.user_id = user.id;
|
req.session.user_id = user.id;
|
||||||
req.session.first_seen_at = Date.now();
|
req.session.first_seen_at = Date.now();
|
||||||
req.session.last_seen_at = Date.now();
|
req.session.last_seen_at = Date.now();
|
||||||
|
|
@ -100,14 +131,93 @@ router.post('/login', async (req, res, next) => {
|
||||||
// Without this, the SPA's next request races the store write, hits 401, and
|
// Without this, the SPA's next request races the store write, hits 401, and
|
||||||
// the prior bounce-to-login logic produced an infinite loop.
|
// the prior bounce-to-login logic produced an infinite loop.
|
||||||
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
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])
|
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));
|
.catch(err => console.error('[auth] last_login_at update failed:', err.message));
|
||||||
|
}
|
||||||
|
|
||||||
ipBackoff.recordSuccess(ip);
|
// POST /api/v1/auth/login/totp { ticket?, code } — second login step. `code` is
|
||||||
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); } catch (err) { next(err); }
|
// 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();
|
||||||
|
|
@ -125,6 +235,7 @@ 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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -149,5 +260,202 @@ 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 };
|
export { realUserCount, resolveGoogleUser, consumeRecoveryCode };
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,60 @@
|
||||||
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));
|
|
||||||
|
|
||||||
// GET / - List bins. Filter by project_id when supplied; otherwise return
|
// Resolve the owning project for a /:id bin, assert 'view' baseline, stash the
|
||||||
// every bin across every project so the Library / asset-context-menu can
|
// project_id for mutating routes to escalate to 'edit'.
|
||||||
// present a global "move to bin" picker.
|
router.param('id', async (req, res, next) => {
|
||||||
|
validateUuid('id')(req, res, () => {});
|
||||||
|
if (res.headersSent) return;
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query('SELECT project_id FROM bins WHERE id = $1', [req.params.id]);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'Bin not found' });
|
||||||
|
req.binProjectId = rows[0].project_id;
|
||||||
|
await assertProjectAccess(req.user, req.binProjectId, 'view');
|
||||||
|
next();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function requireBinEdit(req, res, next) {
|
||||||
|
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) {
|
||||||
where = 'WHERE b.project_id = $1';
|
await assertProjectAccess(req.user, project_id, 'view');
|
||||||
params.push(project_id);
|
const result = await pool.query(
|
||||||
|
`SELECT b.*, p.name AS project_name,
|
||||||
|
(SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count
|
||||||
|
FROM bins b
|
||||||
|
LEFT JOIN projects p ON p.id = b.project_id
|
||||||
|
WHERE b.project_id = $1
|
||||||
|
ORDER BY b.created_at DESC`,
|
||||||
|
[project_id]
|
||||||
|
);
|
||||||
|
return res.json(result.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const access = await accessibleProjectIds(req.user);
|
||||||
|
let where = '';
|
||||||
|
const params = [];
|
||||||
|
if (!access.all) {
|
||||||
|
if (access.ids.size === 0) return res.json([]);
|
||||||
|
where = 'WHERE b.project_id = ANY($1::uuid[])';
|
||||||
|
params.push([...access.ids]);
|
||||||
|
}
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT b.*, p.name AS project_name,
|
`SELECT 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
|
||||||
|
|
@ -29,14 +64,13 @@ 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
|
// POST / - Create bin (requires edit on the target project).
|
||||||
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;
|
||||||
|
|
@ -44,6 +78,7 @@ 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();
|
||||||
|
|
||||||
|
|
@ -61,7 +96,7 @@ router.post('/', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /:id - Update bin
|
// PATCH /:id - Update bin
|
||||||
router.patch('/:id', async (req, res, next) => {
|
router.patch('/:id', requireBinEdit, 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;
|
||||||
|
|
@ -107,7 +142,7 @@ router.patch('/:id', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /:id - Delete bin
|
// DELETE /:id - Delete bin
|
||||||
router.delete('/:id', async (req, res, next) => {
|
router.delete('/:id', requireBinEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
|
@ -126,8 +161,8 @@ router.delete('/:id', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/assets - Add asset to bin
|
// POST /:id/assets - Add asset to bin (requires edit on the bin's project).
|
||||||
router.post('/:id/assets', async (req, res, next) => {
|
router.post('/:id/assets', requireBinEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { asset_id } = req.body;
|
const { asset_id } = req.body;
|
||||||
|
|
@ -136,10 +171,13 @@ router.post('/:id/assets', async (req, res, next) => {
|
||||||
return res.status(400).json({ error: 'asset_id is required' });
|
return res.status(400).json({ error: 'asset_id is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify bin exists
|
// Asset must live in the bin's own project. Without this, an editor in
|
||||||
const binCheck = await pool.query('SELECT id FROM bins WHERE id = $1', [id]);
|
// project A (where the bin lives) could pull an asset from project B (no
|
||||||
if (binCheck.rows.length === 0) {
|
// grant) into A's bin tree, exposing it in A's views.
|
||||||
return res.status(404).json({ error: 'Bin not found' });
|
const a = await pool.query('SELECT project_id FROM assets WHERE id = $1', [asset_id]);
|
||||||
|
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
|
||||||
|
|
@ -158,8 +196,8 @@ router.post('/:id/assets', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /:id/assets/:assetId - Remove asset from bin
|
// DELETE /:id/assets/:assetId - Remove asset from bin (requires edit).
|
||||||
router.delete('/:id/assets/:assetId', async (req, res, next) => {
|
router.delete('/:id/assets/:assetId', requireBinEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id, assetId } = req.params;
|
const { id, assetId } = req.params;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
// 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,10 +4,6 @@ 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 || '');
|
||||||
|
|
@ -41,7 +37,6 @@ function dockerRequest(path, method = 'GET', body = null) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET / – list all registered cluster nodes with online status
|
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const r = await pool.query(
|
const r = await pool.query(
|
||||||
|
|
@ -57,7 +52,6 @@ router.get('/', async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /containers – list all containers on the local Docker host
|
|
||||||
router.get('/containers', async (req, res, next) => {
|
router.get('/containers', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const containers = await dockerRequest('/containers/json?all=true');
|
const containers = await dockerRequest('/containers/json?all=true');
|
||||||
|
|
@ -88,7 +82,6 @@ router.get('/containers', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /containers/:nameOrId/restart
|
|
||||||
router.post('/containers/:nameOrId/restart', async (req, res, next) => {
|
router.post('/containers/:nameOrId/restart', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
await dockerRequest(`/containers/${encodeURIComponent(req.params.nameOrId)}/restart`, 'POST');
|
await dockerRequest(`/containers/${encodeURIComponent(req.params.nameOrId)}/restart`, 'POST');
|
||||||
|
|
@ -96,23 +89,17 @@ 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,
|
capabilities, metadata, metrics,
|
||||||
} = 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) {
|
||||||
|
|
@ -132,8 +119,8 @@ router.post('/heartbeat', async (req, res, next) => {
|
||||||
const r = await pool.query(
|
const r = await pool.query(
|
||||||
`INSERT INTO cluster_nodes
|
`INSERT INTO cluster_nodes
|
||||||
(hostname, ip_address, role, version, api_url,
|
(hostname, ip_address, role, version, api_url,
|
||||||
cpu_usage, mem_used_mb, mem_total_mb, last_seen, capabilities, metadata)
|
cpu_usage, mem_used_mb, mem_total_mb, last_seen, last_seen_at, capabilities, metadata, metrics)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9,$10)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),NOW(),$9,$10,$11)
|
||||||
ON CONFLICT (hostname) DO UPDATE SET
|
ON CONFLICT (hostname) DO UPDATE SET
|
||||||
ip_address = EXCLUDED.ip_address,
|
ip_address = EXCLUDED.ip_address,
|
||||||
role = EXCLUDED.role,
|
role = EXCLUDED.role,
|
||||||
|
|
@ -143,8 +130,10 @@ 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,
|
||||||
|
|
@ -157,48 +146,32 @@ 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;
|
||||||
|
|
@ -206,79 +179,51 @@ router.get('/devices/blackmagic/signal', async (req, res, next) => {
|
||||||
const model = (node.capabilities && node.capabilities.blackmagic_model) || null;
|
const model = (node.capabilities && node.capabilities.blackmagic_model) || null;
|
||||||
const localHostname = process.env.NODE_HOSTNAME || '';
|
const localHostname = process.env.NODE_HOSTNAME || '';
|
||||||
const isRemote = node.api_url && node.hostname !== localHostname;
|
const isRemote = node.api_url && node.hostname !== localHostname;
|
||||||
|
|
||||||
bm.forEach((d, idx) => {
|
bm.forEach((d, idx) => {
|
||||||
const portIndex = d.index !== undefined ? d.index : idx;
|
const portIndex = d.index !== undefined ? d.index : idx;
|
||||||
const rec = recByPort.get(`${node.id}:${portIndex}`);
|
const rec = recByPort.get(`${node.id}:${portIndex}`);
|
||||||
|
|
||||||
tasks.push((async () => {
|
tasks.push((async () => {
|
||||||
const base = {
|
const base = {
|
||||||
node_id: node.id,
|
node_id: node.id, hostname: node.hostname, index: portIndex,
|
||||||
hostname: node.hostname,
|
device: d.device || null, model, node_online: nodeOnline,
|
||||||
index: portIndex,
|
recorder_id: rec ? rec.id : null, recorder_name: rec ? rec.name : null,
|
||||||
device: d.device || null,
|
|
||||||
model,
|
|
||||||
node_online: nodeOnline,
|
|
||||||
recorder_id: rec ? rec.id : null,
|
|
||||||
recorder_name: rec ? rec.name : null,
|
|
||||||
recorder_status: rec ? rec.status : null,
|
recorder_status: rec ? rec.status : null,
|
||||||
signal: 'no-recorder',
|
signal: 'no-recorder', framesReceived: null, currentFps: null,
|
||||||
framesReceived: null,
|
|
||||||
currentFps: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!rec || rec.status !== 'recording' || !rec.container_id) {
|
if (!rec || rec.status !== 'recording' || !rec.container_id) {
|
||||||
// No active capture — if there's a recorder but it's not recording,
|
|
||||||
// report that; otherwise the port is unassigned.
|
|
||||||
if (rec && rec.status !== 'recording') base.signal = 'idle';
|
if (rec && rec.status !== 'recording') base.signal = 'idle';
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active recording — query the capture container for real signal.
|
|
||||||
try {
|
try {
|
||||||
let live = null;
|
let live = null;
|
||||||
if (isRemote) {
|
if (isRemote) {
|
||||||
const r = await fetch(
|
const r = await fetch(`${node.api_url}/sidecar/${rec.container_id}/status`, { signal: AbortSignal.timeout(2500) });
|
||||||
`${node.api_url}/sidecar/${rec.container_id}/status`,
|
|
||||||
{ signal: AbortSignal.timeout(2500) }
|
|
||||||
);
|
|
||||||
if (r.ok) live = (await r.json()).live;
|
if (r.ok) live = (await r.json()).live;
|
||||||
} else {
|
} else {
|
||||||
const r = await fetch(
|
const r = await fetch(`http://recorder-${rec.id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
|
||||||
`http://recorder-${rec.id}:3001/capture/status`,
|
|
||||||
{ signal: AbortSignal.timeout(2000) }
|
|
||||||
);
|
|
||||||
if (r.ok) live = await r.json();
|
if (r.ok) live = await r.json();
|
||||||
}
|
}
|
||||||
if (live && live.signal) {
|
if (live && live.signal) {
|
||||||
base.signal = live.signal;
|
base.signal = live.signal;
|
||||||
base.framesReceived = live.framesReceived ?? null;
|
base.framesReceived = live.framesReceived ?? null;
|
||||||
base.currentFps = live.currentFps ?? null;
|
base.currentFps = live.currentFps ?? null;
|
||||||
} else {
|
} else { base.signal = 'connecting'; }
|
||||||
base.signal = 'connecting';
|
} catch (_) { base.signal = 'connecting'; }
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
base.signal = 'connecting';
|
|
||||||
}
|
|
||||||
return base;
|
return base;
|
||||||
})());
|
})());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.all(tasks);
|
const results = await Promise.all(tasks);
|
||||||
res.json(results);
|
res.json(results);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /devices/blackmagic – flatten every node's DeckLink cards for the
|
|
||||||
// recorder picker. Returns one entry per device with the host node info.
|
|
||||||
router.get('/devices/blackmagic', async (req, res, next) => {
|
router.get('/devices/blackmagic', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const r = await pool.query(
|
const r = await pool.query(
|
||||||
`SELECT id, hostname, ip_address, role, capabilities,
|
`SELECT id, hostname, ip_address, role, capabilities,
|
||||||
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
||||||
FROM cluster_nodes
|
FROM cluster_nodes WHERE capabilities IS NOT NULL`
|
||||||
WHERE capabilities IS NOT NULL`
|
|
||||||
);
|
);
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const row of r.rows) {
|
for (const row of r.rows) {
|
||||||
|
|
@ -286,39 +231,98 @@ router.get('/devices/blackmagic', async (req, res, next) => {
|
||||||
const bm = (row.capabilities && row.capabilities.blackmagic) || [];
|
const bm = (row.capabilities && row.capabilities.blackmagic) || [];
|
||||||
const model = (row.capabilities && row.capabilities.blackmagic_model) || null;
|
const model = (row.capabilities && row.capabilities.blackmagic_model) || null;
|
||||||
bm.forEach((d, idx) => {
|
bm.forEach((d, idx) => {
|
||||||
out.push({
|
out.push({ node_id: row.id, hostname: row.hostname, ip_address: row.ip_address,
|
||||||
node_id: row.id,
|
role: row.role, online, model, index: d.index !== undefined ? d.index : idx, device: d.device });
|
||||||
hostname: row.hostname,
|
|
||||||
ip_address: row.ip_address,
|
|
||||||
role: row.role,
|
|
||||||
online,
|
|
||||||
model,
|
|
||||||
index: d.index !== undefined ? d.index : idx,
|
|
||||||
device: d.device,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.json(out);
|
res.json(out);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id/ping – probe the node's api_url/health endpoint directly
|
router.get('/devices/deltacast', async (req, res, next) => {
|
||||||
router.get('/:id/ping', async (req, res, next) => {
|
|
||||||
try {
|
try {
|
||||||
const r = await pool.query(
|
const r = await pool.query(
|
||||||
'SELECT id, hostname, api_url FROM cluster_nodes WHERE id = $1',
|
`SELECT id, hostname, ip_address, role, capabilities,
|
||||||
[req.params.id]
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
||||||
|
FROM cluster_nodes WHERE capabilities IS NOT NULL`
|
||||||
);
|
);
|
||||||
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
|
const out = [];
|
||||||
|
for (const row of r.rows) {
|
||||||
|
const online = Number(row.stale_seconds) < 120;
|
||||||
|
const dc = (row.capabilities && row.capabilities.deltacast) || [];
|
||||||
|
const model = (row.capabilities && row.capabilities.deltacast_model) || null;
|
||||||
|
dc.forEach((d, idx) => {
|
||||||
|
out.push({ node_id: row.id, hostname: row.hostname, ip_address: row.ip_address,
|
||||||
|
role: row.role, online, model: model || 'Deltacast',
|
||||||
|
index: d.index !== undefined ? d.index : idx, device: d.device,
|
||||||
|
present: d.present !== false, port_count: dc.length });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json(out);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/devices/deltacast/signal', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [nodesRes, recordersRes] = await Promise.all([
|
||||||
|
pool.query(`SELECT id, hostname, ip_address, api_url, capabilities,
|
||||||
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
||||||
|
FROM cluster_nodes WHERE capabilities IS NOT NULL`),
|
||||||
|
pool.query(`SELECT id, node_id, device_index, status, source_type, container_id
|
||||||
|
FROM recorders WHERE source_type = 'deltacast'`),
|
||||||
|
]);
|
||||||
|
const recByNodePort = {};
|
||||||
|
for (const rec of recordersRes.rows) {
|
||||||
|
recByNodePort[`${rec.node_id}:${rec.device_index}`] = rec;
|
||||||
|
}
|
||||||
|
const results = [];
|
||||||
|
const fetchPromises = [];
|
||||||
|
for (const node of nodesRes.rows) {
|
||||||
|
const online = Number(node.stale_seconds) < 120;
|
||||||
|
const dc = (node.capabilities && node.capabilities.deltacast) || [];
|
||||||
|
const model = (node.capabilities && node.capabilities.deltacast_model) || 'Deltacast';
|
||||||
|
for (const port of dc) {
|
||||||
|
const idx = port.index !== undefined ? port.index : dc.indexOf(port);
|
||||||
|
const rec = recByNodePort[`${node.id}:${idx}`];
|
||||||
|
const base = { node_id: node.id, hostname: node.hostname, ip_address: node.ip_address,
|
||||||
|
online, model, index: idx, device: port.device, present: port.present !== false,
|
||||||
|
recorder_id: rec ? rec.id : null, recorder_status: rec ? rec.status : null,
|
||||||
|
signal: 'no-recorder', framesReceived: null, currentFps: null };
|
||||||
|
if (!rec) { results.push(base); continue; }
|
||||||
|
if (rec.status !== 'recording') { base.signal = 'idle'; results.push(base); continue; }
|
||||||
|
const fetchIdx = results.length;
|
||||||
|
results.push(base);
|
||||||
|
fetchPromises.push((async () => {
|
||||||
|
try {
|
||||||
|
const url = node.api_url ? `${node.api_url}/sidecar/${rec.container_id}/status`
|
||||||
|
: `http://recorder-${rec.id}:3001/capture/status`;
|
||||||
|
const r = await fetch(url, { signal: AbortSignal.timeout(2500) });
|
||||||
|
if (r.ok) {
|
||||||
|
const live = await r.json();
|
||||||
|
if (live && live.signal) {
|
||||||
|
results[fetchIdx].signal = live.signal;
|
||||||
|
results[fetchIdx].framesReceived = live.framesReceived ?? null;
|
||||||
|
results[fetchIdx].currentFps = live.currentFps ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) { results[fetchIdx].signal = 'connecting'; }
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(fetchPromises);
|
||||||
|
res.json(results);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id/ping', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const r = await pool.query('SELECT id, hostname, api_url FROM cluster_nodes WHERE id = $1', [req.params.id]);
|
||||||
|
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
|
||||||
const node = r.rows[0];
|
const node = r.rows[0];
|
||||||
if (!node.api_url) return res.json({ reachable: false, reason: 'no api_url registered' });
|
if (!node.api_url) return res.json({ reachable: false, reason: 'no api_url registered' });
|
||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
try {
|
try {
|
||||||
const upstream = await fetch(`${node.api_url}/health`, {
|
const upstream = await fetch(`${node.api_url}/health`, { signal: AbortSignal.timeout(4000) });
|
||||||
signal: AbortSignal.timeout(4000),
|
|
||||||
});
|
|
||||||
const latency_ms = Date.now() - start;
|
const latency_ms = Date.now() - start;
|
||||||
const body = await upstream.json().catch(() => ({}));
|
const body = await upstream.json().catch(() => ({}));
|
||||||
res.json({ reachable: upstream.ok, latency_ms, status: upstream.status, agent: body });
|
res.json({ reachable: upstream.ok, latency_ms, status: upstream.status, agent: body });
|
||||||
|
|
@ -328,13 +332,44 @@ router.get('/:id/ping', async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /:id – deregister a node
|
router.get('/metrics', async (req, res, next) => {
|
||||||
router.delete('/:id', async (req, res, next) => {
|
|
||||||
try {
|
try {
|
||||||
const r = await pool.query(
|
const r = await pool.query(
|
||||||
'DELETE FROM cluster_nodes WHERE id = $1 RETURNING id',
|
`SELECT id, hostname, role, last_seen,
|
||||||
[req.params.id]
|
cpu_usage, mem_used_mb, mem_total_mb,
|
||||||
|
capabilities, metrics,
|
||||||
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
||||||
|
FROM cluster_nodes ORDER BY registered_at ASC`
|
||||||
);
|
);
|
||||||
|
const nodes = r.rows.map(row => {
|
||||||
|
const capGpus = (row.capabilities && row.capabilities.gpus) || [];
|
||||||
|
const liveGpus = (row.metrics && row.metrics.gpus) || [];
|
||||||
|
const gpus = capGpus.map((g, idx) => {
|
||||||
|
const live = liveGpus.find(l => l.index === g.index) || liveGpus[idx] || {};
|
||||||
|
return { name: g.name || null, util_pct: live.util_pct != null ? live.util_pct : null,
|
||||||
|
memory_used_mb: live.memory_used_mb != null ? live.memory_used_mb : null,
|
||||||
|
memory_total_mb: g.memory_mb != null ? g.memory_mb : (live.memory_total_mb ?? null) };
|
||||||
|
});
|
||||||
|
for (const lg of liveGpus) {
|
||||||
|
if (!capGpus.some(g => g.index === lg.index)) {
|
||||||
|
gpus.push({ name: lg.name || null, util_pct: lg.util_pct != null ? lg.util_pct : null,
|
||||||
|
memory_used_mb: lg.memory_used_mb != null ? lg.memory_used_mb : null,
|
||||||
|
memory_total_mb: lg.memory_total_mb != null ? lg.memory_total_mb : null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id: row.id, hostname: row.hostname, role: row.role,
|
||||||
|
online: Number(row.stale_seconds) < 120, last_seen: row.last_seen,
|
||||||
|
cpu_util_pct: row.cpu_usage != null ? Number(row.cpu_usage) : null,
|
||||||
|
ram_used_mb: row.mem_used_mb != null ? row.mem_used_mb : null,
|
||||||
|
ram_total_mb: row.mem_total_mb != null ? row.mem_total_mb : null, gpus };
|
||||||
|
});
|
||||||
|
res.json({ nodes });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const r = await pool.query('DELETE FROM cluster_nodes WHERE id = $1 RETURNING id', [req.params.id]);
|
||||||
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
|
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,9 +5,23 @@
|
||||||
|
|
||||||
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,
|
||||||
|
|
@ -49,8 +63,9 @@ 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' });
|
||||||
}
|
}
|
||||||
// Best-effort author lookup — pull from the session if AUTH_ENABLED is on.
|
// Author is the authenticated user (requireAuth sets req.user for both
|
||||||
const userId = req.session?.userId || null;
|
// session and bearer auth, and the dev user when AUTH_ENABLED=false).
|
||||||
|
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,6 +10,7 @@ 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();
|
||||||
|
|
||||||
|
|
@ -60,6 +61,8 @@ 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,6 +1,7 @@
|
||||||
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"),
|
||||||
|
|
@ -324,6 +325,10 @@ 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,
|
||||||
|
|
|
||||||
620
services/mam-api/src/routes/playout.js
Normal file
620
services/mam-api/src/routes/playout.js
Normal file
|
|
@ -0,0 +1,620 @@
|
||||||
|
// 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,6 +1,8 @@
|
||||||
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();
|
||||||
|
|
@ -16,18 +18,29 @@ const slugify = (str) => {
|
||||||
.replace(/-+/g, '-');
|
.replace(/-+/g, '-');
|
||||||
};
|
};
|
||||||
|
|
||||||
// GET / - List all projects
|
// GET / - List projects the caller can access (admins see all).
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const access = await accessibleProjectIds(req.user);
|
||||||
|
if (access.all) {
|
||||||
const result = await pool.query('SELECT * FROM projects ORDER BY created_at DESC');
|
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
|
// POST / - Create project (admin only; new projects have no grants, so a
|
||||||
router.post('/', async (req, res, next) => {
|
// scoped user could never reach one they just made).
|
||||||
|
router.post('/', requireAdmin, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { name, description } = req.body;
|
const { name, description } = req.body;
|
||||||
|
|
||||||
|
|
@ -51,10 +64,11 @@ router.post('/', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id - Single project with asset count
|
// GET /:id - Single project with asset count (requires view access).
|
||||||
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.*,
|
||||||
|
|
@ -76,10 +90,11 @@ router.get('/:id', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /:id - Update project
|
// PATCH /:id - Update project (requires edit access).
|
||||||
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 = [];
|
||||||
|
|
@ -122,8 +137,9 @@ router.patch('/:id', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /:id - Delete project and cascade
|
// DELETE /:id - Delete project and cascade (admin only — destructive, wipes
|
||||||
router.delete('/:id', async (req, res, next) => {
|
// every asset/bin/recorder under it).
|
||||||
|
router.delete('/:id', requireAdmin, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
|
@ -143,4 +159,78 @@ router.delete('/:id', 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,14 +1,39 @@
|
||||||
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.
|
||||||
|
|
@ -148,6 +173,17 @@ 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
|
||||||
|
|
@ -161,8 +197,9 @@ 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.
|
||||||
|
|
@ -193,10 +230,15 @@ 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: 'prores_hq',
|
recording_codec: 'hevc_nvenc',
|
||||||
recording_resolution: 'native',
|
recording_resolution: 'native',
|
||||||
recording_audio_codec: 'pcm_s24le',
|
recording_audio_codec: 'pcm_s24le',
|
||||||
recording_audio_channels: 2,
|
recording_audio_channels: 2,
|
||||||
|
|
@ -255,7 +297,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', async (req, res, next) => {
|
router.patch('/:id', requireRecorderEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
|
@ -294,7 +336,7 @@ router.patch('/:id', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/start - Start recording
|
// POST /:id/start - Start recording
|
||||||
router.post('/:id/start', async (req, res, next) => {
|
router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
|
@ -318,6 +360,7 @@ router.post('/:id/start', 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
|
||||||
|
|
@ -336,6 +379,21 @@ router.post('/:id/start', 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();
|
||||||
|
|
@ -346,7 +404,7 @@ router.post('/:id/start', 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, recorder.project_id, clipName, `projects/${recorder.project_id}/masters/${clipName}.${ext}`]
|
[assetIdLive, takeProjectId, clipName, `projects/${takeProjectId}/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);
|
||||||
|
|
@ -391,13 +449,21 @@ router.post('/:id/start', 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=${recorder.project_id}`,
|
`PROJECT_ID=${takeProjectId}`,
|
||||||
`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) {
|
||||||
|
|
@ -410,8 +476,20 @@ router.post('/:id/start', 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;
|
||||||
|
|
||||||
|
|
@ -421,7 +499,7 @@ router.post('/:id/start', 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 }),
|
body: JSON.stringify({ image: 'wild-dragon-capture:latest', env, capturePort, sourceType, useGpu }),
|
||||||
signal: AbortSignal.timeout(15000),
|
signal: AbortSignal.timeout(15000),
|
||||||
});
|
});
|
||||||
if (!sidecarRes.ok) {
|
if (!sidecarRes.ok) {
|
||||||
|
|
@ -443,18 +521,39 @@ router.post('/:id/start', 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 containerConfig = {
|
const localEnv = [...env];
|
||||||
Image: 'wild-dragon-capture:latest',
|
if (useGpu) {
|
||||||
Env: env,
|
localEnv.push('NVIDIA_VISIBLE_DEVICES=all');
|
||||||
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
|
localEnv.push('NVIDIA_DRIVER_CAPABILITIES=video,compute,utility');
|
||||||
HostConfig: {
|
}
|
||||||
|
|
||||||
|
const localHostConfig = {
|
||||||
Privileged: true,
|
Privileged: true,
|
||||||
NetworkMode: dockerNetwork,
|
NetworkMode: dockerNetwork,
|
||||||
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
|
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
|
||||||
Binds: hostBinds,
|
Binds: hostBinds,
|
||||||
},
|
...(useGpu && {
|
||||||
|
Runtime: 'nvidia',
|
||||||
|
DeviceRequests: [{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] }],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerConfig = {
|
||||||
|
Image: 'wild-dragon-capture:latest',
|
||||||
|
Env: localEnv,
|
||||||
|
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
|
||||||
|
HostConfig: localHostConfig,
|
||||||
NetworkingConfig: {
|
NetworkingConfig: {
|
||||||
EndpointsConfig: {
|
EndpointsConfig: {
|
||||||
[dockerNetwork]: { Aliases: [alias] },
|
[dockerNetwork]: { Aliases: [alias] },
|
||||||
|
|
@ -501,7 +600,7 @@ router.post('/:id/start', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/stop - Stop recording
|
// POST /:id/stop - Stop recording
|
||||||
router.post('/:id/stop', async (req, res, next) => {
|
router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
|
@ -672,7 +771,7 @@ router.get('/:id/status', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /:id - Delete recorder
|
// DELETE /:id - Delete recorder
|
||||||
router.delete('/:id', async (req, res, next) => {
|
router.delete('/:id', requireRecorderEdit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
|
@ -839,4 +938,37 @@ 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,6 +3,7 @@ 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) => {
|
||||||
|
|
@ -19,7 +20,27 @@ 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
|
||||||
|
|
@ -124,6 +145,7 @@ 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]
|
||||||
|
|
@ -143,6 +165,7 @@ 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 *`,
|
||||||
|
|
@ -188,7 +211,7 @@ router.get('/:id', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PUT /:id – update sequence metadata ──────────────────────────────────────
|
// ── PUT /:id – update sequence metadata ──────────────────────────────────────
|
||||||
router.put('/:id', async (req, res, next) => {
|
router.put('/:id', requireSequenceEdit, 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 = [];
|
||||||
|
|
@ -211,7 +234,7 @@ router.put('/:id', async (req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── DELETE /:id ───────────────────────────────────────────────────────────────
|
// ── DELETE /:id ───────────────────────────────────────────────────────────────
|
||||||
router.delete('/:id', async (req, res, next) => {
|
router.delete('/:id', requireSequenceEdit, 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' });
|
||||||
|
|
@ -220,12 +243,14 @@ router.delete('/:id', 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', async (req, res, next) => {
|
router.put('/:id/clips', requireSequenceEdit, async (req, res, next) => {
|
||||||
// Verify sequence exists first (before acquiring transaction client)
|
const clips = Array.isArray(req.body) ? req.body : [];
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
// Verify sequence exists first (before acquiring transaction client).
|
||||||
const seqCheck = await pool.query(`SELECT id FROM sequences WHERE id = $1`, [req.params.id]);
|
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' });
|
if (!seqCheck.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
||||||
|
|
||||||
const clips = Array.isArray(req.body) ? req.body : [];
|
|
||||||
for (const c of clips) {
|
for (const c of clips) {
|
||||||
if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' });
|
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)) ||
|
if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) ||
|
||||||
|
|
@ -237,8 +262,22 @@ router.put('/:id/clips', async (req, res, next) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = await pool.connect();
|
// Every referenced asset must belong to THIS sequence's project. Without this,
|
||||||
try {
|
// 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`,
|
||||||
|
|
@ -265,10 +304,12 @@ router.put('/:id/clips', 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) {
|
||||||
await client.query('ROLLBACK');
|
// client is only set once we've connected; a failure in the pre-transaction
|
||||||
|
// queries (existence/validation/ownership) has no transaction to roll back.
|
||||||
|
if (client) await client.query('ROLLBACK').catch(() => {});
|
||||||
next(e);
|
next(e);
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
if (client) client.release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -300,7 +341,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', async (req, res, next) => {
|
router.post('/:id/conform', requireSequenceEdit, 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,6 +14,7 @@ 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();
|
||||||
|
|
||||||
|
|
@ -138,16 +139,24 @@ function mediaTypeFromMime(mime = '') {
|
||||||
return 'document';
|
return 'document';
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/v1/upload - List in-progress uploads (#68)
|
// GET /api/v1/upload - List in-progress uploads (#68). Scoped to projects the
|
||||||
|
// 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 result = await pool.query(
|
const access = await accessibleProjectIds(req.user);
|
||||||
`SELECT id, filename, display_name, project_id, bin_id, status, created_at, updated_at
|
let query = `SELECT id, filename, display_name, project_id, bin_id, status, created_at, updated_at
|
||||||
FROM assets
|
FROM assets
|
||||||
WHERE status = 'ingesting'
|
WHERE status = 'ingesting'`;
|
||||||
ORDER BY created_at DESC
|
const params = [];
|
||||||
LIMIT 50`
|
if (!access.all) {
|
||||||
);
|
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); }
|
||||||
});
|
});
|
||||||
|
|
@ -163,6 +172,17 @@ 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]) : [];
|
||||||
|
|
@ -326,6 +346,20 @@ 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,10 +3,12 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import pool from '../db/pool.js';
|
import pool from '../db/pool.js';
|
||||||
import { hashPassword } from '../auth/passwords.js';
|
import { hashPassword } from '../auth/passwords.js';
|
||||||
import { DEV_USER_ID } from '../middleware/auth.js';
|
import { DEV_USER_ID, requireAdmin } from '../middleware/auth.js';
|
||||||
|
import { accessibleProjectIds } from '../auth/authz.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const MIN_PASSWORD_LEN = 12;
|
const MIN_PASSWORD_LEN = 12;
|
||||||
|
const ROLES = ['admin', 'editor', 'viewer'];
|
||||||
|
|
||||||
function bad(res, msg) { return res.status(400).json({ error: msg }); }
|
function bad(res, msg) { return res.status(400).json({ error: msg }); }
|
||||||
|
|
||||||
|
|
@ -14,7 +16,7 @@ function bad(res, msg) { return res.status(400).json({ error: msg }); }
|
||||||
router.get('/', async (_req, res, next) => {
|
router.get('/', async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT id, username, display_name, role, last_login_at, created_at
|
`SELECT id, username, display_name, role, totp_enabled, last_login_at, created_at
|
||||||
FROM users WHERE id <> $1 ORDER BY username`, [DEV_USER_ID]);
|
FROM users WHERE id <> $1 ORDER BY username`, [DEV_USER_ID]);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
|
|
@ -26,6 +28,7 @@ router.post('/', async (req, res, next) => {
|
||||||
const { username, password, display_name, role } = req.body || {};
|
const { username, password, display_name, role } = req.body || {};
|
||||||
if (!username || typeof username !== 'string') return bad(res, 'username required');
|
if (!username || typeof username !== 'string') return bad(res, 'username required');
|
||||||
if (!password || password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
|
if (!password || password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
|
||||||
|
if (role !== undefined && !ROLES.includes(role)) return bad(res, "role must be one of: " + ROLES.join(', '));
|
||||||
const hash = await hashPassword(password);
|
const hash = await hashPassword(password);
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO users (username, password_hash, display_name, role)
|
`INSERT INTO users (username, password_hash, display_name, role)
|
||||||
|
|
@ -76,7 +79,10 @@ router.patch('/:id', async (req, res, next) => {
|
||||||
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' });
|
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' });
|
||||||
const sets = []; const vals = [];
|
const sets = []; const vals = [];
|
||||||
if (typeof req.body?.display_name === 'string') { sets.push('display_name = $' + (sets.length + 1)); vals.push(req.body.display_name); }
|
if (typeof req.body?.display_name === 'string') { sets.push('display_name = $' + (sets.length + 1)); vals.push(req.body.display_name); }
|
||||||
if (typeof req.body?.role === 'string') { sets.push('role = $' + (sets.length + 1)); vals.push(req.body.role); }
|
if (typeof req.body?.role === 'string') {
|
||||||
|
if (!ROLES.includes(req.body.role)) return bad(res, "role must be one of: " + ROLES.join(', '));
|
||||||
|
sets.push('role = $' + (sets.length + 1)); vals.push(req.body.role);
|
||||||
|
}
|
||||||
if (typeof req.body?.password === 'string') {
|
if (typeof req.body?.password === 'string') {
|
||||||
if (req.body.password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
|
if (req.body.password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
|
||||||
sets.push('password_hash = $' + (sets.length + 1) + ', password_updated_at = NOW()');
|
sets.push('password_hash = $' + (sets.length + 1) + ', password_updated_at = NOW()');
|
||||||
|
|
@ -93,4 +99,88 @@ router.patch('/:id', async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /:id/access — effective per-project access for one user (admin only).
|
||||||
|
// Reuses authz.accessibleProjectIds (MAX over direct user grant + every group the
|
||||||
|
// user belongs to). `via` is 'direct' for a user grant, 'group:<name>' otherwise.
|
||||||
|
// When the effective level comes from several sources we report the direct grant
|
||||||
|
// if present, else the first contributing group.
|
||||||
|
router.get('/:id/access', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows: urows } = await pool.query(
|
||||||
|
`SELECT id, role FROM users WHERE id = $1`, [req.params.id]);
|
||||||
|
if (urows.length === 0) return res.status(404).json({ error: 'user not found' });
|
||||||
|
const target = urows[0];
|
||||||
|
|
||||||
|
const { rows: groups } = await pool.query(
|
||||||
|
`SELECT g.id, g.name
|
||||||
|
FROM user_groups ug JOIN groups g ON g.id = ug.group_id
|
||||||
|
WHERE ug.user_id = $1 ORDER BY g.name`, [target.id]);
|
||||||
|
|
||||||
|
// Admins bypass scoping — every project at 'edit', via their role.
|
||||||
|
const access = await accessibleProjectIds(target);
|
||||||
|
if (access.all) {
|
||||||
|
const { rows: projects } = await pool.query(
|
||||||
|
`SELECT id, name FROM projects ORDER BY name`);
|
||||||
|
return res.json({
|
||||||
|
projects: projects.map(p => ({
|
||||||
|
project_id: p.id, project_name: p.name, level: 'edit', via: 'direct',
|
||||||
|
})),
|
||||||
|
groups,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = [...access.ids];
|
||||||
|
if (ids.length === 0) return res.json({ projects: [], groups });
|
||||||
|
|
||||||
|
// Resolve names + the source of each grant. groupNameById lets us label a
|
||||||
|
// group-sourced grant; a direct user grant always wins the `via` label.
|
||||||
|
const groupNameById = new Map(groups.map(g => [g.id, g.name]));
|
||||||
|
const { rows: grants } = await pool.query(
|
||||||
|
`SELECT pa.project_id, pa.subject_type, pa.subject_id, pa.level, p.name AS project_name
|
||||||
|
FROM project_access pa JOIN projects p ON p.id = pa.project_id
|
||||||
|
WHERE (pa.subject_type = 'user' AND pa.subject_id = $1)
|
||||||
|
OR (pa.subject_type = 'group' AND pa.subject_id IN (
|
||||||
|
SELECT group_id FROM user_groups WHERE user_id = $1
|
||||||
|
))`,
|
||||||
|
[target.id]);
|
||||||
|
|
||||||
|
const byProject = new Map();
|
||||||
|
for (const g of grants) {
|
||||||
|
const eff = access.levelByProject.get(g.project_id); // already the MAX
|
||||||
|
const via = g.subject_type === 'user'
|
||||||
|
? 'direct'
|
||||||
|
: 'group:' + (groupNameById.get(g.subject_id) || g.subject_id);
|
||||||
|
const prev = byProject.get(g.project_id);
|
||||||
|
// Keep a row only if it carries the effective level; prefer a direct grant
|
||||||
|
// when both a direct and a group grant hit the same level.
|
||||||
|
if (g.level === eff && (!prev || (prev.via !== 'direct' && via === 'direct'))) {
|
||||||
|
byProject.set(g.project_id, {
|
||||||
|
project_id: g.project_id, project_name: g.project_name, level: eff, via,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
projects: [...byProject.values()].sort((a, b) => a.project_name.localeCompare(b.project_name)),
|
||||||
|
groups,
|
||||||
|
});
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /:id/totp/disable — admin clears a locked-out user's 2FA WITHOUT their
|
||||||
|
// password (the self-service /auth/totp/disable needs the victim's own). Mirrors
|
||||||
|
// that handler's SQL but targets :id and skips the password check. Dev user blocked.
|
||||||
|
router.post('/:id/totp/disable', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' });
|
||||||
|
const { rowCount } = await pool.query(
|
||||||
|
`UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_last_counter = 0
|
||||||
|
WHERE id = $1 AND id <> $2`,
|
||||||
|
[req.params.id, DEV_USER_ID]);
|
||||||
|
if (rowCount === 0) return res.status(404).json({ error: 'user not found' });
|
||||||
|
await pool.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.params.id]);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
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';
|
||||||
|
|
@ -22,6 +23,9 @@ 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,6 +9,8 @@
|
||||||
|
|
||||||
import pool from './db/pool.js';
|
import pool from './db/pool.js';
|
||||||
import { syncToAmpp } from './routes/upload.js';
|
import { syncToAmpp } from './routes/upload.js';
|
||||||
|
import { restartChannel } from './routes/playout.js';
|
||||||
|
import { INTERNAL_TOKEN } from './middleware/auth.js';
|
||||||
|
|
||||||
const TICK_INTERVAL_MS = parseInt(process.env.SCHEDULER_TICK_MS || '15000', 10);
|
const TICK_INTERVAL_MS = parseInt(process.env.SCHEDULER_TICK_MS || '15000', 10);
|
||||||
const SELF_URL = process.env.MAM_API_SELF_URL || `http://127.0.0.1:${process.env.PORT || 3000}`;
|
const SELF_URL = process.env.MAM_API_SELF_URL || `http://127.0.0.1:${process.env.PORT || 3000}`;
|
||||||
|
|
@ -19,7 +21,10 @@ let _interval = null;
|
||||||
async function callSelf(path, method = 'POST') {
|
async function callSelf(path, method = 'POST') {
|
||||||
const res = await fetch(`${SELF_URL}${path}`, {
|
const res = await fetch(`${SELF_URL}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-internal-token': INTERNAL_TOKEN,
|
||||||
|
},
|
||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(30000),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -29,11 +34,7 @@ async function callSelf(path, method = 'POST') {
|
||||||
return res.json().catch(() => ({}));
|
return res.json().catch(() => ({}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue #103 — every mam-api replica runs the same tick on the same interval,
|
const SCHEDULER_LOCK_KEY = 8210301;
|
||||||
// so a multi-node deploy would double-fire recorder starts/stops. We guard
|
|
||||||
// the whole tick with a PG advisory lock (1 = scheduler) so exactly one
|
|
||||||
// replica processes a given interval. Pure-Postgres, no extra infra.
|
|
||||||
const SCHEDULER_LOCK_KEY = 8210301; // arbitrary, must be stable across replicas
|
|
||||||
|
|
||||||
async function tryAcquireSchedulerLock(client) {
|
async function tryAcquireSchedulerLock(client) {
|
||||||
const r = await client.query('SELECT pg_try_advisory_lock($1) AS got', [SCHEDULER_LOCK_KEY]);
|
const r = await client.query('SELECT pg_try_advisory_lock($1) AS got', [SCHEDULER_LOCK_KEY]);
|
||||||
|
|
@ -52,14 +53,9 @@ async function tick() {
|
||||||
try {
|
try {
|
||||||
haveLock = await tryAcquireSchedulerLock(client);
|
haveLock = await tryAcquireSchedulerLock(client);
|
||||||
if (!haveLock) {
|
if (!haveLock) {
|
||||||
// Another replica is processing this interval — bail silently.
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Atomically claim pending schedules whose window has opened. The
|
|
||||||
// UPDATE...RETURNING flips status to 'running' in the same statement
|
|
||||||
// so even if another replica got past the lock (it can't, but
|
|
||||||
// belt-and-braces) each row can only be claimed once.
|
|
||||||
const dueStart = await client.query(
|
const dueStart = await client.query(
|
||||||
`UPDATE recorder_schedules
|
`UPDATE recorder_schedules
|
||||||
SET status = 'starting', updated_at = NOW()
|
SET status = 'starting', updated_at = NOW()
|
||||||
|
|
@ -92,7 +88,6 @@ async function tick() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Atomically claim running schedules whose window has closed.
|
|
||||||
const dueStop = await client.query(
|
const dueStop = await client.query(
|
||||||
`UPDATE recorder_schedules
|
`UPDATE recorder_schedules
|
||||||
SET status = 'stopping', updated_at = NOW()
|
SET status = 'stopping', updated_at = NOW()
|
||||||
|
|
@ -115,7 +110,6 @@ async function tick() {
|
||||||
console.log(`[scheduler] stopped schedule "${s.name}" on recorder ${s.recorder_id}`);
|
console.log(`[scheduler] stopped schedule "${s.name}" on recorder ${s.recorder_id}`);
|
||||||
await enqueueNextOccurrence(s, client);
|
await enqueueNextOccurrence(s, client);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Stop failed — flag as failed but don't keep trying forever.
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE recorder_schedules
|
`UPDATE recorder_schedules
|
||||||
SET status = 'failed', error_message = $2, updated_at = NOW()
|
SET status = 'failed', error_message = $2, updated_at = NOW()
|
||||||
|
|
@ -126,7 +120,6 @@ async function tick() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) If a schedule was cancelled while running, stop the recorder.
|
|
||||||
const cancelledRunning = await client.query(
|
const cancelledRunning = await client.query(
|
||||||
`SELECT s.* FROM recorder_schedules s
|
`SELECT s.* FROM recorder_schedules s
|
||||||
JOIN recorders r ON r.id = s.recorder_id
|
JOIN recorders r ON r.id = s.recorder_id
|
||||||
|
|
@ -142,9 +135,6 @@ async function tick() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Mark stale live assets as 'error' (#66).
|
|
||||||
// If a capture container crashes without calling mark-empty/mark-complete,
|
|
||||||
// the asset row stays status='live' indefinitely. Timeout after 2 hours.
|
|
||||||
const LIVE_TIMEOUT_MINUTES = parseInt(process.env.LIVE_ASSET_TIMEOUT_MINUTES || '120', 10);
|
const LIVE_TIMEOUT_MINUTES = parseInt(process.env.LIVE_ASSET_TIMEOUT_MINUTES || '120', 10);
|
||||||
const staleResult = await client.query(
|
const staleResult = await client.query(
|
||||||
`UPDATE assets
|
`UPDATE assets
|
||||||
|
|
@ -161,9 +151,6 @@ async function tick() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) AMPP sync retry (#77). Pick up any pending/failed rows whose
|
|
||||||
// next-attempt time has arrived and retry them. Cap per tick so we
|
|
||||||
// don't burn budget on a single rough interval.
|
|
||||||
const ampps = await client.query(
|
const ampps = await client.query(
|
||||||
`SELECT id, project_id, bin_id FROM assets
|
`SELECT id, project_id, bin_id FROM assets
|
||||||
WHERE ampp_sync_status IN ('pending', 'failed')
|
WHERE ampp_sync_status IN ('pending', 'failed')
|
||||||
|
|
@ -175,6 +162,8 @@ async function tick() {
|
||||||
for (const row of ampps.rows) {
|
for (const row of ampps.rows) {
|
||||||
await syncToAmpp(row.id, row.project_id, row.bin_id);
|
await syncToAmpp(row.id, row.project_id, row.bin_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await playoutHealthTick(client);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[scheduler] tick error:', err);
|
console.error('[scheduler] tick error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -201,11 +190,73 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
125
services/mam-api/test/auth/authz.test.js
Normal file
125
services/mam-api/test/auth/authz.test.js
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
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(); }
|
||||||
|
});
|
||||||
40
services/mam-api/test/auth/google-oauth.test.js
Normal file
40
services/mam-api/test/auth/google-oauth.test.js
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
// 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'));
|
||||||
|
});
|
||||||
49
services/mam-api/test/auth/mfa-tickets.test.js
Normal file
49
services/mam-api/test/auth/mfa-tickets.test.js
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
// 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');
|
||||||
|
});
|
||||||
96
services/mam-api/test/auth/totp.test.js
Normal file
96
services/mam-api/test/auth/totp.test.js
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
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}$/);
|
||||||
|
});
|
||||||
79
services/mam-api/test/routes/assets-access.test.js
Normal file
79
services/mam-api/test/routes/assets-access.test.js
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
// 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(); }
|
||||||
|
});
|
||||||
76
services/mam-api/test/routes/comments-access.test.js
Normal file
76
services/mam-api/test/routes/comments-access.test.js
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
// 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(); }
|
||||||
|
});
|
||||||
63
services/mam-api/test/routes/google-link.test.js
Normal file
63
services/mam-api/test/routes/google-link.test.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
// 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(); }
|
||||||
|
});
|
||||||
125
services/mam-api/test/routes/project-access.test.js
Normal file
125
services/mam-api/test/routes/project-access.test.js
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
// 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(); }
|
||||||
|
});
|
||||||
107
services/mam-api/test/routes/recorders-access.test.js
Normal file
107
services/mam-api/test/routes/recorders-access.test.js
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
// 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(); }
|
||||||
|
});
|
||||||
103
services/mam-api/test/routes/sequences-access.test.js
Normal file
103
services/mam-api/test/routes/sequences-access.test.js
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
// 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(); }
|
||||||
|
});
|
||||||
143
services/mam-api/test/routes/totp.test.js
Normal file
143
services/mam-api/test/routes/totp.test.js
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
// 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.2.0';
|
const VERSION = '1.3.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,19 +87,54 @@ 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 */ }
|
||||||
|
}
|
||||||
|
|
||||||
const spec = {
|
// Build the sidecar environment, injecting NVIDIA vars when GPU is requested.
|
||||||
Image: image,
|
const sidecarEnv = [...env, `PORT=${capturePort}`];
|
||||||
Env: [...env, `PORT=${capturePort}`],
|
if (useGpu) {
|
||||||
HostConfig: {
|
// 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',
|
NetworkMode: 'host',
|
||||||
Privileged: true,
|
Privileged: true,
|
||||||
Binds: binds,
|
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 = {
|
||||||
|
Image: image,
|
||||||
|
Env: sidecarEnv,
|
||||||
|
HostConfig: hostConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
const createRes = await dockerApi('POST', '/containers/create', spec);
|
const createRes = await dockerApi('POST', '/containers/create', spec);
|
||||||
|
|
@ -108,6 +143,9 @@ 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(() => {});
|
||||||
|
|
@ -120,12 +158,40 @@ 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 {
|
||||||
await dockerApi('POST', `/containers/${containerId}/stop`).catch(() => {});
|
console.log(`[sidecar-stop] stopping ${containerId} (grace 180s)...`);
|
||||||
|
// 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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -180,6 +246,71 @@ 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.
|
||||||
|
|
@ -264,7 +395,7 @@ async function probeGpusViaSmi() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectHardware() {
|
function detectHardware() {
|
||||||
const capabilities = { gpus: [], blackmagic: [] };
|
const capabilities = { gpus: [], blackmagic: [], deltacast: [] };
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -311,12 +442,39 @@ 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();
|
||||||
|
|
@ -332,6 +490,7 @@ 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' };
|
||||||
|
|
@ -347,9 +506,10 @@ 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}\n`
|
`mem=${payload.mem_used_mb}/${payload.mem_total_mb}MB${gpuStr}${bmdStr}${dcStr}\n`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const txt = await res.text().catch(() => '');
|
const txt = await res.text().catch(() => '');
|
||||||
|
|
@ -367,6 +527,22 @@ 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');
|
||||||
|
|
@ -396,6 +572,9 @@ 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();
|
||||||
|
|
|
||||||
58
services/playout/Dockerfile
Normal file
58
services/playout/Dockerfile
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# 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"]
|
||||||
22
services/playout/casparcg.config
Normal file
22
services/playout/casparcg.config
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<paths>
|
||||||
|
<media-path>/media/</media-path>
|
||||||
|
<log-path>/media/casparcg/log/</log-path>
|
||||||
|
<data-path>/media/casparcg/data/</data-path>
|
||||||
|
<template-path>/media/templates/</template-path>
|
||||||
|
</paths>
|
||||||
|
<channels>
|
||||||
|
<channel>
|
||||||
|
<video-mode>1080i5994</video-mode>
|
||||||
|
<consumers>
|
||||||
|
</consumers>
|
||||||
|
</channel>
|
||||||
|
</channels>
|
||||||
|
<controllers>
|
||||||
|
<tcp>
|
||||||
|
<port>5250</port>
|
||||||
|
<protocol>AMCP</protocol>
|
||||||
|
</tcp>
|
||||||
|
</controllers>
|
||||||
|
</configuration>
|
||||||
55
services/playout/entrypoint.sh
Normal file
55
services/playout/entrypoint.sh
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
#!/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
|
||||||
18
services/playout/package.json
Normal file
18
services/playout/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "wild-dragon-playout",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Wild Dragon MAM playout sidecar — wraps a CasparCG Server instance and drives it over AMCP for master-control playout (SDI / NDI / SRT / RTMP).",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.0",
|
||||||
|
"cors": "^2.8.0",
|
||||||
|
"dotenv": "^16.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
182
services/playout/src/amcp.js
Normal file
182
services/playout/src/amcp.js
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import net from 'node:net';
|
||||||
|
|
||||||
|
// Minimal AMCP (Advanced Media Control Protocol) client for CasparCG.
|
||||||
|
//
|
||||||
|
// AMCP is a line-based TCP protocol: each command is a single CRLF-terminated
|
||||||
|
// line, and the server replies with a status line ("201 PLAY OK\r\n") optionally
|
||||||
|
// followed by data lines. We keep one persistent socket per CasparCG instance
|
||||||
|
// and serialize commands through a FIFO queue — CasparCG processes one command
|
||||||
|
// at a time per connection, so interleaving replies would otherwise be
|
||||||
|
// ambiguous.
|
||||||
|
//
|
||||||
|
// We only implement the subset the playout sidecar needs (PLAY / LOADBG / STOP /
|
||||||
|
// CLEAR / INFO / ADD / REMOVE). Responses are returned raw; callers parse the
|
||||||
|
// status code where they care.
|
||||||
|
|
||||||
|
const CRLF = '\r\n';
|
||||||
|
|
||||||
|
export class AmcpClient {
|
||||||
|
constructor({ host = '127.0.0.1', port = 5250 } = {}) {
|
||||||
|
this.host = host;
|
||||||
|
this.port = port;
|
||||||
|
this.socket = null;
|
||||||
|
this.connected = false;
|
||||||
|
this._buffer = '';
|
||||||
|
this._queue = []; // pending { command, resolve, reject, timer }
|
||||||
|
this._active = null; // command currently awaiting a reply
|
||||||
|
this._reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.socket) return;
|
||||||
|
const socket = net.createConnection({ host: this.host, port: this.port });
|
||||||
|
socket.setEncoding('utf8');
|
||||||
|
socket.setKeepAlive(true, 10000);
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
this.connected = true;
|
||||||
|
console.log(`[amcp] connected to ${this.host}:${this.port}`);
|
||||||
|
});
|
||||||
|
socket.on('data', (chunk) => this._onData(chunk));
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
console.error(`[amcp] socket error: ${err.message}`);
|
||||||
|
});
|
||||||
|
socket.on('close', () => {
|
||||||
|
this.connected = false;
|
||||||
|
this.socket = null;
|
||||||
|
// Fail any in-flight + queued commands so callers don't hang.
|
||||||
|
const pending = this._active ? [this._active, ...this._queue] : [...this._queue];
|
||||||
|
this._active = null;
|
||||||
|
this._queue = [];
|
||||||
|
for (const p of pending) {
|
||||||
|
clearTimeout(p.timer);
|
||||||
|
p.reject(new Error('AMCP connection closed'));
|
||||||
|
}
|
||||||
|
this._scheduleReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket = socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleReconnect() {
|
||||||
|
if (this._reconnectTimer) return;
|
||||||
|
this._reconnectTimer = setTimeout(() => {
|
||||||
|
this._reconnectTimer = null;
|
||||||
|
console.log('[amcp] reconnecting...');
|
||||||
|
this.connect();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait until the socket is usable, up to timeoutMs.
|
||||||
|
async waitReady(timeoutMs = 30000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (this.connected) return true;
|
||||||
|
if (!this.socket) this.connect();
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
throw new Error('AMCP not ready within timeout');
|
||||||
|
}
|
||||||
|
|
||||||
|
_onData(chunk) {
|
||||||
|
this._buffer += chunk;
|
||||||
|
// A CasparCG reply is a status line, optionally followed by data lines.
|
||||||
|
// The simplest robust framing: a command's reply is complete when we see a
|
||||||
|
// status line AND (for 2-line "200" multi-line replies) the terminating
|
||||||
|
// blank line. For our command subset, single-status-line replies dominate;
|
||||||
|
// we treat a reply as complete at each newline and let the active command
|
||||||
|
// decide whether it has enough. To keep this correct for INFO (multi-line),
|
||||||
|
// we accumulate until the buffer ends with a known terminator.
|
||||||
|
if (!this._active) {
|
||||||
|
// Unsolicited data (e.g. connection banner) — discard.
|
||||||
|
this._buffer = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// CasparCG ends multi-line replies with CRLF on an empty line. Single-line
|
||||||
|
// replies (201/202/4xx/5xx) end with a single CRLF. Resolve when we have at
|
||||||
|
// least one complete line; for "200 ... OK" (list follows) wait for the
|
||||||
|
// blank-line terminator.
|
||||||
|
const firstLineEnd = this._buffer.indexOf(CRLF);
|
||||||
|
if (firstLineEnd === -1) return;
|
||||||
|
const statusLine = this._buffer.slice(0, firstLineEnd);
|
||||||
|
const code = parseInt(statusLine, 10);
|
||||||
|
|
||||||
|
if (code === 200) {
|
||||||
|
// Multi-line: data lines until an empty line.
|
||||||
|
const term = this._buffer.indexOf(CRLF + CRLF);
|
||||||
|
if (term === -1) return; // wait for more
|
||||||
|
const full = this._buffer.slice(0, term);
|
||||||
|
this._buffer = this._buffer.slice(term + 4);
|
||||||
|
this._finishActive(null, full);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 201 || code === 202) {
|
||||||
|
// 201: one data line follows the status line. 202: status only.
|
||||||
|
if (code === 201) {
|
||||||
|
const secondLineEnd = this._buffer.indexOf(CRLF, firstLineEnd + 2);
|
||||||
|
if (secondLineEnd === -1) return;
|
||||||
|
const full = this._buffer.slice(0, secondLineEnd);
|
||||||
|
this._buffer = this._buffer.slice(secondLineEnd + 2);
|
||||||
|
this._finishActive(null, full);
|
||||||
|
} else {
|
||||||
|
const full = this._buffer.slice(0, firstLineEnd);
|
||||||
|
this._buffer = this._buffer.slice(firstLineEnd + 2);
|
||||||
|
this._finishActive(null, full);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4xx / 5xx error, or any other single-line status.
|
||||||
|
const full = this._buffer.slice(0, firstLineEnd);
|
||||||
|
this._buffer = this._buffer.slice(firstLineEnd + 2);
|
||||||
|
if (code >= 400) this._finishActive(new Error(`AMCP error: ${full}`), full);
|
||||||
|
else this._finishActive(null, full);
|
||||||
|
}
|
||||||
|
|
||||||
|
_finishActive(err, data) {
|
||||||
|
const active = this._active;
|
||||||
|
this._active = null;
|
||||||
|
if (active) {
|
||||||
|
clearTimeout(active.timer);
|
||||||
|
if (err) active.reject(err);
|
||||||
|
else active.resolve(data);
|
||||||
|
}
|
||||||
|
this._pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
_pump() {
|
||||||
|
if (this._active || this._queue.length === 0) return;
|
||||||
|
const next = this._queue.shift();
|
||||||
|
this._active = next;
|
||||||
|
try {
|
||||||
|
this.socket.write(next.command + CRLF);
|
||||||
|
} catch (err) {
|
||||||
|
this._active = null;
|
||||||
|
clearTimeout(next.timer);
|
||||||
|
next.reject(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a single AMCP command and resolve with the raw reply string.
|
||||||
|
send(command, { timeoutMs = 15000 } = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const entry = { command, resolve, reject, timer: null };
|
||||||
|
entry.timer = setTimeout(() => {
|
||||||
|
// Drop from queue if still pending; if active, detach so the next
|
||||||
|
// reply doesn't get misrouted.
|
||||||
|
if (this._active === entry) this._active = null;
|
||||||
|
else this._queue = this._queue.filter((e) => e !== entry);
|
||||||
|
reject(new Error(`AMCP command timed out: ${command}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
this._queue.push(entry);
|
||||||
|
this._pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
|
||||||
|
if (this.socket) { try { this.socket.destroy(); } catch (_) {} this.socket = null; }
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
85
services/playout/src/index.js
Normal file
85
services/playout/src/index.js
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import playoutManager from './playout-manager.js';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3002;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => res.json({ status: 'ok' }));
|
||||||
|
|
||||||
|
// Start the channel's output consumer. Body: { outputType, outputConfig, videoFormat }
|
||||||
|
app.post('/channel/start', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const out = await playoutManager.startChannel(req.body || {});
|
||||||
|
res.json(out);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[playout] /channel/start error:', err.message);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/channel/stop', async (req, res) => {
|
||||||
|
try { res.json(await playoutManager.stopChannel()); }
|
||||||
|
catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load + start a playlist. Body: { items: [...], loop }
|
||||||
|
app.post('/playlist/load', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { items = [], loop = false } = req.body || {};
|
||||||
|
res.json(await playoutManager.loadPlaylist({ items, loop }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[playout] /playlist/load error:', err.message);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/transport/skip', async (req, res) => { try { res.json(await playoutManager.skip()); } catch (e) { res.status(500).json({ error: e.message }); } });
|
||||||
|
app.post('/transport/pause', async (req, res) => { try { res.json(await playoutManager.pause()); } catch (e) { res.status(500).json({ error: e.message }); } });
|
||||||
|
app.post('/transport/resume', async (req, res) => { try { res.json(await playoutManager.resume()); } catch (e) { res.status(500).json({ error: e.message }); } });
|
||||||
|
|
||||||
|
app.get('/status', (req, res) => res.json(playoutManager.getStatus()));
|
||||||
|
|
||||||
|
// Auto-start: when the sidecar is spawned by mam-api with channel env, bring up
|
||||||
|
// the output consumer immediately so the container is "on air idle" (black/slate)
|
||||||
|
// the moment it boots, mirroring the capture sidecar's bootstrap pattern.
|
||||||
|
async function bootstrap() {
|
||||||
|
const outputType = process.env.OUTPUT_TYPE;
|
||||||
|
if (!outputType) {
|
||||||
|
console.log('[bootstrap] no OUTPUT_TYPE — on-demand sidecar, waiting for /channel/start');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let outputConfig = {};
|
||||||
|
try { outputConfig = JSON.parse(process.env.OUTPUT_CONFIG || '{}'); }
|
||||||
|
catch (err) { console.error('[bootstrap] bad OUTPUT_CONFIG json:', err.message); }
|
||||||
|
const videoFormat = process.env.VIDEO_FORMAT || '1080i5994';
|
||||||
|
try {
|
||||||
|
await playoutManager.startChannel({ outputType, outputConfig, videoFormat });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bootstrap] channel start failed:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = app.listen(PORT, () => {
|
||||||
|
console.log(`Wild Dragon Playout Service listening on port ${PORT}`);
|
||||||
|
// Give CasparCG a moment to come up (started by the container entrypoint).
|
||||||
|
playoutManager.amcp.connect();
|
||||||
|
bootstrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
function shutdown(sig) {
|
||||||
|
console.log(`[playout] ${sig} — shutting down`);
|
||||||
|
playoutManager.stopChannel().catch(() => {}).finally(() => {
|
||||||
|
playoutManager.amcp.close();
|
||||||
|
server.close(() => process.exit(0));
|
||||||
|
setTimeout(() => process.exit(0), 5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
316
services/playout/src/playout-manager.js
Normal file
316
services/playout/src/playout-manager.js
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
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();
|
||||||
BIN
services/premiere-plugin-uxp/dragonflight-mam-2.2.2.ccx
Normal file
BIN
services/premiere-plugin-uxp/dragonflight-mam-2.2.2.ccx
Normal file
Binary file not shown.
|
|
@ -9,12 +9,10 @@
|
||||||
<div id="root">
|
<div id="root">
|
||||||
|
|
||||||
<!-- ── Connect Pane ─────────────────────────────────────────────── -->
|
<!-- ── Connect Pane ─────────────────────────────────────────────── -->
|
||||||
<section id="connect-pane" class="pane">
|
<section id="connect-pane" class="pane pane-connect">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-icon">
|
<div class="brand-icon">
|
||||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg width="28" height="28" viewBox="0 0 24 24"><path fill="currentColor" d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
|
||||||
<path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-title">Dragonflight</div>
|
<div class="brand-title">Dragonflight</div>
|
||||||
<div class="brand-tag">Wild Dragon Broadcast</div>
|
<div class="brand-tag">Wild Dragon Broadcast</div>
|
||||||
|
|
@ -28,45 +26,60 @@
|
||||||
<div id="connect-status" class="status-msg muted"></div>
|
<div id="connect-status" class="status-msg muted"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── Library Pane ─────────────────────────────────────────────── -->
|
<!-- ── Library Pane (app-shell: statusbar · rail · main · dock) ──── -->
|
||||||
<section id="library-pane" class="pane hidden">
|
<section id="library-pane" class="pane hidden">
|
||||||
|
<div class="app">
|
||||||
|
|
||||||
<!-- Connection status strip (v2.1.8): dot + identity + ⋯ menu.
|
<!-- Connection collapsed to a status line: dot + host + version + ⋯ -->
|
||||||
Disconnect lives inside the menu so it's not always visible. -->
|
<header class="statusbar">
|
||||||
<header class="status-strip">
|
|
||||||
<span class="signal-dot"></span>
|
<span class="signal-dot"></span>
|
||||||
<span id="connected-host" class="connected-host"></span>
|
<span id="connected-host" class="connected-host"></span>
|
||||||
<span id="panel-version" class="panel-version" title="Plugin version"></span>
|
<span id="panel-version" class="panel-version" title="Plugin version"></span>
|
||||||
<button id="menu-btn" class="btn-ghost" title="More" aria-label="More">⋯</button>
|
<div id="menu-btn" role="button" tabindex="0" class="iconbtn iconbtn--sm" data-tip="More" data-tip-pos="down-left" aria-label="More">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24"><path fill="currentColor" d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>
|
||||||
|
</div>
|
||||||
<div id="status-menu" class="menu hidden" role="menu">
|
<div id="status-menu" class="menu hidden" role="menu">
|
||||||
<button id="disconnect-btn" class="menu-item" role="menuitem">Disconnect</button>
|
<button id="disconnect-btn" class="menu-item" role="menuitem">Disconnect</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<div class="workspace">
|
||||||
<div class="tab-nav">
|
|
||||||
<button id="tab-library" class="tab-btn active">
|
<!-- Vertical icon rail: views on top, global actions below -->
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<nav class="rail">
|
||||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
<div id="tab-library" role="button" tabindex="0" class="rail-btn active" data-tip="Library" data-tip-pos="right">
|
||||||
</svg>
|
<svg width="18" height="18" viewBox="0 0 24 24"><rect x="3" y="3" width="7.5" height="7.5" rx="1.5" fill="currentColor"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.5" fill="currentColor"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.5" fill="currentColor"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.5" fill="currentColor"/></svg>
|
||||||
Library
|
</div>
|
||||||
</button>
|
<div id="tab-growing" role="button" tabindex="0" class="rail-btn" data-tip="Growing" data-tip-pos="right">
|
||||||
<button id="tab-growing" class="tab-btn">
|
<svg width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<span id="growing-count" class="rail-count" style="display:none">0</span>
|
||||||
<path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
|
|
||||||
</svg>
|
|
||||||
Growing
|
|
||||||
<span id="growing-count" class="badge" style="display:none">0</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search + Project filter -->
|
<span class="rail-spacer"></span>
|
||||||
<div class="search-row">
|
|
||||||
<input id="search-input" type="search" placeholder="Search assets…" />
|
<div id="export-timeline-btn" role="button" tabindex="0" class="rail-btn rail-btn--accent" data-tip="Export" data-tip-pos="right">
|
||||||
<select id="project-filter" title="Filter by project">
|
<svg width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div id="refresh-btn" role="button" tabindex="0" class="rail-btn" data-tip="Refresh" data-tip-pos="right">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M17.65 6.35A7.96 7.96 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main column -->
|
||||||
|
<div class="main">
|
||||||
|
|
||||||
|
<!-- Search + project filter -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<label class="search">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 0 0 1.48-5.34c-.47-2.78-2.79-5-5.59-5.34a6.5 6.5 0 0 0-7.27 7.27c.34 2.8 2.56 5.12 5.34 5.59a6.5 6.5 0 0 0 5.34-1.48l.27.28v.79l4.25 4.25c.41.41 1.08.41 1.49 0 .41-.41.41-1.08 0-1.49L15.5 14zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||||
|
<input id="search-input" type="search" placeholder="Search assets" />
|
||||||
|
</label>
|
||||||
|
<select id="project-filter" class="filter-select" title="Filter by project">
|
||||||
<option value="all">All Projects</option>
|
<option value="all">All Projects</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="refresh-btn" class="btn btn-icon" title="Refresh">↻</button>
|
<div id="view-toggle-btn" role="button" tabindex="0" class="iconbtn iconbtn--sm" data-tip="Grid view" data-tip-pos="down-left" aria-label="Toggle layout">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24"><path fill="currentColor" d="M4 11h5V5H4v6zm0 7h5v-6H4v6zm6 0h5v-6h-5v6zm6 0h5v-6h-5v6zm-6-7h5V5h-5v6zm6-6v6h5V5h-5z"/></svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Active sequence info bar -->
|
<!-- Active sequence info bar -->
|
||||||
|
|
@ -89,52 +102,75 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- (v2.2.0) Asset details panel dropped — card meta already carries
|
<!-- Details panel retired (v2.2.0) — card meta carries name/codec/duration. -->
|
||||||
name / codec / duration. If we need richer detail later, surface
|
|
||||||
it on the card hover state rather than reserving permanent space. -->
|
|
||||||
<div id="details-panel" class="hidden"></div>
|
<div id="details-panel" class="hidden"></div>
|
||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Progress + toast sit just above the action dock -->
|
||||||
<footer class="actions">
|
|
||||||
<div class="action-row">
|
|
||||||
<button id="import-proxy-btn" class="btn btn-primary" disabled>Import Proxy</button>
|
|
||||||
<button id="import-hires-btn" class="btn btn-secondary" disabled>Hi-Res</button>
|
|
||||||
</div>
|
|
||||||
<div class="action-row">
|
|
||||||
<button id="mount-live-btn" class="btn btn-secondary" disabled title="Open live file from SMB share">Mount Live</button>
|
|
||||||
<button id="relink-btn" class="btn btn-secondary" disabled title="Relink proxy → hi-res original">Relink Hi-Res</button>
|
|
||||||
</div>
|
|
||||||
<div class="action-row">
|
|
||||||
<button id="import-all-btn" class="btn btn-secondary" disabled>Import All</button>
|
|
||||||
<button id="export-timeline-btn" class="btn btn-secondary" disabled>Export Timeline ↑</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Progress -->
|
|
||||||
<div id="progress-row" class="progress-row hidden">
|
<div id="progress-row" class="progress-row hidden">
|
||||||
<div class="progress-bar"><div id="progress-fill"></div></div>
|
<div class="progress-bar"><div id="progress-fill"></div></div>
|
||||||
<div id="progress-label" class="progress-label">…</div>
|
<div id="progress-label" class="progress-label">…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toast -->
|
|
||||||
<div id="toast" class="toast hidden"></div>
|
<div id="toast" class="toast hidden"></div>
|
||||||
|
|
||||||
|
<!-- Contextual action dock: text buttons replaced by icon buttons
|
||||||
|
with hover labels. Per-asset actions left, batch actions right. -->
|
||||||
|
<footer class="dock">
|
||||||
|
<div id="import-proxy-btn" role="button" tabindex="0" class="iconbtn iconbtn--primary" data-tip="Import Proxy" data-tip-pos="up" disabled>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div id="import-hires-btn" role="button" tabindex="0" class="iconbtn" data-tip="Import Hi-Res" data-tip-pos="up" disabled>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M11.99 18.54l-7.37-5.73L3 14.07l9 7 9-7-1.63-1.27-7.38 5.74zM12 16l7.36-5.73L21 9l-9-7-9 7 1.63 1.27L12 16z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div id="mount-live-btn" role="button" tabindex="0" class="iconbtn" data-tip="Mount Live" data-tip-pos="up" disabled>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M14 12c0 1.11-.89 2-2 2s-2-.89-2-2 .89-2 2-2 2 .89 2 2zm-2-6c-3.31 0-6 2.69-6 6 0 2.22 1.21 4.15 3 5.19l1-1.74A3.98 3.98 0 0 1 8 12c0-2.21 1.79-4 4-4s4 1.79 4 4c0 1.48-.81 2.75-2 3.45l1 1.74c1.79-1.04 3-2.97 3-5.19 0-3.31-2.69-6-6-6zm0-4C7.58 2 4 5.58 4 10c0 2.96 1.61 5.53 4 6.92l1-1.73C7.21 14.07 6 12.18 6 10c0-3.31 2.69-6 6-6s6 2.69 6 6c0 2.18-1.21 4.07-3 5.19l1 1.73c2.39-1.39 4-3.96 4-6.92 0-4.42-3.58-8-8-8z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div id="relink-btn" role="button" tabindex="0" class="iconbtn" data-tip="Relink Hi-Res" data-tip-pos="up" disabled>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="dock-sep"></span>
|
||||||
|
|
||||||
|
<div id="upload-mam-btn" role="button" tabindex="0" class="iconbtn" data-tip="Upload to MAM" data-tip-pos="up-left">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M19.35 10.04A7.49 7.49 0 0 0 12 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 0 0 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/></svg>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<!-- Advanced section — collapsed by default; click the row to expand. -->
|
</div><!-- /main -->
|
||||||
<div class="advanced-section">
|
</div><!-- /workspace -->
|
||||||
<button id="advanced-toggle" class="advanced-toggle" type="button" aria-expanded="false">
|
</div><!-- /app -->
|
||||||
<span class="advanced-caret">▸</span>
|
|
||||||
<span class="advanced-title">Advanced</span>
|
|
||||||
</button>
|
|
||||||
<div id="advanced-body" class="advanced-body hidden">
|
|
||||||
<div class="action-row">
|
|
||||||
<button id="export-conform-btn" class="btn btn-secondary" disabled>Export & Conform</button>
|
|
||||||
<button id="fetch-relink-btn" class="btn btn-secondary" disabled>Fetch & Relink All</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── Export Timeline Slide Panel ──────────────────────────────── -->
|
<!-- Full-panel Export screen -->
|
||||||
|
<div id="export-screen" class="screen hidden">
|
||||||
|
<div class="screen-header">
|
||||||
|
<span class="screen-title">Export</span>
|
||||||
|
<button id="export-screen-close" class="btn btn-icon">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="screen-body">
|
||||||
|
<div id="opt-conform" role="button" tabindex="0" class="export-option">
|
||||||
|
<div class="eo-icon">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24"><path fill="currentColor" d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="eo-text">
|
||||||
|
<div class="eo-title">Conform Timeline → MAM</div>
|
||||||
|
<div class="eo-desc">Render the sequence to a chosen codec and push it to the MAM</div>
|
||||||
|
</div>
|
||||||
|
<span class="eo-arrow">›</span>
|
||||||
|
</div>
|
||||||
|
<div id="opt-local-export" role="button" tabindex="0" class="export-option">
|
||||||
|
<div class="eo-icon">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24"><path fill="currentColor" d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="eo-text">
|
||||||
|
<div class="eo-title">Local Export</div>
|
||||||
|
<div class="eo-desc">Trim the hi-res sources on the MAM, download and relink them in Premiere</div>
|
||||||
|
</div>
|
||||||
|
<span class="eo-arrow">›</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── (retired) Push Timeline Slide Panel — kept hidden/unused ──── -->
|
||||||
<div id="export-overlay" class="slide-overlay hidden"></div>
|
<div id="export-overlay" class="slide-overlay hidden"></div>
|
||||||
<div id="export-panel" class="slide-panel hidden">
|
<div id="export-panel" class="slide-panel hidden">
|
||||||
<div class="slide-header">
|
<div class="slide-header">
|
||||||
|
|
@ -164,13 +200,15 @@
|
||||||
<div class="slide-body">
|
<div class="slide-body">
|
||||||
<label class="field-label">Target project</label>
|
<label class="field-label">Target project</label>
|
||||||
<select id="conform-proj-select"><option value="">— Select project —</option></select>
|
<select id="conform-proj-select"><option value="">— Select project —</option></select>
|
||||||
<label class="field-label">Preset</label>
|
<label class="field-label">Format</label>
|
||||||
<div id="preset-cards" class="preset-grid">
|
<div id="preset-cards" class="preset-list">
|
||||||
<div class="preset-card selected" data-preset="broadcast"><div class="pc-title">Broadcast</div><div class="pc-desc">ProRes 422 HQ · 1080p · 48kHz</div></div>
|
<div class="preset-card selected" data-preset="broadcast"><span class="pc-title">Broadcast</span><span class="pc-desc">ProRes 422 HQ · 1080p</span></div>
|
||||||
<div class="preset-card" data-preset="web"><div class="pc-title">Web</div><div class="pc-desc">H.264 · 1080p · AAC 320k</div></div>
|
<div class="preset-card" data-preset="web"><span class="pc-title">Web</span><span class="pc-desc">H.264 · 1080p · AAC</span></div>
|
||||||
<div class="preset-card" data-preset="archive"><div class="pc-title">Archive</div><div class="pc-desc">ProRes 4444 · UHD · 48kHz</div></div>
|
<div class="preset-card" data-preset="archive"><span class="pc-title">Archive</span><span class="pc-desc">ProRes 4444 · UHD</span></div>
|
||||||
<div class="preset-card" data-preset="custom"><div class="pc-title">Custom</div><div class="pc-desc">Manual settings</div></div>
|
<div class="preset-card" data-preset="custom"><span class="pc-title">Custom</span><span class="pc-desc">Manual settings</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="conform-custom" class="custom-fields hidden">
|
||||||
<label class="field-label">Codec</label>
|
<label class="field-label">Codec</label>
|
||||||
<select id="conform-codec">
|
<select id="conform-codec">
|
||||||
<option value="prores_hq">ProRes 422 HQ</option>
|
<option value="prores_hq">ProRes 422 HQ</option>
|
||||||
|
|
@ -198,6 +236,7 @@
|
||||||
<option value="web">Web (AAC 320k)</option>
|
<option value="web">Web (AAC 320k)</option>
|
||||||
<option value="archive">Archive (96kHz PCM)</option>
|
<option value="archive">Archive (96kHz PCM)</option>
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
<div id="conform-clip-info" class="clip-info"></div>
|
<div id="conform-clip-info" class="clip-info"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="slide-footer">
|
<div class="slide-footer">
|
||||||
|
|
@ -231,6 +270,7 @@
|
||||||
<script src="src/library.js"></script>
|
<script src="src/library.js"></script>
|
||||||
<script src="src/import-flow.js"></script>
|
<script src="src/import-flow.js"></script>
|
||||||
<script src="src/timeline.js"></script>
|
<script src="src/timeline.js"></script>
|
||||||
|
<script src="src/tooltip.js"></script>
|
||||||
<script src="src/main.js"></script>
|
<script src="src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -167,5 +167,57 @@
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Local Export trim job polling + segment retrieval.
|
||||||
|
API.getTrimStatus = function (jobId) {
|
||||||
|
return API.json('/api/v1/assets/trim-status/' + jobId);
|
||||||
|
};
|
||||||
|
API.getTempSegmentUrl = function (clipInstanceId) {
|
||||||
|
return API.json('/api/v1/assets/temp-segment-url/' + clipInstanceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Upload (ingest editor media into the MAM) ────────────────────
|
||||||
|
// Single-shot multipart form upload (server caps simple at <50 MB).
|
||||||
|
API.uploadSimple = async function (blob, meta) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', blob, meta.filename);
|
||||||
|
fd.append('filename', meta.filename);
|
||||||
|
fd.append('projectId', meta.projectId);
|
||||||
|
if (meta.binId) fd.append('binId', meta.binId);
|
||||||
|
if (meta.contentType) fd.append('contentType', meta.contentType);
|
||||||
|
const r = await API.request('/api/v1/upload/simple', { method: 'POST', body: fd });
|
||||||
|
if (!r.ok) throw new Error('Upload HTTP ' + r.status + ' — ' + (await r.text().catch(() => '')).slice(0, 160));
|
||||||
|
return r.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chunked multipart for large originals.
|
||||||
|
API.uploadInit = function (meta) {
|
||||||
|
return API.json('/api/v1/upload/init', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(meta),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
API.uploadPart = async function (blob, meta) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', blob, 'part-' + meta.partNumber);
|
||||||
|
fd.append('uploadId', meta.uploadId);
|
||||||
|
fd.append('key', meta.key);
|
||||||
|
fd.append('partNumber', String(meta.partNumber));
|
||||||
|
const r = await API.request('/api/v1/upload/part', { method: 'POST', body: fd });
|
||||||
|
if (!r.ok) throw new Error('Upload part HTTP ' + r.status);
|
||||||
|
return r.json();
|
||||||
|
};
|
||||||
|
API.uploadComplete = function (meta) {
|
||||||
|
return API.json('/api/v1/upload/complete', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(meta),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
API.uploadAbort = function (meta) {
|
||||||
|
return API.json('/api/v1/upload/abort', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(meta),
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
window.API = API;
|
window.API = API;
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -154,5 +154,111 @@
|
||||||
return destPath;
|
return destPath;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Upload (ingest editor media into the MAM) ────────────────────
|
||||||
|
const SIMPLE_MAX = 45 * 1024 * 1024; // server caps /simple at <50 MB
|
||||||
|
const PART_SIZE = 16 * 1024 * 1024; // chunk size for multipart
|
||||||
|
|
||||||
|
function _contentType(name) {
|
||||||
|
const ext = String(name).split('.').pop().toLowerCase();
|
||||||
|
const map = {
|
||||||
|
mp4:'video/mp4', m4v:'video/mp4', mov:'video/quicktime', mxf:'application/mxf',
|
||||||
|
mkv:'video/x-matroska', avi:'video/x-msvideo', mpg:'video/mpeg', mpeg:'video/mpeg',
|
||||||
|
mts:'video/mp2t', m2ts:'video/mp2t', wav:'audio/wav', aif:'audio/aiff', aiff:'audio/aiff',
|
||||||
|
mp3:'audio/mpeg', png:'image/png', jpg:'image/jpeg', jpeg:'image/jpeg',
|
||||||
|
};
|
||||||
|
return map[ext] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a local file and push it to the MAM. Returns the created asset row.
|
||||||
|
// NOTE: reads the whole file into memory once (fine for typical clips;
|
||||||
|
// very large multi-GB originals may strain memory — revisit with a
|
||||||
|
// positional-read stream if that becomes a problem).
|
||||||
|
Import.uploadFile = async function (nativePath, meta) {
|
||||||
|
meta = meta || {};
|
||||||
|
if (!meta.projectId) throw new Error('No target project for upload');
|
||||||
|
const filename = meta.filename || path.basename(nativePath);
|
||||||
|
const contentType = _contentType(filename);
|
||||||
|
|
||||||
|
const buf = await fs.readFile(nativePath);
|
||||||
|
const size = buf.byteLength != null ? buf.byteLength : buf.length;
|
||||||
|
|
||||||
|
if (size <= SIMPLE_MAX) {
|
||||||
|
const blob = new Blob([buf], { type: contentType });
|
||||||
|
return API.uploadSimple(blob, { filename, projectId: meta.projectId, binId: meta.binId, contentType });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunked multipart for large files.
|
||||||
|
const init = await API.uploadInit({ filename, fileSize: size, contentType, projectId: meta.projectId, binId: meta.binId });
|
||||||
|
const parts = [];
|
||||||
|
try {
|
||||||
|
let partNumber = 1;
|
||||||
|
for (let off = 0; off < size; off += PART_SIZE, partNumber++) {
|
||||||
|
const chunk = buf.slice(off, Math.min(off + PART_SIZE, size));
|
||||||
|
const blob = new Blob([chunk], { type: contentType });
|
||||||
|
if (meta.onProgress) meta.onProgress(off, size);
|
||||||
|
const res = await API.uploadPart(blob, { uploadId: init.uploadId, key: init.key, partNumber });
|
||||||
|
parts.push({ PartNumber: partNumber, ETag: res.etag });
|
||||||
|
}
|
||||||
|
return API.uploadComplete({ uploadId: init.uploadId, key: init.key, assetId: init.assetId, parts });
|
||||||
|
} catch (e) {
|
||||||
|
await API.uploadAbort({ uploadId: init.uploadId, key: init.key, assetId: init.assetId });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Bin selection (best-effort) + file-picker fallback ───────────
|
||||||
|
// Tries to read the highlighted project-panel item(s). The UXP premierepro
|
||||||
|
// selection surface varies by version, so every access is guarded; on any
|
||||||
|
// miss this returns [] and callers fall back to a native file picker.
|
||||||
|
Import.getSelectedBinPaths = async function () {
|
||||||
|
const paths = [];
|
||||||
|
try {
|
||||||
|
const P = _ppro();
|
||||||
|
const project = await P.Project.getActiveProject();
|
||||||
|
if (!project) return paths;
|
||||||
|
let sel = null;
|
||||||
|
try { if (project.getSelection) sel = await project.getSelection(); } catch (_) {}
|
||||||
|
let items = [];
|
||||||
|
if (sel) {
|
||||||
|
if (typeof sel.getItems === 'function') items = await sel.getItems();
|
||||||
|
else if (Array.isArray(sel)) items = sel;
|
||||||
|
else if (Array.isArray(sel.items)) items = sel.items;
|
||||||
|
}
|
||||||
|
for (const it of (items || [])) {
|
||||||
|
try {
|
||||||
|
const ci = await P.ClipProjectItem.cast(it);
|
||||||
|
const mp = await ci.getMediaFilePath();
|
||||||
|
if (mp) paths.push(mp);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Native file picker — returns array of native paths (may be empty).
|
||||||
|
Import.pickFiles = async function () {
|
||||||
|
if (!uxpFs || !uxpFs.getFileForOpening) throw new Error('File picker unavailable in this host');
|
||||||
|
const sel = await uxpFs.getFileForOpening({ allowMultiple: true });
|
||||||
|
if (!sel) return [];
|
||||||
|
const arr = Array.isArray(sel) ? sel : [sel];
|
||||||
|
return arr.map(f => f && f.nativePath).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload any timeline clips not yet in the MAM, recording the path→asset
|
||||||
|
// mapping so resolveClipsToAssets picks them up on the next pass.
|
||||||
|
Import.ensureClipsInMam = async function (clips, projectId, onProgress) {
|
||||||
|
const missing = clips.filter(c => !c.asset_id && c.filePath);
|
||||||
|
for (let i = 0; i < missing.length; i++) {
|
||||||
|
const c = missing[i];
|
||||||
|
if (onProgress) onProgress(c.fileName || path.basename(c.filePath), i + 1, missing.length);
|
||||||
|
const asset = await Import.uploadFile(c.filePath, { projectId, filename: path.basename(c.filePath) });
|
||||||
|
if (asset && asset.id) {
|
||||||
|
Library.recordImport(c.filePath, { assetId: asset.id });
|
||||||
|
if (c.fileName) Library.recordImport('name:' + c.fileName, { assetId: asset.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { uploaded: missing.length };
|
||||||
|
};
|
||||||
|
|
||||||
window.Import = Import;
|
window.Import = Import;
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -220,10 +220,7 @@
|
||||||
_btn('import-hires-btn').disabled = !sel || live || !sel.original_s3_key;
|
_btn('import-hires-btn').disabled = !sel || live || !sel.original_s3_key;
|
||||||
_btn('mount-live-btn').disabled = !sel || !live;
|
_btn('mount-live-btn').disabled = !sel || !live;
|
||||||
_btn('relink-btn').disabled = !(ready && hasLiveImport);
|
_btn('relink-btn').disabled = !(ready && hasLiveImport);
|
||||||
_btn('import-all-btn').disabled = Library.state.currentTab !== 'library';
|
// export-timeline-btn (Export menu) and upload-mam-btn are always available.
|
||||||
_btn('export-timeline-btn').disabled = false; // available once connected
|
|
||||||
_btn('export-conform-btn').disabled = false;
|
|
||||||
_btn('fetch-relink-btn').disabled = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function _btn(id) { return document.getElementById(id) || { disabled: false }; }
|
function _btn(id) { return document.getElementById(id) || { disabled: false }; }
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,50 @@
|
||||||
|
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
// UXP renders native <button> chrome that ignores CSS `background` and does
|
||||||
|
// not draw <svg>-only button content, so the rail/dock icon controls are
|
||||||
|
// <div role="button"> (divs render custom backgrounds + SVG children fine).
|
||||||
|
// Divs have no native `disabled`, so reflect the `.disabled` property the
|
||||||
|
// rest of the code sets onto a [disabled] attribute the stylesheet keys off.
|
||||||
|
const ICON_CONTROLS = [
|
||||||
|
'menu-btn', 'tab-library', 'tab-growing', 'export-timeline-btn', 'refresh-btn',
|
||||||
|
'import-proxy-btn', 'import-hires-btn', 'mount-live-btn', 'relink-btn', 'upload-mam-btn'
|
||||||
|
];
|
||||||
|
function enableDivDisabled() {
|
||||||
|
ICON_CONTROLS.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el || Object.getOwnPropertyDescriptor(el, 'disabled')) return;
|
||||||
|
Object.defineProperty(el, 'disabled', {
|
||||||
|
configurable: true,
|
||||||
|
get() { return this.hasAttribute('disabled'); },
|
||||||
|
set(v) { if (v) this.setAttribute('disabled', ''); else this.removeAttribute('disabled'); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset layout toggle: compact list (default) vs thumbnail grid. Persisted
|
||||||
|
// in localStorage when available (UXP host permitting), else session-only.
|
||||||
|
const GRID_ICON = '<svg width="15" height="15" viewBox="0 0 24 24"><path fill="currentColor" d="M4 11h5V5H4v6zm0 7h5v-6H4v6zm6 0h5v-6h-5v6zm6 0h5v-6h-5v6zm-6-7h5V5h-5v6zm6-6v6h5V5h-5z"/></svg>';
|
||||||
|
const LIST_ICON = '<svg width="15" height="15" viewBox="0 0 24 24"><path fill="currentColor" d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>';
|
||||||
|
let _viewMode = null;
|
||||||
|
function getViewMode() {
|
||||||
|
if (_viewMode) return _viewMode;
|
||||||
|
try { _viewMode = localStorage.getItem('df_view_mode'); } catch (e) {}
|
||||||
|
return _viewMode || 'list';
|
||||||
|
}
|
||||||
|
function applyViewMode(mode) {
|
||||||
|
_viewMode = mode === 'grid' ? 'grid' : 'list';
|
||||||
|
try { localStorage.setItem('df_view_mode', _viewMode); } catch (e) {}
|
||||||
|
const isList = _viewMode === 'list';
|
||||||
|
document.querySelectorAll('.asset-grid').forEach(g => g.classList.toggle('list-view', isList));
|
||||||
|
const btn = $('view-toggle-btn');
|
||||||
|
if (btn) {
|
||||||
|
// Show the icon for the layout a click switches TO.
|
||||||
|
btn.innerHTML = isList ? GRID_ICON : LIST_ICON;
|
||||||
|
btn.setAttribute('data-tip', isList ? 'Grid view' : 'List view');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function syncConnectBtn() {
|
function syncConnectBtn() {
|
||||||
$('connect-btn').disabled = !$('server-url').value.trim() || !$('api-token').value.trim();
|
$('connect-btn').disabled = !$('server-url').value.trim() || !$('api-token').value.trim();
|
||||||
}
|
}
|
||||||
|
|
@ -74,6 +118,11 @@
|
||||||
$('tab-library').addEventListener('click', () => Library.switchTab('library'));
|
$('tab-library').addEventListener('click', () => Library.switchTab('library'));
|
||||||
$('tab-growing').addEventListener('click', () => Library.switchTab('growing'));
|
$('tab-growing').addEventListener('click', () => Library.switchTab('growing'));
|
||||||
|
|
||||||
|
const vt = $('view-toggle-btn');
|
||||||
|
if (vt) vt.addEventListener('click', () => {
|
||||||
|
applyViewMode(getViewMode() === 'list' ? 'grid' : 'list');
|
||||||
|
});
|
||||||
|
|
||||||
let searchTimer;
|
let searchTimer;
|
||||||
$('search-input').addEventListener('input', e => {
|
$('search-input').addEventListener('input', e => {
|
||||||
clearTimeout(searchTimer);
|
clearTimeout(searchTimer);
|
||||||
|
|
@ -114,24 +163,8 @@
|
||||||
finally { _disableImportBtns(false); Library._syncActions(); }
|
finally { _disableImportBtns(false); Library._syncActions(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
$('import-all-btn').addEventListener('click', async () => {
|
// ── Upload highlighted bin file(s) to the MAM ──
|
||||||
const assets = Library.state.assets;
|
$('upload-mam-btn').addEventListener('click', uploadToMam);
|
||||||
if (!assets.length) { UI.toast('No assets', 'error'); return; }
|
|
||||||
_disableImportBtns(true);
|
|
||||||
let ok = 0, fail = 0;
|
|
||||||
for (const a of assets) {
|
|
||||||
try {
|
|
||||||
const { localPath, safeName } = await Import.proxy(a);
|
|
||||||
Library.recordImport(localPath, { assetId: a.id, displayName: a.display_name || a.filename });
|
|
||||||
Library.recordImport('name:' + safeName, { assetId: a.id, displayName: a.display_name || a.filename });
|
|
||||||
ok++;
|
|
||||||
} catch (_) { fail++; }
|
|
||||||
}
|
|
||||||
_disableImportBtns(false);
|
|
||||||
UI.hideProgress();
|
|
||||||
UI.toast('Import all: ' + ok + ' ok' + (fail ? ', ' + fail + ' failed' : ''), fail ? 'error' : 'ok');
|
|
||||||
Library._syncActions();
|
|
||||||
});
|
|
||||||
|
|
||||||
$('mount-live-btn').addEventListener('click', async () => {
|
$('mount-live-btn').addEventListener('click', async () => {
|
||||||
const a = Library.selectedAsset(); if (!a) return;
|
const a = Library.selectedAsset(); if (!a) return;
|
||||||
|
|
@ -181,12 +214,8 @@
|
||||||
finally { Library._syncActions(); }
|
finally { Library._syncActions(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// v2.2.1: Export Timeline is now a single-click pipeline —
|
// Single Export entry → popup menu (Conform Timeline / Local Export).
|
||||||
// push to MAM → start conform → poll → new asset lands in Library.
|
wireExportMenu();
|
||||||
// The Conform slide panel is still wired for Advanced → Export & Conform.
|
|
||||||
$('export-timeline-btn').addEventListener('click', oneClickExport);
|
|
||||||
$('export-conform-btn').addEventListener('click', openConformPanel);
|
|
||||||
$('fetch-relink-btn').addEventListener('click', openRelinkPanel);
|
|
||||||
|
|
||||||
// Advanced collapsible toggle (v2.2.0).
|
// Advanced collapsible toggle (v2.2.0).
|
||||||
const advToggle = $('advanced-toggle');
|
const advToggle = $('advanced-toggle');
|
||||||
|
|
@ -203,6 +232,83 @@
|
||||||
['import-proxy-btn','import-hires-btn'].forEach(id => { $(id).disabled = dis; });
|
['import-proxy-btn','import-hires-btn'].forEach(id => { $(id).disabled = dis; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _basename(p) { return String(p).split(/[\\/]/).pop(); }
|
||||||
|
|
||||||
|
// Target project for uploads/auto-upload: the project filter when specific,
|
||||||
|
// else the only project if there's exactly one, else null (caller prompts).
|
||||||
|
function getTargetProjectId() {
|
||||||
|
const sel = Library.state.selectedProject;
|
||||||
|
if (sel && sel !== 'all') return sel;
|
||||||
|
const projs = Library.state.projects || [];
|
||||||
|
return projs.length === 1 ? projs[0].id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export screen (full-panel chooser → Conform / Local Export) ──
|
||||||
|
function wireExportMenu() {
|
||||||
|
const btn = $('export-timeline-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
const close = () => UI.setHidden('#export-screen', true);
|
||||||
|
btn.addEventListener('click', () => UI.setHidden('#export-screen', false));
|
||||||
|
const closeBtn = $('export-screen-close');
|
||||||
|
if (closeBtn) closeBtn.addEventListener('click', close);
|
||||||
|
const optConform = $('opt-conform');
|
||||||
|
if (optConform) optConform.addEventListener('click', () => { close(); openConformPanel(); });
|
||||||
|
const optLocal = $('opt-local-export');
|
||||||
|
if (optLocal) optLocal.addEventListener('click', () => { close(); runLocalExport(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upload highlighted bin file(s) (or file-picker fallback) ─────
|
||||||
|
async function uploadToMam() {
|
||||||
|
const projectId = getTargetProjectId();
|
||||||
|
if (!projectId) { UI.toast('Pick a target project (project filter) before uploading', 'error'); return; }
|
||||||
|
let paths = [];
|
||||||
|
try { paths = await Import.getSelectedBinPaths(); } catch (_) {}
|
||||||
|
if (!paths.length) {
|
||||||
|
UI.toast('No bin selection — choose file(s) to upload', 'muted');
|
||||||
|
try { paths = await Import.pickFiles(); }
|
||||||
|
catch (e) { UI.toast('File picker unavailable: ' + e.message, 'error'); return; }
|
||||||
|
}
|
||||||
|
if (!paths.length) return;
|
||||||
|
let ok = 0, fail = 0;
|
||||||
|
for (let i = 0; i < paths.length; i++) {
|
||||||
|
const name = _basename(paths[i]);
|
||||||
|
UI.showProgress('Uploading ' + name + ' (' + (i + 1) + '/' + paths.length + ')…', 10 + (i / paths.length) * 80);
|
||||||
|
try { await Import.uploadFile(paths[i], { projectId }); ok++; }
|
||||||
|
catch (e) { fail++; console.warn('[df] upload failed', paths[i], e.message); }
|
||||||
|
}
|
||||||
|
UI.hideProgress();
|
||||||
|
UI.toast('Uploaded ' + ok + (fail ? ', ' + fail + ' failed' : '') + ' to MAM', fail ? 'error' : 'ok');
|
||||||
|
if (ok) Library.refresh(Library.state.searchQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Local Export (server FFMPEG-trims hi-res → download → relink) ─
|
||||||
|
async function runLocalExport() {
|
||||||
|
const projectId = getTargetProjectId();
|
||||||
|
UI.showProgress('Reading Premiere sequence…', 8);
|
||||||
|
let td;
|
||||||
|
try { td = await Timeline.readActiveSequence(); }
|
||||||
|
catch (e) { UI.hideProgress(); UI.toast('Timeline read failed: ' + e.message, 'error'); return; }
|
||||||
|
if (!td.clips.length) { UI.hideProgress(); UI.toast('No clips in active sequence', 'error'); return; }
|
||||||
|
|
||||||
|
let resolved = Library.resolveClipsToAssets(td.clips);
|
||||||
|
const missing = resolved.filter(c => !c.asset_id && c.filePath);
|
||||||
|
if (missing.length) {
|
||||||
|
if (!projectId) { UI.hideProgress(); UI.toast(missing.length + ' clip(s) not in MAM — pick a target project so they can be uploaded', 'error'); return; }
|
||||||
|
try {
|
||||||
|
await Import.ensureClipsInMam(resolved, projectId, (name, n, total) =>
|
||||||
|
UI.showProgress('Uploading missing source ' + name + ' (' + n + '/' + total + ')…', 8 + (n / total) * 20));
|
||||||
|
resolved = Library.resolveClipsToAssets(td.clips);
|
||||||
|
} catch (e) { UI.hideProgress(); UI.toast('Auto-upload failed: ' + e.message, 'error'); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await Timeline.localExport(resolved, (label, pct) => UI.showProgress(label, pct));
|
||||||
|
UI.hideProgress();
|
||||||
|
if (res.failed) UI.toast('Local Export: ' + res.succeeded + ' ok, ' + res.failed + ' failed', 'error');
|
||||||
|
else UI.toast('Local Export complete — ' + res.succeeded + ' clip(s) relinked', 'ok');
|
||||||
|
} catch (e) { UI.hideProgress(); UI.toast('Local Export failed: ' + e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
let _seqCache = null;
|
let _seqCache = null;
|
||||||
|
|
||||||
// ── One-click Export Timeline ────────────────────────────────────
|
// ── One-click Export Timeline ────────────────────────────────────
|
||||||
|
|
@ -331,9 +437,13 @@
|
||||||
if (!_seqCache.clips.length) { UI.hideProgress(); UI.toast('No clips in active sequence', 'error'); return; }
|
if (!_seqCache.clips.length) { UI.hideProgress(); UI.toast('No clips in active sequence', 'error'); return; }
|
||||||
UI.hideProgress();
|
UI.hideProgress();
|
||||||
const resolved = Library.resolveClipsToAssets(_seqCache.clips);
|
const resolved = Library.resolveClipsToAssets(_seqCache.clips);
|
||||||
|
const total = _seqCache.clips.length;
|
||||||
const matched = resolved.filter(c => c.asset_id).length;
|
const matched = resolved.filter(c => c.asset_id).length;
|
||||||
$('conform-clip-info').textContent = matched + ' of ' + _seqCache.clips.length + ' clip(s) matched';
|
const missing = total - matched;
|
||||||
$('conform-start-btn').disabled = matched === 0;
|
$('conform-clip-info').textContent = missing
|
||||||
|
? matched + ' of ' + total + ' clip(s) in MAM — ' + missing + ' will be uploaded first'
|
||||||
|
: matched + ' of ' + total + ' clip(s) in MAM';
|
||||||
|
$('conform-start-btn').disabled = total === 0;
|
||||||
const conformProj = $('conform-proj-select');
|
const conformProj = $('conform-proj-select');
|
||||||
if (conformProj) {
|
if (conformProj) {
|
||||||
conformProj.innerHTML = '<option value="">— Select project —</option>';
|
conformProj.innerHTML = '<option value="">— Select project —</option>';
|
||||||
|
|
@ -360,6 +470,8 @@
|
||||||
};
|
};
|
||||||
const p = presets[card.dataset.preset];
|
const p = presets[card.dataset.preset];
|
||||||
if (p) { $('conform-codec').value=p.codec; $('conform-quality').value=p.quality; $('conform-resolution').value=p.resolution; $('conform-audio').value=p.audio; }
|
if (p) { $('conform-codec').value=p.codec; $('conform-quality').value=p.quality; $('conform-resolution').value=p.resolution; $('conform-audio').value=p.audio; }
|
||||||
|
// Manual codec/quality/res/audio fields appear only for Custom.
|
||||||
|
UI.setHidden('#conform-custom', card.dataset.preset !== 'custom');
|
||||||
});
|
});
|
||||||
$('conform-start-btn').addEventListener('click', async () => {
|
$('conform-start-btn').addEventListener('click', async () => {
|
||||||
if (!_seqCache) return;
|
if (!_seqCache) return;
|
||||||
|
|
@ -367,6 +479,15 @@
|
||||||
const projectId = conformProj ? conformProj.value : '';
|
const projectId = conformProj ? conformProj.value : '';
|
||||||
if (!projectId) { UI.toast('Select a target project', 'error'); return; }
|
if (!projectId) { UI.toast('Select a target project', 'error'); return; }
|
||||||
UI.closeSlide('conform-overlay', 'conform-panel');
|
UI.closeSlide('conform-overlay', 'conform-panel');
|
||||||
|
// Auto-upload any timeline sources not yet in the MAM, then conform.
|
||||||
|
try {
|
||||||
|
const resolved = Library.resolveClipsToAssets(_seqCache.clips);
|
||||||
|
const missing = resolved.filter(c => !c.asset_id && c.filePath);
|
||||||
|
if (missing.length) {
|
||||||
|
await Import.ensureClipsInMam(resolved, projectId, (name, n, tot) =>
|
||||||
|
UI.showProgress('Uploading missing source ' + name + ' (' + n + '/' + tot + ')…', 5 + (n / tot) * 10));
|
||||||
|
}
|
||||||
|
} catch (e) { UI.hideProgress(); UI.toast('Auto-upload failed: ' + e.message, 'error'); return; }
|
||||||
UI.showProgress('Starting conform job…', 15);
|
UI.showProgress('Starting conform job…', 15);
|
||||||
try {
|
try {
|
||||||
const jobId = await Timeline.startConform(projectId, _seqCache.sequenceName, _seqCache, {
|
const jobId = await Timeline.startConform(projectId, _seqCache.sequenceName, _seqCache, {
|
||||||
|
|
@ -465,6 +586,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
enableDivDisabled();
|
||||||
|
applyViewMode(getViewMode());
|
||||||
wireConnectPane(); wireLibraryPane();
|
wireConnectPane(); wireLibraryPane();
|
||||||
wireExportPanel(); wireConformPanel(); wireRelinkPanel();
|
wireExportPanel(); wireConformPanel(); wireRelinkPanel();
|
||||||
showVersion();
|
showVersion();
|
||||||
|
|
|
||||||
|
|
@ -304,5 +304,76 @@
|
||||||
return count;
|
return count;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Local Export ─────────────────────────────────────────────────
|
||||||
|
// Server trims each timeline clip's hi-res via FFMPEG, then we download
|
||||||
|
// the trimmed segments and relink the project items to them.
|
||||||
|
// CAVEAT: relink keys on the source media path, so a source used by
|
||||||
|
// multiple timeline clips with different in/out points will relink to a
|
||||||
|
// single segment (last one wins). Common single-use case is exact.
|
||||||
|
Timeline.localExport = async function (resolvedClips, onProgress) {
|
||||||
|
const P = ppro();
|
||||||
|
const project = await P.Project.getActiveProject();
|
||||||
|
if (!project) throw new Error('No active Premiere project');
|
||||||
|
const matched = (resolvedClips || []).filter(c => c.asset_id);
|
||||||
|
if (!matched.length) throw new Error('No clips matched MAM assets to export');
|
||||||
|
|
||||||
|
const payload = matched.map(c => ({
|
||||||
|
assetId: c.asset_id,
|
||||||
|
filename: c.fileName || (c.filePath ? path.basename(c.filePath) : 'clip'),
|
||||||
|
sourceInFrames: c.sourceInFrames, sourceOutFrames: c.sourceOutFrames,
|
||||||
|
timelineInFrames: c.timelineInFrames, timelineOutFrames: c.timelineOutFrames,
|
||||||
|
trackIndex: c.trackIndex,
|
||||||
|
}));
|
||||||
|
|
||||||
|
onProgress && onProgress('Requesting trim of ' + matched.length + ' clip(s)…', 10);
|
||||||
|
const job = await API.batchTrim(payload);
|
||||||
|
const jobId = job.jobId;
|
||||||
|
const clipByInstance = {};
|
||||||
|
(job.clips || []).forEach((cr, i) => { if (cr.clipInstanceId) clipByInstance[cr.clipInstanceId] = matched[i]; });
|
||||||
|
|
||||||
|
// Poll until every segment is ready (s3Key set) or the job fails.
|
||||||
|
const ready = {};
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const t = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const st = await API.getTrimStatus(jobId);
|
||||||
|
const clips = st.clips || [];
|
||||||
|
const completed = clips.filter(c => c.status === 'completed' && c.s3Key);
|
||||||
|
onProgress && onProgress('Trimming on server… (' + completed.length + '/' + clips.length + ')',
|
||||||
|
15 + (completed.length / Math.max(1, clips.length)) * 45);
|
||||||
|
if (st.status === 'failed') { clearInterval(t); reject(new Error('Server trim job failed')); return; }
|
||||||
|
if (clips.length && completed.length === clips.length) {
|
||||||
|
clearInterval(t); completed.forEach(c => { ready[c.clipInstanceId] = c; }); resolve();
|
||||||
|
}
|
||||||
|
} catch (_) { /* transient — keep polling */ }
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download each segment and relink the source media path to it.
|
||||||
|
const results = { succeeded: 0, failed: 0, errors: [] };
|
||||||
|
const ids = Object.keys(ready);
|
||||||
|
for (let i = 0; i < ids.length; i++) {
|
||||||
|
const cid = ids[i];
|
||||||
|
const clip = clipByInstance[cid];
|
||||||
|
if (!clip) continue;
|
||||||
|
try {
|
||||||
|
onProgress && onProgress('Downloading segment ' + (i + 1) + '/' + ids.length + '…', 60 + (i / ids.length) * 35);
|
||||||
|
const seg = await API.getTempSegmentUrl(cid);
|
||||||
|
const ext = (seg.s3Key && seg.s3Key.split('.').pop()) || 'mov';
|
||||||
|
const base = UI.sanitizeFilename((clip.fileName || 'clip') + '-trim-' + cid.slice(0, 8) + '.' + ext);
|
||||||
|
const dest = await Import._tempPath(base);
|
||||||
|
const r = await API.requestExternal(seg.url);
|
||||||
|
if (!r.ok) throw new Error('Segment download HTTP ' + r.status);
|
||||||
|
await Import._writeBuffer(dest, await r.arrayBuffer());
|
||||||
|
if (clip.filePath) await Timeline._relinkInProject(project, clip.filePath, dest);
|
||||||
|
results.succeeded++;
|
||||||
|
} catch (e) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push((clip && clip.fileName || 'clip') + ': ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
window.Timeline = Timeline;
|
window.Timeline = Timeline;
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
78
services/premiere-plugin-uxp/src/tooltip.js
Normal file
78
services/premiere-plugin-uxp/src/tooltip.js
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
// Hover tooltips — v1
|
||||||
|
// Icon-first UI: every actionable control carries a [data-tip] label that
|
||||||
|
// surfaces on hover. UXP's CSS engine can't be trusted with
|
||||||
|
// `content: attr(data-tip)` on ::after, so we position a single floating
|
||||||
|
// bubble with plain DOM + getBoundingClientRect (both well supported).
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
let bubble = null;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function ensure() {
|
||||||
|
if (bubble) return bubble;
|
||||||
|
bubble = document.createElement('div');
|
||||||
|
bubble.className = 'tip-bubble';
|
||||||
|
document.body.appendChild(bubble);
|
||||||
|
return bubble;
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(el) {
|
||||||
|
const text = el.getAttribute('data-tip');
|
||||||
|
if (!text) return;
|
||||||
|
const tip = ensure();
|
||||||
|
tip.textContent = text;
|
||||||
|
tip.style.display = 'block';
|
||||||
|
tip.style.opacity = '0';
|
||||||
|
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const t = tip.getBoundingClientRect();
|
||||||
|
// position:absolute on a body-level node is offset from the document
|
||||||
|
// origin; add scroll offset (0 in practice, body doesn't scroll) for safety.
|
||||||
|
const sx = window.pageXOffset || document.documentElement.scrollLeft || 0;
|
||||||
|
const sy = window.pageYOffset || document.documentElement.scrollTop || 0;
|
||||||
|
const gap = 7;
|
||||||
|
const pos = el.getAttribute('data-tip-pos') || 'down';
|
||||||
|
let x, y;
|
||||||
|
|
||||||
|
if (pos === 'right') {
|
||||||
|
x = r.right + gap; y = r.top + (r.height - t.height) / 2;
|
||||||
|
} else if (pos === 'up') {
|
||||||
|
x = r.left + (r.width - t.width) / 2; y = r.top - t.height - gap;
|
||||||
|
} else if (pos === 'up-left') {
|
||||||
|
x = r.right - t.width; y = r.top - t.height - gap;
|
||||||
|
} else if (pos === 'down-left') {
|
||||||
|
x = r.right - t.width; y = r.bottom + gap;
|
||||||
|
} else {
|
||||||
|
x = r.left + (r.width - t.width) / 2; y = r.bottom + gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vw = window.innerWidth || document.documentElement.clientWidth || 99999;
|
||||||
|
const vh = window.innerHeight || document.documentElement.clientHeight || 99999;
|
||||||
|
x = Math.max(4, Math.min(x, vw - t.width - 4));
|
||||||
|
y = Math.max(4, Math.min(y, vh - t.height - 4));
|
||||||
|
tip.style.left = (x + sx) + 'px';
|
||||||
|
tip.style.top = (y + sy) + 'px';
|
||||||
|
tip.style.opacity = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (bubble) { bubble.style.opacity = '0'; bubble.style.display = 'none'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind(el) {
|
||||||
|
el.addEventListener('mouseenter', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => show(el), 240);
|
||||||
|
});
|
||||||
|
el.addEventListener('mouseleave', hide);
|
||||||
|
el.addEventListener('click', hide);
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
document.querySelectorAll('[data-tip]').forEach(bind);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||||
|
else init();
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
||||||
// app.jsx — main shell
|
// app.jsx - main shell
|
||||||
|
|
||||||
const ACCENT = '#5B7CFA';
|
const ACCENT = '#5B7CFA';
|
||||||
|
|
||||||
|
|
@ -40,6 +40,14 @@ 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(() => {
|
||||||
|
|
@ -59,7 +67,7 @@ function App() {
|
||||||
schedule: ['Ingest', 'Schedule'],
|
schedule: ['Ingest', 'Schedule'],
|
||||||
youtube: ['Ingest', 'YouTube'],
|
youtube: ['Ingest', 'YouTube'],
|
||||||
capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
|
capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
|
||||||
jobs: ['Jobs'], editor: ['Editor'],
|
jobs: ['Jobs'], editor: ['Editor'], playout: ['Operations', 'Playout'],
|
||||||
users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
|
users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
|
||||||
containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
|
containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
|
||||||
settings: ['Admin', 'Settings'],
|
settings: ['Admin', 'Settings'],
|
||||||
|
|
@ -89,11 +97,18 @@ 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 (route) {
|
switch (effectiveRoute) {
|
||||||
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;
|
||||||
|
|
@ -105,9 +120,10 @@ 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 'editor': content = <Editor />; break;
|
case 'playout': content = <Playout navigate={navigate} />; break;
|
||||||
case 'users': content = <Users />; break;
|
case 'users': content = <Users />; break;
|
||||||
case 'tokens': content = <Tokens />; break;
|
case 'tokens': content = <Tokens />; break;
|
||||||
|
case 'billing': content = <TokensParody />; break;
|
||||||
case '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;
|
||||||
|
|
@ -115,7 +131,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,33 +22,28 @@ 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: '1.2.0',
|
version: '2.2.2',
|
||||||
zxp: '/downloads/dragonflight-premiere-panel-1.2.0.zxp',
|
ccx: '/downloads/dragonflight-mam-2.2.2.ccx',
|
||||||
installer: null,
|
installer: null,
|
||||||
notes: 'Latest — design system refresh, aligned panel UI with web-ui tokens',
|
notes: 'UXP plugin: one-click Export Timeline, library + conform + auto-relink, growing-file mount, runtime version chip. Replaces the legacy CEP/ZXP panel.',
|
||||||
latest: true,
|
latest: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
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: [],
|
||||||
|
|
@ -86,7 +81,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);
|
||||||
|
|
@ -96,7 +91,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';
|
||||||
|
|
@ -104,7 +99,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';
|
||||||
|
|
@ -122,7 +117,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,
|
||||||
|
|
@ -133,7 +128,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') + ':' +
|
||||||
|
|
@ -143,13 +138,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,
|
||||||
};
|
};
|
||||||
|
|
@ -163,9 +158,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,
|
||||||
|
|
|
||||||
BIN
services/web-ui/public/downloads/dragonflight-mam-2.2.2.ccx
Normal file
BIN
services/web-ui/public/downloads/dragonflight-mam-2.2.2.ccx
Normal file
Binary file not shown.
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,10 +8,11 @@ const ICONS = {
|
||||||
upload: <><path d="M12 16V4" /><path d="M6 10l6-6 6 6" /><path d="M4 20h16" /></>,
|
upload: <><path d="M12 16V4" /><path d="M6 10l6-6 6 6" /><path d="M4 20h16" /></>,
|
||||||
record: <><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M22 8l-6 4 6 4V8z" /></>,
|
record: <><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M22 8l-6 4 6 4V8z" /></>,
|
||||||
capture: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="4" /><circle cx="12" cy="12" r="1" /></>,
|
capture: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="4" /><circle cx="12" cy="12" r="1" /></>,
|
||||||
jobs: <><path d="M3 6h18" /><path d="M3 12h18" /><path d="M3 18h12" /></>,
|
jobs: <><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1" fill="currentColor" /><circle cx="4" cy="12" r="1" fill="currentColor" /><circle cx="4" cy="18" r="1" fill="currentColor" /></>,
|
||||||
editor: <><path d="M14.06 2.94l7 7-11 11H3v-7.06l11.06-10.94z" /><path d="M13 4l7 7" /></>,
|
editor: <><path d="M14.06 2.94l7 7-11 11H3v-7.06l11.06-10.94z" /><path d="M13 4l7 7" /></>,
|
||||||
users: <><circle cx="9" cy="8" r="4" /><path d="M2 21a7 7 0 0 1 14 0" /><circle cx="17" cy="6" r="3" /><path d="M22 18a5 5 0 0 0-7-4.5" /></>,
|
users: <><circle cx="9" cy="8" r="4" /><path d="M2 21a7 7 0 0 1 14 0" /><circle cx="17" cy="6" r="3" /><path d="M22 18a5 5 0 0 0-7-4.5" /></>,
|
||||||
token: <><circle cx="8" cy="15" r="4" /><path d="M10.85 12.15L19 4" /><path d="M18 5l3 3" /><path d="M15 8l3 3" /></>,
|
token: <><circle cx="8" cy="15" r="4" /><path d="M10.85 12.15L19 4" /><path d="M18 5l3 3" /><path d="M15 8l3 3" /></>,
|
||||||
|
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" /></>,
|
||||||
|
|
@ -37,14 +38,14 @@ const ICONS = {
|
||||||
x: <path d="M6 6l12 12M6 18L18 6" />,
|
x: <path d="M6 6l12 12M6 18L18 6" />,
|
||||||
filter: <path d="M3 5h18l-7 9v6l-4-2v-4L3 5z" />,
|
filter: <path d="M3 5h18l-7 9v6l-4-2v-4L3 5z" />,
|
||||||
sort: <><path d="M3 6h13M3 12h9M3 18h5" /><path d="M17 14l3 3 3-3M20 9v8" /></>,
|
sort: <><path d="M3 6h13M3 12h9M3 18h5" /><path d="M17 14l3 3 3-3M20 9v8" /></>,
|
||||||
grid: <><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></>,
|
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
|
||||||
list: <><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1" fill="currentColor" /><circle cx="4" cy="12" r="1" fill="currentColor" /><circle cx="4" cy="18" r="1" fill="currentColor" /></>,
|
list: <><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1" fill="currentColor" /><circle cx="4" cy="12" r="1" fill="currentColor" /><circle cx="4" cy="18" r="1" fill="currentColor" /></>,
|
||||||
comment: <path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z" />,
|
comment: <path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z" />,
|
||||||
clock: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>,
|
clock: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>,
|
||||||
layers: <><path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5M2 12l10 5 10-5" /></>,
|
layers: <><path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5M2 12l10 5 10-5" /></>,
|
||||||
gpu: <><rect x="3" y="7" width="18" height="10" rx="1" /><rect x="6" y="10" width="4" height="4" /><rect x="14" y="10" width="4" height="4" /><path d="M3 11H1M3 13H1M23 11h-2M23 13h-2" /></>,
|
gpu: <><rect x="3" y="7" width="18" height="10" rx="1" /><rect x="6" y="10" width="4" height="4" /><rect x="14" y="10" width="4" height="4" /><path d="M3 11H1M3 13H1M23 11h-2M23 13h-2" /></>,
|
||||||
cpu: <><rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" /><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3" /></>,
|
cpu: <><rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" /><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3" /></>,
|
||||||
hdd: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="1" fill="currentColor" /></>,
|
hdd: <><ellipse cx="12" cy="6" rx="9" ry="3" /><path d="M3 6v12c0 1.66 4.03 3 9 3s9-1.34 9-3V6" /></>,
|
||||||
sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></>,
|
sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></>,
|
||||||
moon: <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />,
|
moon: <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />,
|
||||||
signal: <><path d="M2 20h.01M7 20v-4M12 20v-8M17 20V8M22 20V4" /></>,
|
signal: <><path d="M2 20h.01M7 20v-4M12 20v-8M17 20V8M22 20V4" /></>,
|
||||||
|
|
@ -65,7 +66,7 @@ const ICONS = {
|
||||||
power: <><path d="M18.36 6.64a9 9 0 1 1-12.73 0" /><path d="M12 2v10" /></>,
|
power: <><path d="M18.36 6.64a9 9 0 1 1-12.73 0" /><path d="M12 2v10" /></>,
|
||||||
globe: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></>,
|
globe: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></>,
|
||||||
package: <><path d="M3 7l9-4 9 4M3 7v10l9 4 9-4V7M3 7l9 4 9-4M12 11v10" /></>,
|
package: <><path d="M3 7l9-4 9 4M3 7v10l9 4 9-4V7M3 7l9 4 9-4M12 11v10" /></>,
|
||||||
proxy: <><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M9 12l3-3 3 3M12 9v8" /></>,
|
proxy: <><path d="M4 6h11M19 6h1M4 12h2M10 12h10M4 18h7M15 18h5" /><circle cx="17" cy="6" r="2" /><circle cx="8" cy="12" r="2" /><circle cx="13" cy="18" r="2" /></>,
|
||||||
};
|
};
|
||||||
|
|
||||||
function Icon({ name, size = 16, className, style }) {
|
function Icon({ name, size = 16, className, style }) {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
<link rel="stylesheet" href="styles-rest.css" />
|
<link rel="stylesheet" href="styles-rest.css" />
|
||||||
<link rel="stylesheet" href="styles-modal.css" />
|
<link rel="stylesheet" href="styles-modal.css" />
|
||||||
<link rel="stylesheet" href="styles-fixes.css" />
|
<link rel="stylesheet" href="styles-fixes.css" />
|
||||||
|
<link rel="stylesheet" href="styles-playout.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
@ -35,6 +36,7 @@
|
||||||
<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>
|
||||||
|
|
@ -46,6 +48,7 @@
|
||||||
<script src="js/bmd-card.js"></script>
|
<script src="js/bmd-card.js"></script>
|
||||||
<script src="dist/screens-editor.js"></script>
|
<script src="dist/screens-editor.js"></script>
|
||||||
<script src="dist/screens-admin.js"></script>
|
<script src="dist/screens-admin.js"></script>
|
||||||
|
<script src="dist/screens-playout.js"></script>
|
||||||
<script src="dist/modal-new-recorder.js"></script>
|
<script src="dist/modal-new-recorder.js"></script>
|
||||||
<script src="dist/app.js"></script>
|
<script src="dist/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,103 @@
|
||||||
// modal-new-recorder.jsx — New Recorder dialog (SRT / RTMP / SDI)
|
// modal-new-recorder.jsx - New Recorder dialog (SRT / RTMP / SDI / Deltacast)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
|
@ -42,9 +141,25 @@ 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');
|
||||||
const [recCodec, setRecCodec] = React.useState('prores_hq');
|
// All-Intra HEVC (NVENC) is the default master — GPU-encoded, growing-file
|
||||||
const [recContainer, setRecContainer] = React.useState('mov');
|
// capable in fragmented MOV. ProRes stays selectable for 4:2:2 mezzanine.
|
||||||
|
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);
|
||||||
|
|
@ -59,6 +174,13 @@ 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 = () => {
|
||||||
|
|
@ -75,6 +197,7 @@ 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);
|
||||||
|
|
||||||
|
|
@ -85,14 +208,25 @@ 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: device_index and node_id are top-level fields
|
// SDI (DeckLink): 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;
|
||||||
|
|
@ -133,9 +267,10 @@ 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' : ''}`}
|
||||||
|
|
@ -193,54 +328,55 @@ 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 && (
|
||||||
<div className="sdi-port-mini">
|
<DevicePortPicker
|
||||||
{sdiDevices.map((dev, di) => (
|
ports={sdiDevices}
|
||||||
<div key={di} style={{ marginBottom: 8 }}>
|
selectedIdx={sdiDeviceIdx}
|
||||||
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-3)', textTransform: 'uppercase', padding: '4px 0 8px' }}>
|
selectedNode={sdiNodeId}
|
||||||
{(dev.model || dev.device || 'DeckLink').toUpperCase()} · {dev.hostname}
|
onSelect={(idx, nodeId) => { setSdiDeviceIdx(idx); setSdiNodeId(nodeId); }}
|
||||||
</div>
|
portLabel="SDI"
|
||||||
{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 && (
|
||||||
<div style={{ padding: '8px 0' }}>
|
<ManualDevicePicker
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 10 }}>
|
nodes={NODES}
|
||||||
No DeckLink devices auto-detected. Configure manually:
|
nodeId={sdiNodeId}
|
||||||
|
deviceIdx={sdiDeviceIdx}
|
||||||
|
portLabel="SDI"
|
||||||
|
portCount={4}
|
||||||
|
onNodeChange={setSdiNodeId}
|
||||||
|
onIdxChange={setSdiDeviceIdx}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
)}
|
||||||
|
|
||||||
|
{sourceType === 'DELTACAST' && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="field-label">Capture node</label>
|
<label className="field-label">Capture device</label>
|
||||||
<select className="field-input" value={sdiNodeId}
|
{dcDevices === null && (
|
||||||
onChange={e => setSdiNodeId(e.target.value)} style={{ appearance: 'auto' }}>
|
<div style={{ padding: '10px 0', color: 'var(--text-3)', fontSize: 12 }}>Detecting Deltacast devices…</div>
|
||||||
{NODES.length === 0
|
)}
|
||||||
? <option value="">No cluster nodes</option>
|
{dcDevices !== null && dcDevices.length > 0 && (
|
||||||
: NODES.map(n => {
|
<DevicePortPicker
|
||||||
const id = n.id || n.hostname || n.name || '';
|
ports={dcDevices}
|
||||||
const label = n.hostname || n.name || id;
|
selectedIdx={dcDeviceIdx}
|
||||||
return <option key={id} value={id}>{label}</option>;
|
selectedNode={dcNodeId}
|
||||||
})}
|
onSelect={(idx, nodeId) => { setDcDeviceIdx(idx); setDcNodeId(nodeId); }}
|
||||||
</select>
|
portLabel="Port"
|
||||||
</div>
|
showTestBadge
|
||||||
<div className="field">
|
/>
|
||||||
<label className="field-label">Device index</label>
|
)}
|
||||||
<select className="field-input" value={sdiDeviceIdx}
|
{dcDevices !== null && dcDevices.length === 0 && (
|
||||||
onChange={e => setSdiDeviceIdx(Number(e.target.value))} style={{ appearance: 'auto' }}>
|
<ManualDevicePicker
|
||||||
{[0, 1, 2, 3].map(i =>
|
nodes={NODES}
|
||||||
<option key={i} value={i}>SDI {i + 1} (index {i})</option>)}
|
nodeId={dcNodeId}
|
||||||
</select>
|
deviceIdx={dcDeviceIdx}
|
||||||
</div>
|
portLabel="Port"
|
||||||
</div>
|
portCount={8}
|
||||||
</div>
|
onNodeChange={setDcNodeId}
|
||||||
|
onIdxChange={setDcDeviceIdx}
|
||||||
|
emptyNote="No Deltacast devices detected. Configure manually (test-card mode):"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -263,22 +399,48 @@ 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="prores_4444xq">ProRes 4444 XQ</option>
|
<option value="hevc_nvenc">All-Intra HEVC (NVENC) — GPU, growing</option>
|
||||||
<option value="prores_4444">ProRes 4444</option>
|
<option value="h264_nvenc">H.264 (NVENC) — GPU</option>
|
||||||
<option value="prores_hq">ProRes 422 HQ</option>
|
<option value="prores_hq">ProRes 422 HQ — 4:2:2 CPU</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="xdcam_hd422">XDCAM HD422</option>
|
<option value="libx264">H.264 (x264, CPU)</option>
|
||||||
|
<option value="libx265">H.265 (x265, CPU)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<Field label="Resolution" value="Source (native)" select />
|
{codecUsesBitrate ? (
|
||||||
<Field label="Color space" value="Rec. 709" select />
|
<div className="field">
|
||||||
<Field label="Bit depth" value="10-bit" select />
|
<label className="field-label">Target bitrate (Mbps)</label>
|
||||||
|
<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' && (
|
||||||
|
|
@ -291,16 +453,8 @@ 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 }}>
|
||||||
<div className="field">
|
<Field label="Container" value={recContainer === 'mp4' ? 'MP4 (auto)' : 'Fragmented MOV (auto)'} select />
|
||||||
<label className="field-label">Container</label>
|
<Field label="Growing-file" value={recContainer === 'mov' ? 'Supported (edit-while-record)' : 'No'} select />
|
||||||
<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>
|
||||||
|
|
@ -328,7 +482,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,14 +258,7 @@ function Users() {
|
||||||
{tab === 'groups' && <GroupsPanel groups={groups} users={users} onChange={refreshGroups} />}
|
{tab === 'groups' && <GroupsPanel groups={groups} users={users} onChange={refreshGroups} />}
|
||||||
|
|
||||||
{tab === 'policies' && (
|
{tab === 'policies' && (
|
||||||
<div className="panel" style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-3)' }}>
|
<PoliciesPanel users={users} onChange={refreshUsers} />
|
||||||
<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)} />}
|
||||||
|
|
@ -285,6 +278,204 @@ function Users() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// PoliciesPanel - interactive per-user permission matrix for the Policies tab.
|
||||||
|
// Keeps the access-model explainer as a small header, then renders one row per
|
||||||
|
// user with: inline role <select> (PATCH /users/:id), a 2FA badge driven by
|
||||||
|
// totp_enabled, an admin-only "Reset 2FA" action (POST /users/:id/totp/disable,
|
||||||
|
// 204), and an Access expander backed by GET /users/:id/access.
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
function PoliciesPanel({ users, onChange }) {
|
||||||
|
const [expandedId, setExpandedId] = React.useState(null);
|
||||||
|
const [err, setErr] = React.useState(null);
|
||||||
|
|
||||||
|
const changeRole = (u, newRole) => {
|
||||||
|
if (u.role === newRole) return;
|
||||||
|
setErr(null);
|
||||||
|
window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify({ role: newRole }) })
|
||||||
|
.then(() => onChange && onChange())
|
||||||
|
.catch(e => setErr('Role change failed: ' + (e.message || e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset 2FA uses a raw fetch because ZAMPP_API.fetch throws on the 204 (no JSON
|
||||||
|
// body). Mirrors the disable() pattern in TotpSection.
|
||||||
|
const resetTotp = (u) => {
|
||||||
|
if (!confirm(`Reset two-factor for "${u.name}" (@${u.username})?\nThey will be able to sign in without a code until they re-enrol.`)) return;
|
||||||
|
setErr(null);
|
||||||
|
fetch('/api/v1/users/' + u.id + '/totp/disable', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'X-Requested-With': 'dragonflight-ui' },
|
||||||
|
})
|
||||||
|
.then(r => {
|
||||||
|
if (r.status === 204) { onChange && onChange(); return; }
|
||||||
|
return r.json().catch(() => ({})).then(b => { throw new Error(b.error || ('Failed (' + r.status + ')')); });
|
||||||
|
})
|
||||||
|
.catch(e => setErr('Reset 2FA failed: ' + (e.message || e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Access-model explainer (kept from the old static tab, condensed) */}
|
||||||
|
<div className="panel" style={{ padding: '16px 20px', marginBottom: 12, color: 'var(--text-2)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||||
|
<Icon name="lock" size={15} />
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13.5 }}>Access model</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.6, maxWidth: 720 }}>
|
||||||
|
<strong style={{ color: 'var(--text-2)' }}>admin</strong> has full access to every project plus
|
||||||
|
user, group, cluster, and system administration. <strong style={{ color: 'var(--text-2)' }}>editor / viewer</strong> see
|
||||||
|
only the projects they're granted — a <em>view</em> grant is read-only, an <em>edit</em> grant
|
||||||
|
allows changes, and grants can target a user or a group. Edit per-project grants from the{' '}
|
||||||
|
<a href="#" onClick={e => { e.preventDefault(); window.dispatchEvent(new CustomEvent('df:nav', { detail: 'projects' })); }}
|
||||||
|
style={{ color: 'var(--accent-text)' }}>Projects</a> page; manage group membership on the Groups tab above.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginBottom: 8 }}>{err}</div>}
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<div className="user-row head">
|
||||||
|
<div>User</div>
|
||||||
|
<div>Role</div>
|
||||||
|
<div>2FA</div>
|
||||||
|
<div>Access</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
{users.length === 0 && (
|
||||||
|
<div style={{ padding: '32px 0', textAlign: 'center', color: 'var(--text-3)' }}>No users found</div>
|
||||||
|
)}
|
||||||
|
{users.map(u => (
|
||||||
|
<UserPolicyRow key={u.id} user={u}
|
||||||
|
expanded={expandedId === u.id}
|
||||||
|
onToggle={() => setExpandedId(expandedId === u.id ? null : u.id)}
|
||||||
|
onChangeRole={changeRole}
|
||||||
|
onResetTotp={resetTotp} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserPolicyRow({ user: u, expanded, onToggle, onChangeRole, onResetTotp }) {
|
||||||
|
const [access, setAccess] = React.useState(null); // null = not loaded, {} once fetched
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [accessErr, setAccessErr] = React.useState(null);
|
||||||
|
|
||||||
|
// Lazily fetch GET /users/:id/access the first time the row is expanded.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!expanded || access !== null) return;
|
||||||
|
setLoading(true); setAccessErr(null);
|
||||||
|
window.ZAMPP_API.fetch('/users/' + u.id + '/access')
|
||||||
|
.then(d => setAccess(d || {}))
|
||||||
|
.catch(e => { setAccess({}); setAccessErr(e.message || 'Failed to load access'); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [expanded, access, u.id]);
|
||||||
|
|
||||||
|
const projects = (access && access.projects) || [];
|
||||||
|
const memberships = (access && (access.groups || access.memberships)) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div className="user-row" style={{ borderBottom: 'none' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div className="avatar" style={{ width: 32, height: 32, fontSize: 11, background: avatarColor(u.initials || u.id) }}>{u.initials || '??'}</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 13 }}>{u.name}</div>
|
||||||
|
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>@{u.username}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<select value={u.role || 'viewer'}
|
||||||
|
onChange={e => onChangeRole(u, e.target.value)}
|
||||||
|
className="field-input"
|
||||||
|
style={{ width: 90, padding: '3px 6px', fontSize: 11.5, appearance: 'auto' }}>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
<option value="editor">editor</option>
|
||||||
|
<option value="viewer">viewer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{u.totp_enabled
|
||||||
|
? <span className="badge success"><Icon name="key" size={10} /> 2FA on</span>
|
||||||
|
: <span className="badge neutral">2FA off</span>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button className="btn ghost sm" onClick={onToggle}>
|
||||||
|
{expanded ? 'Hide' : 'View'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
{u.totp_enabled && (
|
||||||
|
<button className="btn ghost sm danger" onClick={() => onResetTotp(u)} title="Disable this user's two-factor">
|
||||||
|
<Icon name="key" size={11} />Reset 2FA
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div style={{ padding: '0 16px 16px 16px', background: 'var(--bg-2)' }}>
|
||||||
|
{loading && <div style={{ padding: '12px 0', fontSize: 12.5, color: 'var(--text-3)' }}>Loading access…</div>}
|
||||||
|
{accessErr && <div style={{ padding: '8px 0', fontSize: 12, color: 'var(--danger)' }}>{accessErr}</div>}
|
||||||
|
{!loading && !accessErr && (u.role === 'admin') && (
|
||||||
|
<div style={{ padding: '12px 0', fontSize: 12.5, color: 'var(--text-3)', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Icon name="check" size={12} style={{ color: 'var(--success)' }} />
|
||||||
|
Admin — full access to every project.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && !accessErr && u.role !== 'admin' && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, paddingTop: 12 }}>
|
||||||
|
{/* Accessible projects */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, marginBottom: 8 }}>
|
||||||
|
Projects ({projects.length})
|
||||||
|
</div>
|
||||||
|
{projects.length === 0 && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-4)' }}>No project access granted.</div>
|
||||||
|
)}
|
||||||
|
{projects.map(p => {
|
||||||
|
// Backend `via` is 'direct' for a user grant, or 'group:<name>'
|
||||||
|
// when inherited from a group. Split the label off the prefix.
|
||||||
|
const via = p.via || 'direct';
|
||||||
|
const isGroup = via.indexOf('group') === 0;
|
||||||
|
const viaLabel = isGroup ? (via.indexOf(':') >= 0 ? via.slice(via.indexOf(':') + 1) : 'group') : 'direct';
|
||||||
|
return (
|
||||||
|
<div key={(p.project_id || p.id) + ':' + via}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<span style={{ fontSize: 12.5, flex: 1 }}>{p.project_name || p.name || p.project_id || p.id}</span>
|
||||||
|
<span className={`badge ${(p.level === 'edit') ? 'accent' : 'neutral'}`}>{p.level || 'view'}</span>
|
||||||
|
<span className="badge neutral" title={isGroup ? 'Inherited from group ' + viaLabel : 'Granted directly'}>
|
||||||
|
<Icon name={isGroup ? 'users' : 'user'} size={9} /> {viaLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Group memberships */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, marginBottom: 8 }}>
|
||||||
|
Groups ({memberships.length})
|
||||||
|
</div>
|
||||||
|
{memberships.length === 0 && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-4)' }}>Not a member of any group.</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{memberships.map(g => (
|
||||||
|
<span key={g.id || g.group_id || g.name} className="badge neutral" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
<Icon name="users" size={9} />{g.name || g.group_name || g.group_id}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function EditUserModal({ user, onClose, onSaved }) {
|
function EditUserModal({ user, onClose, onSaved }) {
|
||||||
const [name, setName] = React.useState(user.display_name || user.name || '');
|
const [name, setName] = React.useState(user.display_name || user.name || '');
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
|
@ -329,7 +520,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(() => () => {
|
||||||
|
|
@ -481,7 +672,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 => {
|
||||||
|
|
@ -521,8 +712,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>
|
||||||
)}
|
)}
|
||||||
|
|
@ -536,7 +727,25 @@ 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);
|
||||||
|
|
@ -582,7 +791,7 @@ function Tokens() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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" },
|
||||||
];
|
];
|
||||||
|
|
@ -590,7 +799,7 @@ function Tokens() {
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Tokens</h1>
|
<h1>Billing</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>
|
||||||
|
|
@ -643,7 +852,7 @@ function Tokens() {
|
||||||
</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={[
|
||||||
|
|
@ -699,7 +908,7 @@ function Tokens() {
|
||||||
<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>
|
||||||
|
|
@ -798,7 +1007,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(() => () => {
|
||||||
|
|
@ -960,7 +1169,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 }) {
|
||||||
|
|
@ -995,7 +1204,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>
|
||||||
|
|
@ -1021,7 +1230,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)",
|
||||||
|
|
@ -1070,7 +1279,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)' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1174,7 +1383,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] }));
|
||||||
|
|
@ -1323,7 +1532,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>
|
||||||
|
|
@ -1456,6 +1665,135 @@ 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('');
|
||||||
|
|
@ -1504,7 +1842,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>
|
||||||
|
|
@ -1565,6 +1903,7 @@ function Settings() {
|
||||||
{section === 'account' && (
|
{section === 'account' && (
|
||||||
<>
|
<>
|
||||||
<AccountSection />
|
<AccountSection />
|
||||||
|
<TotpSection />
|
||||||
<ApiTokensSection />
|
<ApiTokensSection />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1580,7 +1919,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.
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -1595,7 +1934,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++; }
|
||||||
|
|
@ -1628,7 +1967,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]);
|
||||||
|
|
@ -1678,9 +2017,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>
|
||||||
|
|
@ -1700,8 +2039,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>
|
||||||
|
|
@ -1767,7 +2106,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>
|
||||||
|
|
@ -1791,7 +2130,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 }); });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1810,7 +2149,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>
|
||||||
|
|
||||||
|
|
@ -1843,9 +2182,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>
|
||||||
|
|
@ -1941,13 +2280,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',
|
||||||
|
|
@ -1956,24 +2295,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;
|
||||||
|
|
||||||
|
|
@ -1988,7 +2327,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 ── */}
|
||||||
|
|
@ -1997,9 +2336,9 @@ 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 CEP panel enables growing-file editing, batch trim, and one-click hi-res relink directly inside Premiere Pro.
|
The Dragonflight UXP plugin enables growing-file editing, batch trim, and one-click hi-res relink directly inside Premiere Pro.
|
||||||
Install the <strong style={{ color: 'var(--text-2)' }}>.zxp</strong> via <a href="https://zxpsign.com/zxp-installer" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>ZXP Installer</a> (Mac/Win),
|
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>,
|
||||||
or run the <strong style={{ color: 'var(--text-2)' }}>Windows Setup</strong> which bundles the installer automatically.
|
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 => (
|
||||||
|
|
@ -2011,12 +2350,16 @@ 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>
|
||||||
<a href={r.zxp} download style={{ textDecoration: 'none' }}>
|
{r.ccx && (
|
||||||
<button className="btn ghost sm">ZXP</button>
|
<a href={r.ccx} download style={{ textDecoration: 'none' }}>
|
||||||
|
<button className="btn ghost sm">UXP (.ccx)</button>
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
|
{r.installer && (
|
||||||
<a href={r.installer} download style={{ textDecoration: 'none' }}>
|
<a href={r.installer} download style={{ textDecoration: 'none' }}>
|
||||||
<button className="btn ghost sm">Win Installer</button>
|
<button className="btn ghost sm">Win Installer</button>
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2059,7 +2402,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);
|
||||||
|
|
@ -2075,7 +2418,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();
|
||||||
};
|
};
|
||||||
|
|
@ -2159,7 +2502,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,7 +65,12 @@ 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.url) {
|
if (r && r.hls_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) {
|
||||||
|
|
@ -89,7 +94,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;
|
||||||
|
|
@ -115,7 +120,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() {
|
||||||
|
|
@ -159,7 +164,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(() => {
|
||||||
|
|
@ -180,7 +185,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.
|
||||||
|
|
@ -212,7 +217,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() {
|
||||||
|
|
@ -258,7 +263,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')); });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -477,7 +482,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')}>
|
||||||
|
|
@ -630,7 +635,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);
|
||||||
|
|
@ -902,7 +907,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}
|
||||||
|
|
@ -929,21 +934,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(' · ') });
|
||||||
});
|
});
|
||||||
|
|
@ -1106,13 +1111,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')}>
|
||||||
|
|
@ -1187,7 +1192,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,27 +116,131 @@
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) { onDone(); return; }
|
if (r.status === 200) {
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -163,7 +267,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,45 +378,23 @@ 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' }}>
|
||||||
|
|
||||||
{/* ── COMING SOON bumper — overlays the entire editor ── */}
|
{/* Beta banner: flat strip across top, no glassmorphism or gradient. */}
|
||||||
<div style={{
|
<div className="editor-beta-banner">
|
||||||
position: 'absolute', inset: 0, zIndex: 100,
|
<Icon name="editor" size={14} />
|
||||||
background: 'rgba(10, 12, 18, 0.92)',
|
<div className="editor-beta-banner-body">
|
||||||
backdropFilter: 'blur(6px)',
|
<strong>NLE editor is in beta.</strong>
|
||||||
display: 'flex', flexDirection: 'column',
|
<span> Use the Premiere Pro panel for frame-accurate editing and growing-file workflows.</span>
|
||||||
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 style={{ textAlign: 'center', maxWidth: 420 }}>
|
<div className="editor-beta-banner-actions">
|
||||||
<div style={{ fontSize: 26, fontWeight: 700, letterSpacing: '-0.02em', color: 'var(--text-primary)', marginBottom: 8 }}>
|
<a href={(window.PREMIERE_LATEST || {}).zxp || '#'} download className="btn primary sm">
|
||||||
NLE Editor — Coming Soon
|
Download Premiere panel
|
||||||
</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 || {}).zxp || '#'} download style={{ textDecoration: 'none' }}>
|
|
||||||
<button className="btn primary">Download ZXP</button>
|
|
||||||
</a>
|
</a>
|
||||||
<a href={(window.PREMIERE_LATEST || {}).installer || '#'} download style={{ textDecoration: 'none' }}>
|
<a href={(window.PREMIERE_LATEST || {}).installer || '#'} download className="btn ghost sm">
|
||||||
<button className="btn ghost">Windows Installer</button>
|
Windows installer
|
||||||
</a>
|
</a>
|
||||||
</div>
|
<span className="editor-beta-banner-version mono">
|
||||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: -4 }}>
|
v{(window.PREMIERE_LATEST || {}).version || '·'}
|
||||||
Dragonflight Premiere Panel v{(window.PREMIERE_LATEST || {}).version || '—'}
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -719,7 +697,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,31 +2,40 @@
|
||||||
//
|
//
|
||||||
// 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);
|
||||||
|
|
@ -62,12 +71,27 @@ function Home({ navigate }) {
|
||||||
desc: 'SDI · SRT · RTMP ingest. Start, stop, schedule.',
|
desc: 'SDI · SRT · RTMP ingest. Start, stop, schedule.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'editor',
|
id: 'playout',
|
||||||
label: 'Editor',
|
label: 'Playout',
|
||||||
icon: 'editor',
|
icon: 'signal',
|
||||||
|
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: 'Beta',
|
sub: 'Plugin · Teams ISO',
|
||||||
desc: 'Timeline editor with cross-clip preview and render queue.',
|
desc: 'Download the Premiere Pro UXP plugin and the Teams ISO installer.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'jobs',
|
id: 'jobs',
|
||||||
|
|
@ -91,6 +115,19 @@ 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">
|
||||||
|
|
@ -103,7 +140,10 @@ function Home({ navigate }) {
|
||||||
/>
|
/>
|
||||||
<h1 className="launcher-wordmark">DRAGONFLIGHT</h1>
|
<h1 className="launcher-wordmark">DRAGONFLIGHT</h1>
|
||||||
<p className="launcher-tagline">
|
<p className="launcher-tagline">
|
||||||
Self-hosted broadcast media-asset management
|
Media Asset Management & Production Platform
|
||||||
|
</p>
|
||||||
|
<p className="launcher-tagline launcher-tagline-motto">
|
||||||
|
Let's create
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -112,7 +152,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={() => navigate(t.id)}
|
onClick={() => t.id === '__downloads' ? setShowDownloads(true) : 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} />
|
||||||
|
|
@ -131,7 +171,7 @@ function Home({ navigate }) {
|
||||||
onClick={() => navigate('dashboard')}
|
onClick={() => navigate('dashboard')}
|
||||||
>
|
>
|
||||||
<span className="launcher-tile-icon">
|
<span className="launcher-tile-icon">
|
||||||
<Icon name="home" size={22} />
|
<Icon name="layout" 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>
|
||||||
|
|
@ -144,6 +184,71 @@ 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
|
||||||
|
|
@ -161,18 +266,117 @@ 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);
|
||||||
|
|
@ -193,7 +397,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;
|
||||||
|
|
@ -209,8 +413,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,
|
||||||
};
|
};
|
||||||
|
|
@ -244,6 +448,22 @@ 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
|
||||||
|
|
@ -325,7 +545,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')}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -403,6 +623,18 @@ 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'}>
|
||||||
|
|
@ -480,7 +712,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} />
|
? <HlsPreview assetId={recorder.live_asset_id} recorderId={recorder.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" />
|
||||||
|
|
@ -491,14 +723,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>
|
||||||
|
|
@ -620,7 +852,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 ? (
|
||||||
|
|
@ -636,7 +868,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,14 +227,32 @@ 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 */ }
|
||||||
setTimeout(tick, 3000);
|
// Poll fairly briskly: the download phase is where the user wants to see
|
||||||
|
// 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; };
|
||||||
|
|
@ -274,7 +292,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 }}>
|
||||||
|
|
@ -384,13 +402,15 @@ 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, muted = true, controls = false, className }) {
|
function HlsPreview({ assetId, recorderId, 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 = '/live/' + assetId + '/index.m3u8';
|
const url = recorderId
|
||||||
|
? '/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;
|
||||||
|
|
@ -471,7 +491,7 @@ function HlsPreview({ assetId, muted = true, controls = false, className }) {
|
||||||
|
|
||||||
/* ===== 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') + ':' +
|
||||||
|
|
@ -481,13 +501,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,
|
||||||
};
|
};
|
||||||
|
|
@ -504,7 +524,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);
|
||||||
|
|
@ -559,13 +579,21 @@ 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.
|
||||||
|
|
@ -592,8 +620,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)'
|
||||||
|
|
@ -605,14 +633,18 @@ 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 on start; stop has no body.
|
// Ship the operator-typed clip name and project override on start; stop has no body.
|
||||||
const body = (action === 'start' && clipName.trim())
|
const body = action === 'start'
|
||||||
? JSON.stringify({ clipName: clipName.trim() })
|
? JSON.stringify({
|
||||||
|
...(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 input on a successful stop so the next take starts fresh.
|
// Clear the clip name 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'));
|
||||||
|
|
@ -640,7 +672,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} />
|
? <HlsPreview assetId={recorder.live_asset_id} recorderId={recorder.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>}
|
||||||
|
|
@ -684,6 +716,19 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
</div>
|
</div>
|
||||||
<div className="recorder-actions">
|
<div className="recorder-actions">
|
||||||
{!isRec && (
|
{!isRec && (
|
||||||
|
<>
|
||||||
|
{PROJECTS.length > 0 && (
|
||||||
|
<select
|
||||||
|
className="field-input"
|
||||||
|
value={takeProjectId}
|
||||||
|
onChange={e => setTakeProjectId(e.target.value)}
|
||||||
|
disabled={pending}
|
||||||
|
style={{ width: 160, padding: '5px 8px', fontSize: 12, appearance: 'auto' }}
|
||||||
|
title="Project clips go to"
|
||||||
|
>
|
||||||
|
{PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
className="field-input"
|
className="field-input"
|
||||||
value={clipName}
|
value={clipName}
|
||||||
|
|
@ -692,9 +737,10 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
maxLength={80}
|
maxLength={80}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
|
onKeyDown={e => { if (e.key === 'Enter') toggle(); }}
|
||||||
style={{ width: 180, padding: '5px 8px', fontSize: 12 }}
|
style={{ width: 160, padding: '5px 8px', fontSize: 12 }}
|
||||||
title="Letters, numbers, spaces, dot, dash, underscore. Blank = auto timestamp."
|
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}>
|
||||||
|
|
@ -725,7 +771,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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -737,7 +783,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,
|
||||||
|
|
@ -1031,7 +1077,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} />
|
? <HlsPreview assetId={feed.live_asset_id} recorderId={feed.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 }}>
|
||||||
|
|
@ -1048,7 +1094,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>
|
||||||
);
|
);
|
||||||
|
|
@ -1065,7 +1111,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, {
|
||||||
|
|
@ -1309,7 +1355,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;
|
||||||
}
|
}
|
||||||
|
|
@ -1463,7 +1509,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>
|
||||||
);
|
);
|
||||||
|
|
@ -1491,7 +1537,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(() => {
|
||||||
|
|
@ -1523,7 +1569,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;
|
||||||
|
|
||||||
|
|
@ -1578,7 +1624,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;
|
||||||
|
|
@ -1833,7 +1879,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">
|
||||||
|
|
@ -1902,11 +1948,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
|
||||||
|
|
@ -1946,7 +1992,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,6 +108,23 @@ 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,
|
||||||
|
|
@ -124,9 +141,14 @@ 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
|
||||||
|
|
@ -148,18 +170,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" style={{ color: counts.failed > 0 ? 'var(--warning)' : '' }}>
|
<div className={'delta' + (counts.failed > 0 ? ' delta-warn' : '')}>
|
||||||
{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" style={{ fontSize: 10.5 }}>Updated {Math.round((Date.now()-lastFetch)/1000)}s ago</div>
|
<div className="delta muted delta-tiny">Updated {Math.round((Date.now()-lastFetch)/1000)}s ago</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tab-group" style={{ marginTop: 20, width: 'fit-content' }}>
|
<div className="tab-group jobs-tabs">
|
||||||
{[
|
{[
|
||||||
{ id: 'all', label: 'All · ' + counts.all },
|
{ id: 'all', label: 'All · ' + counts.all },
|
||||||
{ id: 'running', label: 'Running · ' + counts.running },
|
{ id: 'running', label: 'Running · ' + counts.running },
|
||||||
|
|
@ -171,12 +193,12 @@ function Jobs({ navigate }) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel" style={{ marginTop: 12 }}>
|
<div className="panel jobs-panel">
|
||||||
<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 style={{ padding: '24px', textAlign: 'center', color: 'var(--text-3)' }}>No jobs in this category.</div>
|
? <div className="jobs-empty">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>
|
||||||
|
|
@ -189,36 +211,35 @@ 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 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div className="job-row-kind">
|
||||||
<Icon name={iconMap[job.kind] || 'jobs'} size={13} style={{ color: 'var(--text-3)' }} />
|
<Icon name={iconMap[job.kind] || 'jobs'} size={13} className="job-row-kind-icon" />
|
||||||
<span style={{ fontWeight: 500 }}>{job.kind}</span>
|
<span className="job-row-kind-name">{job.kind}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-2)' }}>{job.asset}</div>
|
<div className="job-row-asset">{job.asset}</div>
|
||||||
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{job.node}</div>
|
<div className="mono job-row-node">{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" style={{ fontSize: 10.5, color: 'var(--text-3)', minWidth: 32, textAlign: 'right' }}>{Math.round(job.progress)}%</span>
|
<span className="mono job-row-progress-pct">{Math.round(job.progress)}%</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{job.status === 'done' && <span className="badge success" style={{ background: 'transparent', padding: 0 }}><Icon name="check" size={12} /> Complete</span>}
|
{job.status === 'done' && <span className="badge success job-row-status-done"><Icon name="check" size={12} /> Complete</span>}
|
||||||
{job.status === 'queued' && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>Waiting…</span>}
|
{job.status === 'queued' && <span className="job-row-status-queued">Waiting…</span>}
|
||||||
{job.status === 'failed' && (
|
{job.status === 'failed' && (
|
||||||
<span title={job.error || 'Failed'}
|
<span title={job.error || 'Failed'} className="job-row-status-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 style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
<span className="job-row-status-failed-msg">
|
||||||
{(job.error || 'Failed').slice(0, 120)}
|
{(job.error || 'Failed').slice(0, 120)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
<div className="mono job-row-time"
|
||||||
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.
|
||||||
|
|
@ -229,16 +250,16 @@ 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 style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
|
<div className="job-row-actions">
|
||||||
{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" onClick={() => onDelete(job, 'cancel')}
|
<button className="btn ghost sm job-row-cancel" onClick={() => onDelete(job, 'cancel')}
|
||||||
style={{ color: 'var(--danger)' }} title="Cancel this running job and free its queue slot">
|
title="Cancel this running job and free its queue slot">
|
||||||
<Icon name="x" />Cancel
|
<Icon name="x" />Cancel
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
<div className="toolbar-title">{displayTitle}</div>
|
<h1 className="toolbar-title">{displayTitle}</h1>
|
||||||
<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'); }}><Icon name="grid" size={12} /></button>
|
<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 === 'list' ? 'active' : ''} onClick={function() { setView('list'); }}><Icon name="list" 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>
|
||||||
</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>
|
||||||
|
|
|
||||||
460
services/web-ui/public/screens-playout.jsx
Normal file
460
services/web-ui/public/screens-playout.jsx
Normal file
|
|
@ -0,0 +1,460 @@
|
||||||
|
// 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,6 +49,9 @@ 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')
|
||||||
|
|
@ -100,8 +103,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')}><Icon name="grid" size={12} /></button>
|
<button className={view === 'grid' ? 'active' : ''} onClick={() => setView('grid')} aria-label="Grid view" title="Grid view"><Icon name="grid" size={12} /></button>
|
||||||
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')}><Icon name="list" size={12} /></button>
|
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')} aria-label="List view" title="List view"><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>
|
||||||
|
|
@ -122,8 +125,10 @@ 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)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -140,14 +145,15 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
@ -165,6 +171,140 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -206,11 +346,11 @@ function RenameProjectModal({ project, onClose, onSaved }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
|
function ProjectCard({ project, assets, onOpen, onRename, onManageAccess, onDelete, canManageAccess }) {
|
||||||
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;
|
||||||
|
|
@ -259,7 +399,7 @@ function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
|
||||||
<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}`}>
|
||||||
|
|
@ -275,6 +415,7 @@ function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
|
|
@ -284,3 +425,4 @@ function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
|
||||||
|
|
||||||
window.Projects = Projects;
|
window.Projects = Projects;
|
||||||
window.RenameProjectModal = RenameProjectModal;
|
window.RenameProjectModal = RenameProjectModal;
|
||||||
|
window.ProjectAccessModal = ProjectAccessModal;
|
||||||
|
|
|
||||||
97
services/web-ui/public/screens-resources.jsx
Normal file
97
services/web-ui/public/screens-resources.jsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// 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,40 +1,62 @@
|
||||||
// shell.jsx - app shell: sidebar nav, topbar, route container
|
// shell.jsx - app shell: sidebar nav, topbar, route container
|
||||||
|
|
||||||
const NAV_TREE = [
|
// Sidebar IA: grouped sections. Renderer prints each section's label, then
|
||||||
|
// its items. Items inside `children` of a `group:true` node still render as
|
||||||
|
// the existing expandable submenu (only used for the Capture-SDK admin tools
|
||||||
|
// today, but kept general).
|
||||||
|
const NAV_SECTIONS = [
|
||||||
|
{
|
||||||
|
label: "Workspace",
|
||||||
|
items: [
|
||||||
{ id: "home", label: "Home", icon: "home" },
|
{ id: "home", label: "Home", icon: "home" },
|
||||||
{ id: "dashboard", label: "Dashboard", icon: "layout" },
|
{ id: "dashboard", label: "Dashboard", icon: "layout" },
|
||||||
{ id: "library", label: "Library", icon: "library" },
|
|
||||||
{ id: "projects", label: "Projects", icon: "folder" },
|
{ id: "projects", label: "Projects", icon: "folder" },
|
||||||
|
{ id: "library", label: "Library", icon: "library" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "ingest", label: "Ingest", icon: "upload", group: true,
|
label: "Ingest",
|
||||||
children: [
|
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: "jobs" },
|
{ id: "schedule", label: "Schedule", icon: "clock" },
|
||||||
{ id: "capture", label: "Capture", icon: "capture" },
|
|
||||||
{ id: "monitors", label: "Monitors", icon: "monitor" },
|
{ id: "monitors", label: "Monitors", icon: "monitor" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Operations",
|
||||||
|
items: [
|
||||||
|
{ id: "capture", label: "Capture", icon: "capture" },
|
||||||
|
{ id: "playout", label: "Playout", icon: "signal" },
|
||||||
{ id: "jobs", label: "Jobs", icon: "jobs" },
|
{ id: "jobs", label: "Jobs", icon: "jobs" },
|
||||||
{ id: "editor", label: "Editor", icon: "editor" },
|
],
|
||||||
];
|
},
|
||||||
|
{
|
||||||
const ADMIN_TREE = [
|
label: "Admin",
|
||||||
|
items: [
|
||||||
{ id: "users", label: "Users", icon: "users" },
|
{ id: "users", label: "Users", icon: "users" },
|
||||||
{ id: "tokens", label: "Tokens", icon: "token" },
|
{ id: "tokens", label: "Tokens", icon: "token" },
|
||||||
|
{ id: "billing", label: "Billing", icon: "dollar" },
|
||||||
{ id: "containers", label: "Containers", icon: "container" },
|
{ id: "containers", label: "Containers", icon: "container" },
|
||||||
{ id: "cluster", label: "Cluster", icon: "cluster" },
|
{ id: "cluster", label: "Cluster", icon: "cluster" },
|
||||||
{ id: "settings", label: "Settings", icon: "settings" },
|
{ id: "settings", label: "Settings", icon: "settings" },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// No hidden routes currently; Billing (the satirical pricing page) lives in
|
||||||
|
// the Admin section above. Real API token management is at /tokens.
|
||||||
|
const NAV_HIDDEN = [];
|
||||||
|
|
||||||
|
// 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 = [];
|
||||||
const visit = (arr) => arr.forEach(n => {
|
NAV_SECTIONS.forEach(s => s.items.forEach(n => out.push({ id: n.id, label: n.label, icon: n.icon })));
|
||||||
if (n.group && n.children) { visit(n.children); return; }
|
NAV_HIDDEN.forEach(n => out.push({ id: n.id, label: n.label, icon: n.icon }));
|
||||||
out.push({ id: n.id, label: n.label, icon: n.icon });
|
|
||||||
});
|
|
||||||
visit(NAV_TREE); visit(ADMIN_TREE);
|
|
||||||
return out;
|
return out;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
@ -80,12 +102,11 @@ 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(["ingest"]));
|
const [openGroups, setOpenGroups] = React.useState(new Set([]));
|
||||||
const [jobsBadge, setJobsBadge] = React.useState(null);
|
const [jobsBadge, setJobsBadge] = React.useState(null);
|
||||||
const [captureBadge, setCaptureBadge] = React.useState(null);
|
|
||||||
|
|
||||||
// Live jobs count (#130) — poll /jobs/count for active jobs and render the
|
// Live jobs count (#130): poll /jobs?status=active and render the result
|
||||||
// result as the sidebar badge. Falls back to hidden on error.
|
// as the sidebar badge on the Jobs item. Falls back to hidden on error.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
|
|
@ -103,43 +124,21 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
|
||||||
return () => { cancelled = true; clearInterval(id); };
|
return () => { cancelled = true; clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Live DeckLink signal presence — poll every 5s, badge shows receiving port count.
|
// (Capture live-signal badge previously lived here; it now belongs in the
|
||||||
React.useEffect(() => {
|
// topbar status pip alongside the cluster pip. See issue #149.)
|
||||||
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 live badges to nav items.
|
// Apply the live Jobs badge to the Operations section, and gate the Admin
|
||||||
const navTree = React.useMemo(
|
// section to admins only (RBAC v2). Non-admins never see Users/Cluster/etc.
|
||||||
() => NAV_TREE.map(n => {
|
// This is UX only — the API enforces the same rules server-side.
|
||||||
if (n.id === 'jobs' && jobsBadge) return { ...n, badge: jobsBadge };
|
const isAdmin = me?.role === 'admin';
|
||||||
if (n.id === 'ingest' && n.children) {
|
const sections = React.useMemo(
|
||||||
return {
|
() => NAV_SECTIONS
|
||||||
...n,
|
.filter(sec => sec.label !== 'Admin' || isAdmin)
|
||||||
children: n.children.map(c =>
|
.map(sec => ({
|
||||||
c.id === 'capture' && captureBadge ? { ...c, badge: captureBadge } : c
|
...sec,
|
||||||
),
|
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 => {
|
||||||
|
|
@ -181,7 +180,10 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-scroll">
|
<div className="sidebar-scroll">
|
||||||
{navTree.map(item => (
|
{sections.map((section, si) => (
|
||||||
|
<React.Fragment key={section.label}>
|
||||||
|
{si > 0 && <div className="nav-section-label">{section.label}</div>}
|
||||||
|
{section.items.map(item => (
|
||||||
<NavItem
|
<NavItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
|
|
@ -191,24 +193,15 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
|
||||||
toggleGroup={toggleGroup}
|
toggleGroup={toggleGroup}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="nav-section-label">Admin</div>
|
</React.Fragment>
|
||||||
{ADMIN_TREE.map(item => (
|
|
||||||
<NavItem
|
|
||||||
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 : (
|
||||||
|
|
@ -265,7 +258,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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -399,7 +392,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(() => {
|
||||||
|
|
@ -478,5 +471,6 @@ 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,13 +60,11 @@
|
||||||
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,6 +324,14 @@
|
||||||
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;
|
||||||
|
|
@ -435,7 +443,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);
|
||||||
|
|
@ -515,7 +523,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 {
|
||||||
|
|
@ -539,7 +547,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.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
@ -678,3 +686,84 @@
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue