Compare commits
51 commits
feat/all-i
...
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 |
94 changed files with 7439 additions and 1121 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
|
||||
# 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.
|
||||
#
|
||||
# RBAC v2 note: with AUTH_ENABLED=true, per-project access is enforced. Service
|
||||
# API tokens (capture sidecar, Premiere panel, integrations) must belong to a
|
||||
# user with the access they need — an 'admin' user (full access), or a user with
|
||||
# the right project grants. A non-admin service token with no grants will get
|
||||
# 403 on asset registration (ingest) and streaming. In dev mode the synthetic
|
||||
# user is admin, so this only matters once auth is on.
|
||||
AUTH_ENABLED=true
|
||||
|
||||
# CORS allowlist — comma-separated origins that may carry credentials to the API.
|
||||
|
|
@ -36,3 +43,30 @@ ALLOWED_ORIGINS=
|
|||
# so secure-cookie + X-Forwarded-Proto behave correctly. ALSO required for accurate
|
||||
# per-IP login rate-limiting (otherwise req.ip is always the nginx IP).
|
||||
TRUST_PROXY=false
|
||||
|
||||
# Google OAuth (OIDC) sign-in — OPTIONAL. Leave the client id/secret blank to
|
||||
# disable; the "Sign in with Google" button and the /auth/google routes only
|
||||
# activate when all three of CLIENT_ID, CLIENT_SECRET, and REDIRECT_URL are set.
|
||||
# Create an OAuth 2.0 Client (type: Web application) in Google Cloud Console and
|
||||
# add OAUTH_REDIRECT_URL to its authorized redirect URIs.
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# Must exactly match a redirect URI on the OAuth client, e.g.
|
||||
# https://dragonflight.live/api/v1/auth/google/callback
|
||||
OAUTH_REDIRECT_URL=
|
||||
# Restrict sign-in to one Google Workspace domain (recommended). First login from
|
||||
# an allowed-domain account auto-provisions a NEW 'viewer' account (matched only
|
||||
# by Google's stable subject id, never by email — so a Google login can never
|
||||
# seize a pre-existing local account). An admin then grants project access.
|
||||
# Leave blank to allow any verified Google account to self-provision (NOT advised).
|
||||
GOOGLE_ALLOWED_DOMAIN=
|
||||
# Note: if a Google-linked account also has TOTP enabled, sign-in still requires
|
||||
# the authenticator code (Google is treated as the first factor). Accounts without
|
||||
# TOTP complete sign-in in one Google step.
|
||||
|
||||
# Playout / Master Control (MCR)
|
||||
# Image tag the mam-api spawns when a channel starts. Build with:
|
||||
# docker compose --profile build-only build playout
|
||||
PLAYOUT_IMAGE=wild-dragon-playout:latest
|
||||
# Base AMCP port — each channel binds to BASE + channel_id (in CasparCG terms).
|
||||
PLAYOUT_AMCP_BASE_PORT=5250
|
||||
|
|
|
|||
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.
|
||||
|
|
@ -40,6 +40,7 @@ services:
|
|||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||
- /mnt/NVME/MAM/wild-dragon-growing:/growing
|
||||
- /mnt/NVME/MAM/wild-dragon-media:/media
|
||||
- /mnt/NVME/MAM/sdk:/sdk
|
||||
- /dev/shm:/dev/shm
|
||||
- /run/dbus:/run/dbus
|
||||
|
|
@ -61,6 +62,8 @@ services:
|
|||
NODE_IP: ${NODE_IP}
|
||||
NODE_HOSTNAME: ${NODE_HOSTNAME:-}
|
||||
CAPTURE_TOKEN: ${CAPTURE_TOKEN}
|
||||
PLAYOUT_IMAGE: ${PLAYOUT_IMAGE:-wild-dragon-playout:latest}
|
||||
PLAYOUT_AMCP_BASE_PORT: ${PLAYOUT_AMCP_BASE_PORT:-5250}
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
|
|
@ -116,14 +119,20 @@ services:
|
|||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
GROWING_PATH: /growing
|
||||
WORKER_QUEUES: proxy,conform,trim
|
||||
# Includes `import` (YouTube importer): the import queue had no consumer
|
||||
# after the capability-routing split, so import jobs sat unprocessed and
|
||||
# assets stayed `ingesting` forever. import is concurrency-1 + network-
|
||||
# bound, so one consumer (this heavy/primary worker) is sufficient.
|
||||
WORKER_QUEUES: proxy,conform,trim,import,playout-stage
|
||||
RUN_PROMOTION: "true"
|
||||
PROXY_CONCURRENCY: "2"
|
||||
PLAYOUT_MEDIA_DIR: /media
|
||||
NVIDIA_VISIBLE_DEVICES: GPU-79afca3e-2ab2-a6ea-1c44-706c1f0a26d6
|
||||
WORKER_LABEL: "zampp1 / Tesla P4"
|
||||
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
|
||||
volumes:
|
||||
- /mnt/NVME/MAM/wild-dragon-growing:/growing
|
||||
- /mnt/NVME/MAM/wild-dragon-media:/media
|
||||
networks:
|
||||
- wild-dragon
|
||||
|
||||
|
|
@ -172,12 +181,22 @@ services:
|
|||
- "${PORT_WEB_UI:-7434}:80"
|
||||
volumes:
|
||||
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||
- /mnt/NVME/MAM/wild-dragon-media:/media:ro
|
||||
- /dev/shm:/dev/shm
|
||||
- /run/dbus:/run/dbus
|
||||
- /run/systemd:/run/systemd
|
||||
networks:
|
||||
- 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:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
|
|
|||
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
|
||||
|
||||
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 decklink-sdk16.patch /decklink-sdk16.patch
|
||||
|
||||
# nv-codec-headers — just the ffnvcodec public headers + a pkg-config file.
|
||||
# Pin to a tag known to work with FFmpeg 7.1 (n12.x series).
|
||||
RUN git clone --depth=1 --branch n12.1.14.0 https://github.com/FFmpeg/nv-codec-headers.git /nv-codec-headers \
|
||||
&& make -C /nv-codec-headers PREFIX=/usr/local install
|
||||
|
||||
# Pull FFmpeg 7.1 source
|
||||
RUN git clone --depth=1 --branch release/7.1 https://git.ffmpeg.org/ffmpeg.git /ffmpeg
|
||||
|
||||
|
|
@ -20,8 +31,15 @@ RUN git clone --depth=1 --branch release/7.1 https://git.ffmpeg.org/ffmpeg.git /
|
|||
RUN python3 /patch_decklink.py
|
||||
|
||||
WORKDIR /ffmpeg
|
||||
# NVENC adds: --enable-nvenc (encoder), --enable-cuvid (decoder), --enable-ffnvcodec.
|
||||
# We deliberately do NOT enable --enable-cuda-nvcc / --enable-libnpp here — those
|
||||
# require the full ~3GB CUDA toolkit and are only needed for GPU filters like
|
||||
# yadif_cuda / scale_cuda. If §5's GPU deinterlace stretch goal goes ahead,
|
||||
# rebuild this image off nvidia/cuda:12.x-devel and flip those flags on.
|
||||
RUN ./configure \
|
||||
--prefix=/usr/local \
|
||||
--extra-cflags="-I/decklink-sdk -I/usr/local/include" \
|
||||
--extra-ldflags="-L/usr/local/lib" \
|
||||
--enable-gpl \
|
||||
--enable-nonfree \
|
||||
--enable-libx264 \
|
||||
|
|
@ -32,13 +50,20 @@ RUN ./configure \
|
|||
--enable-libsrt \
|
||||
--enable-libzmq \
|
||||
--enable-decklink \
|
||||
--extra-cflags="-I/decklink-sdk" \
|
||||
--enable-ffnvcodec \
|
||||
--enable-nvenc \
|
||||
--enable-cuvid \
|
||||
--disable-doc \
|
||||
--disable-debug \
|
||||
--disable-ffplay \
|
||||
&& make -j$(nproc) \
|
||||
&& make install
|
||||
|
||||
# Sanity-check: hevc_nvenc and h264_nvenc must be present in the encoder list,
|
||||
# otherwise the resulting image is useless for the All-Intra HEVC pipeline.
|
||||
RUN /usr/local/bin/ffmpeg -hide_banner -encoders 2>&1 | grep -E 'nvenc' \
|
||||
|| (echo 'FATAL: nvenc encoders missing from ffmpeg build' && exit 1)
|
||||
|
||||
# ── Stage 2: Runtime image ───────────────────────────────────────────────────
|
||||
FROM node:20-bookworm
|
||||
|
||||
|
|
@ -58,6 +83,11 @@ COPY lib/libDeckLinkAPI.so /usr/lib/libDeckLinkAPI.so
|
|||
COPY lib/libDeckLinkPreviewAPI.so /usr/lib/libDeckLinkPreviewAPI.so
|
||||
RUN ldconfig
|
||||
|
||||
# Mount points the recorder lifecycle expects to exist.
|
||||
# /live — HLS preview output (bound from host LIVE_DIR by node-agent)
|
||||
# /growing — growing-file master output (bound from host /mnt/NVME/MAM/growing)
|
||||
RUN mkdir -p /live /growing
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
|
|
|||
|
|
@ -28,7 +28,32 @@ const VIDEO_CODECS = {
|
|||
h264: { args: ['-c:v', 'libx264', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
|
||||
h264_nvenc: { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' },
|
||||
h265: { args: ['-c:v', 'libx265', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@
|
|||
"bullmq": "^5.5.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"uuid": "^9.0.1",
|
||||
"dotenv": "^16.4.5"
|
||||
"dotenv": "^16.4.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"google-auth-library": "^9.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"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;
|
||||
}
|
||||
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;
|
||||
|
|
@ -8,7 +8,7 @@ import os from 'node:os';
|
|||
import { exec } from 'node:child_process';
|
||||
import pool from './db/pool.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 authRouter from './routes/auth.js';
|
||||
|
|
@ -22,6 +22,7 @@ import jobsRouter from './routes/jobs.js';
|
|||
import captureRouter from './routes/capture.js';
|
||||
import uploadRouter from './routes/upload.js';
|
||||
import recordersRouter from './routes/recorders.js';
|
||||
import playoutRouter from './routes/playout.js';
|
||||
import settingsRouter from './routes/settings.js';
|
||||
import amppRouter from './routes/ampp.js';
|
||||
import groupsRouter from './routes/groups.js';
|
||||
|
|
@ -40,18 +41,12 @@ import { startCleanupLoop } from './tasks/cleanupTempSegments.js';
|
|||
const app = express();
|
||||
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 || '')
|
||||
.split(',').map(s => s.trim()).filter(Boolean);
|
||||
app.use(cors({
|
||||
origin: (origin, cb) => {
|
||||
// No Origin header (same-origin or curl) — allow.
|
||||
if (!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);
|
||||
return cb(null, false);
|
||||
},
|
||||
|
|
@ -59,14 +54,8 @@ app.use(cors({
|
|||
}));
|
||||
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);
|
||||
|
||||
// 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') {
|
||||
app.use((req, res, next) => {
|
||||
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) {
|
||||
console.error('[fatal] SESSION_SECRET is required when AUTH_ENABLED=true');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Session — actually wired this time. See specs/2026-05-27-auth-system-design.md.
|
||||
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,
|
||||
name: 'dragonflight.sid',
|
||||
cookie: {
|
||||
|
|
@ -94,31 +79,26 @@ app.use(session({
|
|||
path: '/',
|
||||
maxAge: 8 * 3600 * 1000,
|
||||
},
|
||||
rolling: false, // sliding renewal handled in requireAuth so idle + absolute can be enforced separately
|
||||
rolling: false,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
}));
|
||||
|
||||
// ── Health ────────────────────────────────────────────────────────────────────
|
||||
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
||||
|
||||
// ── Auth gate ─────────────────────────────────────────────────────────────────
|
||||
// req.path is relative to the /api/v1 mount, so /auth/login NOT /api/v1/auth/login.
|
||||
const UNAUTH_PATHS = new Set(['/auth/login', '/auth/setup', '/auth/setup-required']);
|
||||
// node-agent now authenticates /cluster/heartbeat with a bound api_token
|
||||
// (migration 019 + bound_hostname on the token). requireAuth handles the
|
||||
// bearer lookup and sets req.tokenBoundHostname; the heartbeat handler in
|
||||
// routes/cluster.js verifies body.hostname matches that binding.
|
||||
const UNAUTH_PATHS = new Set([
|
||||
'/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required',
|
||||
'/auth/google', '/auth/google/callback', '/auth/google/enabled',
|
||||
]);
|
||||
app.use('/api/v1', requireUiHeader);
|
||||
app.use('/api/v1', (req, res, next) => {
|
||||
if (UNAUTH_PATHS.has(req.path)) return next();
|
||||
return requireAuth(req, res, next);
|
||||
});
|
||||
|
||||
// ── API Routes ────────────────────────────────────────────────────────────────
|
||||
app.use('/api/v1/auth', authRouter);
|
||||
app.use('/api/v1/auth/users', usersRouter);
|
||||
app.use('/api/v1/users', usersRouter); // alias for the existing SPA Users page that calls /api/v1/users; keeps the same auth gate
|
||||
app.use('/api/v1/auth/users', requireAdmin, usersRouter);
|
||||
app.use('/api/v1/users', requireAdmin, usersRouter);
|
||||
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
|
||||
app.use('/api/v1/assets', assetsRouter);
|
||||
app.use('/api/v1/projects', projectsRouter);
|
||||
|
|
@ -127,9 +107,10 @@ app.use('/api/v1/jobs', jobsRouter);
|
|||
app.use('/api/v1/capture', captureRouter);
|
||||
app.use('/api/v1/upload', uploadRouter);
|
||||
app.use('/api/v1/recorders', recordersRouter);
|
||||
app.use('/api/v1/playout', playoutRouter);
|
||||
app.use('/api/v1/settings', settingsRouter);
|
||||
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/system', systemRouter);
|
||||
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/storage', storageRouter);
|
||||
|
||||
// ── Error handler ─────────────────────────────────────────────────────────────
|
||||
app.use(errorHandler);
|
||||
|
||||
// ── Start ────────────────────────────────────────────────────────────────────
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirnameMig = dirname(fileURLToPath(import.meta.url));
|
||||
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');
|
||||
let files = [];
|
||||
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 allowFailures = process.env.MIGRATIONS_ALLOW_FAILURES === '1';
|
||||
|
||||
|
|
@ -193,7 +166,6 @@ async function runMigrations() {
|
|||
console.error('[migration] FAILED ' + f + ': ' + err.message);
|
||||
client.release();
|
||||
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.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -202,13 +174,9 @@ async function runMigrations() {
|
|||
}
|
||||
await runMigrations();
|
||||
|
||||
// Load S3 config from DB so any settings saved via the Settings page override env vars
|
||||
await loadS3ConfigFromDb();
|
||||
|
||||
// ── Cluster self-heartbeat ────────────────────────────────────────────────────
|
||||
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;
|
||||
|
||||
const ifaces = os.networkInterfaces();
|
||||
|
|
@ -220,9 +188,6 @@ function getLocalIp() {
|
|||
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() {
|
||||
return new Promise(resolve => {
|
||||
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() {
|
||||
const load = os.loadavg()[0];
|
||||
const total = os.totalmem();
|
||||
|
|
@ -255,14 +224,15 @@ async function selfHeartbeat() {
|
|||
pool.query(
|
||||
`INSERT INTO cluster_nodes
|
||||
(hostname, ip_address, role, version, api_url,
|
||||
cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen)
|
||||
VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW())
|
||||
cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen, last_seen_at)
|
||||
VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW(),NOW())
|
||||
ON CONFLICT (hostname) DO UPDATE SET
|
||||
ip_address = EXCLUDED.ip_address,
|
||||
cpu_usage = EXCLUDED.cpu_usage,
|
||||
mem_used_mb = EXCLUDED.mem_used_mb,
|
||||
mem_total_mb = EXCLUDED.mem_total_mb,
|
||||
capabilities = EXCLUDED.capabilities,
|
||||
last_seen_at = NOW(),
|
||||
last_seen = NOW()`,
|
||||
[
|
||||
process.env.NODE_HOSTNAME || os.hostname(),
|
||||
|
|
@ -287,39 +257,26 @@ const server = app.listen(PORT, () => {
|
|||
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.');
|
||||
}
|
||||
// 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();
|
||||
|
||||
// Boot the temp-segment cleanup loop (runs hourly).
|
||||
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;
|
||||
async function gracefulShutdown(signal) {
|
||||
if (_shuttingDown) return;
|
||||
_shuttingDown = true;
|
||||
console.log(`[shutdown] received ${signal} — closing gracefully…`);
|
||||
|
||||
// Stop accepting new requests + wind down the scheduler tick.
|
||||
try { stopSchedulerLoop(); } catch (_) {}
|
||||
|
||||
// Force-exit watchdog so a hung connection can't keep us alive forever.
|
||||
const killSwitch = setTimeout(() => {
|
||||
console.error('[shutdown] forced exit after 25s timeout');
|
||||
process.exit(1);
|
||||
}, 25_000);
|
||||
killSwitch.unref();
|
||||
|
||||
// Stop the HTTP server (waits for in-flight requests to finish).
|
||||
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); }
|
||||
|
||||
console.log('[shutdown] clean exit');
|
||||
|
|
|
|||
|
|
@ -1,10 +1,29 @@
|
|||
import crypto from 'crypto';
|
||||
import pool from '../db/pool.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.
|
||||
/** 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: 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 IDLE_MS = 1 * 3600 * 1000;
|
||||
|
|
@ -18,11 +37,18 @@ async function destroyAnd401(req, res) {
|
|||
|
||||
async function loadUser(id) {
|
||||
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;
|
||||
}
|
||||
|
||||
export async function requireAuth(req, res, next) {
|
||||
// Internal loopback self-call (scheduler). Acts as the seeded admin so RBAC
|
||||
// and FK-bearing routes work, regardless of AUTH_ENABLED.
|
||||
if (isInternalCall(req)) {
|
||||
req.user = DEV_USER;
|
||||
return next();
|
||||
}
|
||||
|
||||
// Dev mode — attach the seeded dev user so FK-bearing routes work.
|
||||
if (process.env.AUTH_ENABLED !== 'true') {
|
||||
req.user = DEV_USER;
|
||||
|
|
@ -73,6 +99,14 @@ export async function requireAuth(req, res, next) {
|
|||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
|
||||
// Gate a route to admins only. requireAuth must run first (it sets req.user).
|
||||
// 401 when unauthenticated, 403 when authenticated but not an admin.
|
||||
export function requireAdmin(req, res, next) {
|
||||
if (!req.user) return res.status(401).json({ error: 'unauthorized' });
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'forbidden' });
|
||||
return next();
|
||||
}
|
||||
|
||||
// Belt-and-suspenders CSRF: SameSite=Lax already blocks most cross-site
|
||||
// cookie sends, but a custom header that no <form> can produce hardens
|
||||
// against the edge cases. Applied to mutating verbs only.
|
||||
|
|
@ -88,6 +122,8 @@ const CSRF_EXEMPT_PATHS = new Set(['/cluster/heartbeat']);
|
|||
|
||||
export function requireUiHeader(req, res, next) {
|
||||
if (!MUTATING.has(req.method)) return next();
|
||||
// Internal loopback self-call (scheduler) — not a browser, can't be drive-by'd.
|
||||
if (isInternalCall(req)) return next();
|
||||
// Bearer-authed requests (Premiere panel, scripts) are exempt — they're not
|
||||
// browsers and can't be drive-by'd from another origin.
|
||||
if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next();
|
||||
|
|
|
|||
|
|
@ -7,9 +7,36 @@ import pool from '../db/pool.js';
|
|||
import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js';
|
||||
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
|
||||
import { requireAdmin } from '../middleware/auth.js';
|
||||
|
||||
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)
|
||||
const parseRedisUrl = (url) => {
|
||||
|
|
@ -33,6 +60,10 @@ const filmstripQueue = new Queue('filmstrip', {
|
|||
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
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
|
|
@ -62,6 +93,15 @@ router.get('/', async (req, res, next) => {
|
|||
const params = [];
|
||||
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
|
||||
if (include_archived !== 'true') {
|
||||
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' });
|
||||
}
|
||||
|
||||
// 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;
|
||||
if (durationNum !== null && !Number.isFinite(durationNum)) {
|
||||
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
|
||||
router.post('/cleanup-live', async (req, res, next) => {
|
||||
// POST /cleanup-live — cross-project maintenance, admin only.
|
||||
router.post('/cleanup-live', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const maxAgeHours = Math.max(1, parseInt(req.query.max_age_hours || '4', 10));
|
||||
const result = await pool.query(
|
||||
|
|
@ -230,8 +273,8 @@ router.post('/cleanup-live', async (req, res, next) => {
|
|||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /cleanup-live-orphans
|
||||
router.post('/cleanup-live-orphans', async (_req, res, next) => {
|
||||
// POST /cleanup-live-orphans — cross-project maintenance, admin only.
|
||||
router.post('/cleanup-live-orphans', requireAdmin, async (_req, res, next) => {
|
||||
try {
|
||||
const liveRoot = process.env.LIVE_DIR || '/live';
|
||||
let entries;
|
||||
|
|
@ -273,10 +316,22 @@ router.get('/:id', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// PATCH /:id
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
router.patch('/:id', requireAssetEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
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 = [];
|
||||
let paramCount = 1;
|
||||
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
|
||||
router.post('/:id/copy', async (req, res, next) => {
|
||||
router.post('/:id/copy', requireAssetEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { binId, projectId } = req.body;
|
||||
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' });
|
||||
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();
|
||||
// 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
|
||||
|
|
@ -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()
|
||||
) RETURNING *`,
|
||||
[
|
||||
newId, projectId || src.project_id,
|
||||
binId === undefined ? src.bin_id : (binId || null),
|
||||
newId, destProjectId,
|
||||
destBinId,
|
||||
src.filename, src.display_name, 'processing', src.media_type,
|
||||
src.original_s3_key,
|
||||
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
|
||||
router.post('/:id/mark-empty', async (req, res, next) => {
|
||||
router.post('/:id/mark-empty', requireAssetEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
// Bug #66: first check the asset exists and what status it is in
|
||||
|
|
@ -380,7 +454,7 @@ router.post('/:id/mark-empty', async (req, res, next) => {
|
|||
// 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', async (req, res, next) => {
|
||||
router.post('/:id/finalize', requireAssetEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { hiresKey, proxyKey, duration } = req.body;
|
||||
|
|
@ -432,7 +506,7 @@ router.post('/:id/finalize', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// POST /:id/generate-proxy
|
||||
router.post('/:id/generate-proxy', async (req, res, next) => {
|
||||
router.post('/:id/generate-proxy', requireAssetEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
||||
|
|
@ -448,8 +522,8 @@ router.post('/:id/generate-proxy', async (req, res, next) => {
|
|||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /backfill-proxies
|
||||
router.post('/backfill-proxies', async (_req, res, next) => {
|
||||
// POST /backfill-proxies — cross-project maintenance, admin only.
|
||||
router.post('/backfill-proxies', requireAdmin, async (_req, res, next) => {
|
||||
try {
|
||||
const targets = await pool.query(
|
||||
`SELECT id, original_s3_key FROM assets
|
||||
|
|
@ -473,12 +547,12 @@ router.post('/backfill-proxies', async (_req, res, next) => {
|
|||
|
||||
// POST /:id/reprocess?type=proxy|thumbnail|filmstrip
|
||||
// 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 {
|
||||
const { id } = req.params;
|
||||
const type = req.query.type || 'proxy';
|
||||
if (!['proxy', 'thumbnail', 'filmstrip'].includes(type)) {
|
||||
return res.status(400).json({ error: 'type must be "proxy", "thumbnail", or "filmstrip"' });
|
||||
if (!['proxy', 'thumbnail', 'filmstrip', 'hls'].includes(type)) {
|
||||
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]);
|
||||
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||
|
|
@ -501,6 +575,12 @@ router.post('/:id/reprocess', async (req, res, next) => {
|
|||
await filmstripQueue.add('generate', { assetId: id, proxyKey: asset.proxy_s3_key });
|
||||
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); }
|
||||
});
|
||||
|
||||
|
|
@ -518,7 +598,7 @@ router.get('/:id/filmstrip', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// POST /:id/retry
|
||||
router.post('/:id/retry', async (req, res, next) => {
|
||||
router.post('/:id/retry', requireAssetEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
||||
|
|
@ -537,7 +617,7 @@ router.post('/:id/retry', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// DELETE /:id
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
router.delete('/:id', requireAssetEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { hard } = req.query;
|
||||
|
|
@ -585,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' });
|
||||
const a = r.rows[0];
|
||||
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 key = a.proxy_s3_key ||
|
||||
(a.original_s3_key && VIDEO_EXTS.some(ext => a.original_s3_key.toLowerCase().endsWith(ext))
|
||||
|
|
@ -596,6 +690,42 @@ router.get('/:id/stream', async (req, res, next) => {
|
|||
} 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
|
||||
router.get('/:id/live-path', async (req, res, next) => {
|
||||
try {
|
||||
|
|
@ -848,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' });
|
||||
}
|
||||
}
|
||||
// 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 expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
await pool.query(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,14 @@ import pool from '../db/pool.js';
|
|||
import { DEV_USER_ID, requireAuth } from '../middleware/auth.js';
|
||||
import { hashPassword, comparePassword } from '../auth/passwords.js';
|
||||
import { ipBackoff } from '../auth/rate-limit.js';
|
||||
import {
|
||||
generateSecret, verifyToken, otpauthURI, generateRecoveryCodes,
|
||||
} from '../auth/totp.js';
|
||||
import { issueTicket, redeemTicket } from '../auth/mfa-tickets.js';
|
||||
import {
|
||||
isConfigured as googleConfigured, buildAuthUrl, exchangeAndVerify,
|
||||
} from '../auth/google-oauth.js';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K';
|
||||
|
||||
|
|
@ -76,7 +84,7 @@ router.post('/login', async (req, res, next) => {
|
|||
}
|
||||
|
||||
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]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
|
|
@ -93,6 +101,29 @@ router.post('/login', async (req, res, next) => {
|
|||
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.first_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
|
||||
// the prior bounce-to-login logic produced an infinite loop.
|
||||
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||
|
||||
if (ip) ipBackoff.recordSuccess(ip);
|
||||
pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id])
|
||||
.catch(err => console.error('[auth] last_login_at update failed:', err.message));
|
||||
}
|
||||
|
||||
ipBackoff.recordSuccess(ip);
|
||||
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); } catch (err) { next(err); }
|
||||
// POST /api/v1/auth/login/totp { ticket?, code } — second login step. `code` is
|
||||
// either a 6-digit TOTP or a one-time recovery code. The ticket comes from the
|
||||
// request body (password-login path) or req.session.mfa_ticket (Google path).
|
||||
router.post('/login/totp', async (req, res, next) => {
|
||||
try {
|
||||
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
// Rate-limit the second factor with the same per-IP backoff as /login so
|
||||
// the 6-digit code space can't be hammered.
|
||||
const delay = ipBackoff.delayMs(ip);
|
||||
if (delay > 0) await new Promise(r => setTimeout(r, delay));
|
||||
|
||||
const { ticket: bodyTicket, code } = req.body || {};
|
||||
const ticket = bodyTicket || req.session?.mfa_ticket;
|
||||
if (req.session?.mfa_ticket) delete req.session.mfa_ticket;
|
||||
// Bound to the issuing request's IP + UA — replays from a different origin
|
||||
// redeem to null. See mfa-tickets.js for the binding model.
|
||||
const userId = redeemTicket(ticket, { ip, userAgent: req.get('user-agent') });
|
||||
if (!userId) {
|
||||
ipBackoff.recordFailure(ip);
|
||||
return res.status(401).json({ error: 'invalid or expired ticket' });
|
||||
}
|
||||
if (!code) return res.status(400).json({ error: 'code required' });
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, display_name, totp_secret, totp_enabled, totp_last_counter
|
||||
FROM users WHERE id = $1`, [userId]);
|
||||
const user = rows[0];
|
||||
if (!user || !user.totp_enabled || !user.totp_secret) {
|
||||
return res.status(401).json({ error: 'invalid credentials' });
|
||||
}
|
||||
|
||||
// verifyToken returns the matched counter on success. Reject codes at
|
||||
// counters ≤ totp_last_counter to prevent replay within the same step.
|
||||
// The CAS-style UPDATE makes this race-free under concurrent submissions.
|
||||
const matchedCounter = verifyToken(user.totp_secret, code);
|
||||
let ok = false;
|
||||
if (matchedCounter !== null) {
|
||||
const lastCounter = BigInt(user.totp_last_counter || 0);
|
||||
if (BigInt(matchedCounter) > lastCounter) {
|
||||
const upd = await pool.query(
|
||||
`UPDATE users SET totp_last_counter = $1
|
||||
WHERE id = $2 AND totp_last_counter < $1`,
|
||||
[String(matchedCounter), user.id]
|
||||
);
|
||||
ok = upd.rowCount === 1;
|
||||
}
|
||||
// matchedCounter ≤ last → silent replay; falls through to recovery-code
|
||||
// path which also fails → 401. Same UX as a wrong code, no info leak.
|
||||
}
|
||||
if (!ok) ok = await consumeRecoveryCode(user.id, code);
|
||||
if (!ok) {
|
||||
ipBackoff.recordFailure(ip);
|
||||
// The ticket was single-use; the client must restart from /login.
|
||||
return res.status(401).json({ error: 'invalid code' });
|
||||
}
|
||||
|
||||
// recordSuccess is called by establishSession once the session lands —
|
||||
// that's the first moment we know every required factor has passed.
|
||||
await establishSession(req, user, ip);
|
||||
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Check a recovery code against the user's unused codes; mark it spent on match.
|
||||
// The marking is atomic (UPDATE ... WHERE used_at IS NULL with a rowCount check)
|
||||
// so two concurrent redemptions of the same code can't both succeed.
|
||||
async function consumeRecoveryCode(userId, code) {
|
||||
const cleaned = String(code).trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{5}-[0-9a-f]{5}$/.test(cleaned)) return false;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, code_hash FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL`, [userId]);
|
||||
for (const row of rows) {
|
||||
if (await comparePassword(cleaned, row.code_hash)) {
|
||||
const upd = await pool.query(
|
||||
`UPDATE user_recovery_codes SET used_at = NOW() WHERE id = $1 AND used_at IS NULL`, [row.id]);
|
||||
// Lost the race if another request already consumed it.
|
||||
return upd.rowCount === 1;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/logout — destroys the session and clears the cookie.
|
||||
router.post('/logout', (req, res) => {
|
||||
if (!req.session) return res.status(204).end();
|
||||
|
|
@ -125,6 +235,7 @@ router.get('/me', requireAuth, (req, res) => {
|
|||
username: req.user.username,
|
||||
display_name: req.user.display_name,
|
||||
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); }
|
||||
});
|
||||
|
||||
// ── 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 { realUserCount };
|
||||
export { realUserCount, resolveGoogleUser, consumeRecoveryCode };
|
||||
|
|
|
|||
|
|
@ -1,25 +1,60 @@
|
|||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
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
|
||||
// every bin across every project so the Library / asset-context-menu can
|
||||
// present a global "move to bin" picker.
|
||||
// Resolve the owning project for a /:id bin, assert 'view' baseline, stash the
|
||||
// project_id for mutating routes to escalate to 'edit'.
|
||||
router.param('id', async (req, res, next) => {
|
||||
validateUuid('id')(req, res, () => {});
|
||||
if (res.headersSent) return;
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT project_id FROM bins WHERE id = $1', [req.params.id]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Bin not found' });
|
||||
req.binProjectId = rows[0].project_id;
|
||||
await assertProjectAccess(req.user, req.binProjectId, 'view');
|
||||
next();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
async function requireBinEdit(req, res, next) {
|
||||
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) => {
|
||||
try {
|
||||
const { project_id } = req.query;
|
||||
|
||||
const params = [];
|
||||
let where = '';
|
||||
if (project_id) {
|
||||
where = 'WHERE b.project_id = $1';
|
||||
params.push(project_id);
|
||||
await assertProjectAccess(req.user, project_id, 'view');
|
||||
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(
|
||||
`SELECT b.*, p.name AS project_name,
|
||||
(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`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST / - Create bin
|
||||
// POST / - Create bin (requires edit on the target project).
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { project_id, name, parent_id } = req.body;
|
||||
|
|
@ -44,6 +78,7 @@ router.post('/', async (req, res, next) => {
|
|||
if (!project_id || !name) {
|
||||
return res.status(400).json({ error: 'project_id and name are required' });
|
||||
}
|
||||
await assertProjectAccess(req.user, project_id, 'edit');
|
||||
|
||||
const id = uuidv4();
|
||||
|
||||
|
|
@ -61,7 +96,7 @@ router.post('/', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// PATCH /:id - Update bin
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
router.patch('/:id', requireBinEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, parent_id } = req.body;
|
||||
|
|
@ -107,7 +142,7 @@ router.patch('/:id', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// DELETE /:id - Delete bin
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
router.delete('/:id', requireBinEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
|
@ -126,8 +161,8 @@ router.delete('/:id', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
// POST /:id/assets - Add asset to bin
|
||||
router.post('/:id/assets', async (req, res, next) => {
|
||||
// POST /:id/assets - Add asset to bin (requires edit on the bin's project).
|
||||
router.post('/:id/assets', requireBinEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
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' });
|
||||
}
|
||||
|
||||
// Verify bin exists
|
||||
const binCheck = await pool.query('SELECT id FROM bins WHERE id = $1', [id]);
|
||||
if (binCheck.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Bin not found' });
|
||||
// Asset must live in the bin's own project. Without this, an editor in
|
||||
// project A (where the bin lives) could pull an asset from project B (no
|
||||
// grant) into A's bin tree, exposing it in A's views.
|
||||
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
|
||||
|
|
@ -158,8 +196,8 @@ router.post('/:id/assets', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
// DELETE /:id/assets/:assetId - Remove asset from bin
|
||||
router.delete('/:id/assets/:assetId', async (req, res, next) => {
|
||||
// DELETE /:id/assets/:assetId - Remove asset from bin (requires edit).
|
||||
router.delete('/:id/assets/:assetId', requireBinEdit, async (req, res, next) => {
|
||||
try {
|
||||
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';
|
||||
|
||||
const router = express.Router();
|
||||
|
|
|
|||
|
|
@ -4,10 +4,6 @@ import pool from '../db/pool.js';
|
|||
|
||||
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) {
|
||||
const clean = (s) => (s || '').replace(/^::ffff:/, '');
|
||||
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) => {
|
||||
try {
|
||||
const r = await pool.query(
|
||||
|
|
@ -57,7 +52,6 @@ router.get('/', async (req, res, next) => {
|
|||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /containers – list all containers on the local Docker host
|
||||
router.get('/containers', async (req, res, next) => {
|
||||
try {
|
||||
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) => {
|
||||
try {
|
||||
await dockerRequest(`/containers/${encodeURIComponent(req.params.nameOrId)}/restart`, 'POST');
|
||||
|
|
@ -96,7 +89,6 @@ router.post('/containers/:nameOrId/restart', async (req, res, next) => {
|
|||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /heartbeat – upsert this node's registration (includes hardware capabilities)
|
||||
router.post('/heartbeat', async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
|
|
@ -108,11 +100,6 @@ router.post('/heartbeat', async (req, res, next) => {
|
|||
|
||||
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') {
|
||||
const bound = req.tokenBoundHostname;
|
||||
if (bound && bound !== hostname) {
|
||||
|
|
@ -132,8 +119,8 @@ router.post('/heartbeat', async (req, res, next) => {
|
|||
const r = await pool.query(
|
||||
`INSERT INTO cluster_nodes
|
||||
(hostname, ip_address, role, version, api_url,
|
||||
cpu_usage, mem_used_mb, mem_total_mb, last_seen, capabilities, metadata, metrics)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9,$10,$11)
|
||||
cpu_usage, mem_used_mb, mem_total_mb, last_seen, last_seen_at, capabilities, metadata, metrics)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),NOW(),$9,$10,$11)
|
||||
ON CONFLICT (hostname) DO UPDATE SET
|
||||
ip_address = EXCLUDED.ip_address,
|
||||
role = EXCLUDED.role,
|
||||
|
|
@ -143,6 +130,7 @@ router.post('/heartbeat', async (req, res, next) => {
|
|||
mem_used_mb = EXCLUDED.mem_used_mb,
|
||||
mem_total_mb = EXCLUDED.mem_total_mb,
|
||||
last_seen = NOW(),
|
||||
last_seen_at = NOW(),
|
||||
capabilities = EXCLUDED.capabilities,
|
||||
metadata = EXCLUDED.metadata,
|
||||
metrics = COALESCE(EXCLUDED.metrics, cluster_nodes.metrics)
|
||||
|
|
@ -165,42 +153,25 @@ router.post('/heartbeat', async (req, res, next) => {
|
|||
} 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) => {
|
||||
try {
|
||||
// 1. Fetch all cluster nodes with DeckLink capabilities.
|
||||
const nodesResult = await pool.query(
|
||||
`SELECT id, hostname, ip_address, api_url, capabilities,
|
||||
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
||||
FROM cluster_nodes
|
||||
WHERE capabilities IS NOT NULL`
|
||||
);
|
||||
|
||||
// 2. Fetch all SDI recorders that are pinned to a node+device_index.
|
||||
const recResult = await pool.query(
|
||||
`SELECT id, name, status, container_id, node_id, device_index,
|
||||
source_config
|
||||
FROM recorders
|
||||
WHERE source_type = 'sdi' AND node_id IS NOT NULL`
|
||||
);
|
||||
|
||||
// Build a fast lookup: "${node_id}:${device_index}" → recorder row.
|
||||
const recByPort = new Map();
|
||||
for (const r of recResult.rows) {
|
||||
const devIdx = r.device_index ?? r.source_config?.device ?? 0;
|
||||
recByPort.set(`${r.node_id}:${devIdx}`, r);
|
||||
}
|
||||
|
||||
// 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 = [];
|
||||
for (const node of nodesResult.rows) {
|
||||
const nodeOnline = Number(node.stale_seconds) < 120;
|
||||
|
|
@ -208,79 +179,51 @@ router.get('/devices/blackmagic/signal', async (req, res, next) => {
|
|||
const model = (node.capabilities && node.capabilities.blackmagic_model) || null;
|
||||
const localHostname = process.env.NODE_HOSTNAME || '';
|
||||
const isRemote = node.api_url && node.hostname !== localHostname;
|
||||
|
||||
bm.forEach((d, idx) => {
|
||||
const portIndex = d.index !== undefined ? d.index : idx;
|
||||
const rec = recByPort.get(`${node.id}:${portIndex}`);
|
||||
|
||||
tasks.push((async () => {
|
||||
const base = {
|
||||
node_id: node.id,
|
||||
hostname: node.hostname,
|
||||
index: portIndex,
|
||||
device: d.device || null,
|
||||
model,
|
||||
node_online: nodeOnline,
|
||||
recorder_id: rec ? rec.id : null,
|
||||
recorder_name: rec ? rec.name : null,
|
||||
node_id: node.id, hostname: node.hostname, index: portIndex,
|
||||
device: d.device || null, model, node_online: nodeOnline,
|
||||
recorder_id: rec ? rec.id : null, recorder_name: rec ? rec.name : null,
|
||||
recorder_status: rec ? rec.status : null,
|
||||
signal: 'no-recorder',
|
||||
framesReceived: null,
|
||||
currentFps: null,
|
||||
signal: 'no-recorder', framesReceived: null, currentFps: null,
|
||||
};
|
||||
|
||||
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';
|
||||
return base;
|
||||
}
|
||||
|
||||
// Active recording — query the capture container for real signal.
|
||||
try {
|
||||
let live = null;
|
||||
if (isRemote) {
|
||||
const r = await fetch(
|
||||
`${node.api_url}/sidecar/${rec.container_id}/status`,
|
||||
{ signal: AbortSignal.timeout(2500) }
|
||||
);
|
||||
const r = await fetch(`${node.api_url}/sidecar/${rec.container_id}/status`, { signal: AbortSignal.timeout(2500) });
|
||||
if (r.ok) live = (await r.json()).live;
|
||||
} else {
|
||||
const r = await fetch(
|
||||
`http://recorder-${rec.id}:3001/capture/status`,
|
||||
{ signal: AbortSignal.timeout(2000) }
|
||||
);
|
||||
const r = await fetch(`http://recorder-${rec.id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
|
||||
if (r.ok) live = await r.json();
|
||||
}
|
||||
if (live && live.signal) {
|
||||
base.signal = live.signal;
|
||||
base.framesReceived = live.framesReceived ?? null;
|
||||
base.currentFps = live.currentFps ?? null;
|
||||
} else {
|
||||
base.signal = 'connecting';
|
||||
}
|
||||
} catch (_) {
|
||||
base.signal = 'connecting';
|
||||
}
|
||||
} else { base.signal = 'connecting'; }
|
||||
} catch (_) { base.signal = 'connecting'; }
|
||||
return base;
|
||||
})());
|
||||
});
|
||||
}
|
||||
|
||||
const results = await Promise.all(tasks);
|
||||
res.json(results);
|
||||
} 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) => {
|
||||
try {
|
||||
const r = await pool.query(
|
||||
`SELECT id, hostname, ip_address, role, capabilities,
|
||||
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
||||
FROM cluster_nodes
|
||||
WHERE capabilities IS NOT NULL`
|
||||
FROM cluster_nodes WHERE capabilities IS NOT NULL`
|
||||
);
|
||||
const out = [];
|
||||
for (const row of r.rows) {
|
||||
|
|
@ -288,118 +231,70 @@ router.get('/devices/blackmagic', async (req, res, next) => {
|
|||
const bm = (row.capabilities && row.capabilities.blackmagic) || [];
|
||||
const model = (row.capabilities && row.capabilities.blackmagic_model) || null;
|
||||
bm.forEach((d, idx) => {
|
||||
out.push({
|
||||
node_id: row.id,
|
||||
hostname: row.hostname,
|
||||
ip_address: row.ip_address,
|
||||
role: row.role,
|
||||
online,
|
||||
model,
|
||||
index: d.index !== undefined ? d.index : idx,
|
||||
device: d.device,
|
||||
});
|
||||
out.push({ node_id: row.id, hostname: row.hostname, ip_address: row.ip_address,
|
||||
role: row.role, online, model, index: d.index !== undefined ? d.index : idx, device: d.device });
|
||||
});
|
||||
}
|
||||
res.json(out);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /devices/deltacast – flatten every node's Deltacast cards for the
|
||||
// recorder picker. Mirrors /devices/blackmagic shape so the UI can treat
|
||||
// both card types uniformly.
|
||||
router.get('/devices/deltacast', async (req, res, next) => {
|
||||
try {
|
||||
const r = await pool.query(
|
||||
`SELECT id, hostname, ip_address, role, capabilities,
|
||||
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
||||
FROM cluster_nodes
|
||||
WHERE capabilities IS NOT NULL`
|
||||
FROM cluster_nodes WHERE capabilities IS NOT NULL`
|
||||
);
|
||||
const out = [];
|
||||
for (const row of r.rows) {
|
||||
const online = Number(row.stale_seconds) < 120;
|
||||
const dc = (row.capabilities && row.capabilities.deltacast) || [];
|
||||
const model = (row.capabilities && row.capabilities.deltacast_model) || null;
|
||||
// Also synthesise entries from DELTACAST_PORT_COUNT if no entries reported yet —
|
||||
// useful for nodes that haven't sent a heartbeat since the agent was updated.
|
||||
dc.forEach((d, idx) => {
|
||||
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,
|
||||
});
|
||||
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); }
|
||||
});
|
||||
|
||||
// GET /devices/deltacast/signal – live signal state for Deltacast ports.
|
||||
// Same pattern as /devices/blackmagic/signal.
|
||||
router.get('/devices/deltacast/signal', async (req, res, next) => {
|
||||
try {
|
||||
const [nodesRes, recordersRes] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT id, hostname, ip_address, api_url, capabilities,
|
||||
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'`
|
||||
),
|
||||
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,
|
||||
};
|
||||
|
||||
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; }
|
||||
|
||||
// Active recording — query capture container for real signal.
|
||||
const fetchIdx = results.length;
|
||||
results.push(base);
|
||||
fetchPromises.push((async () => {
|
||||
try {
|
||||
const url = node.api_url
|
||||
? `${node.api_url}/sidecar/${rec.container_id}/status`
|
||||
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) {
|
||||
|
|
@ -410,35 +305,24 @@ router.get('/devices/deltacast/signal', async (req, res, next) => {
|
|||
results[fetchIdx].currentFps = live.currentFps ?? null;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
results[fetchIdx].signal = 'connecting';
|
||||
}
|
||||
} catch (_) { results[fetchIdx].signal = 'connecting'; }
|
||||
})());
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(fetchPromises);
|
||||
res.json(results);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /:id/ping – probe the node's api_url/health endpoint directly
|
||||
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]
|
||||
);
|
||||
const r = await pool.query('SELECT id, hostname, api_url FROM cluster_nodes WHERE id = $1', [req.params.id]);
|
||||
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
|
||||
|
||||
const node = r.rows[0];
|
||||
if (!node.api_url) return res.json({ reachable: false, reason: 'no api_url registered' });
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
const upstream = await fetch(`${node.api_url}/health`, {
|
||||
signal: AbortSignal.timeout(4000),
|
||||
});
|
||||
const upstream = await fetch(`${node.api_url}/health`, { signal: AbortSignal.timeout(4000) });
|
||||
const latency_ms = Date.now() - start;
|
||||
const body = await upstream.json().catch(() => ({}));
|
||||
res.json({ reachable: upstream.ok, latency_ms, status: upstream.status, agent: body });
|
||||
|
|
@ -448,8 +332,6 @@ router.get('/:id/ping', async (req, res, next) => {
|
|||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
|
||||
// GET /metrics - live per-node utilization (CPU, RAM, GPU)
|
||||
router.get('/metrics', async (req, res, next) => {
|
||||
try {
|
||||
const r = await pool.query(
|
||||
|
|
@ -457,59 +339,37 @@ router.get('/metrics', async (req, res, next) => {
|
|||
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`
|
||||
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,
|
||||
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),
|
||||
};
|
||||
memory_total_mb: g.memory_mb != null ? g.memory_mb : (live.memory_total_mb ?? null) };
|
||||
});
|
||||
// include any live GPUs not in static capabilities
|
||||
for (const lg of liveGpus) {
|
||||
if (!capGpus.some(g => g.index === lg.index)) {
|
||||
gpus.push({
|
||||
name: lg.name || null,
|
||||
util_pct: lg.util_pct != null ? lg.util_pct : null,
|
||||
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,
|
||||
});
|
||||
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,
|
||||
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,
|
||||
};
|
||||
ram_total_mb: row.mem_total_mb != null ? row.mem_total_mb : null, gpus };
|
||||
});
|
||||
|
||||
res.json({ nodes });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /:id – deregister a node
|
||||
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]
|
||||
);
|
||||
const r = await pool.query('DELETE FROM cluster_nodes WHERE id = $1 RETURNING id', [req.params.id]);
|
||||
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
|
||||
res.json({ ok: true });
|
||||
} catch (err) { next(err); }
|
||||
|
|
|
|||
|
|
@ -5,9 +5,23 @@
|
|||
|
||||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { assertProjectAccess } from '../auth/authz.js';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
// Scope every comment route to the parent asset's project: resolve project_id
|
||||
// via the asset, then require 'view' to read and 'edit' to write. A non-UUID or
|
||||
// unknown asset is a clean 404 before any access decision leaks its existence.
|
||||
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
router.use(async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT project_id FROM assets WHERE id = $1', [req.params.assetId]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||
await assertProjectAccess(req.user, rows[0].project_id, MUTATING.has(req.method) ? 'edit' : 'view');
|
||||
next();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
function rowToJson(r) {
|
||||
return {
|
||||
id: r.id,
|
||||
|
|
@ -49,8 +63,9 @@ router.post('/', async (req, res, next) => {
|
|||
if (!body || !String(body).trim()) {
|
||||
return res.status(400).json({ error: 'body is required' });
|
||||
}
|
||||
// Best-effort author lookup — pull from the session if AUTH_ENABLED is on.
|
||||
const userId = req.session?.userId || null;
|
||||
// Author is the authenticated user (requireAuth sets req.user for both
|
||||
// session and bearer auth, and the dev user when AUTH_ENABLED=false).
|
||||
const userId = req.user?.id || null;
|
||||
|
||||
const ins = await pool.query(
|
||||
`INSERT INTO asset_comments (asset_id, user_id, body, frame_ms)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import express from 'express';
|
|||
import { Queue } from 'bullmq';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import pool from '../db/pool.js';
|
||||
import { assertProjectAccess } from '../auth/authz.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -60,6 +61,8 @@ router.post('/youtube', async (req, res, next) => {
|
|||
if (projCheck.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
// Importing writes an asset into the project — require edit access.
|
||||
await assertProjectAccess(req.user, projectId, 'edit');
|
||||
|
||||
const assetId = uuidv4();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { Queue } from 'bullmq';
|
||||
import { assertProjectAccess } from '../auth/authz.js';
|
||||
|
||||
const router = express.Router();
|
||||
// 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', {
|
||||
edl,
|
||||
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 pool from '../db/pool.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';
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -16,18 +18,29 @@ const slugify = (str) => {
|
|||
.replace(/-+/g, '-');
|
||||
};
|
||||
|
||||
// GET / - List all projects
|
||||
// GET / - List projects the caller can access (admins see all).
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const access = await accessibleProjectIds(req.user);
|
||||
if (access.all) {
|
||||
const result = await pool.query('SELECT * FROM projects ORDER BY created_at DESC');
|
||||
return res.json(result.rows);
|
||||
}
|
||||
if (access.ids.size === 0) return res.json([]);
|
||||
const ids = [...access.ids];
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM projects WHERE id = ANY($1::uuid[]) ORDER BY created_at DESC`,
|
||||
[ids]
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST / - Create project
|
||||
router.post('/', async (req, res, next) => {
|
||||
// POST / - Create project (admin only; new projects have no grants, so a
|
||||
// scoped user could never reach one they just made).
|
||||
router.post('/', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
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) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await assertProjectAccess(req.user, id, 'view');
|
||||
|
||||
const result = await pool.query(
|
||||
`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) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await assertProjectAccess(req.user, id, 'edit');
|
||||
const { name, description } = req.body;
|
||||
|
||||
const updates = [];
|
||||
|
|
@ -122,8 +137,9 @@ router.patch('/:id', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
// DELETE /:id - Delete project and cascade
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
// DELETE /:id - Delete project and cascade (admin only — destructive, wipes
|
||||
// every asset/bin/recorder under it).
|
||||
router.delete('/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -6,10 +6,34 @@ import dgram from 'dgram';
|
|||
import pool from '../db/pool.js';
|
||||
import { getS3Bucket } from '../s3/client.js';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
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.
|
||||
// Device index 0 → 7438, index 1 → 7439, etc.
|
||||
|
|
@ -149,6 +173,17 @@ function pickRecorderFields(body) {
|
|||
// parallel with a per-call timeout from `dockerApi`.
|
||||
router.get('/', async (req, res, next) => {
|
||||
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(`
|
||||
SELECT r.*, la.live_asset_id
|
||||
FROM recorders r
|
||||
|
|
@ -162,8 +197,9 @@ router.get('/', async (req, res, next) => {
|
|||
ORDER BY a.created_at DESC
|
||||
LIMIT 1
|
||||
) la ON TRUE
|
||||
${scopeClause}
|
||||
ORDER BY r.created_at DESC
|
||||
`);
|
||||
`, params);
|
||||
const rows = result.rows;
|
||||
|
||||
// Only inspect containers for recorders that actually claim to be recording.
|
||||
|
|
@ -194,10 +230,15 @@ router.post('/', async (req, res, next) => {
|
|||
.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.
|
||||
const defaults = {
|
||||
source_config: {},
|
||||
recording_codec: 'prores_hq',
|
||||
recording_codec: 'hevc_nvenc',
|
||||
recording_resolution: 'native',
|
||||
recording_audio_codec: 'pcm_s24le',
|
||||
recording_audio_channels: 2,
|
||||
|
|
@ -256,7 +297,7 @@ router.get('/:id', async (req, res, next) => {
|
|||
|
||||
// PATCH /:id - Edit recorder settings
|
||||
// Blocked while recorder is actively recording to prevent config drift.
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
router.patch('/:id', requireRecorderEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
|
@ -295,7 +336,7 @@ router.patch('/:id', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// POST /:id/start - Start recording
|
||||
router.post('/:id/start', async (req, res, next) => {
|
||||
router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
|
@ -345,6 +386,14 @@ router.post('/:id/start', async (req, res, next) => {
|
|||
? req.body.projectId
|
||||
: recorder.project_id;
|
||||
|
||||
// requireRecorderEdit only covered the recorder's own project. If this take
|
||||
// is being routed into a DIFFERENT project, the caller must have edit there
|
||||
// too — otherwise edit on recorder A's project would let them write live
|
||||
// assets into any project B.
|
||||
if (takeProjectId !== recorder.project_id) {
|
||||
await assertProjectAccess(req.user, takeProjectId, 'edit');
|
||||
}
|
||||
|
||||
// live-asset: create the asset row right now (status='live') so the
|
||||
// library shows the recording while it is happening.
|
||||
const assetIdLive = uuidv4();
|
||||
|
|
@ -427,6 +476,12 @@ 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.
|
||||
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
|
||||
// For remote sidecars, the capture container runs on the worker host network and cannot
|
||||
|
|
@ -444,7 +499,7 @@ router.post('/:id/start', async (req, res, next) => {
|
|||
const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image: 'wild-dragon-capture:latest', env, capturePort, sourceType }),
|
||||
body: JSON.stringify({ image: 'wild-dragon-capture:latest', env, capturePort, sourceType, useGpu }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!sidecarRes.ok) {
|
||||
|
|
@ -477,16 +532,28 @@ router.post('/:id/start', async (req, res, next) => {
|
|||
}
|
||||
if (growingEnabled) hostBinds.push('/mnt/NVME/MAM/wild-dragon-growing:/growing');
|
||||
|
||||
const containerConfig = {
|
||||
Image: 'wild-dragon-capture:latest',
|
||||
Env: env,
|
||||
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
|
||||
HostConfig: {
|
||||
const localEnv = [...env];
|
||||
if (useGpu) {
|
||||
localEnv.push('NVIDIA_VISIBLE_DEVICES=all');
|
||||
localEnv.push('NVIDIA_DRIVER_CAPABILITIES=video,compute,utility');
|
||||
}
|
||||
|
||||
const localHostConfig = {
|
||||
Privileged: true,
|
||||
NetworkMode: dockerNetwork,
|
||||
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
|
||||
Binds: hostBinds,
|
||||
},
|
||||
...(useGpu && {
|
||||
Runtime: 'nvidia',
|
||||
DeviceRequests: [{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] }],
|
||||
}),
|
||||
};
|
||||
|
||||
const containerConfig = {
|
||||
Image: 'wild-dragon-capture:latest',
|
||||
Env: localEnv,
|
||||
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
|
||||
HostConfig: localHostConfig,
|
||||
NetworkingConfig: {
|
||||
EndpointsConfig: {
|
||||
[dockerNetwork]: { Aliases: [alias] },
|
||||
|
|
@ -533,7 +600,7 @@ router.post('/:id/start', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// POST /:id/stop - Stop recording
|
||||
router.post('/:id/stop', async (req, res, next) => {
|
||||
router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
|
@ -704,7 +771,7 @@ router.get('/:id/status', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// DELETE /:id - Delete recorder
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
router.delete('/:id', requireRecorderEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import express from 'express';
|
|||
import pool from '../db/pool.js';
|
||||
import { getSignedUrlForObject } from '../s3/client.js';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
import { assertProjectAccess } from '../auth/authz.js';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
const parseRedisUrl = (url) => {
|
||||
|
|
@ -19,7 +20,27 @@ const conformQueue = new Queue('conform', {
|
|||
});
|
||||
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
// node-postgres returns NUMERIC columns as strings. Coerce frame_rate to a
|
||||
|
|
@ -124,6 +145,7 @@ router.get('/', async (req, res, next) => {
|
|||
try {
|
||||
const { project_id } = req.query;
|
||||
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(
|
||||
`SELECT * FROM sequences WHERE project_id = $1 ORDER BY updated_at DESC`,
|
||||
[project_id]
|
||||
|
|
@ -143,6 +165,7 @@ router.post('/', async (req, res, next) => {
|
|||
height = 1080,
|
||||
} = req.body;
|
||||
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(
|
||||
`INSERT INTO sequences (project_id, name, frame_rate, width, height)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
|
|
@ -188,7 +211,7 @@ router.get('/:id', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// ── PUT /:id – update sequence metadata ──────────────────────────────────────
|
||||
router.put('/:id', async (req, res, next) => {
|
||||
router.put('/:id', requireSequenceEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { name, frame_rate, width, height } = req.body;
|
||||
const updates = [];
|
||||
|
|
@ -211,7 +234,7 @@ router.put('/:id', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// ── DELETE /:id ───────────────────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
router.delete('/:id', requireSequenceEdit, async (req, res, next) => {
|
||||
try {
|
||||
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' });
|
||||
|
|
@ -220,12 +243,14 @@ router.delete('/:id', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// ── PUT /:id/clips – full replace of clip array (single transaction) ──────────
|
||||
router.put('/:id/clips', async (req, res, next) => {
|
||||
// Verify sequence exists first (before acquiring transaction client)
|
||||
router.put('/:id/clips', requireSequenceEdit, async (req, res, next) => {
|
||||
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]);
|
||||
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) {
|
||||
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)) ||
|
||||
|
|
@ -237,8 +262,22 @@ router.put('/:id/clips', async (req, res, next) => {
|
|||
}
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
// Every referenced asset must belong to THIS sequence's project. Without this,
|
||||
// a user with edit on the sequence could splice in assets from a project they
|
||||
// can't access — and GET /:id would then hand back those assets' names and
|
||||
// signed proxy URLs (cross-project leak).
|
||||
const assetIds = [...new Set(clips.map(c => c.asset_id))];
|
||||
if (assetIds.length) {
|
||||
const owning = await pool.query(
|
||||
`SELECT id FROM assets WHERE id = ANY($1::uuid[]) AND project_id = $2`,
|
||||
[assetIds, req.sequenceProjectId]
|
||||
);
|
||||
if (owning.rows.length !== assetIds.length) {
|
||||
return res.status(400).json({ error: 'All clip assets must belong to the sequence\'s project' });
|
||||
}
|
||||
}
|
||||
|
||||
client = await pool.connect();
|
||||
await client.query('BEGIN');
|
||||
await client.query(
|
||||
`DELETE FROM sequence_clips WHERE sequence_id = $1`,
|
||||
|
|
@ -265,10 +304,12 @@ router.put('/:id/clips', async (req, res, next) => {
|
|||
await client.query('COMMIT');
|
||||
res.json({ ok: true, count: clips.length });
|
||||
} 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);
|
||||
} 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 ─────────────────────────
|
||||
// Accepts FCP XML content and encode settings from the Premiere plugin,
|
||||
// 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 {
|
||||
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' });
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
AbortMultipartUploadCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getAmppConfig, ensureFolderPath } from '../ampp/client.js';
|
||||
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -138,16 +139,24 @@ function mediaTypeFromMime(mime = '') {
|
|||
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) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, filename, display_name, project_id, bin_id, status, created_at, updated_at
|
||||
const access = await accessibleProjectIds(req.user);
|
||||
let query = `SELECT id, filename, display_name, project_id, bin_id, status, created_at, updated_at
|
||||
FROM assets
|
||||
WHERE status = 'ingesting'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`
|
||||
);
|
||||
WHERE status = 'ingesting'`;
|
||||
const params = [];
|
||||
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);
|
||||
} 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 s3Key = `originals/${assetId}/${filename}`;
|
||||
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 s3Key = `originals/${assetId}/${filename}`;
|
||||
const mimeType = contentType || req.file.mimetype;
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
import express from 'express';
|
||||
import pool from '../db/pool.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 MIN_PASSWORD_LEN = 12;
|
||||
const ROLES = ['admin', 'editor', 'viewer'];
|
||||
|
||||
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) => {
|
||||
try {
|
||||
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]);
|
||||
res.json(rows);
|
||||
} catch (err) { next(err); }
|
||||
|
|
@ -26,6 +28,7 @@ router.post('/', async (req, res, next) => {
|
|||
const { username, password, display_name, role } = req.body || {};
|
||||
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 (role !== undefined && !ROLES.includes(role)) return bad(res, "role must be one of: " + ROLES.join(', '));
|
||||
const hash = await hashPassword(password);
|
||||
const { rows } = await pool.query(
|
||||
`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' });
|
||||
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?.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 (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()');
|
||||
|
|
@ -93,4 +99,88 @@ router.patch('/:id', async (req, res, next) => {
|
|||
} 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;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
||||
import { S3Client, GetObjectCommand, DeleteObjectCommand, HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
|
|
@ -22,6 +23,9 @@ function buildClient(cfg) {
|
|||
secretAccessKey: cfg.secretKey,
|
||||
},
|
||||
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',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
|
||||
import pool from './db/pool.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 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') {
|
||||
const res = await fetch(`${SELF_URL}${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-internal-token': INTERNAL_TOKEN,
|
||||
},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
|
@ -29,11 +34,7 @@ async function callSelf(path, method = 'POST') {
|
|||
return res.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
// Issue #103 — every mam-api replica runs the same tick on the same interval,
|
||||
// so a multi-node deploy would double-fire recorder starts/stops. We guard
|
||||
// the whole tick with a PG advisory lock (1 = scheduler) so exactly one
|
||||
// replica processes a given interval. Pure-Postgres, no extra infra.
|
||||
const SCHEDULER_LOCK_KEY = 8210301; // arbitrary, must be stable across replicas
|
||||
const SCHEDULER_LOCK_KEY = 8210301;
|
||||
|
||||
async function tryAcquireSchedulerLock(client) {
|
||||
const r = await client.query('SELECT pg_try_advisory_lock($1) AS got', [SCHEDULER_LOCK_KEY]);
|
||||
|
|
@ -52,14 +53,9 @@ async function tick() {
|
|||
try {
|
||||
haveLock = await tryAcquireSchedulerLock(client);
|
||||
if (!haveLock) {
|
||||
// Another replica is processing this interval — bail silently.
|
||||
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(
|
||||
`UPDATE recorder_schedules
|
||||
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(
|
||||
`UPDATE recorder_schedules
|
||||
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}`);
|
||||
await enqueueNextOccurrence(s, client);
|
||||
} catch (err) {
|
||||
// Stop failed — flag as failed but don't keep trying forever.
|
||||
await client.query(
|
||||
`UPDATE recorder_schedules
|
||||
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(
|
||||
`SELECT s.* FROM recorder_schedules s
|
||||
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 staleResult = await client.query(
|
||||
`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(
|
||||
`SELECT id, project_id, bin_id FROM assets
|
||||
WHERE ampp_sync_status IN ('pending', 'failed')
|
||||
|
|
@ -175,6 +162,8 @@ async function tick() {
|
|||
for (const row of ampps.rows) {
|
||||
await syncToAmpp(row.id, row.project_id, row.bin_id);
|
||||
}
|
||||
|
||||
await playoutHealthTick(client);
|
||||
} catch (err) {
|
||||
console.error('[scheduler] tick error:', err);
|
||||
} finally {
|
||||
|
|
@ -201,11 +190,73 @@ async function enqueueNextOccurrence(schedule, client) {
|
|||
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() {
|
||||
if (_interval) return;
|
||||
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);
|
||||
_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(); }
|
||||
});
|
||||
|
|
@ -87,6 +87,12 @@ async function handleSidecarStart(body, res) {
|
|||
env = [],
|
||||
capturePort = 3001,
|
||||
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;
|
||||
|
||||
const binds = [`${LIVE_DIR}:/live`];
|
||||
|
|
@ -100,14 +106,35 @@ async function handleSidecarStart(body, res) {
|
|||
} catch (_) { /* /dev always exists */ }
|
||||
}
|
||||
|
||||
const spec = {
|
||||
Image: image,
|
||||
Env: [...env, `PORT=${capturePort}`],
|
||||
HostConfig: {
|
||||
// Build the sidecar environment, injecting NVIDIA vars when GPU is requested.
|
||||
const sidecarEnv = [...env, `PORT=${capturePort}`];
|
||||
if (useGpu) {
|
||||
// NVIDIA_VISIBLE_DEVICES=all exposes every GPU on the host.
|
||||
// For a single-GPU node (zampp2 / L4) this is equivalent to pinning GPU 0.
|
||||
// When we later store per-recorder GPU affinity in the DB we can pass a
|
||||
// specific UUID here instead.
|
||||
sidecarEnv.push('NVIDIA_VISIBLE_DEVICES=all');
|
||||
sidecarEnv.push('NVIDIA_DRIVER_CAPABILITIES=video,compute,utility');
|
||||
}
|
||||
|
||||
const hostConfig = {
|
||||
NetworkMode: 'host',
|
||||
Privileged: true,
|
||||
Binds: binds,
|
||||
},
|
||||
};
|
||||
if (useGpu) {
|
||||
// Tell Docker to use the NVIDIA container runtime for this container.
|
||||
// Equivalent to `docker run --gpus all` / `--runtime=nvidia`.
|
||||
hostConfig.Runtime = 'nvidia';
|
||||
hostConfig.DeviceRequests = [
|
||||
{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] },
|
||||
];
|
||||
}
|
||||
|
||||
const spec = {
|
||||
Image: image,
|
||||
Env: sidecarEnv,
|
||||
HostConfig: hostConfig,
|
||||
};
|
||||
|
||||
const createRes = await dockerApi('POST', '/containers/create', spec);
|
||||
|
|
|
|||
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">
|
||||
|
||||
<!-- ── Connect Pane ─────────────────────────────────────────────── -->
|
||||
<section id="connect-pane" class="pane">
|
||||
<section id="connect-pane" class="pane pane-connect">
|
||||
<div class="brand">
|
||||
<div class="brand-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/>
|
||||
</svg>
|
||||
<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>
|
||||
</div>
|
||||
<div class="brand-title">Dragonflight</div>
|
||||
<div class="brand-tag">Wild Dragon Broadcast</div>
|
||||
|
|
@ -28,45 +26,60 @@
|
|||
<div id="connect-status" class="status-msg muted"></div>
|
||||
</section>
|
||||
|
||||
<!-- ── Library Pane ─────────────────────────────────────────────── -->
|
||||
<!-- ── Library Pane (app-shell: statusbar · rail · main · dock) ──── -->
|
||||
<section id="library-pane" class="pane hidden">
|
||||
<div class="app">
|
||||
|
||||
<!-- Connection status strip (v2.1.8): dot + identity + ⋯ menu.
|
||||
Disconnect lives inside the menu so it's not always visible. -->
|
||||
<header class="status-strip">
|
||||
<!-- Connection collapsed to a status line: dot + host + version + ⋯ -->
|
||||
<header class="statusbar">
|
||||
<span class="signal-dot"></span>
|
||||
<span id="connected-host" class="connected-host"></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">
|
||||
<button id="disconnect-btn" class="menu-item" role="menuitem">Disconnect</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tab-nav">
|
||||
<button id="tab-library" class="tab-btn active">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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"/>
|
||||
</svg>
|
||||
Library
|
||||
</button>
|
||||
<button id="tab-growing" class="tab-btn">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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 class="workspace">
|
||||
|
||||
<!-- Vertical icon rail: views on top, global actions below -->
|
||||
<nav class="rail">
|
||||
<div id="tab-library" role="button" tabindex="0" class="rail-btn active" data-tip="Library" data-tip-pos="right">
|
||||
<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>
|
||||
</div>
|
||||
<div id="tab-growing" role="button" tabindex="0" class="rail-btn" data-tip="Growing" data-tip-pos="right">
|
||||
<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>
|
||||
<span id="growing-count" class="rail-count" style="display:none">0</span>
|
||||
</div>
|
||||
|
||||
<!-- Search + Project filter -->
|
||||
<div class="search-row">
|
||||
<input id="search-input" type="search" placeholder="Search assets…" />
|
||||
<select id="project-filter" title="Filter by project">
|
||||
<span class="rail-spacer"></span>
|
||||
|
||||
<div id="export-timeline-btn" role="button" tabindex="0" class="rail-btn rail-btn--accent" data-tip="Export" data-tip-pos="right">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- Active sequence info bar -->
|
||||
|
|
@ -89,52 +102,75 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- (v2.2.0) Asset details panel dropped — card meta already carries
|
||||
name / codec / duration. If we need richer detail later, surface
|
||||
it on the card hover state rather than reserving permanent space. -->
|
||||
<!-- Details panel retired (v2.2.0) — card meta carries name/codec/duration. -->
|
||||
<div id="details-panel" class="hidden"></div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<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 -->
|
||||
<!-- Progress + toast sit just above the action dock -->
|
||||
<div id="progress-row" class="progress-row hidden">
|
||||
<div class="progress-bar"><div id="progress-fill"></div></div>
|
||||
<div id="progress-label" class="progress-label">…</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<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>
|
||||
|
||||
<!-- Advanced section — collapsed by default; click the row to expand. -->
|
||||
<div class="advanced-section">
|
||||
<button id="advanced-toggle" class="advanced-toggle" type="button" aria-expanded="false">
|
||||
<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>
|
||||
</div><!-- /main -->
|
||||
</div><!-- /workspace -->
|
||||
</div><!-- /app -->
|
||||
</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-panel" class="slide-panel hidden">
|
||||
<div class="slide-header">
|
||||
|
|
@ -164,13 +200,15 @@
|
|||
<div class="slide-body">
|
||||
<label class="field-label">Target project</label>
|
||||
<select id="conform-proj-select"><option value="">— Select project —</option></select>
|
||||
<label class="field-label">Preset</label>
|
||||
<div id="preset-cards" class="preset-grid">
|
||||
<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" 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="archive"><div class="pc-title">Archive</div><div class="pc-desc">ProRes 4444 · UHD · 48kHz</div></div>
|
||||
<div class="preset-card" data-preset="custom"><div class="pc-title">Custom</div><div class="pc-desc">Manual settings</div></div>
|
||||
<label class="field-label">Format</label>
|
||||
<div id="preset-cards" class="preset-list">
|
||||
<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"><span class="pc-title">Web</span><span class="pc-desc">H.264 · 1080p · AAC</span></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"><span class="pc-title">Custom</span><span class="pc-desc">Manual settings</span></div>
|
||||
</div>
|
||||
|
||||
<div id="conform-custom" class="custom-fields hidden">
|
||||
<label class="field-label">Codec</label>
|
||||
<select id="conform-codec">
|
||||
<option value="prores_hq">ProRes 422 HQ</option>
|
||||
|
|
@ -198,6 +236,7 @@
|
|||
<option value="web">Web (AAC 320k)</option>
|
||||
<option value="archive">Archive (96kHz PCM)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="conform-clip-info" class="clip-info"></div>
|
||||
</div>
|
||||
<div class="slide-footer">
|
||||
|
|
@ -231,6 +270,7 @@
|
|||
<script src="src/library.js"></script>
|
||||
<script src="src/import-flow.js"></script>
|
||||
<script src="src/timeline.js"></script>
|
||||
<script src="src/tooltip.js"></script>
|
||||
<script src="src/main.js"></script>
|
||||
</body>
|
||||
</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;
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -154,5 +154,111 @@
|
|||
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;
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -220,10 +220,7 @@
|
|||
_btn('import-hires-btn').disabled = !sel || live || !sel.original_s3_key;
|
||||
_btn('mount-live-btn').disabled = !sel || !live;
|
||||
_btn('relink-btn').disabled = !(ready && hasLiveImport);
|
||||
_btn('import-all-btn').disabled = Library.state.currentTab !== 'library';
|
||||
_btn('export-timeline-btn').disabled = false; // available once connected
|
||||
_btn('export-conform-btn').disabled = false;
|
||||
_btn('fetch-relink-btn').disabled = false;
|
||||
// export-timeline-btn (Export menu) and upload-mam-btn are always available.
|
||||
};
|
||||
|
||||
function _btn(id) { return document.getElementById(id) || { disabled: false }; }
|
||||
|
|
|
|||
|
|
@ -6,6 +6,50 @@
|
|||
|
||||
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() {
|
||||
$('connect-btn').disabled = !$('server-url').value.trim() || !$('api-token').value.trim();
|
||||
}
|
||||
|
|
@ -74,6 +118,11 @@
|
|||
$('tab-library').addEventListener('click', () => Library.switchTab('library'));
|
||||
$('tab-growing').addEventListener('click', () => Library.switchTab('growing'));
|
||||
|
||||
const vt = $('view-toggle-btn');
|
||||
if (vt) vt.addEventListener('click', () => {
|
||||
applyViewMode(getViewMode() === 'list' ? 'grid' : 'list');
|
||||
});
|
||||
|
||||
let searchTimer;
|
||||
$('search-input').addEventListener('input', e => {
|
||||
clearTimeout(searchTimer);
|
||||
|
|
@ -114,24 +163,8 @@
|
|||
finally { _disableImportBtns(false); Library._syncActions(); }
|
||||
});
|
||||
|
||||
$('import-all-btn').addEventListener('click', async () => {
|
||||
const assets = Library.state.assets;
|
||||
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();
|
||||
});
|
||||
// ── Upload highlighted bin file(s) to the MAM ──
|
||||
$('upload-mam-btn').addEventListener('click', uploadToMam);
|
||||
|
||||
$('mount-live-btn').addEventListener('click', async () => {
|
||||
const a = Library.selectedAsset(); if (!a) return;
|
||||
|
|
@ -181,12 +214,8 @@
|
|||
finally { Library._syncActions(); }
|
||||
});
|
||||
|
||||
// v2.2.1: Export Timeline is now a single-click pipeline —
|
||||
// push to MAM → start conform → poll → new asset lands in Library.
|
||||
// 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);
|
||||
// Single Export entry → popup menu (Conform Timeline / Local Export).
|
||||
wireExportMenu();
|
||||
|
||||
// Advanced collapsible toggle (v2.2.0).
|
||||
const advToggle = $('advanced-toggle');
|
||||
|
|
@ -203,6 +232,83 @@
|
|||
['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;
|
||||
|
||||
// ── One-click Export Timeline ────────────────────────────────────
|
||||
|
|
@ -331,9 +437,13 @@
|
|||
if (!_seqCache.clips.length) { UI.hideProgress(); UI.toast('No clips in active sequence', 'error'); return; }
|
||||
UI.hideProgress();
|
||||
const resolved = Library.resolveClipsToAssets(_seqCache.clips);
|
||||
const total = _seqCache.clips.length;
|
||||
const matched = resolved.filter(c => c.asset_id).length;
|
||||
$('conform-clip-info').textContent = matched + ' of ' + _seqCache.clips.length + ' clip(s) matched';
|
||||
$('conform-start-btn').disabled = matched === 0;
|
||||
const missing = total - matched;
|
||||
$('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');
|
||||
if (conformProj) {
|
||||
conformProj.innerHTML = '<option value="">— Select project —</option>';
|
||||
|
|
@ -360,6 +470,8 @@
|
|||
};
|
||||
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; }
|
||||
// Manual codec/quality/res/audio fields appear only for Custom.
|
||||
UI.setHidden('#conform-custom', card.dataset.preset !== 'custom');
|
||||
});
|
||||
$('conform-start-btn').addEventListener('click', async () => {
|
||||
if (!_seqCache) return;
|
||||
|
|
@ -367,6 +479,15 @@
|
|||
const projectId = conformProj ? conformProj.value : '';
|
||||
if (!projectId) { UI.toast('Select a target project', 'error'); return; }
|
||||
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);
|
||||
try {
|
||||
const jobId = await Timeline.startConform(projectId, _seqCache.sequenceName, _seqCache, {
|
||||
|
|
@ -465,6 +586,8 @@
|
|||
}
|
||||
|
||||
function init() {
|
||||
enableDivDisabled();
|
||||
applyViewMode(getViewMode());
|
||||
wireConnectPane(); wireLibraryPane();
|
||||
wireExportPanel(); wireConformPanel(); wireRelinkPanel();
|
||||
showVersion();
|
||||
|
|
|
|||
|
|
@ -304,5 +304,76 @@
|
|||
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;
|
||||
})();
|
||||
|
|
|
|||
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
|
|
@ -67,7 +67,7 @@ function App() {
|
|||
schedule: ['Ingest', 'Schedule'],
|
||||
youtube: ['Ingest', 'YouTube'],
|
||||
capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
|
||||
jobs: ['Jobs'], editor: ['Editor'],
|
||||
jobs: ['Jobs'], editor: ['Editor'], playout: ['Operations', 'Playout'],
|
||||
users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
|
||||
containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
|
||||
settings: ['Admin', 'Settings'],
|
||||
|
|
@ -97,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;
|
||||
if (openAsset) {
|
||||
content = <AssetDetail asset={openAsset} onClose={() => setOpenAsset(null)} />;
|
||||
} else {
|
||||
switch (route) {
|
||||
switch (effectiveRoute) {
|
||||
case 'home': content = <Home navigate={navigate} />; break;
|
||||
case 'dashboard': content = <Dashboard navigate={navigate} />; break;
|
||||
case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} onClearProject={() => setOpenProject(null)} />; break;
|
||||
|
|
@ -113,6 +120,7 @@ function App() {
|
|||
case 'capture': content = <Capture navigate={navigate} />; break;
|
||||
case 'monitors': content = <Monitors navigate={navigate} />; break;
|
||||
case 'jobs': content = <Jobs navigate={navigate} />; break;
|
||||
case 'playout': content = <Playout navigate={navigate} />; break;
|
||||
case 'users': content = <Users />; break;
|
||||
case 'tokens': content = <Tokens />; break;
|
||||
case 'billing': content = <TokensParody />; break;
|
||||
|
|
|
|||
|
|
@ -24,31 +24,26 @@ window.ZAMPP_API_PREFIX = API; // single source of truth (#115)
|
|||
// Premiere panel releases embedded in this deployment. Bumping the version
|
||||
// here is the single source of truth - both the Editor download buttons and
|
||||
// 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 = [
|
||||
{
|
||||
version: '1.2.0',
|
||||
zxp: '/downloads/dragonflight-premiere-panel-1.2.0.zxp',
|
||||
version: '2.2.2',
|
||||
ccx: '/downloads/dragonflight-mam-2.2.2.ccx',
|
||||
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,
|
||||
},
|
||||
{
|
||||
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];
|
||||
|
||||
// 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 = {
|
||||
PROJECTS: [],
|
||||
ASSETS: [],
|
||||
|
|
|
|||
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" /></>,
|
||||
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" /></>,
|
||||
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" /></>,
|
||||
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" /></>,
|
||||
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" /></>,
|
||||
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" /></>,
|
||||
|
|
@ -37,14 +38,14 @@ const ICONS = {
|
|||
x: <path d="M6 6l12 12M6 18L18 6" />,
|
||||
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" /></>,
|
||||
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" /></>,
|
||||
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" /></>,
|
||||
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" /></>,
|
||||
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" /></>,
|
||||
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" /></>,
|
||||
|
|
@ -65,7 +66,7 @@ const ICONS = {
|
|||
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" /></>,
|
||||
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 }) {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
<link rel="stylesheet" href="styles-rest.css" />
|
||||
<link rel="stylesheet" href="styles-modal.css" />
|
||||
<link rel="stylesheet" href="styles-fixes.css" />
|
||||
<link rel="stylesheet" href="styles-playout.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
@ -47,6 +48,7 @@
|
|||
<script src="js/bmd-card.js"></script>
|
||||
<script src="dist/screens-editor.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/app.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -148,8 +148,18 @@ function NewRecorderModal({ open, onClose }) {
|
|||
});
|
||||
const [dcDevices, setDcDevices] = React.useState(null);
|
||||
const [recTab, setRecTab] = React.useState('video');
|
||||
const [recCodec, setRecCodec] = React.useState('prores_hq');
|
||||
const [recContainer, setRecContainer] = React.useState('mov');
|
||||
// All-Intra HEVC (NVENC) is the default master — GPU-encoded, growing-file
|
||||
// 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 [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
|
|
@ -198,7 +208,14 @@ function NewRecorderModal({ open, onClose }) {
|
|||
generate_proxy: proxyOn,
|
||||
recording_codec: recCodec,
|
||||
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') {
|
||||
body.source_config = { url: srtUrl };
|
||||
|
|
@ -382,22 +399,48 @@ function NewRecorderModal({ open, onClose }) {
|
|||
<div className="field">
|
||||
<label className="field-label">Video codec</label>
|
||||
<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="prores_4444">ProRes 4444</option>
|
||||
<option value="prores_hq">ProRes 422 HQ</option>
|
||||
<option value="hevc_nvenc">All-Intra HEVC (NVENC) — GPU, growing</option>
|
||||
<option value="h264_nvenc">H.264 (NVENC) — GPU</option>
|
||||
<option value="prores_hq">ProRes 422 HQ — 4:2:2 CPU</option>
|
||||
<option value="prores">ProRes 422</option>
|
||||
<option value="prores_lt">ProRes 422 LT</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="xdcam_hd422">XDCAM HD422</option>
|
||||
<option value="libx264">H.264 (x264, CPU)</option>
|
||||
<option value="libx265">H.265 (x265, CPU)</option>
|
||||
</select>
|
||||
</div>
|
||||
<Field label="Resolution" value="Source (native)" select />
|
||||
<Field label="Color space" value="Rec. 709" select />
|
||||
<Field label="Bit depth" value="10-bit" select />
|
||||
{codecUsesBitrate ? (
|
||||
<div className="field">
|
||||
<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>
|
||||
)}
|
||||
{recTab === 'audio' && (
|
||||
|
|
@ -410,16 +453,8 @@ function NewRecorderModal({ open, onClose }) {
|
|||
)}
|
||||
{recTab === 'container' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div className="field">
|
||||
<label className="field-label">Container</label>
|
||||
<select className="field-input" value={recContainer} onChange={e => setRecContainer(e.target.value)} style={{ appearance: 'auto' }}>
|
||||
<option value="mov">MOV (QuickTime)</option>
|
||||
<option value="mxf">MXF (SMPTE)</option>
|
||||
<option value="mkv">MKV (Matroska)</option>
|
||||
<option value="mp4">MP4</option>
|
||||
</select>
|
||||
</div>
|
||||
<Field label="Segment" value="None (single file)" select />
|
||||
<Field label="Container" value={recContainer === 'mp4' ? 'MP4 (auto)' : 'Fragmented MOV (auto)'} select />
|
||||
<Field label="Growing-file" value={recContainer === 'mov' ? 'Supported (edit-while-record)' : 'No'} select />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -258,14 +258,7 @@ function Users() {
|
|||
{tab === 'groups' && <GroupsPanel groups={groups} users={users} onChange={refreshGroups} />}
|
||||
|
||||
{tab === 'policies' && (
|
||||
<div className="panel" style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-3)' }}>
|
||||
<Icon name="lock" size={24} />
|
||||
<div style={{ marginTop: 10, fontWeight: 500, fontSize: 14, color: 'var(--text-2)' }}>Access policies</div>
|
||||
<div style={{ fontSize: 12, marginTop: 4 }}>
|
||||
Per-project and per-bin permissions are coming soon. For now, role-based access<br />
|
||||
(admin / editor / viewer) is enforced API-wide.
|
||||
</div>
|
||||
</div>
|
||||
<PoliciesPanel users={users} onChange={refreshUsers} />
|
||||
)}
|
||||
</div>
|
||||
{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 }) {
|
||||
const [name, setName] = React.useState(user.display_name || user.name || '');
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
|
@ -1474,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() {
|
||||
const [tokens, setTokens] = React.useState([]);
|
||||
const [name, setName] = React.useState('');
|
||||
|
|
@ -1583,6 +1903,7 @@ function Settings() {
|
|||
{section === 'account' && (
|
||||
<>
|
||||
<AccountSection />
|
||||
<TotpSection />
|
||||
<ApiTokensSection />
|
||||
</>
|
||||
)}
|
||||
|
|
@ -2015,9 +2336,9 @@ function SdkSettingsCard() {
|
|||
Premiere Pro Panel
|
||||
</div>
|
||||
<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.
|
||||
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),
|
||||
or run the <strong style={{ color: 'var(--text-2)' }}>Windows Setup</strong> which bundles the installer automatically.
|
||||
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)' }}>.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 double-click it with Creative Cloud installed.
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{PREMIERE_RELEASES.map(r => (
|
||||
|
|
@ -2029,12 +2350,16 @@ function SdkSettingsCard() {
|
|||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>{r.notes}</div>
|
||||
</div>
|
||||
<a href={r.zxp} download style={{ textDecoration: 'none' }}>
|
||||
<button className="btn ghost sm">ZXP</button>
|
||||
{r.ccx && (
|
||||
<a href={r.ccx} download style={{ textDecoration: 'none' }}>
|
||||
<button className="btn ghost sm">UXP (.ccx)</button>
|
||||
</a>
|
||||
)}
|
||||
{r.installer && (
|
||||
<a href={r.installer} download style={{ textDecoration: 'none' }}>
|
||||
<button className="btn ghost sm">Win Installer</button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -65,7 +65,12 @@ function AssetDetail({ asset, onClose }) {
|
|||
setStreamLoading(true);
|
||||
window.ZAMPP_API.fetch('/assets/' + assetId + '/stream')
|
||||
.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);
|
||||
setStreamType(r.type || 'mp4');
|
||||
} else if (r) {
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
const [username, setUsername] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [error, setError] = React.useState('');
|
||||
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 () => {
|
||||
setError(''); setBusy(true);
|
||||
try {
|
||||
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(() => ({}));
|
||||
setError(body.error || ('Login failed: ' + r.status));
|
||||
} catch (e) { setError(e.message || 'Login failed'); }
|
||||
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 (
|
||||
<Screen>
|
||||
<ErrorRow text={error} />
|
||||
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
|
||||
<Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="current-password" />
|
||||
<Button type="submit" disabled={busy || !username || !password} onClick={submit}>Sign in</Button>
|
||||
{googleEnabled && <><Divider /><GoogleButton /></>}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,17 +18,24 @@
|
|||
// Anything that would just say "all clear" is hidden, not rendered.
|
||||
|
||||
function Home({ navigate }) {
|
||||
const [showPremiereDownload, setShowPremiereDownload] = React.useState(false);
|
||||
const [showDownloads, setShowDownloads] = React.useState(false);
|
||||
|
||||
// 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.
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
const load = () => {
|
||||
window.ZAMPP_API.fetch('/metrics/home?hours=1')
|
||||
.then(d => { if (!cancelled) setCards(d?.cards || {}); })
|
||||
.catch(() => {});
|
||||
window.ZAMPP_API.fetch('/playout/channels')
|
||||
.then(d => { if (!cancelled) setPlayoutChannels(Array.isArray(d) ? d : []); })
|
||||
.catch(() => { if (!cancelled) setPlayoutChannels([]); });
|
||||
};
|
||||
load();
|
||||
const t = setInterval(load, 30_000);
|
||||
|
|
@ -64,12 +71,27 @@ function Home({ navigate }) {
|
|||
desc: 'SDI · SRT · RTMP ingest. Start, stop, schedule.',
|
||||
},
|
||||
{
|
||||
id: '__premiere',
|
||||
label: 'Premiere panel',
|
||||
icon: 'editor',
|
||||
id: 'playout',
|
||||
label: 'Playout',
|
||||
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',
|
||||
sub: 'v' + ((window.PREMIERE_LATEST || {}).version || '·'),
|
||||
desc: 'Download the Adobe Premiere Pro panel for frame-accurate editing.',
|
||||
sub: 'Plugin · Teams ISO',
|
||||
desc: 'Download the Premiere Pro UXP plugin and the Teams ISO installer.',
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
|
|
@ -118,7 +140,10 @@ function Home({ navigate }) {
|
|||
/>
|
||||
<h1 className="launcher-wordmark">DRAGONFLIGHT</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
@ -127,7 +152,7 @@ function Home({ navigate }) {
|
|||
<button
|
||||
key={t.id}
|
||||
className={'launcher-tile tone-' + t.tone}
|
||||
onClick={() => t.id === '__premiere' ? setShowPremiereDownload(true) : navigate(t.id)}
|
||||
onClick={() => t.id === '__downloads' ? setShowDownloads(true) : navigate(t.id)}
|
||||
>
|
||||
<span className="launcher-tile-icon">
|
||||
<Icon name={t.icon} size={26} />
|
||||
|
|
@ -146,7 +171,7 @@ function Home({ navigate }) {
|
|||
onClick={() => navigate('dashboard')}
|
||||
>
|
||||
<span className="launcher-tile-icon">
|
||||
<Icon name="home" size={22} />
|
||||
<Icon name="layout" size={22} />
|
||||
</span>
|
||||
<span className="launcher-tile-label">Dashboard</span>
|
||||
<span className="launcher-tile-sub">Operations view</span>
|
||||
|
|
@ -241,15 +266,17 @@ function Home({ navigate }) {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showPremiereDownload && <PremiereDownloadModal onClose={() => setShowPremiereDownload(false)} />}
|
||||
{showDownloads && <DownloadsModal onClose={() => setShowDownloads(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Modal listing all Premiere panel downloads (ZXP + Windows installer for
|
||||
// each released version). Sourced from window.PREMIERE_RELEASES, written by
|
||||
// the Settings → SDKs section in screens-admin.jsx.
|
||||
function PremiereDownloadModal({ onClose }) {
|
||||
// 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 || '');
|
||||
|
|
@ -262,15 +289,40 @@ function PremiereDownloadModal({ onClose }) {
|
|||
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 560 }}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600 }}>Premiere panel</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600 }}>Downloads</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>
|
||||
Adobe Premiere Pro integration. Install one ZXP per workstation.
|
||||
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.
|
||||
|
|
@ -291,9 +343,9 @@ function PremiereDownloadModal({ onClose }) {
|
|||
</div>
|
||||
{rel.notes && <div className="premiere-release-notes">{rel.notes}</div>}
|
||||
<div className="premiere-release-actions">
|
||||
{rel.zxp && (
|
||||
<a href={rel.zxp} download className="btn primary sm">
|
||||
<Icon name="download" />ZXP
|
||||
{rel.ccx && (
|
||||
<a href={rel.ccx} download className="btn primary sm">
|
||||
<Icon name="download" />UXP plugin (.ccx)
|
||||
</a>
|
||||
)}
|
||||
{rel.installer && (
|
||||
|
|
|
|||
|
|
@ -230,11 +230,29 @@ function YouTubeImport({ navigate }) {
|
|||
patch.error = patch.error || 'Import failed: check the Jobs screen for details.';
|
||||
} else if (asset.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 (asset.status === 'ready' || asset.status === 'error') return;
|
||||
} 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();
|
||||
return () => { stopped = true; };
|
||||
|
|
|
|||
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 [menuFor, setMenuFor] = 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(() => {
|
||||
window.ZAMPP_API.fetch('/projects')
|
||||
|
|
@ -122,8 +125,10 @@ function Projects({ onOpenProject, navigate }) {
|
|||
key={p.id}
|
||||
project={p}
|
||||
assets={ASSETS}
|
||||
canManageAccess={isAdmin}
|
||||
onOpen={() => onOpenProject(p)}
|
||||
onRename={() => renameProject(p)}
|
||||
onManageAccess={() => manageAccess(p)}
|
||||
onDelete={() => deleteProject(p)}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -148,6 +153,7 @@ function Projects({ onOpenProject, navigate }) {
|
|||
<div className="row-menu" onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => { setMenuFor(null); onOpenProject(p); }}><Icon name="library" size={11} />Open</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>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -165,6 +171,140 @@ function Projects({ onOpenProject, navigate }) {
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -206,7 +346,7 @@ 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 thumbAssets = ofProject.slice(0, 4);
|
||||
|
||||
|
|
@ -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()}>
|
||||
<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>
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -284,3 +425,4 @@ function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
|
|||
|
||||
window.Projects = Projects;
|
||||
window.RenameProjectModal = RenameProjectModal;
|
||||
window.ProjectAccessModal = ProjectAccessModal;
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ const NAV_SECTIONS = [
|
|||
items: [
|
||||
{ id: "home", label: "Home", icon: "home" },
|
||||
{ id: "dashboard", label: "Dashboard", icon: "layout" },
|
||||
{ id: "library", label: "Library", icon: "library" },
|
||||
{ id: "projects", label: "Projects", icon: "folder" },
|
||||
{ id: "library", label: "Library", icon: "library" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -20,7 +20,7 @@ const NAV_SECTIONS = [
|
|||
{ id: "upload", label: "Upload", icon: "upload" },
|
||||
{ id: "youtube", label: "YouTube", icon: "download" },
|
||||
{ id: "recorders", label: "Recorders", icon: "record" },
|
||||
{ id: "schedule", label: "Schedule", icon: "jobs" },
|
||||
{ id: "schedule", label: "Schedule", icon: "clock" },
|
||||
{ id: "monitors", label: "Monitors", icon: "monitor" },
|
||||
],
|
||||
},
|
||||
|
|
@ -28,6 +28,7 @@ const NAV_SECTIONS = [
|
|||
label: "Operations",
|
||||
items: [
|
||||
{ id: "capture", label: "Capture", icon: "capture" },
|
||||
{ id: "playout", label: "Playout", icon: "signal" },
|
||||
{ id: "jobs", label: "Jobs", icon: "jobs" },
|
||||
],
|
||||
},
|
||||
|
|
@ -36,7 +37,7 @@ const NAV_SECTIONS = [
|
|||
items: [
|
||||
{ id: "users", label: "Users", icon: "users" },
|
||||
{ id: "tokens", label: "Tokens", icon: "token" },
|
||||
{ id: "billing", label: "Billing", icon: "token" },
|
||||
{ id: "billing", label: "Billing", icon: "dollar" },
|
||||
{ id: "containers", label: "Containers", icon: "container" },
|
||||
{ id: "cluster", label: "Cluster", icon: "cluster" },
|
||||
{ id: "settings", label: "Settings", icon: "settings" },
|
||||
|
|
@ -126,13 +127,18 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
|
|||
// (Capture live-signal badge previously lived here; it now belongs in the
|
||||
// topbar status pip alongside the cluster pip. See issue #149.)
|
||||
|
||||
// Apply the live Jobs badge to the Operations section.
|
||||
// Apply the live Jobs badge to the Operations section, and gate the Admin
|
||||
// section to admins only (RBAC v2). Non-admins never see Users/Cluster/etc.
|
||||
// This is UX only — the API enforces the same rules server-side.
|
||||
const isAdmin = me?.role === 'admin';
|
||||
const sections = React.useMemo(
|
||||
() => NAV_SECTIONS.map(sec => ({
|
||||
() => NAV_SECTIONS
|
||||
.filter(sec => sec.label !== 'Admin' || isAdmin)
|
||||
.map(sec => ({
|
||||
...sec,
|
||||
items: sec.items.map(n => (n.id === 'jobs' && jobsBadge) ? { ...n, badge: jobsBadge } : n),
|
||||
})),
|
||||
[jobsBadge]
|
||||
[jobsBadge, isAdmin]
|
||||
);
|
||||
const toggleGroup = (id) => {
|
||||
setOpenGroups(prev => {
|
||||
|
|
|
|||
|
|
@ -324,6 +324,14 @@
|
|||
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 {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
|
|
|
|||
104
services/web-ui/public/styles-playout.css
Normal file
104
services/web-ui/public/styles-playout.css
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/* Playout / Master Control (MCR) page styles. */
|
||||
|
||||
.po-page { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
/* Channel tab bar */
|
||||
.po-channels-bar {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
padding-bottom: 10px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.po-chan-tab {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 6px 12px; border-radius: 8px;
|
||||
background: var(--bg-2); border: 1px solid var(--border);
|
||||
color: var(--text-2); font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.po-chan-tab:hover { background: var(--bg-3); color: var(--text-1); }
|
||||
.po-chan-tab.active { background: var(--accent-soft); color: var(--accent-text); border-color: var(--accent-soft-2); }
|
||||
.po-chan-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--text-3);
|
||||
}
|
||||
.po-chan-dot.live { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); }
|
||||
|
||||
.po-empty { text-align: center; padding: 48px 0; display: flex; flex-direction: column; gap: 12px; align-items: center; }
|
||||
|
||||
/* Channel detail */
|
||||
.po-detail { display: flex; flex-direction: column; gap: 14px; }
|
||||
.po-detail-head { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.po-detail-actions { display: flex; gap: 8px; }
|
||||
|
||||
.po-grid {
|
||||
display: grid; grid-template-columns: 1.4fr 1fr; gap: 14px;
|
||||
}
|
||||
@media (max-width: 900px) { .po-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.po-section-label {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: var(--text-3); font-weight: 600;
|
||||
}
|
||||
|
||||
/* Program monitor */
|
||||
.po-monitor {
|
||||
background: var(--bg-1); border: 1px solid var(--border); border-radius: 12px;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.po-monitor-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 12px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.po-onair { font-size: 12px; font-weight: 700; color: var(--text-3); letter-spacing: 0.04em; }
|
||||
.po-onair.live { color: var(--danger); }
|
||||
.po-monitor-screen {
|
||||
flex: 1; min-height: 220px; background: #000;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.po-monitor-clip { font-family: var(--font-mono); font-size: 14px; color: var(--text-1); }
|
||||
.po-monitor-foot { padding: 8px 12px; border-top: 1px solid var(--border); font-size: 11px; }
|
||||
|
||||
/* Media bin */
|
||||
.po-bin {
|
||||
display: flex; flex-direction: column; min-height: 260px; max-height: 360px;
|
||||
border-radius: 12px; overflow: hidden;
|
||||
}
|
||||
.po-bin-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||
.po-bin-list { overflow-y: auto; flex: 1; }
|
||||
.po-bin-item {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||||
cursor: grab; user-select: none;
|
||||
}
|
||||
.po-bin-item:hover { background: var(--bg-3); }
|
||||
.po-bin-item:active { cursor: grabbing; }
|
||||
.po-bin-name { font-size: 13px; color: var(--text-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Transport */
|
||||
.po-transport {
|
||||
display: flex; gap: 8px; flex-wrap: wrap;
|
||||
padding: 12px; background: var(--bg-1); border: 1px solid var(--border); border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Playlist */
|
||||
.po-playlist {
|
||||
border-radius: 12px; overflow: hidden;
|
||||
min-height: 120px;
|
||||
}
|
||||
.po-playlist-empty { padding: 28px 12px; text-align: center; }
|
||||
.po-pl-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 9px 12px; border-bottom: 1px solid var(--border);
|
||||
cursor: grab; user-select: none;
|
||||
}
|
||||
.po-pl-item:hover { background: var(--bg-3); }
|
||||
.po-pl-item:active { cursor: grabbing; }
|
||||
.po-pl-index {
|
||||
width: 22px; text-align: center; font-family: var(--font-mono);
|
||||
font-size: 12px; color: var(--text-3);
|
||||
}
|
||||
.po-pl-name { flex: 1; font-size: 13px; color: var(--text-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Small button variants reused */
|
||||
.btn.xs { padding: 2px 8px; font-size: 11px; }
|
||||
.btn.sm { padding: 5px 10px; font-size: 12px; }
|
||||
.field-input.sm { padding: 5px 8px; font-size: 12px; }
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
FROM node:20-alpine
|
||||
# yt-dlp powers the YouTube importer; python3 is its runtime dep.
|
||||
RUN apk add --no-cache ffmpeg yt-dlp python3
|
||||
# Not from apk: the packaged yt-dlp goes stale and YouTube breaks old versions.
|
||||
# Pull the latest python-zipapp release (runs on musl via system python3) so a
|
||||
# rebuild always refreshes it. /usr/local/bin precedes /usr/bin on PATH.
|
||||
RUN apk add --no-cache ffmpeg python3 curl \
|
||||
&& curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp \
|
||||
-o /usr/local/bin/yt-dlp \
|
||||
&& chmod a+rx /usr/local/bin/yt-dlp
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
|
|
|||
|
|
@ -9,11 +9,20 @@
|
|||
FROM nvcr.io/nvidia/cuda:12.3.1-base-ubuntu22.04
|
||||
|
||||
# Install Node.js 20, ffmpeg (Ubuntu's ffmpeg includes h264_nvenc/hevc_nvenc),
|
||||
# and yt-dlp (+ python3 runtime) for the YouTube importer.
|
||||
# and yt-dlp for the YouTube importer.
|
||||
#
|
||||
# yt-dlp is NOT installed from apt: Ubuntu 22.04's package is pinned to a 2022
|
||||
# release, which YouTube has long since broken (extraction fails). yt-dlp must
|
||||
# track YouTube's frequent changes, so we pull the latest self-contained
|
||||
# release binary at build time. /usr/local/bin precedes /usr/bin on PATH, so
|
||||
# `yt-dlp` resolves to this one. Rebuild the worker image to refresh it.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates ffmpeg yt-dlp python3 \
|
||||
curl ca-certificates ffmpeg python3 \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux \
|
||||
-o /usr/local/bin/yt-dlp \
|
||||
&& chmod a+rx /usr/local/bin/yt-dlp \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { filmstripWorker } from './workers/filmstrip.js';
|
|||
import { conformWorker } from './workers/conform.js';
|
||||
import { youtubeImportWorker, proxyQueue as youtubeProxyQueue } from './workers/youtube-import.js';
|
||||
import { trimWorker } from './workers/trimWorker.js';
|
||||
import { hlsWorker } from './workers/hls.js';
|
||||
import { playoutStageWorker } from './workers/playout-stage.js';
|
||||
import { startPromotionWorker } from './workers/promotion.js';
|
||||
|
||||
const parseRedisUrl = (url) => {
|
||||
|
|
@ -85,11 +87,17 @@ const workers = [
|
|||
want('filmstrip') && createWorker('filmstrip', filmstripWorker, { concurrency: FILMSTRIP_CONCURRENCY }),
|
||||
want('conform') && createWorker('conform', conformWorker, { concurrency: CONFORM_CONCURRENCY }),
|
||||
want('trim') && createWorker('trim', trimWorker, { concurrency: TRIM_CONCURRENCY }),
|
||||
// HLS backfill remux is a light stream-copy. Run it wherever proxy runs so
|
||||
// existing proxy nodes pick up reprocess?type=hls jobs without an env change.
|
||||
(want('proxy') || want('hls')) && createWorker('hls', hlsWorker, { concurrency: 2 }),
|
||||
want('import') && createWorker('import', youtubeImportWorker, {
|
||||
concurrency: 1,
|
||||
lockDuration: 10 * 60 * 1000,
|
||||
lockRenewTime: 60000,
|
||||
}),
|
||||
// playout-stage = S3 → /media volume + EBU R128 loudnorm. CPU/IO-bound;
|
||||
// colocate with workers that already have ffmpeg + the media mount.
|
||||
want('playout-stage') && createWorker('playout-stage', playoutStageWorker, { concurrency: 1 }),
|
||||
].filter(Boolean);
|
||||
console.log(`WORKER_QUEUES=${_wq || '(all)'}`);
|
||||
|
||||
|
|
|
|||
73
services/worker/src/workers/hls.js
Normal file
73
services/worker/src/workers/hls.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { mkdtemp, readdir, rm, unlink } from 'fs/promises';
|
||||
import { Queue } from 'bullmq';
|
||||
import { query } from '../db/client.js';
|
||||
import { downloadFromS3, uploadToS3 } from '../s3/client.js';
|
||||
import { segmentToHls } from '../ffmpeg/executor.js';
|
||||
|
||||
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
|
||||
|
||||
const parseRedisUrl = (url) => {
|
||||
const parsed = new URL(url);
|
||||
return { host: parsed.hostname, port: parseInt(parsed.port, 10) };
|
||||
};
|
||||
|
||||
// Remux a local, already-browser-compatible MP4 (H.264/AAC, as the proxy
|
||||
// worker produces) into an fMP4 HLS rendition and upload it to hls/<assetId>/.
|
||||
// This is a stream COPY (no re-encode) — it costs seconds, not minutes.
|
||||
//
|
||||
// HLS is served to the browser as whole-file GETs through mam-api, which
|
||||
// sidesteps the RustFS ranged-GET bug that the MP4 /video path has to stitch
|
||||
// around. The MP4 proxy is kept for the Premiere panel + downloads.
|
||||
//
|
||||
// Returns the playlist S3 key and sets assets.hls_s3_key.
|
||||
export async function remuxToHls(localMp4Path, assetId) {
|
||||
const workDir = await mkdtemp(join(tmpdir(), `hls-${assetId}-`));
|
||||
try {
|
||||
// Produces playlist.m3u8 + init.mp4 + segment_NNNNN.m4s in workDir.
|
||||
await segmentToHls(localMp4Path, workDir);
|
||||
|
||||
const prefix = `hls/${assetId}`;
|
||||
const files = await readdir(workDir);
|
||||
if (!files.includes('playlist.m3u8')) {
|
||||
throw new Error('segmentToHls produced no playlist.m3u8');
|
||||
}
|
||||
for (const f of files) {
|
||||
await uploadToS3(S3_BUCKET, `${prefix}/${f}`, join(workDir, f));
|
||||
}
|
||||
|
||||
const playlistKey = `${prefix}/playlist.m3u8`;
|
||||
await query(
|
||||
'UPDATE assets SET hls_s3_key = $1, updated_at = NOW() WHERE id = $2',
|
||||
[playlistKey, assetId]
|
||||
);
|
||||
return playlistKey;
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill worker: remux an EXISTING proxy MP4 into HLS for assets that
|
||||
// predate the proxy-worker HLS step. Enqueued by POST /assets/:id/reprocess?type=hls.
|
||||
export const hlsWorker = async (job) => {
|
||||
const { assetId, proxyKey } = job.data;
|
||||
if (!proxyKey) throw new Error('hls job requires proxyKey');
|
||||
const tmpPath = join(tmpdir(), `hls-src-${job.id}.mp4`);
|
||||
try {
|
||||
await job.updateProgress(10);
|
||||
console.log(`[hls] Downloading proxy ${proxyKey} for asset ${assetId}`);
|
||||
await downloadFromS3(S3_BUCKET, proxyKey, tmpPath);
|
||||
await job.updateProgress(40);
|
||||
const key = await remuxToHls(tmpPath, assetId);
|
||||
console.log(`[hls] Asset ${assetId} HLS rendition complete → ${key}`);
|
||||
await job.updateProgress(100);
|
||||
return { assetId, hlsKey: key };
|
||||
} finally {
|
||||
await unlink(tmpPath).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
export const hlsQueue = new Queue('hls', {
|
||||
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
|
||||
});
|
||||
137
services/worker/src/workers/playout-stage.js
Normal file
137
services/worker/src/workers/playout-stage.js
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { join, extname } from 'path';
|
||||
import { mkdir, stat, rename, unlink } from 'fs/promises';
|
||||
import { spawn } from 'child_process';
|
||||
import { query } from '../db/client.js';
|
||||
import { downloadFromS3 } from '../s3/client.js';
|
||||
|
||||
// Playout media staging — copy an asset from S3 into the shared CasparCG media
|
||||
// volume so a playout channel can play it. CasparCG plays from a local folder
|
||||
// (/media), not from S3, so every playlist item must be staged to 'ready'
|
||||
// before it can go on air. See docs/superpowers/specs/2026-05-30-playout-mcr-design.md §4.
|
||||
//
|
||||
// Two passes:
|
||||
// 1. download from S3 to /media/playout/<assetId><ext>.raw
|
||||
// 2. ffmpeg loudnorm (EBU R128, target I=-23 LUFS, TP=-1 dBTP, LRA=11) to the
|
||||
// final path, then atomic rename. Skipped when items.audio_normalized=true.
|
||||
//
|
||||
// The media volume is mounted into BOTH this worker and the playout sidecars at
|
||||
// the same path (PLAYOUT_MEDIA_DIR, default /media). We stage under a per-asset
|
||||
// filename so re-staging is idempotent and multiple items referencing the same
|
||||
// asset share one file.
|
||||
|
||||
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
|
||||
const MEDIA_DIR = process.env.PLAYOUT_MEDIA_DIR || '/media';
|
||||
|
||||
async function fileExists(p) {
|
||||
try { const s = await stat(p); return s.size > 0; } catch { return false; }
|
||||
}
|
||||
|
||||
// Two-pass loudnorm: pass 1 measures, pass 2 applies linear normalization with
|
||||
// the measured values. Linear mode preserves dynamics at the cost of accuracy
|
||||
// vs the target — fine for broadcast playout where transparent levels matter
|
||||
// more than hitting -23 LUFS to the decibel.
|
||||
function runFfmpeg(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stderr = '';
|
||||
proc.stderr.on('data', (d) => { stderr += d.toString(); });
|
||||
proc.on('error', reject);
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) resolve(stderr);
|
||||
else reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(-500)}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function measureLoudness(inputPath) {
|
||||
// -23 / -1 / 11 are the EBU R128 broadcast targets; loudnorm prints a JSON
|
||||
// block to stderr after the analysis pass which feeds pass 2's measured_*
|
||||
// params.
|
||||
const stderr = await runFfmpeg([
|
||||
'-hide_banner', '-nostats', '-i', inputPath,
|
||||
'-af', 'loudnorm=I=-23:TP=-1:LRA=11:print_format=json',
|
||||
'-f', 'null', '-',
|
||||
]);
|
||||
const match = stderr.match(/\{[\s\S]*?"input_i"[\s\S]*?\}/);
|
||||
if (!match) throw new Error('loudnorm pass 1 produced no JSON');
|
||||
return JSON.parse(match[0]);
|
||||
}
|
||||
|
||||
async function applyLoudnorm(inputPath, outputPath, m) {
|
||||
// Pass 2: linear normalization using pass 1's measurements. -c:v copy keeps
|
||||
// the video stream intact so we only re-encode audio (target AAC stereo, the
|
||||
// common-denominator CasparCG ffmpeg producer accepts).
|
||||
await runFfmpeg([
|
||||
'-hide_banner', '-nostats', '-y', '-i', inputPath,
|
||||
'-af', `loudnorm=I=-23:TP=-1:LRA=11:measured_I=${m.input_i}:measured_TP=${m.input_tp}:measured_LRA=${m.input_lra}:measured_thresh=${m.input_thresh}:offset=${m.target_offset}:linear=true:print_format=summary`,
|
||||
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000',
|
||||
outputPath,
|
||||
]);
|
||||
}
|
||||
|
||||
export async function playoutStageWorker(job) {
|
||||
const { itemId, assetId } = job.data;
|
||||
if (!itemId || !assetId) throw new Error('playout-stage requires itemId + assetId');
|
||||
|
||||
await query("UPDATE playout_items SET media_status = 'staging', updated_at = NOW() WHERE id = $1", [itemId]);
|
||||
|
||||
try {
|
||||
const a = await query(
|
||||
'SELECT id, filename, original_s3_key, proxy_s3_key FROM assets WHERE id = $1', [assetId]);
|
||||
if (a.rows.length === 0) throw new Error(`asset ${assetId} not found`);
|
||||
const asset = a.rows[0];
|
||||
|
||||
// Prefer the master for air quality; fall back to proxy if no master key.
|
||||
const s3Key = asset.original_s3_key || asset.proxy_s3_key;
|
||||
if (!s3Key) throw new Error(`asset ${assetId} has no S3 media key to stage`);
|
||||
|
||||
const ext = extname(s3Key) || extname(asset.filename || '') || '.mp4';
|
||||
// Stable per-asset path under the media volume; CasparCG resolves the token
|
||||
// "playout/<assetId>" against MEDIA_DIR.
|
||||
const relDir = 'playout';
|
||||
const fileName = `${assetId}${ext}`;
|
||||
const absDir = join(MEDIA_DIR, relDir);
|
||||
const absPath = join(absDir, fileName);
|
||||
const mediaPath = join(MEDIA_DIR, relDir, fileName);
|
||||
|
||||
await mkdir(absDir, { recursive: true });
|
||||
|
||||
// Skip the whole pipeline when the final file already exists from a prior
|
||||
// stage of the same asset. The audio_normalized flag is per-item so a
|
||||
// second item referencing the same staged file gets flipped to true below.
|
||||
const itemRow = await query('SELECT audio_normalized FROM playout_items WHERE id = $1', [itemId]);
|
||||
const alreadyNormalized = itemRow.rows[0]?.audio_normalized === true;
|
||||
|
||||
if (!(await fileExists(absPath))) {
|
||||
const rawPath = `${absPath}.raw${ext}`;
|
||||
console.log(`[playout-stage] downloading ${s3Key} -> ${rawPath}`);
|
||||
await downloadFromS3(S3_BUCKET, s3Key, rawPath);
|
||||
|
||||
if (alreadyNormalized) {
|
||||
// Asset was previously normalized for another item — keep the bytes
|
||||
// as-is. Atomic rename so CasparCG never sees a partial file.
|
||||
await rename(rawPath, absPath);
|
||||
} else {
|
||||
console.log(`[playout-stage] loudnorm pass 1: ${rawPath}`);
|
||||
const measured = await measureLoudness(rawPath);
|
||||
const tmpOut = `${absPath}.tmp${ext}`;
|
||||
console.log(`[playout-stage] loudnorm pass 2: I=${measured.input_i} TP=${measured.input_tp} -> ${tmpOut}`);
|
||||
await applyLoudnorm(rawPath, tmpOut, measured);
|
||||
await rename(tmpOut, absPath);
|
||||
await unlink(rawPath).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
console.log(`[playout-stage] already staged: ${absPath}`);
|
||||
}
|
||||
|
||||
await query(
|
||||
"UPDATE playout_items SET media_status = 'ready', media_path = $1, audio_normalized = TRUE, updated_at = NOW() WHERE id = $2",
|
||||
[mediaPath, itemId]);
|
||||
console.log(`[playout-stage] item ${itemId} ready at ${mediaPath}`);
|
||||
return { itemId, mediaPath };
|
||||
} catch (err) {
|
||||
await query("UPDATE playout_items SET media_status = 'error', updated_at = NOW() WHERE id = $1", [itemId])
|
||||
.catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { Queue } from 'bullmq';
|
|||
import { query } from '../db/client.js';
|
||||
import { downloadFromS3, uploadToS3 } from '../s3/client.js';
|
||||
import { transcodeVideo, transcodeImage, getMediaInfo, isHwCodec } from '../ffmpeg/executor.js';
|
||||
import { remuxToHls } from './hls.js';
|
||||
|
||||
// Read the global proxy-encoder settings from the DB. These are written by
|
||||
// Settings → Proxy encoding in the GUI. Falls back to libx264 defaults if
|
||||
|
|
@ -223,6 +224,19 @@ export const proxyWorker = async (job) => {
|
|||
]
|
||||
);
|
||||
|
||||
// Generate the HLS rendition from the proxy we just wrote. The file is
|
||||
// still on local disk, so this is a fast stream-copy remux (no download,
|
||||
// no re-encode). Best-effort: HLS is the preferred browser playback path,
|
||||
// but the MP4 /video fallback still works if this fails, so never fail the
|
||||
// proxy job over it.
|
||||
await job.updateProgress(80);
|
||||
try {
|
||||
const hlsKey = await remuxToHls(outputPath, assetId);
|
||||
console.log(`[proxy] HLS rendition generated for ${assetId} → ${hlsKey}`);
|
||||
} catch (hlsErr) {
|
||||
console.warn(`[proxy] HLS generation failed for ${assetId} (non-fatal): ${hlsErr.message}`);
|
||||
}
|
||||
|
||||
// Now proxy exists in S3 — safe to queue thumbnail generation
|
||||
const thumbnailKey = `thumbnails/${assetId}.jpg`;
|
||||
await thumbnailQueue.add('generate', {
|
||||
|
|
|
|||
|
|
@ -67,7 +67,12 @@ async function runYtDlp({ url, outputTemplate, onProgress }) {
|
|||
'--no-playlist',
|
||||
'--no-warnings',
|
||||
'--restrict-filenames',
|
||||
'-f', "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b",
|
||||
// Prefer H.264 (avc1) so the downloaded ORIGINAL is Premiere-native.
|
||||
// YouTube now packs AV1 inside .mp4, so the old `bv*[ext=mp4]` selector
|
||||
// grabbed AV1 — which Premiere rejects on hi-res import ("unsupported
|
||||
// file type"). avc1 tops out at 1080p on YouTube; only if no H.264
|
||||
// rendition exists do we fall back to the previous any-mp4 behaviour.
|
||||
'-f', "bv*[vcodec^=avc1]+ba[ext=m4a]/bv*[vcodec^=avc1]+ba/b[vcodec^=avc1]/bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b",
|
||||
'--merge-output-format', 'mp4',
|
||||
'--print-json',
|
||||
'--newline',
|
||||
|
|
|
|||
Loading…
Reference in a new issue