Compare commits
2 commits
3b2f9fe6a0
...
5968d4f681
| Author | SHA1 | Date | |
|---|---|---|---|
| 5968d4f681 | |||
| 3122dfd1b9 |
8 changed files with 414 additions and 36 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -154,6 +154,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) {
|
||||
|
|
@ -363,14 +364,25 @@ router.post('/:id/start', requireRecorderEdit, 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
|
||||
|
|
@ -455,6 +467,13 @@ router.post('/:id/start', requireRecorderEdit, 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
|
||||
|
|
@ -530,7 +549,15 @@ router.post('/:id/start', requireRecorderEdit, 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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ function NewRecorderModal({ open, onClose }) {
|
|||
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);
|
||||
|
|
@ -206,6 +207,7 @@ 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.
|
||||
|
|
@ -473,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>
|
||||
|
|
|
|||
|
|
@ -1749,6 +1749,7 @@ function Settings() {
|
|||
function StorageSection() {
|
||||
return (
|
||||
<>
|
||||
<StorageWarningBanner />
|
||||
<MountHealthStrip />
|
||||
<S3SettingsCard />
|
||||
<GrowingSettingsCard />
|
||||
|
|
@ -1756,6 +1757,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'];
|
||||
|
|
@ -1828,8 +1850,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'} />
|
||||
)}
|
||||
|
|
@ -1842,7 +1864,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>
|
||||
|
|
@ -2036,35 +2059,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" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue