diff --git a/services/capture/Dockerfile b/services/capture/Dockerfile index 1f94e4e..455dc2c 100644 --- a/services/capture/Dockerfile +++ b/services/capture/Dockerfile @@ -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 diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 5f1315f..50108d8 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -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); diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index 8367500..017ac83 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -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) { diff --git a/services/mam-api/src/routes/settings.js b/services/mam-api/src/routes/settings.js index f7cefdc..82fbe17 100644 --- a/services/mam-api/src/routes/settings.js +++ b/services/mam-api/src/routes/settings.js @@ -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); diff --git a/services/mam-api/src/routes/storage.js b/services/mam-api/src/routes/storage.js index 9d767a8..493d87b 100644 --- a/services/mam-api/src/routes/storage.js +++ b/services/mam-api/src/routes/storage.js @@ -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, diff --git a/services/web-ui/public/modal-new-recorder.jsx b/services/web-ui/public/modal-new-recorder.jsx index fa341c7..e89900a 100644 --- a/services/web-ui/public/modal-new-recorder.jsx +++ b/services/web-ui/public/modal-new-recorder.jsx @@ -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 }) { +
+ +
+
Growing-files mode
+
+ 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. +
+
+
+ {proxyOn && (
Proxy
diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index 16ae8e8..555080f 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -1749,6 +1749,7 @@ function Settings() { function StorageSection() { return ( <> + @@ -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 ( +
+ +
+ 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. +
+
+ ); +} + function formatBytes(n) { if (n == null || isNaN(n)) return '·'; const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; @@ -1828,8 +1850,8 @@ function MountHealthStrip() {
Growing files {g.enabled - ? - : disabled} + ? + : not configured} {g.enabled && g.exists && ( )} @@ -1842,7 +1864,8 @@ function MountHealthStrip() {
Container{g.container_path || '·'} Host{g.host_path || '·'} - SMB{g.smb_url || '·'} + SMB mount{g.smb_mount || '·'} + SMB (editors){g.smb_url || '·'} Promote idle{g.promote_after_seconds}s {g.error && <>Error{g.error}}
@@ -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
; 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 ( - enabled : disabled}> + configured : not configured}>
{ e.preventDefault(); save(); }} autoComplete="off"> - - +
+ Growing-file mode is enabled per recorder (New recorder → Growing-files mode). + These settings describe the SMB share that capture mounts and writes the live master to. +
+ + set('growing_smb_mount', e.target.value)} placeholder="//10.0.0.25/mam-growing" /> + + + set('growing_smb_username', e.target.value)} placeholder="capture" autoComplete="off" /> + + + setPwd(e.target.value)} + placeholder={pwdExists ? '•••••••• (saved — leave blank to keep)' : 'Enter SMB password'} /> + {pwdExists && ( + + )} + + + set('growing_smb_vers', e.target.value)} placeholder="3.0" /> set('growing_path', e.target.value)} placeholder="/growing" />