HLS VOD playback for browser (supplements MP4 proxy) #170
88 changed files with 9683 additions and 1292 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.
|
||||
|
|
@ -103,6 +103,7 @@ services:
|
|||
# worker-l4: HEAVY tier (proxy/conform/trim) on the L4 (NVENC). Talks to
|
||||
# zampp1's Redis/Postgres/S3 over the LAN (.200). No promotion scanner here.
|
||||
worker-l4:
|
||||
profiles: [gpu]
|
||||
build:
|
||||
context: ./services/worker
|
||||
dockerfile: Dockerfile.gpu
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
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 |
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
# Storage Settings Warning + Growing-Files SMB Auth + Per-Recorder Growing Mode
|
||||
**Date:** 2026-05-31
|
||||
**Branch:** `feat/playout-mcr` (Forgejo: WildDragonLLC/dragonflight)
|
||||
**Status:** Approved, ready for implementation plan
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
Three related refinements to the Settings page and growing-files capture pipeline:
|
||||
|
||||
1. **Storage warning header** — a prominent set-once warning at the top of the Storage settings section.
|
||||
2. **Growing-files SMB credentials + system CIFS mount** — store an SMB username/password and have the capture stack mount the growing share itself (Approach A).
|
||||
3. **Per-recorder growing mode** — remove the global "capture writes to local SMB share first" toggle; make growing-files mode a per-recorder setting.
|
||||
|
||||
All changes live on the `growing-files` / storage-settings path. No playout changes (handled separately).
|
||||
|
||||
---
|
||||
|
||||
## Background (current state)
|
||||
|
||||
- **Settings storage:** single key/value `settings(key TEXT PRIMARY KEY, value TEXT, updated_at)` table (migration 006). Secrets like `s3_secret_key` are stored but `GET /settings/s3` returns only `s3_secret_key_exists` (never the value).
|
||||
- **Growing settings UI:** `GrowingSettingsCard` in `services/web-ui/public/screens-admin.jsx` (rendered by `StorageSection` alongside `MountHealthStrip` and `S3SettingsCard`). Current keys: `growing_enabled`, `growing_path`, `growing_smb_url`, `growing_promote_after_seconds`.
|
||||
- **Settings API:** `services/mam-api/src/routes/settings.js` — `GET/PUT /settings/growing` over `GROWING_KEYS`.
|
||||
- **Global enable today:** the `growing_enabled` setting (checkbox labelled "Capture writes to the local SMB share first; Premier can edit while it's still growing"). `recorders.js` reads this global key at recorder-start and passes `GROWING_ENABLED=true/false` to the capture container.
|
||||
- **Capture write path:** `services/capture/src/capture-manager.js` reads `process.env.GROWING_ENABLED` and `GROWING_PATH` (default `/growing`). When on, it writes the master to `/growing/{projectId}/{clipName}.{ext}` instead of streaming to S3; the promotion worker uploads to S3 after stop.
|
||||
- **Current mount model:** `/growing` is a pre-mounted host bind-mount; the app never authenticates to SMB.
|
||||
- **Per-recorder column already exists:** migration 014 added `recorders.growing_enabled BOOLEAN DEFAULT NULL` ("NULL = use global"), but recorder-start logic ignores it and the new-recorder modal does not expose it.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Storage warning header
|
||||
|
||||
Add a danger-styled banner at the **top of `StorageSection()`**, above `MountHealthStrip`.
|
||||
|
||||
- Visual: full-width banner, danger token styling (`--danger` border + subtle danger background), alert icon, bold uppercase text.
|
||||
- Exact copy:
|
||||
> **⚠ WARNING — THESE SETTINGS ARE MEANT TO BE SET ONCE AT INITIAL DEPLOYMENT. CHANGING STORAGE PATHS MID-CYCLE CAN CAUSE DATABASE CORRUPTION AND FILE LOSS. PLEASE USE WITH CAUTION.**
|
||||
- Pure presentational; no backend, no dismiss state (always visible).
|
||||
|
||||
**Files:** `services/web-ui/public/screens-admin.jsx` (and a small style rule in the appropriate CSS file if needed).
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Growing-files SMB credentials + system CIFS mount (Approach A)
|
||||
|
||||
The growing share is **shared infrastructure**, so the SMB connection config is global.
|
||||
|
||||
### New settings keys
|
||||
| Key | Purpose | Notes |
|
||||
|-----|---------|-------|
|
||||
| `growing_smb_mount` | CIFS source for the system mount, e.g. `//10.0.0.25/mam-growing` | Distinct from `growing_smb_url` |
|
||||
| `growing_smb_username` | SMB user | Returned in GET (not secret) |
|
||||
| `growing_smb_password` | SMB password | **Write-only** — never returned |
|
||||
| `growing_smb_vers` *(optional)* | CIFS protocol version, default `3.0` | Avoids mount negotiation failures |
|
||||
|
||||
`growing_smb_url` (the `smb://…` string) is retained unchanged as the **editor-facing** display value (Premiere connect string).
|
||||
|
||||
### Settings API (`settings.js`)
|
||||
- Extend `GROWING_KEYS` with the new keys (except the password is handled specially).
|
||||
- `GET /settings/growing`: return `growing_smb_mount`, `growing_smb_username`, `growing_smb_vers`, and `growing_smb_password_exists: boolean` — **never** the password value. (Mirror the existing `s3_secret_key_exists` pattern.)
|
||||
- `PUT /settings/growing`: upsert each provided key. For `growing_smb_password`, only write it when a non-empty value is provided (an empty/omitted field leaves the stored password unchanged). Provide a way to clear it (explicit empty sentinel or a "clear" affordance) — see Resolved decisions below.
|
||||
|
||||
### Settings UI (`GrowingSettingsCard`)
|
||||
Add three fields to the card:
|
||||
- **SMB mount (CIFS):** text input bound to `growing_smb_mount`, placeholder `//10.0.0.25/mam-growing`.
|
||||
- **SMB username:** text input bound to `growing_smb_username`.
|
||||
- **SMB password:** masked password input. Shows a "saved" indicator when `growing_smb_password_exists` is true; typing a new value replaces it; leaving it blank keeps the existing one. `autoComplete="new-password"`.
|
||||
|
||||
### Capture image (`services/capture/Dockerfile`)
|
||||
Add `cifs-utils` to the installed packages so `mount -t cifs` is available inside the capture container.
|
||||
|
||||
### Capture-manager (`capture-manager.js`)
|
||||
On capture start, when growing mode is active **and** `GROWING_SMB_MOUNT` is set:
|
||||
1. Write a root-only credentials file (e.g. `/run/smb-creds`, mode `0600`) containing:
|
||||
```
|
||||
username=<GROWING_SMB_USERNAME>
|
||||
password=<GROWING_SMB_PASSWORD>
|
||||
```
|
||||
(Credentials go in the file, **not** the mount command line, to avoid `ps`/log exposure.)
|
||||
2. `mkdir -p $GROWING_PATH` then `mount -t cifs $GROWING_SMB_MOUNT $GROWING_PATH -o credentials=/run/smb-creds,uid=0,gid=0,file_mode=0664,dir_mode=0775,vers=$GROWING_SMB_VERS`.
|
||||
3. If the mount succeeds → proceed writing the master to `$GROWING_PATH/...` (existing behaviour).
|
||||
4. If the mount **fails** → log the error and fall back to S3 streaming (`growingPath = null`), so a recording is never lost.
|
||||
5. On capture stop/cleanup, unmount `$GROWING_PATH` (best-effort; ignore "not mounted").
|
||||
|
||||
Mount isolation: each recorder runs in its **own** capture container, so each container mounts CIFS at its own private `/growing` — no cross-recorder mount conflicts, no ref-counting needed.
|
||||
|
||||
### Recorder start (`recorders.js`)
|
||||
- Pass new env to the spawned capture container: `GROWING_SMB_MOUNT`, `GROWING_SMB_USERNAME`, `GROWING_SMB_PASSWORD`, `GROWING_SMB_VERS` (read from the `settings` table at start).
|
||||
- The dynamically-spawned capture container must get `/growing` as an **empty mountpoint** (not a host bind-mount) so the in-container CIFS mount lands cleanly. Confirm/adjust the container spec accordingly. The container is already privileged (required for `mount`).
|
||||
|
||||
### Security notes
|
||||
- The password is stored plaintext in the `settings` table, identical to the existing `s3_secret_key` handling — acceptable within this app's current secret model.
|
||||
- The password reaches the capture container as an env var (visible via `docker inspect`), same as S3 keys already are. The credentials **file** (not the command line) is used for the actual mount.
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Per-recorder growing mode (remove the global toggle)
|
||||
|
||||
### Remove global enable
|
||||
- Delete the global "Capture writes to the local SMB share first…" checkbox (`growing_enabled` key) from `GrowingSettingsCard`. The card no longer carries a global on/off — it is **infrastructure-only**: container mount path, SMB URL (editor), SMB mount + credentials, promote threshold.
|
||||
- The `growing_enabled` settings *key* is retired from the UI. (It may remain in the table harmlessly; recorder-start no longer reads it.)
|
||||
|
||||
### Per-recorder semantics
|
||||
- Reuse the existing `recorders.growing_enabled BOOLEAN` column. New semantics (no global to defer to): `TRUE` = this recorder uses growing-files mode; `NULL`/`FALSE` = off.
|
||||
- `recorders.js` recorder-start: read the **recorder's own** `growing_enabled` (defaulting `NULL`→off) and set `GROWING_ENABLED` for the capture container from that, instead of the global setting.
|
||||
- Add `growing_enabled` to `RECORDER_FIELDS` so create/update accept it.
|
||||
|
||||
### UI
|
||||
- **New-recorder modal** (`modal-new-recorder.jsx`): add a "Growing-files mode" toggle that sets `growing_enabled` on the created recorder (default off).
|
||||
- **Recorder edit** (wherever recorders are edited): same toggle.
|
||||
- Helper text on the toggle notes that growing-files requires the SMB share to be configured in Settings → Storage.
|
||||
|
||||
### Fallback
|
||||
If a recorder has `growing_enabled = true` but `growing_smb_mount` is not configured globally, capture logs a warning and falls back to S3 streaming (same fallback path as a failed mount). Recording is never blocked.
|
||||
|
||||
---
|
||||
|
||||
## Files changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `services/web-ui/public/screens-admin.jsx` | Storage warning banner; SMB mount/username/password fields in `GrowingSettingsCard`; remove global growing-enable checkbox |
|
||||
| `services/web-ui/public/modal-new-recorder.jsx` | Per-recorder "Growing-files mode" toggle |
|
||||
| `services/mam-api/src/routes/settings.js` | New growing SMB keys; write-only password (`growing_smb_password_exists`) |
|
||||
| `services/mam-api/src/routes/recorders.js` | Read per-recorder `growing_enabled`; pass SMB env to capture; `RECORDER_FIELDS` += `growing_enabled`; empty `/growing` mountpoint |
|
||||
| `services/capture/Dockerfile` | Add `cifs-utils` |
|
||||
| `services/capture/src/capture-manager.js` | CIFS mount-on-start (creds file), unmount-on-stop, fallback to S3 on failure |
|
||||
| CSS (storage warning / fields) | Minor styles if needed |
|
||||
|
||||
No DB migration required (the `recorders.growing_enabled` column already exists; new settings are key/value rows).
|
||||
|
||||
---
|
||||
|
||||
## Resolved decisions
|
||||
|
||||
- **Clearing the SMB password:** `PUT /settings/growing` treats a field value of the literal sentinel `""` with an explicit `growing_smb_password_clear: true` flag as "remove the stored password"; a blank field with no clear flag leaves it unchanged. (Keeps the common "don't retype the password on every save" UX while still allowing removal.)
|
||||
- **CIFS version:** default `growing_smb_vers = 3.0`; overridable via settings to support older NAS targets.
|
||||
- **Recorders already recording when the toggle changes:** the per-recorder `growing_enabled` is read at **start** only; changing it mid-recording has no effect on the active session (consistent with how all recorder encode settings already behave).
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- Encrypting secrets at rest (the app's existing model stores `s3_secret_key` in plaintext; SMB password follows the same model).
|
||||
- A global "growing-files master kill switch" (removed by design — control is now per-recorder).
|
||||
- Exposing `growing_retention_days` in the UI (seeded in DB, still unsurfaced; unrelated to this work).
|
||||
- Playout HLS preview fix (handled by a separate parallel effort).
|
||||
|
|
@ -67,10 +67,14 @@ RUN /usr/local/bin/ffmpeg -hide_banner -encoders 2>&1 | grep -E 'nvenc' \
|
|||
# ── Stage 2: Runtime image ───────────────────────────────────────────────────
|
||||
FROM node:20-bookworm
|
||||
|
||||
# Runtime deps for compiled ffmpeg libs
|
||||
# Runtime deps for compiled ffmpeg libs.
|
||||
# cifs-utils provides mount.cifs so growing-files capture can mount the SMB
|
||||
# landing-zone share inside the (privileged) container at start (Approach A).
|
||||
# util-linux supplies mount/umount/mountpoint.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libx264-164 libx265-199 libvpx7 libopus0 libmp3lame0 \
|
||||
libsrt1.5-openssl libzmq5 libstdc++6 libc++1 libc++abi1 \
|
||||
cifs-utils util-linux \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy compiled ffmpeg/ffprobe
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { spawn } from 'child_process';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { spawn, execFileSync } from 'child_process';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { createUploadStream } from './s3/client.js';
|
||||
|
|
@ -9,11 +9,76 @@ const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
|
|||
// Growing-files mode: writes the master to a local SMB-backed share that the
|
||||
// editor can mount, instead of streaming to S3 in real time. The promotion
|
||||
// worker uploads the finalized file to S3 after the recording stops.
|
||||
// Toggled per-process by `GROWING_ENABLED=true` on the capture container
|
||||
// Toggled per-recorder via `GROWING_ENABLED=true` on the capture container
|
||||
// (see routes/recorders.js where the env is composed).
|
||||
const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true';
|
||||
const GROWING_PATH = process.env.GROWING_PATH || '/growing';
|
||||
|
||||
// Approach A: when a CIFS source is supplied, this (privileged) container mounts
|
||||
// the SMB landing-zone share at GROWING_PATH itself, using credentials supplied
|
||||
// by the API from global Settings. Empty GROWING_SMB_MOUNT → no system mount
|
||||
// (the host-bound /growing volume is used instead, or S3 streaming if growing
|
||||
// is off).
|
||||
const GROWING_SMB_MOUNT = process.env.GROWING_SMB_MOUNT || '';
|
||||
const GROWING_SMB_USERNAME = process.env.GROWING_SMB_USERNAME || '';
|
||||
const GROWING_SMB_PASSWORD = process.env.GROWING_SMB_PASSWORD || '';
|
||||
const GROWING_SMB_VERS = process.env.GROWING_SMB_VERS || '3.0';
|
||||
const SMB_CREDS_FILE = '/run/smb-creds';
|
||||
|
||||
// True when GROWING_PATH is already a mountpoint (e.g. a prior session left it
|
||||
// mounted, or a host bind-mount is present).
|
||||
function isMounted(path) {
|
||||
try { execFileSync('mountpoint', ['-q', path]); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
// Mount the CIFS growing share at GROWING_PATH. Credentials go in a root-only
|
||||
// file (NOT the command line) so they never appear in `ps`/process listings.
|
||||
// Returns true on success (or if already mounted), false on failure — callers
|
||||
// fall back to S3 streaming so a recording is never lost.
|
||||
function mountGrowingShare() {
|
||||
if (!GROWING_SMB_MOUNT) return false;
|
||||
try {
|
||||
if (isMounted(GROWING_PATH)) {
|
||||
console.log('[capture] growing share already mounted at', GROWING_PATH);
|
||||
return true;
|
||||
}
|
||||
try { mkdirSync(GROWING_PATH, { recursive: true }); } catch (_) {}
|
||||
writeFileSync(
|
||||
SMB_CREDS_FILE,
|
||||
`username=${GROWING_SMB_USERNAME}\npassword=${GROWING_SMB_PASSWORD}\n`,
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
const opts = [
|
||||
`credentials=${SMB_CREDS_FILE}`,
|
||||
'uid=0', 'gid=0', 'file_mode=0664', 'dir_mode=0775',
|
||||
`vers=${GROWING_SMB_VERS}`,
|
||||
].join(',');
|
||||
execFileSync('mount', ['-t', 'cifs', GROWING_SMB_MOUNT, GROWING_PATH, '-o', opts],
|
||||
{ stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
console.log('[capture] mounted CIFS growing share', GROWING_SMB_MOUNT, '->', GROWING_PATH);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
|
||||
console.error('[capture] CIFS mount failed (falling back to S3 streaming):', stderr);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort unmount on session stop. Ignores "not mounted".
|
||||
function unmountGrowingShare() {
|
||||
if (!GROWING_SMB_MOUNT) return;
|
||||
try {
|
||||
if (isMounted(GROWING_PATH)) {
|
||||
execFileSync('umount', [GROWING_PATH], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
console.log('[capture] unmounted growing share at', GROWING_PATH);
|
||||
}
|
||||
} catch (err) {
|
||||
const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
|
||||
console.warn('[capture] growing share unmount failed (ignored):', stderr);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Codec catalogue ──────────────────────────────────────────────────────
|
||||
// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate
|
||||
// / pix_fmt are layered on top from the per-recorder configuration.
|
||||
|
|
@ -283,7 +348,15 @@ class CaptureManager {
|
|||
|
||||
// Growing-files: write master to the local SMB share instead of streaming
|
||||
// to S3. Path is relative to the container's GROWING_PATH mount.
|
||||
const growingPath = GROWING_ENABLED
|
||||
//
|
||||
// Approach A: if a CIFS source is configured, mount it now. A mount failure
|
||||
// is non-fatal — we fall back to S3 streaming so the recording is never
|
||||
// lost.
|
||||
let growingActive = GROWING_ENABLED;
|
||||
if (growingActive && GROWING_SMB_MOUNT) {
|
||||
if (!mountGrowingShare()) growingActive = false; // fall back to S3
|
||||
}
|
||||
const growingPath = growingActive
|
||||
? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}`
|
||||
: null;
|
||||
if (growingPath) {
|
||||
|
|
@ -455,6 +528,11 @@ class CaptureManager {
|
|||
if (processes.proxy) processes.proxy.kill('SIGINT');
|
||||
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
|
||||
|
||||
// Release the CIFS mount (best-effort) once the ffmpeg writers are done with
|
||||
// it. The promotion worker reads the staged file from the host/S3 side, not
|
||||
// through this container's mount, so unmounting here is safe.
|
||||
unmountGrowingShare();
|
||||
|
||||
try {
|
||||
const uploadPromises = [currentSession.uploads.hires];
|
||||
if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -104,7 +105,10 @@ 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']);
|
||||
const UNAUTH_PATHS = new Set([
|
||||
'/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required',
|
||||
'/auth/google', '/auth/google/callback', '/auth/google/enabled',
|
||||
]);
|
||||
// node-agent now authenticates /cluster/heartbeat with a bound api_token
|
||||
// (migration 019 + bound_hostname on the token). requireAuth handles the
|
||||
// bearer lookup and sets req.tokenBoundHostname; the heartbeat handler in
|
||||
|
|
@ -117,8 +121,10 @@ app.use('/api/v1', (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
|
||||
// User and group administration is admin-only (RBAC v2). The auth gate above
|
||||
// already established req.user; requireAdmin rejects non-admins with 403.
|
||||
app.use('/api/v1/auth/users', requireAdmin, usersRouter);
|
||||
app.use('/api/v1/users', requireAdmin, usersRouter); // alias for the SPA Users page
|
||||
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
|
||||
app.use('/api/v1/assets', assetsRouter);
|
||||
app.use('/api/v1/projects', projectsRouter);
|
||||
|
|
@ -127,9 +133,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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
@ -66,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'`;
|
||||
|
|
@ -132,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)' });
|
||||
|
|
@ -220,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(
|
||||
|
|
@ -234,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;
|
||||
|
|
@ -277,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); }
|
||||
|
|
@ -299,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
|
||||
|
|
@ -320,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,
|
||||
|
|
@ -346,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
|
||||
|
|
@ -384,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;
|
||||
|
|
@ -436,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]);
|
||||
|
|
@ -452,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
|
||||
|
|
@ -477,7 +547,7 @@ 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';
|
||||
|
|
@ -528,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]);
|
||||
|
|
@ -547,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;
|
||||
|
|
@ -595,10 +665,19 @@ 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 });
|
||||
// Prefer the HLS rendition for recorded assets — whole-file segment GETs
|
||||
// avoid the RustFS ranged-GET stitching the MP4 /video path has to do.
|
||||
// `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}/hls/playlist.m3u8`, type: 'hls', source: 'proxy' });
|
||||
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 ||
|
||||
|
|
@ -655,11 +734,14 @@ router.get('/:id/live-path', async (req, res, next) => {
|
|||
if (a.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||
const asset = a.rows[0];
|
||||
if (asset.status !== 'live') return res.status(409).json({ error: 'Asset is not currently growing', status: asset.status });
|
||||
const s = await pool.query(`SELECT key, value FROM settings WHERE key IN ('growing_enabled','growing_smb_url')`);
|
||||
// Growing-files mode is now per-recorder (recorders.growing_enabled), so we
|
||||
// no longer gate on the removed global `growing_enabled` setting. A
|
||||
// status='live' asset already proves a growing recorder is producing this
|
||||
// file; we only need the editor-facing SMB URL to build the UNC path.
|
||||
const s = await pool.query(`SELECT key, value FROM settings WHERE key = 'growing_smb_url'`);
|
||||
const cfg = {};
|
||||
for (const { key, value } of s.rows) cfg[key] = value;
|
||||
if (cfg.growing_enabled !== 'true') return res.status(409).json({ error: 'Growing-files mode is disabled' });
|
||||
if (!cfg.growing_smb_url) return res.status(409).json({ error: 'No SMB URL configured — set growing_smb_url in Settings' });
|
||||
if (!cfg.growing_smb_url) return res.status(409).json({ error: 'No SMB URL configured — set the editor SMB URL in Settings → Storage' });
|
||||
const rec = await pool.query(
|
||||
`SELECT recording_container FROM recorders WHERE current_session_id = $1 ORDER BY updated_at DESC LIMIT 1`,
|
||||
[asset.id]
|
||||
|
|
@ -899,6 +981,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,21 +101,123 @@ router.post('/login', async (req, res, next) => {
|
|||
return res.status(401).json({ error: 'invalid credentials' });
|
||||
}
|
||||
|
||||
req.session.user_id = user.id;
|
||||
req.session.first_seen_at = Date.now();
|
||||
req.session.last_seen_at = Date.now();
|
||||
// The critical line — wait for the row to land in `sessions` before responding.
|
||||
// Without this, the SPA's next request races the store write, hits 401, and
|
||||
// the prior bounce-to-login logic produced an infinite loop.
|
||||
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||
// 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') }),
|
||||
});
|
||||
}
|
||||
|
||||
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); }
|
||||
await establishSession(req, user, ip);
|
||||
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Write the session and wait for it to persist before responding. Extracted so
|
||||
// both the password-only and the MFA-completion paths share one implementation.
|
||||
// Clears the per-IP failure counter only here — after every required factor has
|
||||
// actually been proven (password [+ TOTP if enabled, or OAuth + TOTP]).
|
||||
async function establishSession(req, user, ip) {
|
||||
req.session.user_id = user.id;
|
||||
req.session.first_seen_at = Date.now();
|
||||
req.session.last_seen_at = Date.now();
|
||||
// The critical line — wait for the row to land in `sessions` before responding.
|
||||
// Without this, the SPA's next request races the store write, hits 401, and
|
||||
// the prior bounce-to-login logic produced an infinite loop.
|
||||
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||
if (ip) ipBackoff.recordSuccess(ip);
|
||||
pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id])
|
||||
.catch(err => console.error('[auth] last_login_at update failed:', err.message));
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/login/totp { ticket?, code } — second login step. `code` is
|
||||
// either a 6-digit TOTP or a one-time recovery code. The ticket comes from the
|
||||
// request body (password-login path) or req.session.mfa_ticket (Google path).
|
||||
router.post('/login/totp', async (req, res, next) => {
|
||||
try {
|
||||
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
// Rate-limit the second factor with the same per-IP backoff as /login so
|
||||
// the 6-digit code space can't be hammered.
|
||||
const delay = ipBackoff.delayMs(ip);
|
||||
if (delay > 0) await new Promise(r => setTimeout(r, delay));
|
||||
|
||||
const { ticket: bodyTicket, code } = req.body || {};
|
||||
const ticket = bodyTicket || req.session?.mfa_ticket;
|
||||
if (req.session?.mfa_ticket) delete req.session.mfa_ticket;
|
||||
// Bound to the issuing request's IP + UA — replays from a different origin
|
||||
// redeem to null. See mfa-tickets.js for the binding model.
|
||||
const userId = redeemTicket(ticket, { ip, userAgent: req.get('user-agent') });
|
||||
if (!userId) {
|
||||
ipBackoff.recordFailure(ip);
|
||||
return res.status(401).json({ error: 'invalid or expired ticket' });
|
||||
}
|
||||
if (!code) return res.status(400).json({ error: 'code required' });
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, display_name, totp_secret, totp_enabled, totp_last_counter
|
||||
FROM users WHERE id = $1`, [userId]);
|
||||
const user = rows[0];
|
||||
if (!user || !user.totp_enabled || !user.totp_secret) {
|
||||
return res.status(401).json({ error: 'invalid credentials' });
|
||||
}
|
||||
|
||||
// verifyToken returns the matched counter on success. Reject codes at
|
||||
// counters ≤ totp_last_counter to prevent replay within the same step.
|
||||
// The CAS-style UPDATE makes this race-free under concurrent submissions.
|
||||
const matchedCounter = verifyToken(user.totp_secret, code);
|
||||
let ok = false;
|
||||
if (matchedCounter !== null) {
|
||||
const lastCounter = BigInt(user.totp_last_counter || 0);
|
||||
if (BigInt(matchedCounter) > lastCounter) {
|
||||
const upd = await pool.query(
|
||||
`UPDATE users SET totp_last_counter = $1
|
||||
WHERE id = $2 AND totp_last_counter < $1`,
|
||||
[String(matchedCounter), user.id]
|
||||
);
|
||||
ok = upd.rowCount === 1;
|
||||
}
|
||||
// matchedCounter ≤ last → silent replay; falls through to recovery-code
|
||||
// path which also fails → 401. Same UX as a wrong code, no info leak.
|
||||
}
|
||||
if (!ok) ok = await consumeRecoveryCode(user.id, code);
|
||||
if (!ok) {
|
||||
ipBackoff.recordFailure(ip);
|
||||
// The ticket was single-use; the client must restart from /login.
|
||||
return res.status(401).json({ error: 'invalid code' });
|
||||
}
|
||||
|
||||
// recordSuccess is called by establishSession once the session lands —
|
||||
// that's the first moment we know every required factor has passed.
|
||||
await establishSession(req, user, ip);
|
||||
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Check a recovery code against the user's unused codes; mark it spent on match.
|
||||
// The marking is atomic (UPDATE ... WHERE used_at IS NULL with a rowCount check)
|
||||
// so two concurrent redemptions of the same code can't both succeed.
|
||||
async function consumeRecoveryCode(userId, code) {
|
||||
const cleaned = String(code).trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{5}-[0-9a-f]{5}$/.test(cleaned)) return false;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, code_hash FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL`, [userId]);
|
||||
for (const row of rows) {
|
||||
if (await comparePassword(cleaned, row.code_hash)) {
|
||||
const upd = await pool.query(
|
||||
`UPDATE user_recovery_codes SET used_at = NOW() WHERE id = $1 AND used_at IS NULL`, [row.id]);
|
||||
// Lost the race if another request already consumed it.
|
||||
return upd.rowCount === 1;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/logout — destroys the session and clears the cookie.
|
||||
router.post('/logout', (req, res) => {
|
||||
if (!req.session) return res.status(204).end();
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,23 @@
|
|||
import express from 'express';
|
||||
import http from 'http';
|
||||
import pool from '../db/pool.js';
|
||||
import { requireAdmin } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /onboard-info – admin-only. Supplies the Add Node wizard with the bits it
|
||||
// needs to build a `curl … | bash` onboarding command: the primary API URL the
|
||||
// remote node-agent should heartbeat to, the raw URL of onboard-node.sh, and
|
||||
// the deploy branch. apiUrl is a best guess the UI lets the operator edit.
|
||||
router.get('/onboard-info', requireAdmin, (req, res) => {
|
||||
const branch = process.env.DEPLOY_BRANCH || 'main';
|
||||
const apiUrl = process.env.PUBLIC_API_URL
|
||||
|| `${req.protocol}://${req.hostname}:${process.env.API_PORT || 47432}`;
|
||||
const scriptUrl =
|
||||
`https://forge.wilddragon.net/zgaetano/wild-dragon/raw/branch/${branch}/deploy/onboard-node.sh`;
|
||||
res.json({ apiUrl, scriptUrl, branch });
|
||||
});
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
@ -21,20 +22,22 @@ const parseRedisUrl = (url) => {
|
|||
|
||||
const redisConn = parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379');
|
||||
|
||||
const proxyQueue = new Queue('proxy', { connection: redisConn });
|
||||
const thumbnailQueue = new Queue('thumbnail', { connection: redisConn });
|
||||
const filmstripQueue = new Queue('filmstrip', { connection: redisConn });
|
||||
const conformQueue = new Queue('conform', { connection: redisConn });
|
||||
const importQueue = new Queue('import', { connection: redisConn });
|
||||
const trimQueue = new Queue('trim', { connection: redisConn });
|
||||
const proxyQueue = new Queue('proxy', { connection: redisConn });
|
||||
const thumbnailQueue = new Queue('thumbnail', { connection: redisConn });
|
||||
const filmstripQueue = new Queue('filmstrip', { connection: redisConn });
|
||||
const conformQueue = new Queue('conform', { connection: redisConn });
|
||||
const importQueue = new Queue('import', { connection: redisConn });
|
||||
const trimQueue = new Queue('trim', { connection: redisConn });
|
||||
const playoutStageQueue = new Queue('playout-stage', { connection: redisConn });
|
||||
|
||||
const QUEUES = [
|
||||
{ queue: proxyQueue, type: 'proxy' },
|
||||
{ queue: thumbnailQueue, type: 'thumbnail' },
|
||||
{ queue: filmstripQueue, type: 'filmstrip' },
|
||||
{ queue: conformQueue, type: 'conform' },
|
||||
{ queue: importQueue, type: 'import' },
|
||||
{ queue: trimQueue, type: 'trim' },
|
||||
{ queue: proxyQueue, type: 'proxy' },
|
||||
{ queue: thumbnailQueue, type: 'thumbnail' },
|
||||
{ queue: filmstripQueue, type: 'filmstrip' },
|
||||
{ queue: conformQueue, type: 'conform' },
|
||||
{ queue: importQueue, type: 'import' },
|
||||
{ queue: trimQueue, type: 'trim' },
|
||||
{ queue: playoutStageQueue, type: 'playout-stage' },
|
||||
];
|
||||
|
||||
// BullMQ state → API status mapping
|
||||
|
|
@ -324,6 +327,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,
|
||||
|
|
|
|||
735
services/mam-api/src/routes/playout.js
Normal file
735
services/mam-api/src/routes/playout.js
Normal file
|
|
@ -0,0 +1,735 @@
|
|||
// 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 { readFile } from 'fs/promises';
|
||||
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();
|
||||
|
||||
// ── BullMQ: media staging queue (S3 -> /media volume) ────────────────────────
|
||||
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'),
|
||||
});
|
||||
|
||||
// ── Sidecar orchestration (mirrors recorders.js) ─────────────────────────────
|
||||
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 };
|
||||
}
|
||||
|
||||
// The sidecar shim listens on this port inside the container. The mam-api talks
|
||||
// to it by container alias on the shared docker network (local) or via the
|
||||
// node-agent's returned host:port (remote).
|
||||
const SIDECAR_HTTP_PORT = 3002;
|
||||
|
||||
function channelAlias(id) { return `playout-${id}`; }
|
||||
|
||||
// Resolve the base URL the API uses to reach a running channel's sidecar shim.
|
||||
// Local: the docker-network alias. Remote: the node-agent reported the host the
|
||||
// container is published on (stored in container_meta.sidecar_url).
|
||||
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(() => ({}));
|
||||
}
|
||||
|
||||
// ── Serialization ────────────────────────────────────────────────────────────
|
||||
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']);
|
||||
|
||||
// ── Param resolver: scope every /:id route to the channel's project ──────────
|
||||
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); }
|
||||
}
|
||||
|
||||
// ── Channels ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /playout/channels — list (filtered to accessible projects)
|
||||
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); }
|
||||
});
|
||||
|
||||
// POST /playout/channels — create
|
||||
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(', ')}` });
|
||||
}
|
||||
// Creating a project-scoped channel requires edit on that project; a
|
||||
// null-project (admin-only) channel requires admin.
|
||||
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); }
|
||||
});
|
||||
|
||||
// PATCH /playout/channels/:id — update config (only while stopped)
|
||||
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); }
|
||||
});
|
||||
|
||||
// DELETE /playout/channels/:id
|
||||
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); }
|
||||
});
|
||||
|
||||
// ── Port-contention guard (DeckLink) ─────────────────────────────────────────
|
||||
// A DeckLink device on a node is exclusive: an active recorder OR another active
|
||||
// channel on the same node+index blocks a new SDI channel. NDI/SRT/RTMP have no
|
||||
// hardware contention.
|
||||
async function assertDeckLinkFree(channel) {
|
||||
if (channel.output_type !== 'decklink') return;
|
||||
const idx = (channel.output_config && channel.output_config.device_index) || 1;
|
||||
// Another running channel on the same node + device index?
|
||||
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 });
|
||||
}
|
||||
// An active recorder using the same device index on the same node?
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn the CasparCG sidecar for a channel and flip it to 'running'. Shared by
|
||||
// the /start route and the scheduler failover path (restartChannel) so neither
|
||||
// duplicates the docker/node-agent orchestration. Caller is responsible for the
|
||||
// pre-flight guards (status check, DeckLink contention) appropriate to its path.
|
||||
//
|
||||
// On any spawn failure the channel is left status='error' with a message and an
|
||||
// Error carrying { httpStatus } is thrown. On success returns the updated row.
|
||||
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}`,
|
||||
// Drives the HLS preview path (/media/live/<channel_id>/index.m3u8) and
|
||||
// the per-channel resource naming inside the sidecar.
|
||||
`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;
|
||||
// node-agent returns the reachable host:port the shim is published on.
|
||||
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: {
|
||||
Privileged: true,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// Set last_heartbeat_at = NOW() so the scheduler health tick treats this
|
||||
// channel as freshly alive. Without this, last_heartbeat_at starts as NULL
|
||||
// (epoch=0), and the very first tick sees ageMs >> TIMEOUT_MS and triggers
|
||||
// failover immediately — before the sidecar has had a chance to respond.
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE playout_channels
|
||||
SET status = 'running', container_id = $1, container_meta = $2,
|
||||
last_heartbeat_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $3 RETURNING *`,
|
||||
[containerId, JSON.stringify(containerMeta), channel.id]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// POST /playout/channels/:id/start — spawn the CasparCG sidecar + bring up output
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /playout/channels/:id/stop — tear down the sidecar
|
||||
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); }
|
||||
});
|
||||
|
||||
// GET /playout/channels/:id/status — live engine status (proxied to sidecar)
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /playout/channels/:id/hls/index.m3u8 — the live preview playlist, served
|
||||
// through the API (not the static /media/live path) so it bypasses the public
|
||||
// reverse proxy's static cache. That proxy caches the .m3u8 by path with a
|
||||
// multi-second TTL and ignores the origin's no-store, so hls.js's ~1s reloads
|
||||
// always got a STALE playlist ("MISSED" forever → monitor stayed black). The
|
||||
// /api/ path is not proxy-cached (the status poll updates fine), so this always
|
||||
// returns the fresh live edge. Segment (.ts) lines are rewritten to absolute
|
||||
// /media/live/<id>/ URLs so they still load from the static path (immutable,
|
||||
// caching them is fine). mam-api shares the same /media volume the sidecars
|
||||
// write to.
|
||||
const HLS_MEDIA_DIR = process.env.PLAYOUT_MEDIA_DIR || '/media';
|
||||
router.get('/channels/:id/hls/index.m3u8', async (req, res, next) => {
|
||||
try {
|
||||
const cid = req.channel.id;
|
||||
let body;
|
||||
try {
|
||||
body = await readFile(`${HLS_MEDIA_DIR}/live/${cid}/index.m3u8`, 'utf8');
|
||||
} catch (e) {
|
||||
return res.status(404).json({ error: 'No live preview for this channel yet' });
|
||||
}
|
||||
// Rewrite bare segment names to absolute static URLs.
|
||||
const rewritten = body
|
||||
.split('\n')
|
||||
.map((line) => (/^[^#].*\.ts\s*$/.test(line) ? `/media/live/${cid}/${line.trim()}` : line))
|
||||
.join('\n');
|
||||
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.send(rewritten);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Transport ────────────────────────────────────────────────────────────────
|
||||
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 }); }
|
||||
}
|
||||
|
||||
// POST /playout/channels/:id/play — resolve the channel's playlist, stage-check,
|
||||
// and hand the engine the ordered list of ready clips.
|
||||
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,
|
||||
})),
|
||||
};
|
||||
// callSidecar throws on network/timeout errors. Return 502 (not 409) so
|
||||
// the UI and operators know it's a gateway problem, not a state conflict.
|
||||
let out;
|
||||
try {
|
||||
out = await callSidecar(req.channel, '/playlist/load', 'POST', payload);
|
||||
} catch (err) {
|
||||
return res.status(502).json({ error: 'Sidecar unreachable: ' + err.message });
|
||||
}
|
||||
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'));
|
||||
|
||||
// GET /playout/channels/:id/asrun — as-run log
|
||||
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); }
|
||||
});
|
||||
|
||||
// ── Playlists ────────────────────────────────────────────────────────────────
|
||||
async function loadChannelForBody(req, res, next) {
|
||||
// For playlist/item routes the channel is referenced indirectly; resolve it
|
||||
// and assert edit. Used on create/mutate routes that carry channel_id.
|
||||
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); }
|
||||
}
|
||||
|
||||
// GET /playout/playlists?channel_id=...
|
||||
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); }
|
||||
});
|
||||
|
||||
// POST /playout/playlists
|
||||
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); }
|
||||
});
|
||||
|
||||
// GET /playout/playlists/:plid/items
|
||||
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); }
|
||||
});
|
||||
|
||||
// Helper: load a playlist + assert edit on its channel's project.
|
||||
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];
|
||||
}
|
||||
|
||||
// POST /playout/playlists/:plid/items — add an asset to a playlist
|
||||
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' });
|
||||
|
||||
// Append at the end of the playlist.
|
||||
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]);
|
||||
|
||||
// Kick staging immediately so the clip is air-ready by the time the operator
|
||||
// hits play.
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /playout/playlists/:plid/reorder — body { order: [itemId, itemId, ...] }
|
||||
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(); }
|
||||
});
|
||||
|
||||
// DELETE /playout/items/:itemId
|
||||
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); }
|
||||
});
|
||||
|
||||
// POST /playout/items/:itemId/stage — (re)kick staging for one item
|
||||
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); }
|
||||
});
|
||||
|
||||
// ── Failover (called by scheduler tick) ──────────────────────────────────────
|
||||
// Tear down a (presumed dead) sidecar and re-spawn it on another cluster node
|
||||
// matching the original capability. DeckLink channels are excluded — the
|
||||
// device-index pinning makes blind re-placement risky, so they alert only.
|
||||
//
|
||||
// Returns { restarted: true, new_node_id } on success, or { restarted: false,
|
||||
// reason } when no eligible node exists or the channel is decklink.
|
||||
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' };
|
||||
}
|
||||
|
||||
// Best-effort teardown of the old container — it may already be dead.
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Pick a different healthy node. For NDI/SRT/RTMP every online node is
|
||||
// eligible (no hardware contention). Prefer the original if it's still
|
||||
// online — the failure may have been transient.
|
||||
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;
|
||||
|
||||
// Move the channel to the new node + bump the restart counters; the operator
|
||||
// UI surfaces these to flag restarts. container_meta is cleared so the new
|
||||
// spawn re-derives the sidecar URL.
|
||||
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]
|
||||
);
|
||||
|
||||
// Spawn the sidecar directly via the shared helper. We do NOT route through
|
||||
// the HTTP /start endpoint: its guard rejects status 'starting'/'running' and
|
||||
// would deadlock the failover. spawnChannelSidecar flips the channel to
|
||||
// running (or leaves it 'error' and throws on spawn failure).
|
||||
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 result = await pool.query('SELECT * FROM projects ORDER BY created_at DESC');
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,53 @@
|
|||
import express from 'express';
|
||||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { stat } from 'fs/promises';
|
||||
import net from 'net';
|
||||
import dgram from 'dgram';
|
||||
import pool from '../db/pool.js';
|
||||
import { getS3Bucket } from '../s3/client.js';
|
||||
import { s3Client, getS3Bucket } from '../s3/client.js';
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
const router = express.Router();
|
||||
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||
|
||||
// BullMQ proxy queue — used by the growing-file stop handler to queue proxy
|
||||
// jobs when the capture container's finalize call races with the S3 upload.
|
||||
const parseRedisUrl = (url) => {
|
||||
const parsed = new URL(url);
|
||||
return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 };
|
||||
};
|
||||
const proxyQueue = new Queue('proxy', {
|
||||
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
|
||||
});
|
||||
|
||||
// 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.
|
||||
|
|
@ -130,6 +168,7 @@ const RECORDER_FIELDS = [
|
|||
'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels',
|
||||
'proxy_container',
|
||||
'project_id', 'node_id', 'device_index',
|
||||
'growing_enabled',
|
||||
];
|
||||
|
||||
function pickRecorderFields(body) {
|
||||
|
|
@ -149,6 +188,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 +212,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 +245,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 +312,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 +351,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;
|
||||
|
||||
|
|
@ -322,14 +378,25 @@ router.post('/:id/start', async (req, res, next) => {
|
|||
const externalMamApiUrl = `http://${process.env.NODE_IP || '172.18.91.200'}:${process.env.PORT_MAM_API || 47432}`;
|
||||
const dockerNetwork = process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon';
|
||||
|
||||
// Growing-files mode is a global setting (settings table). When on, the
|
||||
// capture container writes the master to its /growing/ mount instead of
|
||||
// streaming it to S3 — Premiere can mount the SMB share and edit it live.
|
||||
const growingRow = await pool.query(
|
||||
`SELECT value FROM settings WHERE key = 'growing_enabled'`
|
||||
);
|
||||
const growingEnabled =
|
||||
growingRow.rows[0]?.value === 'true' || growingRow.rows[0]?.value === true;
|
||||
// Growing-files mode is a PER-RECORDER setting (recorders.growing_enabled).
|
||||
// When on, the capture container writes the master to its /growing/ mount
|
||||
// instead of streaming it to S3 — editors can mount the SMB share and cut it
|
||||
// live. The SMB share itself (mount source + credentials) is shared
|
||||
// infrastructure configured globally in Settings → Storage.
|
||||
const growingEnabled = recorder.growing_enabled === true;
|
||||
|
||||
// Shared growing-files SMB infrastructure (global settings). Used to mount
|
||||
// the CIFS share inside the capture container (services/capture mounts it
|
||||
// with these credentials when GROWING_SMB_MOUNT is set).
|
||||
const growingInfra = {};
|
||||
{
|
||||
const r = await pool.query(
|
||||
`SELECT key, value FROM settings WHERE key = ANY($1)`,
|
||||
[['growing_smb_mount', 'growing_smb_username', 'growing_smb_password', 'growing_smb_vers']]
|
||||
);
|
||||
for (const { key, value } of r.rows) growingInfra[key] = value;
|
||||
}
|
||||
const smbMount = growingEnabled ? (growingInfra.growing_smb_mount || '') : '';
|
||||
|
||||
// Operator-supplied clip name wins over the auto-timestamped fallback.
|
||||
// The Recorders UI passes this on the start request when the user types
|
||||
|
|
@ -345,6 +412,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();
|
||||
|
|
@ -406,6 +481,13 @@ router.post('/:id/start', async (req, res, next) => {
|
|||
`MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`,
|
||||
`GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`,
|
||||
`GROWING_PATH=/growing`,
|
||||
// SMB mount details for the in-container CIFS mount (Approach A). Empty
|
||||
// GROWING_SMB_MOUNT → capture falls back to the host-bound /growing volume
|
||||
// (or to S3 streaming if growing isn't enabled).
|
||||
`GROWING_SMB_MOUNT=${smbMount}`,
|
||||
`GROWING_SMB_USERNAME=${growingInfra.growing_smb_username || ''}`,
|
||||
`GROWING_SMB_PASSWORD=${growingInfra.growing_smb_password || ''}`,
|
||||
`GROWING_SMB_VERS=${growingInfra.growing_smb_vers || '3.0'}`,
|
||||
];
|
||||
|
||||
// Deltacast: pass port count so the capture container can enumerate
|
||||
|
|
@ -481,7 +563,15 @@ router.post('/:id/start', async (req, res, next) => {
|
|||
for (const d of dcEntries) hostBinds.push(`/dev/${d}:/dev/${d}`);
|
||||
} catch (_) { /* no /dev/deltacast* nodes on this host */ }
|
||||
}
|
||||
if (growingEnabled) hostBinds.push('/mnt/NVME/MAM/wild-dragon-growing:/growing');
|
||||
// /growing handling:
|
||||
// - SMB mount configured → DON'T host-bind; the capture container mounts
|
||||
// the CIFS share at /growing itself (Approach A). A bind-mount here
|
||||
// would shadow the in-container mount.
|
||||
// - growing on but no SMB mount → legacy host bind-mount fallback.
|
||||
// - growing off → no /growing mount at all.
|
||||
if (growingEnabled && !smbMount) {
|
||||
hostBinds.push('/mnt/NVME/MAM/wild-dragon-growing:/growing');
|
||||
}
|
||||
|
||||
const localEnv = [...env];
|
||||
if (useGpu) {
|
||||
|
|
@ -551,7 +641,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;
|
||||
|
||||
|
|
@ -616,6 +706,28 @@ router.post('/:id/stop', async (req, res, next) => {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Growing-files S3 promotion ────────────────────────────────────────────
|
||||
// When growing_enabled=true the capture container writes the master file to
|
||||
// /growing/{projectId}/{clipName}.{ext} (a host bind-mount that the mam-api
|
||||
// container also has at /growing). The capture container's graceful-shutdown
|
||||
// handler (triggered by the Docker stop above) calls POST /assets/:id/finalize
|
||||
// with the expected S3 key, which queues the proxy job — but the file was
|
||||
// never uploaded to S3, so the proxy worker fails with "unable to open file".
|
||||
//
|
||||
// Fix: after the container has exited (ffmpeg is done flushing), upload the
|
||||
// growing file to the canonical S3 key from here. This is synchronous and
|
||||
// completes before the HTTP response reaches the client, so the already-queued
|
||||
// proxy job will find a valid S3 object when the worker dequeues it.
|
||||
//
|
||||
// Only applies to LOCAL recorders — remote recorders write to a different
|
||||
// node's /growing mount which this process cannot access.
|
||||
if (!isRemote && recorder.growing_enabled === true && recorder.current_session_id) {
|
||||
await promoteGrowingFileToS3(recorder).catch(err => {
|
||||
// Non-fatal — log and continue so the stop always succeeds.
|
||||
console.error('[recorders/stop] growing-file promotion failed (non-fatal):', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
const updateResult = await pool.query(
|
||||
`UPDATE recorders
|
||||
SET container_id = NULL, status = $1, updated_at = NOW()
|
||||
|
|
@ -630,6 +742,109 @@ router.post('/:id/stop', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Upload a completed growing-file master from /growing to S3 so the proxy
|
||||
* worker can find it at the expected original_s3_key.
|
||||
*
|
||||
* The capture container writes to:
|
||||
* /growing/{projectId}/{clipName}.{ext}
|
||||
*
|
||||
* The canonical S3 key (set on the asset row at recording start) is:
|
||||
* projects/{projectId}/masters/{clipName}.{ext}
|
||||
*
|
||||
* We look up the live/processing asset to derive both paths, do a multipart
|
||||
* upload, update the asset's original_s3_key and file_size to match what we
|
||||
* actually uploaded, then ensure a proxy job exists for it.
|
||||
*/
|
||||
async function promoteGrowingFileToS3(recorder) {
|
||||
const clipName = recorder.current_session_id;
|
||||
const container = recorder.recording_container || 'mov';
|
||||
|
||||
// Find the asset that was pre-created at recording start. It could be in
|
||||
// 'live' (finalize hasn't fired yet) or 'processing' (finalize already ran
|
||||
// from the container's SIGTERM handler). We need both its id and its
|
||||
// project_id to reconstruct the growing path.
|
||||
const assetRes = await pool.query(
|
||||
`SELECT id, project_id, status, original_s3_key
|
||||
FROM assets
|
||||
WHERE display_name = $1
|
||||
AND status IN ('live', 'processing', 'error')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1`,
|
||||
[clipName]
|
||||
);
|
||||
|
||||
if (assetRes.rows.length === 0) {
|
||||
console.warn(`[recorders/stop] no asset found for clip "${clipName}" — skipping growing-file promotion`);
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = assetRes.rows[0];
|
||||
const projectId = asset.project_id;
|
||||
const growingDir = process.env.GROWING_DIR || '/growing';
|
||||
const localPath = `${growingDir}/${projectId}/${clipName}.${container}`;
|
||||
const s3Key = `projects/${projectId}/masters/${clipName}.${container}`;
|
||||
|
||||
if (!existsSync(localPath)) {
|
||||
console.warn(`[recorders/stop] growing file not found at ${localPath} — nothing to promote (empty recording?)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileStat = await stat(localPath);
|
||||
if (fileStat.size === 0) {
|
||||
console.warn(`[recorders/stop] growing file at ${localPath} is empty — skipping promotion`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[recorders/stop] promoting growing file ${localPath} (${fileStat.size} bytes) → s3://${getS3Bucket()}/${s3Key}`);
|
||||
|
||||
const upload = new Upload({
|
||||
client: s3Client,
|
||||
params: {
|
||||
Bucket: getS3Bucket(),
|
||||
Key: s3Key,
|
||||
Body: createReadStream(localPath),
|
||||
},
|
||||
queueSize: 4,
|
||||
partSize: 8 * 1024 * 1024,
|
||||
});
|
||||
await upload.done();
|
||||
|
||||
console.log(`[recorders/stop] S3 upload complete for ${s3Key}`);
|
||||
|
||||
// Ensure the asset row reflects the correct S3 key and file size. The
|
||||
// capture container's finalize call may have already set original_s3_key to
|
||||
// this same value (it was pre-set at start), but update file_size which
|
||||
// finalize doesn't touch.
|
||||
await pool.query(
|
||||
`UPDATE assets
|
||||
SET original_s3_key = $1,
|
||||
file_size = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3`,
|
||||
[s3Key, fileStat.size, asset.id]
|
||||
);
|
||||
|
||||
// If the asset is still 'live' (capture container's finalize hasn't fired or
|
||||
// failed), flip it to 'processing' and queue the proxy job ourselves so the
|
||||
// clip doesn't get stuck in the library as "Recording…".
|
||||
if (asset.status === 'live') {
|
||||
console.log(`[recorders/stop] finalize not yet called — queueing proxy and flipping asset ${asset.id} to processing`);
|
||||
await pool.query(
|
||||
`UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1`,
|
||||
[asset.id]
|
||||
);
|
||||
await proxyQueue.add('generate', {
|
||||
assetId: asset.id,
|
||||
inputKey: s3Key,
|
||||
outputKey: `proxies/${asset.id}.mp4`,
|
||||
});
|
||||
}
|
||||
// If status is already 'processing', the capture container's finalize already
|
||||
// ran and queued the proxy job. The S3 upload we just did ensures the worker
|
||||
// will find a valid object when it dequeues that job — nothing else to do.
|
||||
}
|
||||
|
||||
// GET /:id/status - Get live status
|
||||
router.get('/:id/status', async (req, res, next) => {
|
||||
try {
|
||||
|
|
@ -722,7 +937,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,25 +243,41 @@ 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)
|
||||
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' });
|
||||
|
||||
router.put('/:id/clips', requireSequenceEdit, async (req, res, next) => {
|
||||
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)) ||
|
||||
!Number.isFinite(Number(c.source_in_frames)) || !Number.isFinite(Number(c.source_out_frames))) {
|
||||
return res.status(400).json({ error: 'Clip frame fields must be finite numbers' });
|
||||
}
|
||||
if (!Number.isInteger(Number(c.track)) || Number(c.track) < 0) {
|
||||
return res.status(400).json({ error: 'Clip track must be a non-negative integer' });
|
||||
}
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
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' });
|
||||
|
||||
for (const c of clips) {
|
||||
if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' });
|
||||
if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) ||
|
||||
!Number.isFinite(Number(c.source_in_frames)) || !Number.isFinite(Number(c.source_out_frames))) {
|
||||
return res.status(400).json({ error: 'Clip frame fields must be finite numbers' });
|
||||
}
|
||||
if (!Number.isInteger(Number(c.track)) || Number(c.track) < 0) {
|
||||
return res.status(400).json({ error: 'Clip track must be a non-negative integer' });
|
||||
}
|
||||
}
|
||||
|
||||
// Every referenced asset must belong to THIS sequence's project. Without this,
|
||||
// a user with edit on the sequence could splice in assets from a project they
|
||||
// can't access — and GET /:id would then hand back those assets' names and
|
||||
// signed proxy URLs (cross-project leak).
|
||||
const assetIds = [...new Set(clips.map(c => c.asset_id))];
|
||||
if (assetIds.length) {
|
||||
const owning = await pool.query(
|
||||
`SELECT id FROM assets WHERE id = ANY($1::uuid[]) AND project_id = $2`,
|
||||
[assetIds, req.sequenceProjectId]
|
||||
);
|
||||
if (owning.rows.length !== assetIds.length) {
|
||||
return res.status(400).json({ error: 'All clip assets must belong to the sequence\'s project' });
|
||||
}
|
||||
}
|
||||
|
||||
client = await pool.connect();
|
||||
await client.query('BEGIN');
|
||||
await client.query(
|
||||
`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' });
|
||||
|
|
|
|||
|
|
@ -258,21 +258,45 @@ router.put('/transcoding', async (req, res, next) => {
|
|||
// while it's still being written; the promotion worker later moves the
|
||||
// finalized file to S3 and flips the asset to status='ready'.
|
||||
|
||||
const GROWING_KEYS = ['growing_enabled', 'growing_path', 'growing_smb_url', 'growing_promote_after_seconds'];
|
||||
// Growing-files mode is now a PER-RECORDER setting (recorders.growing_enabled);
|
||||
// the legacy global `growing_enabled` key is no longer read at recorder start.
|
||||
// These global keys describe the shared SMB landing-zone infrastructure only:
|
||||
// - growing_path container mount point (default /growing)
|
||||
// - growing_smb_url smb://... display string for editors (Premiere)
|
||||
// - growing_smb_mount //host/share CIFS source the capture container mounts
|
||||
// - growing_smb_username SMB user for the system-side CIFS mount
|
||||
// - growing_smb_password SMB password (WRITE-ONLY; never returned)
|
||||
// - growing_smb_vers CIFS protocol version (default 3.0)
|
||||
// - growing_promote_after_seconds idle threshold before S3 promotion
|
||||
const GROWING_KEYS = [
|
||||
'growing_path', 'growing_smb_url', 'growing_smb_mount',
|
||||
'growing_smb_username', 'growing_smb_vers', 'growing_promote_after_seconds',
|
||||
];
|
||||
// growing_smb_password is handled separately: stored on PUT but NEVER returned
|
||||
// on GET (only a *_exists flag), mirroring s3_secret_key.
|
||||
|
||||
router.get('/growing', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT key, value FROM settings WHERE key = ANY($1)`,
|
||||
[GROWING_KEYS]
|
||||
[[...GROWING_KEYS, 'growing_smb_password']]
|
||||
);
|
||||
const out = {
|
||||
growing_enabled: 'false',
|
||||
growing_path: '/growing',
|
||||
growing_smb_url: '',
|
||||
growing_smb_mount: '',
|
||||
growing_smb_username: '',
|
||||
growing_smb_vers: '3.0',
|
||||
growing_promote_after_seconds: '8',
|
||||
growing_smb_password_exists: false,
|
||||
};
|
||||
for (const { key, value } of result.rows) out[key] = value;
|
||||
for (const { key, value } of result.rows) {
|
||||
if (key === 'growing_smb_password') {
|
||||
out.growing_smb_password_exists = !!(value && value.length);
|
||||
} else {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
res.json(out);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
|
@ -290,6 +314,19 @@ router.put('/growing', async (req, res, next) => {
|
|||
);
|
||||
}
|
||||
}
|
||||
// SMB password is write-only. A non-empty value sets/replaces it. To remove
|
||||
// it, send growing_smb_password_clear:true. A blank/omitted password field
|
||||
// leaves the stored value untouched (so operators don't retype it on every
|
||||
// save).
|
||||
if (req.body.growing_smb_password_clear === true) {
|
||||
await pool.query(`DELETE FROM settings WHERE key = 'growing_smb_password'`);
|
||||
} else if (typeof req.body.growing_smb_password === 'string' && req.body.growing_smb_password.length > 0) {
|
||||
await pool.query(
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES ('growing_smb_password', $1, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
|
||||
[req.body.growing_smb_password]
|
||||
);
|
||||
}
|
||||
res.json({ message: 'Growing-files settings saved' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
|
|
|||
|
|
@ -14,10 +14,12 @@ const exec = promisify(execCb);
|
|||
const router = express.Router();
|
||||
|
||||
// Defaults mirrored from settings.js so the overview never returns nulls.
|
||||
// Growing-file mode is now per-recorder; "enabled" here means the shared SMB
|
||||
// landing zone is CONFIGURED (a mount source is set), not a global on/off.
|
||||
const GROWING_DEFAULTS = {
|
||||
growing_enabled: 'false',
|
||||
growing_path: '/growing',
|
||||
growing_smb_url: '',
|
||||
growing_smb_mount: '',
|
||||
growing_promote_after_seconds: '8',
|
||||
};
|
||||
|
||||
|
|
@ -100,7 +102,9 @@ router.get('/overview', async (req, res, next) => {
|
|||
try {
|
||||
// Growing files — merge defaults with whatever's in `settings`.
|
||||
const growingRaw = { ...GROWING_DEFAULTS, ...(await readSettings(Object.keys(GROWING_DEFAULTS))) };
|
||||
const growingEnabled = growingRaw.growing_enabled === 'true' || growingRaw.growing_enabled === true;
|
||||
// "enabled" now means the shared SMB landing zone is configured (a mount
|
||||
// source is set). Per-recorder toggles decide which recorders actually use it.
|
||||
const growingEnabled = !!(growingRaw.growing_smb_mount && growingRaw.growing_smb_mount.trim());
|
||||
const containerPath = growingRaw.growing_path || '/growing';
|
||||
const mount = await probeGrowingPath(containerPath);
|
||||
|
||||
|
|
@ -117,6 +121,7 @@ router.get('/overview', async (req, res, next) => {
|
|||
// existing deploy uses this symlink — surface it for operator context.
|
||||
host_path: '/mnt/NVME/MAM/wild-dragon-growing',
|
||||
smb_url: growingRaw.growing_smb_url || '',
|
||||
smb_mount: growingRaw.growing_smb_mount || '',
|
||||
promote_after_seconds: parseInt(growingRaw.growing_promote_after_seconds, 10) || 8,
|
||||
exists: mount.exists,
|
||||
writable: mount.writable,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
FROM assets
|
||||
WHERE status = 'ingesting'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`
|
||||
);
|
||||
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'`;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -175,6 +180,13 @@ async function tick() {
|
|||
for (const row of ampps.rows) {
|
||||
await syncToAmpp(row.id, row.project_id, row.bin_id);
|
||||
}
|
||||
|
||||
// 6) Playout channel health checks. Ping each running channel's sidecar
|
||||
// /status; on success bump last_heartbeat_at, on failure increment a
|
||||
// transient miss counter (in playout_sidecars.last_heartbeat_at age).
|
||||
// Three consecutive misses → auto-restart on a healthy node (non-
|
||||
// decklink), or alert-only for decklink.
|
||||
await playoutHealthTick(client);
|
||||
} catch (err) {
|
||||
console.error('[scheduler] tick error:', err);
|
||||
} finally {
|
||||
|
|
@ -201,6 +213,142 @@ 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; multi-replica pings would just waste cycles. A missed probe is
|
||||
// counted via last_heartbeat_at age: > 3 * TICK_INTERVAL means 3 consecutive
|
||||
// misses.
|
||||
// Persist the as-run compliance log for one channel from a sidecar /status
|
||||
// payload. The sidecar reports the currently on-air item via currentItemId /
|
||||
// currentClip / currentItemStartedAt (playout-manager.getStatus). We keep at
|
||||
// most one "open" row (ended_at IS NULL) per channel: when the on-air item
|
||||
// changes (or playout stops) we close the open row — stamping ended_at and a
|
||||
// computed duration_s — and, if a new clip is on air, open a fresh row.
|
||||
//
|
||||
// playout_as_run columns (migration 029): id, channel_id, asset_id, item_id,
|
||||
// clip_name, started_at, ended_at, duration_s, result.
|
||||
async function writeAsRun(client, channelId, engine) {
|
||||
const currentItemId = engine && engine.currentItemId ? engine.currentItemId : null;
|
||||
|
||||
// The currently-open as-run row for this channel, if any.
|
||||
const { rows: openRows } = await client.query(
|
||||
`SELECT id, item_id, started_at FROM playout_as_run
|
||||
WHERE channel_id = $1 AND ended_at IS NULL
|
||||
ORDER BY started_at DESC LIMIT 1`,
|
||||
[channelId]
|
||||
);
|
||||
const open = openRows[0] || null;
|
||||
|
||||
// Same clip still on air → nothing to do.
|
||||
if (open && currentItemId && open.item_id === currentItemId) return;
|
||||
// Nothing on air and nothing open → nothing to do.
|
||||
if (!open && !currentItemId) return;
|
||||
|
||||
// Close the previous open row (clip changed, or playout stopped).
|
||||
if (open) {
|
||||
await client.query(
|
||||
`UPDATE playout_as_run
|
||||
SET ended_at = NOW(),
|
||||
duration_s = EXTRACT(EPOCH FROM (NOW() - started_at))
|
||||
WHERE id = $1`,
|
||||
[open.id]
|
||||
);
|
||||
}
|
||||
|
||||
// Open a new row for the clip now on air. Resolve the item's asset_id so the
|
||||
// compliance log links back to the source asset even after the playlist item
|
||||
// is later deleted.
|
||||
if (currentItemId) {
|
||||
let assetId = null;
|
||||
try {
|
||||
const { rows } = await client.query(
|
||||
'SELECT asset_id FROM playout_items WHERE id = $1', [currentItemId]
|
||||
);
|
||||
if (rows.length > 0) assetId = rows[0].asset_id;
|
||||
} catch (_) { /* item may have been deleted; log without asset link */ }
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO playout_as_run
|
||||
(channel_id, asset_id, item_id, clip_name, started_at, result)
|
||||
VALUES ($1, $2, $3, $4, COALESCE($5::timestamptz, NOW()), 'played')`,
|
||||
[channelId, assetId, currentItemId, engine.currentClip || null,
|
||||
engine.currentItemStartedAt || null]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// Migration 029 may not be applied yet — bail silently rather than crash.
|
||||
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]
|
||||
);
|
||||
// As-run compliance log: the sidecar only tracks the on-air clip locally
|
||||
// (playout-manager._reportAsRunStart). On every successful status poll we
|
||||
// detect a clip change here and persist it to playout_as_run — close the
|
||||
// previous open row and open a new one. Failures are swallowed so a logging
|
||||
// hiccup never knocks a healthy channel into failover.
|
||||
try {
|
||||
const engine = await r.json().catch(() => null);
|
||||
if (engine) await writeAsRun(client, ch.id, engine);
|
||||
} catch (e) {
|
||||
console.warn(`[scheduler] as-run write failed for ${ch.id}: ${e.message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// When last_heartbeat_at is NULL (channel just spawned), fall back to
|
||||
// updated_at (set to NOW() by spawnChannelSidecar). This prevents a
|
||||
// brand-new channel from being failed over on the very first tick because
|
||||
// epoch-0 age always exceeds TIMEOUT_MS.
|
||||
const baseline = ch.last_heartbeat_at || ch.updated_at;
|
||||
const lastSeen = baseline ? new Date(baseline).getTime() : Date.now();
|
||||
const ageMs = Date.now() - lastSeen;
|
||||
if (ageMs < TIMEOUT_MS) continue; // not yet 3 misses
|
||||
|
||||
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 {
|
||||
// restartChannel re-places the channel on a healthy node AND spawns the
|
||||
// new sidecar directly (shared helper) — no /start self-call needed.
|
||||
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)`);
|
||||
|
|
|
|||
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(); }
|
||||
});
|
||||
109
services/playout/Dockerfile
Normal file
109
services/playout/Dockerfile
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Wild Dragon Playout sidecar — CasparCG Server + Node AMCP control shim.
|
||||
#
|
||||
# CasparCG's mixer needs an OpenGL context. On a node with a real GPU we'd pass
|
||||
# the device + driver through; for the headless / no-GPU case we run a virtual
|
||||
# framebuffer (Xvfb) so the GL context initialises. The container is launched
|
||||
# --privileged by mam-api (same as capture) so DeckLink / NDI hardware is
|
||||
# reachable when present.
|
||||
#
|
||||
# CasparCG 2.4.x no longer ships a self-contained Linux tarball — the GitHub
|
||||
# release provides either Ubuntu .deb packages or an "ubuntu22" zip that bundles
|
||||
# the binary + its .so files under bin/ and lib/. We use the zip on an
|
||||
# ubuntu:22.04 base so the bundled libs match the host glibc/abi, then install
|
||||
# Node 20 from NodeSource on top.
|
||||
#
|
||||
# NDI + DeckLink SDKs are NOT redistributable. They are fetched at build time
|
||||
# from a URL supplied as a build arg (mirror it into your own artifact store);
|
||||
# the build still succeeds without it (NDI/DeckLink consumers simply won't be
|
||||
# available — SRT/RTMP/test output still work).
|
||||
|
||||
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
|
||||
|
||||
# CasparCG 2.4 runtime deps + Xvfb for headless GL + CEF (HTML producer) deps +
|
||||
# Node 20 (NodeSource).
|
||||
#
|
||||
# NOTE: we deliberately do NOT `apt-get install ffmpeg`. That package drags in
|
||||
# ~80 transitive shared libraries (libav*, libx264, libdrm, libva, ...) that
|
||||
# perturb CasparCG 2.4.0's runtime linking and make its headless startup abort
|
||||
# with SIGABRT (exit 134) on nearly every launch. A self-contained STATIC
|
||||
# ffmpeg binary (installed below) gives us the standalone CLI the preview
|
||||
# re-muxer needs with ZERO new shared libs, keeping CasparCG's environment
|
||||
# identical to the known-good image.
|
||||
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/*
|
||||
|
||||
# ── Standalone STATIC ffmpeg CLI (for the HLS preview re-muxer) ───────────────
|
||||
# john van sickle's static build is fully self-contained (no shared-lib deps),
|
||||
# so it can't perturb CasparCG's runtime linking. Override FFMPEG_URL to mirror
|
||||
# this into your own artifact store if upstream availability is a concern.
|
||||
ARG FFMPEG_URL=https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
|
||||
RUN set -eux; \
|
||||
curl -fsSL "$FFMPEG_URL" -o /tmp/ffmpeg.tar.xz; \
|
||||
mkdir -p /tmp/ffmpeg; \
|
||||
tar xJf /tmp/ffmpeg.tar.xz -C /tmp/ffmpeg --strip-components=1; \
|
||||
cp /tmp/ffmpeg/ffmpeg /tmp/ffmpeg/ffprobe /usr/local/bin/; \
|
||||
chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe; \
|
||||
rm -rf /tmp/ffmpeg /tmp/ffmpeg.tar.xz; \
|
||||
/usr/local/bin/ffmpeg -version | head -1
|
||||
|
||||
# ── CasparCG Server (ubuntu22 zip bundle) ────────────────────────────────────
|
||||
# The zip extracts to /opt/casparcg_server with the binary at bin/casparcg and
|
||||
# its bundled .so files under lib/ (added to LD_LIBRARY_PATH by entrypoint.sh).
|
||||
# Symlink to /opt/casparcg so the config/entrypoint paths stay stable.
|
||||
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
|
||||
|
||||
# ── NDI runtime (optional) ───────────────────────────────────────────────────
|
||||
# If an NDI SDK tarball URL is provided, extract its libs to /opt/ndi-lib and
|
||||
# point CasparCG at them via NDI_RUNTIME_DIR_V6. Pin the SDK version to what the
|
||||
# server expects (the common docker failure is a libndi .so version mismatch).
|
||||
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
|
||||
|
||||
# CasparCG media folder — mam-api stages assets from S3 into this volume.
|
||||
RUN mkdir -p /media
|
||||
|
||||
# ── Node control shim ────────────────────────────────────────────────────────
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY . .
|
||||
|
||||
# CasparCG config + entrypoint
|
||||
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>
|
||||
59
services/playout/entrypoint.sh
Normal file
59
services/playout/entrypoint.sh
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Headless GL: start a virtual framebuffer unless a real DISPLAY is provided
|
||||
# (a GPU node may pass through an X socket). CasparCG's mixer needs a GL context.
|
||||
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
|
||||
|
||||
# Ensure the HLS preview directory exists before the re-mux ffmpeg writes to it
|
||||
# (mam-api serves /live/<channel_id>/* from the shared media volume).
|
||||
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'));
|
||||
444
services/playout/src/playout-manager.js
Normal file
444
services/playout/src/playout-manager.js
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
import { AmcpClient } from './amcp.js';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
// 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}` : '';
|
||||
|
||||
// Loopback UDP port CasparCG's preview STREAM consumer publishes mpegts to, and
|
||||
// the standalone ffmpeg re-muxer reads from. One CasparCG per sidecar, so a
|
||||
// fixed port is fine; allow override for parallel local testing.
|
||||
const PREVIEW_UDP_PORT = parseInt(process.env.PREVIEW_UDP_PORT || '9710', 10);
|
||||
const PREVIEW_UDP_URL = `udp://127.0.0.1:${PREVIEW_UDP_PORT}`;
|
||||
|
||||
// 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;
|
||||
this._hlsProc = null; // standalone ffmpeg re-mux child process
|
||||
this._hlsRestartTimer = 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).
|
||||
//
|
||||
// The primary consumer failure is NON-FATAL. CasparCG can decode and route
|
||||
// media through its pipeline even without an output consumer. This means:
|
||||
// - NDI channels work (load/play/transport) even if libndi.so is absent.
|
||||
// - SRT/RTMP channels work even if the destination URL is unreachable.
|
||||
// - The HLS preview consumer is always attempted independently.
|
||||
//
|
||||
// state.consumerError is set when the primary consumer fails so the mam-api
|
||||
// can surface a warning in the channel status without blocking operation.
|
||||
async startChannel({ outputType, outputConfig = {}, videoFormat = '1080p5994' }) {
|
||||
await this.amcp.waitReady(30000);
|
||||
|
||||
// Set the channel video mode first.
|
||||
try { await this.amcp.send(`SET ${CHANNEL} MODE ${videoFormat}`); }
|
||||
catch (err) { console.warn(`[playout] SET MODE failed (continuing): ${err.message}`); }
|
||||
|
||||
// Primary output consumer — non-fatal.
|
||||
let consumerError = null;
|
||||
try {
|
||||
const consumer = await this._consumerCommand(outputType, outputConfig);
|
||||
await this.amcp.send(`ADD ${CHANNEL} ${consumer}`);
|
||||
} catch (err) {
|
||||
consumerError = err.message;
|
||||
console.warn(`[playout] primary consumer ADD failed (continuing without output): ${err.message}`);
|
||||
}
|
||||
|
||||
// HLS preview consumer — always attempt, independently non-fatal.
|
||||
if (HLS_DIR) {
|
||||
try {
|
||||
await this._addHlsConsumer();
|
||||
console.log(`[playout] HLS preview at ${HLS_DIR}/index.m3u8`);
|
||||
} catch (err) {
|
||||
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 = consumerError;
|
||||
console.log(`[playout] channel started output=${outputType} mode=${videoFormat} fps=${this.state.fps.toFixed(3)}${consumerError ? ' ⚠ consumer: ' + consumerError : ''}`);
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
// HLS preview for the web UI confidence monitor.
|
||||
//
|
||||
// ── Why not CasparCG's own HLS (FILE/STREAM ".../index.m3u8") ──────────────
|
||||
// CasparCG's bundled FFMPEG consumer muxes a BROKEN audio track into the HLS:
|
||||
// ffprobe reports `aac, sample_rate=0` and ffmpeg decoding the playlist fails
|
||||
// with "Invalid data ... abuffer: Value inf for parameter 'time_base' ...
|
||||
// time_base 1/0". That corrupt audio prevents BOTH ffmpeg and hls.js from
|
||||
// decoding, so the browser <video> sits at readyState 0 and the preview stays
|
||||
// black. The video track itself is perfectly clean h264. Critically, the
|
||||
// consumer IGNORES every arg that would fix it — `-an`, `-codec:a`, `-g`,
|
||||
// `-r`, `-force_key_frames` are all silently dropped ("Unused option"), so we
|
||||
// CANNOT remove the audio from inside CasparCG.
|
||||
//
|
||||
// ── The fix: STREAM mpegts to UDP loopback, re-mux with a STANDALONE ffmpeg ─
|
||||
// CasparCG outputs a plain mpegts elementary stream to a local UDP port (its
|
||||
// STREAM/mpegts path is fine — the breakage is specific to its HLS muxer). A
|
||||
// Node-spawned standalone ffmpeg (where `-an` actually works) reads that UDP
|
||||
// stream, drops audio, copies the clean h264 video, and writes proper HLS.
|
||||
// `-c:v copy` avoids re-encoding. The program audio is untouched — it rides
|
||||
// the PRIMARY SRT/RTMP/SDI/NDI consumer, which we never modify.
|
||||
async _addHlsConsumer() {
|
||||
// 1) CasparCG → mpegts over UDP loopback. The channel feeds RGBA, so a
|
||||
// format=yuv420p filter is required before libx264.
|
||||
const streamArgs = [
|
||||
`STREAM "${PREVIEW_UDP_URL}?pkt_size=1316"`,
|
||||
'-format mpegts',
|
||||
'-codec:v libx264 -preset:v veryfast -tune:v zerolatency',
|
||||
'-b:v 2M -maxrate 2M -bufsize 4M',
|
||||
'-codec:a aac -b:a 96k',
|
||||
'-filter:v format=yuv420p',
|
||||
].join(' ');
|
||||
await this.amcp.send(`ADD ${CHANNEL} ${streamArgs}`);
|
||||
|
||||
// 2) Standalone ffmpeg re-mux: UDP mpegts → clean video-only HLS.
|
||||
this._startHlsRemux();
|
||||
}
|
||||
|
||||
// Spawn (or respawn) the standalone ffmpeg that re-muxes the loopback mpegts
|
||||
// into video-only HLS. Restarts automatically if it dies while the channel is
|
||||
// still running (e.g. brief UDP gap before CasparCG's consumer is up).
|
||||
_startHlsRemux() {
|
||||
if (!HLS_DIR) return;
|
||||
try { mkdirSync(HLS_DIR, { recursive: true }); } catch (_) {}
|
||||
this._stopHlsRemux();
|
||||
|
||||
const out = `${HLS_DIR}/index.m3u8`;
|
||||
const args = [
|
||||
'-hide_banner', '-loglevel', 'warning',
|
||||
// Read the live mpegts loopback. genpts rebuilds timestamps; the analyze/
|
||||
// probe sizes are kept small so playback starts promptly.
|
||||
'-fflags', '+genpts',
|
||||
'-analyzeduration', '2000000', '-probesize', '2000000',
|
||||
'-i', `${PREVIEW_UDP_URL}?fifo_size=1000000&overrun_nonfatal=1`,
|
||||
// Drop the (broken) audio entirely.
|
||||
'-an',
|
||||
// Re-encode (NOT -c:v copy) to uniform, keyframe-aligned 2s segments with
|
||||
// regenerated CFR timestamps. -c:v copy passed CasparCG's erratic
|
||||
// real-time keyframes straight through, producing segments of 0.6–2.8s
|
||||
// and irregular PTS; hls.js can't build a live timeline from that — it
|
||||
// logs "sliding 0.00 / MISSED", never loads a fragment, and the monitor
|
||||
// stays black even though the stream decodes cleanly server-side. A
|
||||
// standalone ffmpeg honours -force_key_frames, so every GOP (and thus
|
||||
// every HLS segment) is exactly 2.0s.
|
||||
//
|
||||
// This is a CONFIDENCE MONITOR, kept deliberately tiny: 360p / 20fps /
|
||||
// ultrafast. The sidecar has no NVENC, so this is a CPU libx264 encode
|
||||
// running ALONGSIDE CasparCG's mixer + its own STREAM consumer. At 720p30
|
||||
// the re-encode couldn't sustain real time, the UDP input overran, and the
|
||||
// HLS output stalled (playlist froze → monitor black). 360p20 ultrafast is
|
||||
// a fraction of the cost and keeps up comfortably. fps=20 forces CFR;
|
||||
// -g 40 = 2.0s GOP at 20fps.
|
||||
'-vf', 'fps=20,scale=-2:360,format=yuv420p',
|
||||
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
|
||||
'-b:v', '600k', '-maxrate', '800k', '-bufsize', '1200k',
|
||||
'-g', '40', '-keyint_min', '40', '-sc_threshold', '0',
|
||||
'-force_key_frames', 'expr:gte(t,n_forced*2)',
|
||||
'-f', 'hls',
|
||||
'-hls_time', '2',
|
||||
'-hls_list_size', '8',
|
||||
'-hls_flags', 'delete_segments+append_list+independent_segments',
|
||||
'-hls_segment_filename', `${HLS_DIR}/index%d.ts`,
|
||||
out,
|
||||
];
|
||||
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
this._hlsProc = proc;
|
||||
proc.stderr.on('data', (d) => {
|
||||
const line = d.toString().trim();
|
||||
if (line) console.warn(`[playout][hls-ffmpeg] ${line}`);
|
||||
});
|
||||
proc.on('exit', (code, signal) => {
|
||||
console.warn(`[playout] HLS re-mux ffmpeg exited code=${code} signal=${signal}`);
|
||||
if (this._hlsProc === proc) this._hlsProc = null;
|
||||
// Auto-respawn while the channel is running (and we didn't kill it).
|
||||
if (this.state.running && signal !== 'SIGTERM' && signal !== 'SIGKILL') {
|
||||
this._hlsRestartTimer = setTimeout(() => {
|
||||
this._hlsRestartTimer = null;
|
||||
if (this.state.running) {
|
||||
console.log('[playout] respawning HLS re-mux ffmpeg');
|
||||
this._startHlsRemux();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
console.warn(`[playout] HLS re-mux ffmpeg spawn error: ${err.message}`);
|
||||
});
|
||||
console.log(`[playout] HLS re-mux ffmpeg started: ${PREVIEW_UDP_URL} -> ${out}`);
|
||||
}
|
||||
|
||||
_stopHlsRemux() {
|
||||
if (this._hlsRestartTimer) {
|
||||
clearTimeout(this._hlsRestartTimer);
|
||||
this._hlsRestartTimer = null;
|
||||
}
|
||||
if (this._hlsProc) {
|
||||
const proc = this._hlsProc;
|
||||
this._hlsProc = null;
|
||||
try { proc.kill('SIGTERM'); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
async stopChannel() {
|
||||
this._clearAdvance();
|
||||
this.state.running = false; // set first so the ffmpeg exit handler won't respawn
|
||||
this._stopHlsRemux();
|
||||
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 }) {
|
||||
if (!this.state.running) {
|
||||
throw new Error('Channel not started — call /channel/start first');
|
||||
}
|
||||
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,113 +26,151 @@
|
|||
<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">
|
||||
<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="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>
|
||||
|
||||
<!-- Search + Project filter -->
|
||||
<div class="search-row">
|
||||
<input id="search-input" type="search" placeholder="Search assets…" />
|
||||
<select id="project-filter" title="Filter by project">
|
||||
<option value="all">All Projects</option>
|
||||
</select>
|
||||
<button id="refresh-btn" class="btn btn-icon" title="Refresh">↻</button>
|
||||
</div>
|
||||
|
||||
<!-- Active sequence info bar -->
|
||||
<div id="seq-info-bar" class="seq-info-bar hidden">
|
||||
<span class="chip chip-ok">SEQ</span>
|
||||
<span id="seq-info-name" class="seq-info-name"></span>
|
||||
</div>
|
||||
|
||||
<!-- Asset grid (library tab) -->
|
||||
<div id="library-container" class="grid-container">
|
||||
<div id="asset-grid" class="asset-grid">
|
||||
<div class="empty muted">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Growing grid -->
|
||||
<div id="growing-container" class="grid-container hidden">
|
||||
<div id="growing-grid" class="asset-grid">
|
||||
<div class="empty muted">No growing files</div>
|
||||
</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. -->
|
||||
<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 -->
|
||||
<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>
|
||||
</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>
|
||||
<!-- 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>
|
||||
<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>
|
||||
</div>
|
||||
<div id="status-menu" class="menu hidden" role="menu">
|
||||
<button id="disconnect-btn" class="menu-item" role="menuitem">Disconnect</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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 -->
|
||||
<div id="seq-info-bar" class="seq-info-bar hidden">
|
||||
<span class="chip chip-ok">SEQ</span>
|
||||
<span id="seq-info-name" class="seq-info-name"></span>
|
||||
</div>
|
||||
|
||||
<!-- Asset grid (library tab) -->
|
||||
<div id="library-container" class="grid-container">
|
||||
<div id="asset-grid" class="asset-grid">
|
||||
<div class="empty muted">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Growing grid -->
|
||||
<div id="growing-container" class="grid-container hidden">
|
||||
<div id="growing-grid" class="asset-grid">
|
||||
<div class="empty muted">No growing files</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details panel retired (v2.2.0) — card meta carries name/codec/duration. -->
|
||||
<div id="details-panel" class="hidden"></div>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
</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,40 +200,43 @@
|
|||
<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>
|
||||
<option value="prores_4444">ProRes 4444</option>
|
||||
<option value="h264">H.264</option>
|
||||
<option value="h265">H.265 / HEVC</option>
|
||||
<option value="dnxhr_hq">DNxHR HQ</option>
|
||||
</select>
|
||||
<label class="field-label">Quality</label>
|
||||
<select id="conform-quality">
|
||||
<option value="high">High</option>
|
||||
<option value="medium" selected>Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
<label class="field-label">Resolution</label>
|
||||
<select id="conform-resolution">
|
||||
<option value="uhd">UHD (3840×2160)</option>
|
||||
<option value="1080p" selected>Full HD (1920×1080)</option>
|
||||
<option value="720p">HD (1280×720)</option>
|
||||
<option value="source">Source</option>
|
||||
</select>
|
||||
<label class="field-label">Audio</label>
|
||||
<select id="conform-audio">
|
||||
<option value="broadcast">Broadcast (48kHz PCM)</option>
|
||||
<option value="web">Web (AAC 320k)</option>
|
||||
<option value="archive">Archive (96kHz PCM)</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="field-label">Codec</label>
|
||||
<select id="conform-codec">
|
||||
<option value="prores_hq">ProRes 422 HQ</option>
|
||||
<option value="prores_4444">ProRes 4444</option>
|
||||
<option value="h264">H.264</option>
|
||||
<option value="h265">H.265 / HEVC</option>
|
||||
<option value="dnxhr_hq">DNxHR HQ</option>
|
||||
</select>
|
||||
<label class="field-label">Quality</label>
|
||||
<select id="conform-quality">
|
||||
<option value="high">High</option>
|
||||
<option value="medium" selected>Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
<label class="field-label">Resolution</label>
|
||||
<select id="conform-resolution">
|
||||
<option value="uhd">UHD (3840×2160)</option>
|
||||
<option value="1080p" selected>Full HD (1920×1080)</option>
|
||||
<option value="720p">HD (1280×720)</option>
|
||||
<option value="source">Source</option>
|
||||
</select>
|
||||
<label class="field-label">Audio</label>
|
||||
<select id="conform-audio">
|
||||
<option value="broadcast">Broadcast (48kHz PCM)</option>
|
||||
<option value="web">Web (AAC 320k)</option>
|
||||
<option value="archive">Archive (96kHz PCM)</option>
|
||||
</select>
|
||||
<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
|
|
@ -61,12 +61,29 @@ server {
|
|||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# Live HLS — served from /live (bind-mounted shared volume), low cache so playlist refreshes
|
||||
# Live HLS — served from /live (bind-mounted capture live volume).
|
||||
# no-store (not just no-cache): with "no-cache" the browser still caches the
|
||||
# playlist and serves a STALE copy to hls.js's reloads, so hls.js sees the
|
||||
# live playlist as never advancing ("MISSED" forever) and never plays — the
|
||||
# monitor stays black. no-store forbids caching entirely so every reload
|
||||
# fetches the fresh live edge. Segments are short-lived; not caching them is
|
||||
# fine for a live preview.
|
||||
location /live/ {
|
||||
alias /live/;
|
||||
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
|
||||
add_header Cache-Control "no-cache";
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
}
|
||||
|
||||
# Playout HLS preview — CasparCG sidecar writes to the media volume under
|
||||
# /media/live/<channel_id>/. This is a separate volume from /live/ (capture).
|
||||
location /media/live/ {
|
||||
alias /media/live/;
|
||||
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
}
|
||||
|
||||
# API proxy - forward to mam-api service
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
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" /></>,
|
||||
|
|
@ -28,6 +29,7 @@ const ICONS = {
|
|||
audio: <><path d="M3 12v-2a2 2 0 0 1 2-2h2l5-4v16l-5-4H5a2 2 0 0 1-2-2z" /><path d="M16 8a5 5 0 0 1 0 8M19 5a9 9 0 0 1 0 14" /></>,
|
||||
image: <><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="2" /><path d="M21 15l-5-5L5 21" /></>,
|
||||
download: <><path d="M12 4v12M6 10l6 6 6-6" /><path d="M4 20h16" /></>,
|
||||
import: <><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><path d="M10 17l5-5-5-5" /><path d="M15 12H3" /></>,
|
||||
key: <><circle cx="7.5" cy="15.5" r="3.5" /><path d="M10 13l9-9M16 7l3 3M14 9l3 3" /></>,
|
||||
lock: <><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></>,
|
||||
edit: <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /></>,
|
||||
|
|
@ -37,14 +39,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 +67,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,9 +148,20 @@ 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 [growingOn, setGrowingOn] = React.useState(false);
|
||||
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [submitErr, setSubmitErr] = React.useState(null);
|
||||
|
|
@ -196,9 +207,17 @@ function NewRecorderModal({ open, onClose }) {
|
|||
source_type: sourceType.toLowerCase(),
|
||||
project_id: projectId || undefined,
|
||||
generate_proxy: proxyOn,
|
||||
growing_enabled: growingOn,
|
||||
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 +401,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 +455,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>
|
||||
|
|
@ -438,6 +475,20 @@ function NewRecorderModal({ open, onClose }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-toggle-row">
|
||||
<label className="switch">
|
||||
<input type="checkbox" checked={growingOn} onChange={e => setGrowingOn(e.target.checked)} />
|
||||
<span className="switch-track"><span className="switch-knob" /></span>
|
||||
</label>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 13 }}>Growing-files mode</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>
|
||||
Write the live master to the SMB share so editors can cut while it's still recording.
|
||||
Requires the SMB share to be configured in Settings → Storage.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{proxyOn && (
|
||||
<div className="modal-section">
|
||||
<div className="modal-section-head"><span>Proxy</span></div>
|
||||
|
|
|
|||
|
|
@ -258,12 +258,26 @@ 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 className="panel" style={{ padding: '32px 24px', color: 'var(--text-2)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<Icon name="lock" size={16} />
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>Access model</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.7, maxWidth: 640 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<strong style={{ color: 'var(--text-2)' }}>admin</strong> — full access to every
|
||||
project plus user, group, cluster, and system administration.
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<strong style={{ color: 'var(--text-2)' }}>editor / viewer</strong> — see only the
|
||||
projects they've been granted. A <em>view</em> grant is read-only; an
|
||||
<em> edit</em> grant allows changes. Grants can target an individual user or a group.
|
||||
</div>
|
||||
<div>
|
||||
Manage a project's grants from the <strong style={{ color: 'var(--text-2)' }}>Projects</strong> page
|
||||
→ a project's <em>Manage access…</em> menu. Group membership is managed on the
|
||||
Groups tab above.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1167,18 +1181,8 @@ function Cluster() {
|
|||
|
||||
const [adviceModal, setAdviceModal] = React.useState(null); // {title, lines:[], commands?}
|
||||
|
||||
const addNode = () => setAdviceModal({
|
||||
title: 'Add a worker node',
|
||||
lines: [
|
||||
'Worker nodes auto-register with the cluster on first heartbeat.',
|
||||
'Run these on the new host (replace 10.0.0.25 with this MAM\'s IP):',
|
||||
],
|
||||
commands: [
|
||||
'git clone https://forge.wilddragon.net/zgaetano/dragonflight.git /opt/dragonflight',
|
||||
'cd /opt/dragonflight && cp .env.example .env && nano .env # set NODE_ROLE=worker, point at MAM IP',
|
||||
'docker compose -f docker-compose.worker.yml up -d',
|
||||
],
|
||||
});
|
||||
const [showAddNode, setShowAddNode] = React.useState(false);
|
||||
const addNode = () => setShowAddNode(true);
|
||||
|
||||
const drainNode = (node) => setAdviceModal({
|
||||
title: `Drain ${node.id}`,
|
||||
|
|
@ -1385,6 +1389,7 @@ function Cluster() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showAddNode && <AddNodeModal onClose={() => setShowAddNode(false)} />}
|
||||
{adviceModal && (
|
||||
<div className="modal-backdrop" onClick={() => setAdviceModal(null)}>
|
||||
<div className="modal" style={{ width: 560 }} onClick={e => e.stopPropagation()}>
|
||||
|
|
@ -1415,6 +1420,165 @@ function Cluster() {
|
|||
);
|
||||
}
|
||||
|
||||
// AddNodeModal — Approach A onboarding wizard. Collects a node name + role,
|
||||
// mints a one-time auth token via /auth/tokens, and renders a ready-to-paste
|
||||
// `curl … | bash` command that provisions the machine via deploy/onboard-node.sh.
|
||||
//
|
||||
// Role → compose PROFILES mapping (see docker-compose.worker.yml):
|
||||
// Worker → "worker"
|
||||
// Capture → "worker capture"
|
||||
// GPU → "worker gpu" (worker-l4 service, profiles: [gpu])
|
||||
const ADD_NODE_ROLES = [
|
||||
{ id: 'worker', label: 'Worker', profiles: 'worker', desc: 'CPU transcode / general jobs' },
|
||||
{ id: 'capture', label: 'Capture', profiles: 'worker capture', desc: 'SDI / DeckLink ingest' },
|
||||
{ id: 'gpu', label: 'GPU', profiles: 'worker gpu', desc: 'NVENC-accelerated transcode' },
|
||||
];
|
||||
|
||||
function AddNodeModal({ onClose }) {
|
||||
const [nodeName, setNodeName] = React.useState('');
|
||||
const [role, setRole] = React.useState('worker');
|
||||
const [apiUrl, setApiUrl] = React.useState('');
|
||||
const [info, setInfo] = React.useState(null); // { scriptUrl, branch }
|
||||
const [command, setCommand] = React.useState(null); // generated string
|
||||
const [error, setError] = React.useState(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
// On open, prefill the editable apiUrl + capture scriptUrl/branch.
|
||||
React.useEffect(() => {
|
||||
window.ZAMPP_API.fetch('/cluster/onboard-info')
|
||||
.then(d => {
|
||||
setInfo({ scriptUrl: d.scriptUrl, branch: d.branch });
|
||||
if (d.apiUrl) setApiUrl(d.apiUrl);
|
||||
})
|
||||
.catch(() => {}); // leave apiUrl empty → user must fill it before Generate
|
||||
}, []);
|
||||
|
||||
const roleDef = ADD_NODE_ROLES.find(r => r.id === role) || ADD_NODE_ROLES[0];
|
||||
|
||||
const generate = async () => {
|
||||
setError(null);
|
||||
if (!nodeName.trim()) { setError('Node name is required.'); return; }
|
||||
if (!apiUrl.trim()) { setError('Primary API URL is required.'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await fetch('/api/v1/auth/tokens', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
|
||||
body: JSON.stringify({ name: 'node: ' + nodeName.trim() }),
|
||||
});
|
||||
if (r.status !== 201) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
setError(body.error || ('Failed to mint token (' + r.status + ')'));
|
||||
return;
|
||||
}
|
||||
const { token } = await r.json();
|
||||
const scriptUrl = (info && info.scriptUrl)
|
||||
|| 'https://forge.wilddragon.net/zgaetano/wild-dragon/raw/branch/main/deploy/onboard-node.sh';
|
||||
const cmd =
|
||||
`curl -sL ${scriptUrl} | NODE_TOKEN=${token} MAM_API_URL=${apiUrl.trim()} ` +
|
||||
`NODE_ROLE=${role} PROFILES="${roleDef.profiles}" bash`;
|
||||
setCommand(cmd);
|
||||
} catch (e) {
|
||||
setError(e.message || 'Network error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = () => {
|
||||
if (!command || !navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(command)
|
||||
.then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); })
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" style={{ width: 620 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<div style={{ fontSize: 15, fontWeight: 600 }}>Add cluster node</div>
|
||||
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{!command && (
|
||||
<React.Fragment>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 11.5, color: 'var(--text-3)', marginBottom: 5 }}>Node name</label>
|
||||
<input className="field-input" style={{ width: '100%' }} autoFocus
|
||||
placeholder="e.g. zampp3"
|
||||
value={nodeName} onChange={e => setNodeName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 11.5, color: 'var(--text-3)', marginBottom: 5 }}>Role</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{ADD_NODE_ROLES.map(rd => (
|
||||
<button key={rd.id}
|
||||
className={'btn sm' + (role === rd.id ? ' primary' : ' ghost')}
|
||||
style={{ flex: 1, flexDirection: 'column', alignItems: 'flex-start', gap: 2, padding: '8px 10px' }}
|
||||
onClick={() => setRole(rd.id)}>
|
||||
<span style={{ fontWeight: 600 }}>{rd.label}</span>
|
||||
<span style={{ fontSize: 10, opacity: 0.8 }}>{rd.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<label style={{ display: 'block', fontSize: 11.5, color: 'var(--text-3)', marginBottom: 5 }}>Primary API URL</label>
|
||||
<input className="field-input mono" style={{ width: '100%', fontSize: 12 }}
|
||||
placeholder="http://10.0.0.25:47432"
|
||||
value={apiUrl} onChange={e => setApiUrl(e.target.value)} />
|
||||
<div style={{ fontSize: 11, color: 'var(--text-4)', marginTop: 4 }}>
|
||||
The LAN address this new node will heartbeat to. Edit if the guess is wrong.
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{command && (
|
||||
<React.Fragment>
|
||||
<div style={{ marginBottom: 10, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)' }}>
|
||||
This token is shown only once — copy the command now.
|
||||
</div>
|
||||
</div>
|
||||
<code className="mono" style={{ display: 'block', background: 'var(--bg-2)', padding: 12, borderRadius: 6, fontSize: 11.5, lineHeight: 1.5, wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}>{command}</code>
|
||||
<ol style={{ margin: '12px 0 0', paddingLeft: 18, fontSize: 12, color: 'var(--text-2)', lineHeight: 1.6 }}>
|
||||
<li>SSH into the fresh Ubuntu machine.</li>
|
||||
<li>Paste and run this command.</li>
|
||||
<li>The node appears in this Cluster view within ~30s.</li>
|
||||
</ol>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ marginTop: 10, fontSize: 11.5, color: 'var(--danger)' }}>{error}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
{!command && (
|
||||
<React.Fragment>
|
||||
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
|
||||
<button className="btn primary sm" disabled={busy} onClick={generate}>
|
||||
{busy ? 'Generating…' : 'Generate command'}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{command && (
|
||||
<React.Fragment>
|
||||
<button className="btn ghost sm" onClick={copy}>{copied ? 'Copied' : 'Copy'}</button>
|
||||
<button className="btn primary sm" onClick={onClose}>Done</button>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ k, v, mono }) {
|
||||
return (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "90px 1fr", alignItems: "center", fontSize: 12 }}>
|
||||
|
|
@ -1474,6 +1638,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 +1876,7 @@ function Settings() {
|
|||
{section === 'account' && (
|
||||
<>
|
||||
<AccountSection />
|
||||
<TotpSection />
|
||||
<ApiTokensSection />
|
||||
</>
|
||||
)}
|
||||
|
|
@ -1605,6 +1899,7 @@ function Settings() {
|
|||
function StorageSection() {
|
||||
return (
|
||||
<>
|
||||
<StorageWarningBanner />
|
||||
<MountHealthStrip />
|
||||
<S3SettingsCard />
|
||||
<GrowingSettingsCard />
|
||||
|
|
@ -1612,6 +1907,27 @@ function StorageSection() {
|
|||
);
|
||||
}
|
||||
|
||||
// Set-once deployment warning. Storage paths are written into asset rows and
|
||||
// the S3 layout at ingest time; changing them after assets exist orphans files
|
||||
// and can corrupt the library's view of where masters/proxies live.
|
||||
function StorageWarningBanner() {
|
||||
return (
|
||||
<div role="alert" style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 12,
|
||||
padding: '14px 16px', marginBottom: 14, borderRadius: 10,
|
||||
border: '1px solid var(--danger)',
|
||||
background: 'color-mix(in srgb, var(--danger) 12%, transparent)',
|
||||
}}>
|
||||
<Icon name="alert" size={20} style={{ color: 'var(--danger)', flexShrink: 0, marginTop: 1 }} />
|
||||
<div style={{ fontSize: 13, fontWeight: 700, letterSpacing: '0.02em', lineHeight: 1.5, color: 'var(--text-1)' }}>
|
||||
WARNING — THESE SETTINGS ARE MEANT TO BE SET ONCE AT INITIAL DEPLOYMENT.
|
||||
CHANGING STORAGE PATHS MID-CYCLE CAN CAUSE DATABASE CORRUPTION AND FILE LOSS.
|
||||
PLEASE USE WITH CAUTION.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (n == null || isNaN(n)) return '·';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
|
|
@ -1684,8 +2000,8 @@ function MountHealthStrip() {
|
|||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<strong style={{ fontSize: 12.5 }}>Growing files</strong>
|
||||
{g.enabled
|
||||
? <HealthPill ok={growingHealthy} label={growingHealthy ? 'mounted' : 'unreachable'} detail={g.error || ''} />
|
||||
: <span className="badge neutral">disabled</span>}
|
||||
? <HealthPill ok={growingHealthy} label={growingHealthy ? 'configured' : 'unreachable'} detail={g.error || ''} />
|
||||
: <span className="badge neutral">not configured</span>}
|
||||
{g.enabled && g.exists && (
|
||||
<HealthPill ok={g.writable} label={g.writable ? 'writable' : 'read-only'} />
|
||||
)}
|
||||
|
|
@ -1698,7 +2014,8 @@ function MountHealthStrip() {
|
|||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
|
||||
<span>Container</span><span className="mono">{g.container_path || '·'}</span>
|
||||
<span>Host</span><span className="mono">{g.host_path || '·'}</span>
|
||||
<span>SMB</span><span className="mono">{g.smb_url || '·'}</span>
|
||||
<span>SMB mount</span><span className="mono">{g.smb_mount || '·'}</span>
|
||||
<span>SMB (editors)</span><span className="mono">{g.smb_url || '·'}</span>
|
||||
<span>Promote idle</span><span className="mono">{g.promote_after_seconds}s</span>
|
||||
{g.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{g.error}</span></>}
|
||||
</div>
|
||||
|
|
@ -1892,35 +2209,75 @@ function GpuSettingsCard() {
|
|||
|
||||
function GrowingSettingsCard() {
|
||||
const [cfg, setCfg] = React.useState(null);
|
||||
const [pwd, setPwd] = React.useState(''); // new password to set; '' = leave unchanged
|
||||
const [pwdExists, setPwdExists] = React.useState(false);
|
||||
const [clearPwd, setClearPwd] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [msg, setMsg] = React.useState(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.ZAMPP_API.fetch('/settings/growing').then(setCfg).catch(() => setCfg({
|
||||
growing_enabled: 'false', growing_path: '/growing', growing_smb_url: '', growing_promote_after_seconds: '8',
|
||||
}));
|
||||
window.ZAMPP_API.fetch('/settings/growing')
|
||||
.then(d => { setCfg(d); setPwdExists(!!d.growing_smb_password_exists); })
|
||||
.catch(() => setCfg({
|
||||
growing_path: '/growing', growing_smb_url: '', growing_smb_mount: '',
|
||||
growing_smb_username: '', growing_smb_vers: '3.0', growing_promote_after_seconds: '8',
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const save = () => {
|
||||
setSaving(true); setMsg(null);
|
||||
window.ZAMPP_API.fetch('/settings/growing', { method: 'PUT', body: JSON.stringify(cfg) })
|
||||
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved.' }); })
|
||||
const body = {
|
||||
growing_path: cfg.growing_path,
|
||||
growing_smb_url: cfg.growing_smb_url,
|
||||
growing_smb_mount: cfg.growing_smb_mount,
|
||||
growing_smb_username: cfg.growing_smb_username,
|
||||
growing_smb_vers: cfg.growing_smb_vers,
|
||||
growing_promote_after_seconds: cfg.growing_promote_after_seconds,
|
||||
};
|
||||
if (clearPwd) body.growing_smb_password_clear = true;
|
||||
else if (pwd) body.growing_smb_password = pwd;
|
||||
window.ZAMPP_API.fetch('/settings/growing', { method: 'PUT', body: JSON.stringify(body) })
|
||||
.then(() => {
|
||||
setSaving(false); setMsg({ ok: true, text: 'Saved.' });
|
||||
if (clearPwd) { setPwdExists(false); setClearPwd(false); }
|
||||
else if (pwd) { setPwdExists(true); setPwd(''); }
|
||||
})
|
||||
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
|
||||
};
|
||||
|
||||
if (!cfg) return <SettingsCard icon="hdd" title="Growing files (SMB)" sub="Loading…"><div style={{color:'var(--text-3)'}}>…</div></SettingsCard>;
|
||||
const set = (k, v) => setCfg(c => ({ ...c, [k]: v }));
|
||||
const enabled = cfg.growing_enabled === 'true' || cfg.growing_enabled === true;
|
||||
const mountConfigured = !!(cfg.growing_smb_mount && cfg.growing_smb_mount.trim());
|
||||
|
||||
return (
|
||||
<SettingsCard icon="hdd" title="Growing files (SMB)" sub="High-speed local landing zone; promote to S3 on stop"
|
||||
tag={enabled ? <span className="badge success">enabled</span> : <span className="badge neutral">disabled</span>}>
|
||||
<SettingsCard icon="hdd" title="Growing files (SMB)" sub="Shared SMB landing zone. Enable per-recorder under Ingest → Recorders."
|
||||
tag={mountConfigured ? <span className="badge success">configured</span> : <span className="badge neutral">not configured</span>}>
|
||||
<form onSubmit={e => { e.preventDefault(); save(); }} autoComplete="off">
|
||||
<SField label="Enable growing-file capture">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
|
||||
<input type="checkbox" checked={enabled} onChange={e => set('growing_enabled', String(e.target.checked))} />
|
||||
<span style={{ color: 'var(--text-2)' }}>Capture writes to the local SMB share first; Premier can edit while it's still growing.</span>
|
||||
</label>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginBottom: 10, lineHeight: 1.5 }}>
|
||||
Growing-file mode is enabled <strong style={{ color: 'var(--text-2)' }}>per recorder</strong> (New recorder → Growing-files mode).
|
||||
These settings describe the SMB share that capture mounts and writes the live master to.
|
||||
</div>
|
||||
<SField label="SMB mount source (CIFS)">
|
||||
<input className="field-input mono" value={cfg.growing_smb_mount || ''} onChange={e => set('growing_smb_mount', e.target.value)} placeholder="//10.0.0.25/mam-growing" />
|
||||
</SField>
|
||||
<SField label="SMB username">
|
||||
<input className="field-input mono" value={cfg.growing_smb_username || ''} onChange={e => set('growing_smb_username', e.target.value)} placeholder="capture" autoComplete="off" />
|
||||
</SField>
|
||||
<SField label="SMB password">
|
||||
<input className="field-input mono" type="password" autoComplete="new-password"
|
||||
value={pwd}
|
||||
disabled={clearPwd}
|
||||
onChange={e => setPwd(e.target.value)}
|
||||
placeholder={pwdExists ? '•••••••• (saved — leave blank to keep)' : 'Enter SMB password'} />
|
||||
{pwdExists && (
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11.5, color: 'var(--text-3)', marginTop: 4 }}>
|
||||
<input type="checkbox" checked={clearPwd} onChange={e => { setClearPwd(e.target.checked); if (e.target.checked) setPwd(''); }} />
|
||||
Remove saved password
|
||||
</label>
|
||||
)}
|
||||
</SField>
|
||||
<SField label="CIFS protocol version">
|
||||
<input className="field-input mono" value={cfg.growing_smb_vers || ''} onChange={e => set('growing_smb_vers', e.target.value)} placeholder="3.0" />
|
||||
</SField>
|
||||
<SField label="Container mount path">
|
||||
<input className="field-input mono" value={cfg.growing_path || ''} onChange={e => set('growing_path', e.target.value)} placeholder="/growing" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -305,7 +305,20 @@ function Editor() {
|
|||
if (r && r.url) { url = r.url; cache[asset.id] = url; }
|
||||
} catch (e) { window.DF_LOG.warn('[editor] stream URL failed', e); }
|
||||
}
|
||||
if (url) { vid.src = url; vid.load(); }
|
||||
if (url) {
|
||||
if (url.endsWith('.m3u8')) {
|
||||
if (window.Hls && window.Hls.isSupported()) {
|
||||
const hls = new window.Hls();
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(vid);
|
||||
} else {
|
||||
vid.src = url;
|
||||
}
|
||||
} else {
|
||||
vid.src = url;
|
||||
}
|
||||
vid.load();
|
||||
}
|
||||
}
|
||||
|
||||
function markSrcIn() {
|
||||
|
|
@ -649,7 +662,20 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
|
|||
if (r && r.url) { url = r.url; cache[clip.asset_id] = url; }
|
||||
} catch (e) { window.DF_LOG.warn('[editor] stream fetch failed', e); return; }
|
||||
}
|
||||
if (vid.src !== url) { vid.src = url; vid.load(); }
|
||||
if (vid.src !== url) {
|
||||
if (url.endsWith('.m3u8')) {
|
||||
if (window.Hls && window.Hls.isSupported()) {
|
||||
const hls = new window.Hls();
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(vid);
|
||||
} else {
|
||||
vid.src = url;
|
||||
}
|
||||
} else {
|
||||
vid.src = url;
|
||||
}
|
||||
vid.load();
|
||||
}
|
||||
const srcInSecs = clip.source_in_frames / (window.TC ? window.TC.FPS : 59.94);
|
||||
vid.currentTime = srcInSecs;
|
||||
vid.play().catch(() => {});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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; };
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ function Jobs({ navigate }) {
|
|||
|
||||
const normalizeJob = (j) => {
|
||||
const statusMap = { waiting: 'queued', active: 'running', completed: 'done', failed: 'failed' };
|
||||
const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', import: 'YouTube' };
|
||||
const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', import: 'YouTube', 'playout-stage': 'Stage' };
|
||||
const meta = j.metadata || {};
|
||||
return {
|
||||
...j,
|
||||
|
|
@ -207,7 +207,7 @@ function Jobs({ navigate }) {
|
|||
}
|
||||
|
||||
function JobRow({ job, onRetry, onDelete }) {
|
||||
const iconMap = { Proxy: 'proxy', Transcode: 'film', Thumbnail: 'image', Conform: 'layers' };
|
||||
const iconMap = { Proxy: 'proxy', Transcode: 'film', Thumbnail: 'image', Conform: 'layers', Stage: 'monitor' };
|
||||
return (
|
||||
<div className="job-row">
|
||||
<div><StatusDot status={job.status} /></div>
|
||||
|
|
|
|||
1088
services/web-ui/public/screens-playout.jsx
Normal file
1088
services/web-ui/public/screens-playout.jsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,17 +10,17 @@ 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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Ingest",
|
||||
items: [
|
||||
{ id: "upload", label: "Upload", icon: "upload" },
|
||||
{ id: "youtube", label: "YouTube", icon: "download" },
|
||||
{ id: "youtube", label: "YouTube", icon: "import" },
|
||||
{ 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 => ({
|
||||
...sec,
|
||||
items: sec.items.map(n => (n.id === 'jobs' && jobsBadge) ? { ...n, badge: jobsBadge } : n),
|
||||
})),
|
||||
[jobsBadge]
|
||||
() => 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, isAdmin]
|
||||
);
|
||||
const toggleGroup = (id) => {
|
||||
setOpenGroups(prev => {
|
||||
|
|
|
|||
|
|
@ -293,7 +293,40 @@
|
|||
text-align: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
/* Logo wrapper holds the animated pulse halo behind the image. */
|
||||
.launcher-logo-wrap {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
}
|
||||
.launcher-logo-pulse {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
transform: translate(-50%, -50%) scale(0.85);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
color-mix(in srgb, var(--accent) 55%, transparent) 0%,
|
||||
color-mix(in srgb, var(--accent) 22%, transparent) 38%,
|
||||
transparent 68%
|
||||
);
|
||||
filter: blur(2px);
|
||||
animation: launcherLogoPulse 3.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes launcherLogoPulse {
|
||||
0%, 100% { transform: translate(-50%, -50%) scale(0.82); opacity: 0.45; }
|
||||
50% { transform: translate(-50%, -50%) scale(1.08); opacity: 0.9; }
|
||||
}
|
||||
.launcher-logo {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
object-fit: contain;
|
||||
|
|
@ -308,6 +341,9 @@
|
|||
from { opacity: 0; transform: translateY(8px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.launcher-logo-pulse { animation: none; opacity: 0.5; }
|
||||
}
|
||||
.launcher-wordmark {
|
||||
margin: 0;
|
||||
font-size: 44px;
|
||||
|
|
@ -317,11 +353,23 @@
|
|||
color: var(--text-1);
|
||||
text-shadow: 0 0 32px rgba(91, 124, 250, 0.15);
|
||||
}
|
||||
.launcher-kicker {
|
||||
margin: 2px 0 0;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.launcher-tagline {
|
||||
margin: 0;
|
||||
color: var(--text-3);
|
||||
font-size: 13.5px;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.launcher-tagline { font-size: 11.5px; letter-spacing: 0; }
|
||||
}
|
||||
|
||||
.launcher-grid {
|
||||
|
|
@ -333,6 +381,19 @@
|
|||
@media (max-width: 960px) { .launcher-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 620px) { .launcher-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Settings sits on its own centered row beneath the main grid. */
|
||||
.launcher-settings-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.launcher-tile-settings {
|
||||
width: 100%;
|
||||
max-width: calc((100% - 28px) / 3);
|
||||
}
|
||||
@media (max-width: 960px) { .launcher-tile-settings { max-width: calc((100% - 14px) / 2); } }
|
||||
@media (max-width: 620px) { .launcher-tile-settings { max-width: 100%; } }
|
||||
|
||||
.launcher-tile {
|
||||
position: relative;
|
||||
display: grid;
|
||||
|
|
|
|||
541
services/web-ui/public/styles-playout.css
Normal file
541
services/web-ui/public/styles-playout.css
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
/* Playout / Master Control (MCR) page styles — redesigned. */
|
||||
|
||||
/* ── Page header ─────────────────────────────────────────────────────────────── */
|
||||
.po-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 16px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-1);
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.po-head-left { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.po-head-title {
|
||||
font-size: 15px; font-weight: 700; color: var(--text-1);
|
||||
letter-spacing: 0.02em; white-space: nowrap;
|
||||
}
|
||||
.po-clock {
|
||||
font-size: 22px; font-weight: 700; color: var(--text-1);
|
||||
letter-spacing: 0.05em; min-width: 90px; text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Channel tab bar ─────────────────────────────────────────────────────────── */
|
||||
.po-channels-bar {
|
||||
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
|
||||
}
|
||||
.po-chan-tab {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 5px 12px; border-radius: 8px;
|
||||
background: var(--bg-2); border: 1px solid var(--border);
|
||||
color: var(--text-2); font-size: 13px; cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.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); flex-shrink: 0;
|
||||
transition: background 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
.po-chan-dot.live {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 25%, transparent);
|
||||
animation: po-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes po-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 25%, transparent); }
|
||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--danger) 10%, transparent); }
|
||||
}
|
||||
|
||||
/* ── Page layout ─────────────────────────────────────────────────────────────── */
|
||||
.po-page { display: flex; flex-direction: column; gap: 0; padding: 0; }
|
||||
.po-root { display: flex; flex-direction: column; gap: 12px; padding: 14px 16px; }
|
||||
|
||||
.po-empty {
|
||||
text-align: center; padding: 48px 0;
|
||||
display: flex; flex-direction: column; gap: 12px; align-items: center;
|
||||
}
|
||||
|
||||
/* ── Top row: PGM + right rail ───────────────────────────────────────────────── */
|
||||
.po-top {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.po-top { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── PGM monitor column ──────────────────────────────────────────────────────── */
|
||||
.po-pgm-col {
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
|
||||
/* ── Screen ──────────────────────────────────────────────────────────────────── */
|
||||
.po-pgm {
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.po-screen {
|
||||
position: relative;
|
||||
background: #0a0a0a;
|
||||
aspect-ratio: 16 / 9;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.po-monitor-video {
|
||||
width: 100%; height: 100%; object-fit: contain; display: block;
|
||||
}
|
||||
.po-onair-badge {
|
||||
position: absolute; top: 10px; left: 10px;
|
||||
background: var(--danger); color: #fff;
|
||||
font-size: 11px; font-weight: 800; letter-spacing: 0.12em;
|
||||
padding: 3px 8px; border-radius: 4px;
|
||||
animation: po-onair-blink 2s step-end infinite;
|
||||
z-index: 2;
|
||||
}
|
||||
@keyframes po-onair-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
.po-screen-offline {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
background: rgba(0,0,0,0.7); color: var(--text-3); font-size: 13px;
|
||||
}
|
||||
.po-screen-offline-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; background: var(--text-3);
|
||||
}
|
||||
.po-tc-overlay {
|
||||
position: absolute; bottom: 8px; left: 10px;
|
||||
font-size: 12px; color: rgba(255,255,255,0.7);
|
||||
letter-spacing: 0.06em; font-variant-numeric: tabular-nums;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.9);
|
||||
pointer-events: none; z-index: 2;
|
||||
}
|
||||
.po-meters-wrap {
|
||||
position: absolute; right: 10px; bottom: 8px;
|
||||
display: flex; align-items: flex-end;
|
||||
z-index: 2;
|
||||
}
|
||||
.po-vu-meter { display: block; }
|
||||
|
||||
/* Clip progress bar (below video) */
|
||||
.po-clip-progress {
|
||||
height: 3px; background: var(--bg-3); flex-shrink: 0;
|
||||
}
|
||||
.po-clip-progress-fill {
|
||||
height: 100%; background: var(--danger);
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
/* Monitor metadata footer */
|
||||
.po-monitor-meta {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px; min-height: 32px;
|
||||
}
|
||||
.po-monitor-clip-name {
|
||||
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
color: var(--text-1);
|
||||
}
|
||||
.po-monitor-right {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
flex-shrink: 0; color: var(--text-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.po-clip-pos { color: var(--text-2); }
|
||||
.po-clip-remain { color: var(--warning); }
|
||||
|
||||
/* ── Transport bar ───────────────────────────────────────────────────────────── */
|
||||
.po-transport {
|
||||
display: flex; align-items: center;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-1); border: 1px solid var(--border); border-radius: 10px;
|
||||
}
|
||||
.po-transport-group { display: flex; align-items: center; gap: 4px; }
|
||||
.po-trans-btn {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 7px 12px; border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-2); color: var(--text-1);
|
||||
cursor: pointer; font-size: 13px;
|
||||
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.po-trans-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.po-trans-btn:not(:disabled):hover { background: var(--bg-3); }
|
||||
.po-trans-btn:not(:disabled):active { background: var(--bg-4, var(--bg-3)); }
|
||||
.po-trans-play {
|
||||
background: color-mix(in srgb, var(--accent) 15%, var(--bg-2));
|
||||
border-color: var(--accent-soft-2);
|
||||
color: var(--accent-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.po-trans-play:not(:disabled):hover {
|
||||
background: color-mix(in srgb, var(--accent) 25%, var(--bg-2));
|
||||
}
|
||||
.po-trans-stop {
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 30%, var(--border));
|
||||
}
|
||||
.po-trans-stop:not(:disabled):hover {
|
||||
background: color-mix(in srgb, var(--danger) 10%, var(--bg-2));
|
||||
}
|
||||
.po-trans-icon { font-size: 14px; line-height: 1; }
|
||||
.po-trans-label { font-size: 12px; }
|
||||
|
||||
/* ── Right rail ──────────────────────────────────────────────────────────────── */
|
||||
.po-rail {
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
|
||||
/* Generic card */
|
||||
.po-card {
|
||||
background: var(--bg-1); border: 1px solid var(--border); border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.po-card-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 9px 12px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Channel card */
|
||||
.po-channel-card {}
|
||||
.po-channel-actions { display: flex; gap: 6px; align-items: center; margin-left: auto; }
|
||||
.po-channel-meta {
|
||||
padding: 8px 12px; font-size: 11px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.po-restart-badge {
|
||||
font-size: 10px; color: var(--warning); font-weight: 600;
|
||||
}
|
||||
|
||||
/* Now Playing card */
|
||||
.po-nowplaying-card {}
|
||||
.po-nowplaying-pos { margin-left: auto; font-size: 11px; }
|
||||
.po-nowplaying-empty { padding: 16px 12px; font-size: 12px; }
|
||||
.po-nowplaying-name {
|
||||
padding: 8px 12px 6px;
|
||||
font-size: 13px; font-weight: 600; color: var(--text-1);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.po-nowplaying-progress { padding: 0 12px 8px; }
|
||||
.po-nowplaying-bar {
|
||||
height: 4px; background: var(--bg-3); border-radius: 2px; overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.po-nowplaying-fill {
|
||||
height: 100%; background: var(--danger); border-radius: 2px;
|
||||
transition: width 0.5s linear;
|
||||
}
|
||||
.po-nowplaying-times {
|
||||
display: flex; justify-content: space-between; font-size: 10px;
|
||||
}
|
||||
.po-nowplaying-elapsed { color: var(--text-2); }
|
||||
.po-nowplaying-remain { color: var(--text-3); }
|
||||
.po-nowplaying-next {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px; border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
}
|
||||
.po-nowplaying-next-name {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* SCTE-35 card */
|
||||
.po-scte-card {}
|
||||
.po-scte-stub-badge {
|
||||
margin-left: auto;
|
||||
font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
|
||||
color: var(--warning); background: color-mix(in srgb, var(--warning) 15%, transparent);
|
||||
padding: 2px 5px; border-radius: 3px;
|
||||
}
|
||||
.po-scte-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.po-scte-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.po-fire {
|
||||
flex: 1; min-width: 0;
|
||||
padding: 6px 10px; border-radius: 7px;
|
||||
font-size: 11px; font-weight: 600; letter-spacing: 0.03em;
|
||||
cursor: pointer; border: 1px solid transparent;
|
||||
transition: background 0.12s, opacity 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.po-fire:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.po-fire.amber {
|
||||
background: color-mix(in srgb, #f59e0b 18%, var(--bg-2));
|
||||
border-color: color-mix(in srgb, #f59e0b 40%, transparent);
|
||||
color: #f59e0b;
|
||||
}
|
||||
.po-fire.amber:not(:disabled):hover {
|
||||
background: color-mix(in srgb, #f59e0b 28%, var(--bg-2));
|
||||
}
|
||||
.po-fire.in {
|
||||
background: color-mix(in srgb, #22c55e 18%, var(--bg-2));
|
||||
border-color: color-mix(in srgb, #22c55e 40%, transparent);
|
||||
color: #22c55e;
|
||||
}
|
||||
.po-fire.in:not(:disabled):hover {
|
||||
background: color-mix(in srgb, #22c55e 28%, var(--bg-2));
|
||||
}
|
||||
.po-fire.out {
|
||||
background: color-mix(in srgb, var(--danger) 18%, var(--bg-2));
|
||||
border-color: color-mix(in srgb, var(--danger) 40%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
.po-fire.out:not(:disabled):hover {
|
||||
background: color-mix(in srgb, var(--danger) 28%, var(--bg-2));
|
||||
}
|
||||
.po-scte-last {
|
||||
font-size: 10px; color: var(--text-3); margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Rail quick-actions */
|
||||
.po-rail-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.po-rail-action-btn { flex: 1; text-align: center; }
|
||||
|
||||
/* ── Section label ───────────────────────────────────────────────────────────── */
|
||||
.po-section-label {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: var(--text-3); font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Media bin ───────────────────────────────────────────────────────────────── */
|
||||
.po-bin {
|
||||
display: flex; flex-direction: column;
|
||||
min-height: 180px; max-height: 280px;
|
||||
border-radius: 10px; overflow: hidden;
|
||||
background: var(--bg-1); border: 1px solid var(--border);
|
||||
}
|
||||
.po-bin-head {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
padding: 8px 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: 7px 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;
|
||||
}
|
||||
|
||||
/* ── Playlist ─────────────────────────────────────────────────────────────────── */
|
||||
.po-playlist {
|
||||
background: var(--bg-1); border: 1px solid var(--border);
|
||||
border-radius: 10px; overflow: hidden;
|
||||
min-height: 100px;
|
||||
}
|
||||
.po-playlist-head {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.po-drop-err { font-size: 11px; color: var(--danger); }
|
||||
.po-playlist-empty { padding: 24px 12px; text-align: center; color: var(--text-3); font-size: 13px; }
|
||||
|
||||
.po-pl-item {
|
||||
position: relative;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 9px 12px 13px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: grab; user-select: none;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.po-pl-item:last-child { border-bottom: none; }
|
||||
.po-pl-item:hover { background: var(--bg-3); }
|
||||
.po-pl-item:active { cursor: grabbing; }
|
||||
.po-pl-item--active {
|
||||
background: color-mix(in srgb, var(--danger) 8%, transparent);
|
||||
border-left: 3px solid var(--danger);
|
||||
}
|
||||
.po-pl-item--active:hover {
|
||||
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||
}
|
||||
.po-pl-index {
|
||||
width: 22px; text-align: center; font-family: var(--font-mono);
|
||||
font-size: 12px; color: var(--text-3); flex-shrink: 0;
|
||||
}
|
||||
.po-pl-onair { color: var(--danger); font-size: 11px; }
|
||||
.po-pl-name {
|
||||
flex: 1; font-size: 13px; color: var(--text-1);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.po-pl-dur {
|
||||
font-size: 11px; color: var(--text-3); flex-shrink: 0;
|
||||
min-width: 40px; text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.po-pl-badge { flex-shrink: 0; }
|
||||
|
||||
/* Staging progress bar */
|
||||
.po-staging-bar {
|
||||
position: absolute; bottom: 0; left: 0; right: 0; height: 3px;
|
||||
}
|
||||
.po-staging-bar--pending { background: var(--text-3); opacity: 0.25; }
|
||||
.po-staging-bar--staging {
|
||||
background: linear-gradient(90deg, transparent 0%, var(--warning) 50%, transparent 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: po-staging-sweep 1.4s linear infinite;
|
||||
}
|
||||
.po-staging-bar--ready { background: var(--success); opacity: 0.8; }
|
||||
.po-staging-bar--error { background: var(--danger); }
|
||||
@keyframes po-staging-sweep {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* ── Timeline ─────────────────────────────────────────────────────────────────── */
|
||||
.po-tl {
|
||||
background: var(--bg-1); border: 1px solid var(--border);
|
||||
border-radius: 10px; overflow: hidden;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.po-tl-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.po-tl-empty {
|
||||
padding: 20px 12px; text-align: center; font-size: 12px; color: var(--text-3);
|
||||
}
|
||||
.po-tl-track-wrap {
|
||||
position: relative; padding: 10px 12px 28px;
|
||||
}
|
||||
.po-tl-playhead {
|
||||
position: absolute; top: 10px; bottom: 28px;
|
||||
width: 2px; background: var(--danger);
|
||||
z-index: 3; transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.po-tl-playhead::before {
|
||||
content: '';
|
||||
position: absolute; top: -4px; left: 50%; transform: translateX(-50%);
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid var(--danger);
|
||||
}
|
||||
.po-tl-track {
|
||||
display: flex; height: 44px;
|
||||
border-radius: 6px; overflow: hidden;
|
||||
background: var(--bg-3);
|
||||
gap: 1px;
|
||||
}
|
||||
.po-tl-clip {
|
||||
position: relative; display: flex;
|
||||
flex-direction: column; justify-content: center;
|
||||
padding: 4px 6px; min-width: 0; overflow: hidden;
|
||||
background: color-mix(in srgb, var(--clip-color, #3b82f6) 20%, var(--bg-2));
|
||||
border-left: 2px solid var(--clip-color, #3b82f6);
|
||||
cursor: default; transition: background 0.15s;
|
||||
}
|
||||
.po-tl-clip:hover {
|
||||
background: color-mix(in srgb, var(--clip-color, #3b82f6) 30%, var(--bg-2));
|
||||
}
|
||||
.po-tl-clip--active {
|
||||
background: color-mix(in srgb, var(--clip-color, #3b82f6) 35%, var(--bg-2));
|
||||
border-left-color: var(--clip-color, #3b82f6);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--clip-color, #3b82f6) 50%, transparent);
|
||||
}
|
||||
.po-tl-clip-name {
|
||||
font-size: 10px; font-weight: 600; color: var(--text-1);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.po-tl-clip-dur {
|
||||
font-size: 9px; color: var(--text-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.po-tl-staging-dot {
|
||||
position: absolute; top: 3px; right: 4px;
|
||||
width: 5px; height: 5px; border-radius: 50%;
|
||||
background: var(--warning);
|
||||
animation: po-staging-sweep 1s ease-in-out infinite alternate;
|
||||
}
|
||||
.po-tl-error-dot {
|
||||
position: absolute; top: 3px; right: 4px;
|
||||
width: 5px; height: 5px; border-radius: 50%;
|
||||
background: var(--danger);
|
||||
}
|
||||
/* Time ruler */
|
||||
.po-tl-ruler {
|
||||
position: absolute; bottom: 4px; left: 12px; right: 12px; height: 18px;
|
||||
}
|
||||
.po-tl-ruler-mark {
|
||||
position: absolute; font-size: 9px; color: var(--text-3);
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── As-run drawer ────────────────────────────────────────────────────────────── */
|
||||
.po-drawer-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
|
||||
z-index: 90; backdrop-filter: blur(2px);
|
||||
}
|
||||
.po-drawer {
|
||||
position: fixed; right: 0; top: 0; bottom: 0;
|
||||
width: 420px; max-width: 95vw;
|
||||
background: var(--bg-1); border-left: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
z-index: 91;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: -4px 0 24px rgba(0,0,0,0.2);
|
||||
}
|
||||
.po-drawer--open { transform: translateX(0); }
|
||||
.po-drawer-head {
|
||||
display: flex; align-items: center;
|
||||
padding: 14px 16px; border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
.po-drawer-close { margin-left: auto; }
|
||||
.po-drawer-body { flex: 1; overflow-y: auto; padding: 12px 16px; }
|
||||
|
||||
/* ── As-run table (shared by drawer) ─────────────────────────────────────────── */
|
||||
.po-asrun-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 12px;
|
||||
}
|
||||
.po-asrun-table th {
|
||||
text-align: left; font-weight: 600; color: var(--text-3);
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
padding: 4px 8px; border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; background: var(--bg-1);
|
||||
}
|
||||
.po-asrun-table td {
|
||||
padding: 5px 8px; border-bottom: 1px solid var(--border);
|
||||
color: var(--text-1); overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap; max-width: 200px;
|
||||
}
|
||||
.po-asrun-table tr:last-child td { border-bottom: none; }
|
||||
.po-asrun-result {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.po-asrun-played { color: var(--success); }
|
||||
.po-asrun-skipped { color: var(--warning); }
|
||||
.po-asrun-error { color: var(--danger); }
|
||||
|
||||
/* ── Utility overrides ───────────────────────────────────────────────────────── */
|
||||
.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; }
|
||||
|
||||
/* Downloads modal section header (preserved from original) */
|
||||
.downloads-section-head {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--text-3);
|
||||
padding-bottom: 8px; border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
|
@ -1376,3 +1376,231 @@
|
|||
/* Tint Cancel-all-failed button to signal destructive action without
|
||||
making it loud — same pattern as the per-row Cancel. */
|
||||
.jobs-cancel-all { color: var(--danger); }
|
||||
|
||||
/* ========================================================================
|
||||
Dashboard (operations overview) - design rebuild.
|
||||
Appended last so the design's .dash-grid / .dash-statusbar override the
|
||||
earlier (pre-redesign) definitions of those two container classes.
|
||||
======================================================================== */
|
||||
|
||||
.page.dashboard { padding: 0; }
|
||||
|
||||
.ops-header {
|
||||
display: flex; align-items: flex-end; gap: 16px;
|
||||
padding: 24px 28px 18px;
|
||||
}
|
||||
.ops-header h1 { margin: 0; font-size: 22px; font-weight: 600; letter-spacing: -0.02em; }
|
||||
.ops-sub { margin-top: 5px; color: var(--text-3); font-size: 12.5px; }
|
||||
.ops-header-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
.ops-clock {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
height: 32px; padding: 0 12px;
|
||||
border: 1px solid var(--border); border-radius: var(--r-sm);
|
||||
background: var(--bg-1);
|
||||
}
|
||||
.ops-clock-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--live); box-shadow: 0 0 0 3px var(--live-soft);
|
||||
animation: dotpulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.ops-clock-time { font-size: 13px; font-weight: 500; letter-spacing: 0.03em; color: var(--text-1); font-variant-numeric: tabular-nums; }
|
||||
.ops-clock-day { font-size: 10px; color: var(--text-3); letter-spacing: 0.08em; }
|
||||
|
||||
.ops-nodes-pill {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
height: 32px; padding: 0 12px;
|
||||
border: 1px solid var(--border); border-radius: var(--r-sm);
|
||||
background: var(--bg-1);
|
||||
font-size: 12px; color: var(--text-2); font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ---- status strip ---- */
|
||||
.ops-stats {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr);
|
||||
margin: 0 28px;
|
||||
background: var(--bg-1); border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg); overflow: hidden;
|
||||
}
|
||||
.ops-stats.six { grid-template-columns: repeat(6, 1fr); }
|
||||
.stat-cell { padding: 15px 16px 14px; border-left: 1px solid var(--border); min-width: 0; }
|
||||
.stat-cell:first-child { border-left: 0; }
|
||||
.stat-cell-label {
|
||||
font-size: 10.5px; color: var(--text-3); font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.06em; white-space: nowrap;
|
||||
overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.stat-cell-value {
|
||||
margin-top: 9px; font-size: 26px; font-weight: 600; line-height: 1;
|
||||
letter-spacing: -0.02em; font-variant-numeric: tabular-nums;
|
||||
display: flex; align-items: baseline; gap: 6px;
|
||||
}
|
||||
.stat-cell-unit { font-size: 12px; font-weight: 500; color: var(--text-3); letter-spacing: 0; }
|
||||
.stat-cell-foot {
|
||||
margin-top: 10px; font-size: 11.5px; color: var(--text-3);
|
||||
display: flex; align-items: center; gap: 8px; white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-cell-foot .foot-danger { color: var(--danger); }
|
||||
.stat-cell-foot .foot-warn { color: var(--warning); }
|
||||
.stat-pips { display: flex; align-items: center; gap: 11px; }
|
||||
.stat-pip { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text-2); font-variant-numeric: tabular-nums; }
|
||||
.stat-pip i { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
.stat-pip.armed i { background: var(--accent); }
|
||||
.stat-pip.idle i { background: var(--text-4); }
|
||||
.stat-pip.zero { color: var(--text-4); }
|
||||
.stat-pip.zero i { opacity: 0.4; }
|
||||
|
||||
/* ---- section heads ---- */
|
||||
.section-head { display: flex; align-items: center; gap: 10px; padding: 22px 0 11px; }
|
||||
.section-head:first-child { padding-top: 6px; }
|
||||
.section-head-title { font-size: 13px; font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; }
|
||||
.section-head-sub { font-size: 11.5px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.section-head-count { font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; color: var(--danger); background: var(--danger-soft); padding: 1px 7px; border-radius: 99px; }
|
||||
.section-head .btn { margin-left: auto; flex-shrink: 0; }
|
||||
.section-head-live {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--live); box-shadow: 0 0 0 3px var(--live-soft);
|
||||
animation: dotpulse 1.6s ease-in-out infinite; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- dashboard grid (overrides earlier .dash-grid) ---- */
|
||||
.page.dashboard .dash-grid {
|
||||
display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(300px, 1fr);
|
||||
gap: 22px; padding: 6px 28px 8px; align-items: start;
|
||||
}
|
||||
.dash-main, .dash-side { min-width: 0; }
|
||||
|
||||
/* ---- live ingest tiles ---- */
|
||||
.live-now-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(188px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.ingest-tile {
|
||||
background: var(--bg-1); border: 1px solid var(--border);
|
||||
border-radius: var(--r-md); overflow: hidden; cursor: pointer;
|
||||
transition: border-color 120ms, transform 120ms;
|
||||
}
|
||||
.ingest-tile:hover { border-color: var(--border-strong); transform: translateY(-1px); }
|
||||
.ingest-tile.recording { border-color: rgba(255,59,48,0.28); }
|
||||
.ingest-tile-screen { position: relative; aspect-ratio: 16 / 9; background: var(--bg-2); overflow: hidden; }
|
||||
.ingest-tile-audio {
|
||||
position: absolute; inset: 0; display: grid; place-items: center; padding: 16px;
|
||||
background: linear-gradient(160deg, var(--bg-2), var(--bg-1));
|
||||
}
|
||||
.ingest-tile-audio .waveform { width: 100%; height: 58%; opacity: 0.85; }
|
||||
.ingest-tile-veil { position: absolute; inset: 0; background: rgba(11,13,17,0.5); z-index: 1; }
|
||||
.ingest-tile-top { position: absolute; top: 8px; left: 8px; right: 8px; display: flex; align-items: center; gap: 6px; z-index: 2; }
|
||||
.ingest-tile-top .badge.outline { margin-left: auto; background: rgba(0,0,0,0.45); border-color: rgba(255,255,255,0.18); color: #fff; backdrop-filter: blur(4px); }
|
||||
.ingest-tile-bottom { position: absolute; left: 8px; right: 8px; bottom: 8px; display: flex; align-items: center; gap: 6px; z-index: 2; }
|
||||
.ingest-tile-name {
|
||||
color: #fff; font-size: 12px; font-weight: 500;
|
||||
background: rgba(0,0,0,0.6); padding: 3px 8px; border-radius: 4px; backdrop-filter: blur(4px);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0;
|
||||
}
|
||||
.ingest-tile-tc { margin-left: auto; color: #fff; font-size: 11px; background: rgba(0,0,0,0.6); padding: 3px 6px; border-radius: 4px; backdrop-filter: blur(4px); flex-shrink: 0; }
|
||||
.ingest-tile-foot { display: flex; align-items: center; gap: 8px; padding: 8px 11px; font-size: 11px; color: var(--text-3); }
|
||||
.ingest-tile-foot .dot-sep { color: var(--text-4); }
|
||||
.ingest-tile-node { margin-left: auto; color: var(--text-4); }
|
||||
|
||||
/* ---- on-air empty / standby ---- */
|
||||
.onair-empty { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; }
|
||||
.onair-empty-head { display: flex; align-items: center; gap: 14px; padding: 18px; }
|
||||
.onair-empty-icon {
|
||||
width: 38px; height: 38px; flex-shrink: 0; border-radius: 50%;
|
||||
background: var(--bg-3); border: 1px solid var(--border);
|
||||
display: grid; place-items: center; color: var(--text-3);
|
||||
}
|
||||
.onair-empty-copy { flex: 1; min-width: 0; }
|
||||
.onair-empty-title { font-size: 13.5px; font-weight: 600; }
|
||||
.onair-empty-sub { font-size: 12px; color: var(--text-3); margin-top: 2px; }
|
||||
.onair-empty-head .btn { flex-shrink: 0; }
|
||||
.onair-sources {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 8px; padding: 14px; border-top: 1px solid var(--border); background: var(--bg-0);
|
||||
}
|
||||
.onair-source {
|
||||
display: flex; align-items: center; gap: 9px; padding: 9px 11px;
|
||||
background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--r-md);
|
||||
text-align: left; cursor: pointer; transition: background 80ms, border-color 80ms;
|
||||
}
|
||||
.onair-source:hover { background: var(--bg-3); border-color: var(--border-strong); }
|
||||
.onair-source-name { font-size: 12.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
||||
.onair-source-src { font-size: 10px; font-family: var(--font-mono); color: var(--text-3); padding: 1px 6px; border: 1px solid var(--border-strong); border-radius: 4px; }
|
||||
.onair-source-go { margin-left: auto; display: flex; align-items: center; gap: 3px; font-size: 11px; color: var(--accent-text); white-space: nowrap; }
|
||||
|
||||
/* ---- job queue table ---- */
|
||||
.job-table { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; }
|
||||
.job-table-head, .job-table-row {
|
||||
display: grid; grid-template-columns: 148px minmax(0, 1fr) 84px 170px 52px;
|
||||
gap: 14px; align-items: center; padding: 0 14px;
|
||||
}
|
||||
.job-table-head {
|
||||
height: 34px; border-bottom: 1px solid var(--border);
|
||||
font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-4);
|
||||
}
|
||||
.job-table-row { height: 42px; border-bottom: 1px solid var(--border); }
|
||||
.job-table-row:last-child { border-bottom: 0; }
|
||||
.jt-job { display: flex; align-items: center; gap: 8px; color: var(--text-2); font-size: 11.5px; }
|
||||
.jt-asset { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-1); font-size: 12.5px; }
|
||||
.jt-node { color: var(--text-3); font-size: 11px; }
|
||||
.jt-progress { display: flex; align-items: center; }
|
||||
.jt-bar { display: block; width: 100%; height: 5px; background: var(--bg-3); border-radius: 99px; overflow: hidden; }
|
||||
.jt-bar > span { display: block; height: 100%; background: var(--accent); border-radius: 99px; transition: width 300ms; }
|
||||
.jt-eta { color: var(--text-3); font-size: 11px; text-align: right; }
|
||||
|
||||
/* ---- needs attention ---- */
|
||||
.attention-panel { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; }
|
||||
.attn-row { display: flex; align-items: center; gap: 11px; padding: 11px 13px; border-bottom: 1px solid var(--border); }
|
||||
.attn-row:last-child { border-bottom: 0; }
|
||||
.attn-sev { width: 26px; height: 26px; flex-shrink: 0; border-radius: var(--r-sm); display: grid; place-items: center; }
|
||||
.attn-sev.danger { background: var(--danger-soft); color: var(--danger); }
|
||||
.attn-sev.warning { background: var(--warning-soft); color: var(--warning); }
|
||||
.attn-body { flex: 1; min-width: 0; }
|
||||
.attn-title { font-size: 12.5px; font-weight: 500; color: var(--text-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.attn-meta { font-size: 10.5px; color: var(--text-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.attn-row .btn { flex-shrink: 0; }
|
||||
|
||||
/* ---- cluster node list ---- */
|
||||
.node-list { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; }
|
||||
.node-row { display: flex; align-items: center; gap: 14px; padding: 11px 14px; border-bottom: 1px solid var(--border); }
|
||||
.node-row:last-child { border-bottom: 0; }
|
||||
.node-row.offline { opacity: 0.55; }
|
||||
.node-row-id { display: flex; align-items: center; gap: 8px; width: 158px; flex-shrink: 0; }
|
||||
.node-name { font-size: 12px; color: var(--text-1); font-weight: 500; }
|
||||
.badge.node-role { height: 17px; padding: 0 5px; font-size: 9px; }
|
||||
.node-row-metrics { flex: 1; display: grid; grid-template-columns: 1fr 1fr auto; gap: 16px; align-items: center; min-width: 0; }
|
||||
.node-metric { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.node-metric-label { font-size: 10px; color: var(--text-4); width: 24px; flex-shrink: 0; letter-spacing: 0.04em; }
|
||||
.node-metric-bar { flex: 1; height: 5px; background: var(--bg-3); border-radius: 99px; overflow: hidden; min-width: 26px; }
|
||||
.node-metric-bar > span { display: block; height: 100%; border-radius: 99px; transition: width 300ms; }
|
||||
.node-metric-text { font-size: 10.5px; color: var(--text-3); white-space: nowrap; flex-shrink: 0; }
|
||||
.node-gpu { font-size: 10.5px; color: var(--text-3); white-space: nowrap; justify-self: end; }
|
||||
.node-row-off { flex: 1; color: var(--text-4); font-size: 11.5px; font-family: var(--font-mono); }
|
||||
|
||||
/* ---- footer status bar (overrides earlier .dash-statusbar) ---- */
|
||||
.page.dashboard .dash-statusbar {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
margin: 14px 28px 30px; padding-top: 13px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11.5px; color: var(--text-3); font-family: var(--font-mono);
|
||||
}
|
||||
.dash-statusbar .sb-item { display: flex; align-items: center; gap: 6px; }
|
||||
.dash-statusbar .sb-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-4); }
|
||||
.dash-statusbar .sb-dot.live { background: var(--live); }
|
||||
.dash-statusbar .sb-dot.run { background: var(--accent); }
|
||||
.dash-statusbar .sb-dot.fail { background: var(--danger); }
|
||||
.dash-statusbar .sb-spacer { flex: 1; }
|
||||
.dash-statusbar .sb-sep { color: var(--text-4); }
|
||||
|
||||
@media (max-width: 1340px) {
|
||||
.ops-stats.six { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.ops-stats { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 1080px) {
|
||||
.page.dashboard .dash-grid { grid-template-columns: 1fr; }
|
||||
.job-table-head, .job-table-row { grid-template-columns: 130px minmax(0, 1fr) 140px 48px; }
|
||||
.job-table-head span:nth-child(3), .job-table-row .jt-node { display: none; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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) => {
|
||||
|
|
@ -94,6 +95,9 @@ const workers = [
|
|||
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)'}`);
|
||||
|
||||
|
|
|
|||
157
services/worker/src/workers/playout-stage.js
Normal file
157
services/worker/src/workers/playout-stage.js
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
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]);
|
||||
}
|
||||
|
||||
function isFiniteLoudness(val) {
|
||||
const n = parseFloat(val);
|
||||
return isFinite(n);
|
||||
}
|
||||
|
||||
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).
|
||||
//
|
||||
// Silent / no-audio clips measure I=-inf which ffmpeg rejects in pass 2.
|
||||
// When any loudnorm measurement is non-finite, fall back to a plain audio
|
||||
// transcode (AAC 192k) with no loudness adjustment — the clip has no
|
||||
// meaningful audio to normalize.
|
||||
const silentOrNoAudio = !isFiniteLoudness(m.input_i) || !isFiniteLoudness(m.input_tp);
|
||||
if (silentOrNoAudio) {
|
||||
console.log(`[playout-stage] loudnorm skip — silent/no audio (I=${m.input_i}), transcoding audio only`);
|
||||
await runFfmpeg([
|
||||
'-hide_banner', '-nostats', '-y', '-i', inputPath,
|
||||
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000',
|
||||
outputPath,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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