From 4c657533587760b670dcf39b9ae5aa50b2d09312 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Thu, 21 May 2026 00:17:45 -0400 Subject: [PATCH] recorders route: accept full codec field set + node/port pinning POST/PATCH now persist all new codec columns via a whitelist. /start forwards every codec setting to the capture container as an env var. The live-asset created during /start now uses the recorder's container ext (.mov vs .mp4 etc.) instead of always assuming .mov. --- services/mam-api/src/routes/recorders.js | 211 ++++++++++------------- 1 file changed, 94 insertions(+), 117 deletions(-) diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index a4774cf..ca89c10 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -48,7 +48,6 @@ function generateClipName(recorderName) { /** * Build Docker PortBindings and ExposedPorts for listener-mode recorders. - * Returns { portBindings, exposedPorts } — both empty objects for non-listener sources. */ function buildPortConfig(sourceType, sourceConfig) { const portBindings = {}; @@ -71,15 +70,35 @@ function buildPortConfig(sourceType, sourceConfig) { return { portBindings, exposedPorts }; } +// Whitelist of recorder columns the API accepts on POST/PATCH. Keeping it +// explicit prevents accidental writes to status / container_id / timestamps. +const RECORDER_FIELDS = [ + 'name', 'source_type', 'source_config', + 'recording_codec', 'recording_resolution', + 'recording_video_bitrate', 'recording_framerate', + 'recording_audio_codec', 'recording_audio_bitrate', 'recording_audio_channels', + 'recording_container', + 'proxy_enabled', 'proxy_codec', 'proxy_resolution', + 'proxy_video_bitrate', 'proxy_framerate', + 'proxy_audio_codec', 'proxy_audio_bitrate', 'proxy_audio_channels', + 'proxy_container', + 'project_id', 'node_id', 'device_index', +]; + +function pickRecorderFields(body) { + const out = {}; + for (const k of RECORDER_FIELDS) { + if (body[k] !== undefined) out[k] = body[k]; + } + return out; +} + // GET / - List all recorders router.get('/', async (req, res, next) => { try { const result = await pool.query( 'SELECT * FROM recorders ORDER BY created_at DESC' ); - // Annotate recording rows with the container's actual StartedAt + the - // pre-created live asset id so the UI can keep a stable elapsed timer - // and embed an HLS preview. const rows = await Promise.all(result.rows.map(async (r) => { if (r.status === 'recording' && r.container_id) { try { @@ -99,7 +118,6 @@ router.get('/', async (req, res, next) => { return r; })); res.json(rows); - } catch (err) { next(err); } @@ -108,57 +126,46 @@ router.get('/', async (req, res, next) => { // POST / - Create a new recorder router.post('/', async (req, res, next) => { try { - const { - name, - source_type, - source_config, - recording_codec, - recording_resolution, - proxy_enabled, - proxy_codec, - proxy_resolution, - project_id, - } = req.body; + const fields = pickRecorderFields(req.body); - if (!name || !source_type) { + if (!fields.name || !fields.source_type) { return res .status(400) .json({ error: 'Name and source_type are required' }); } - const id = uuidv4(); + // Defaults — written on insert so the DB row is always self-contained. + const defaults = { + source_config: {}, + recording_codec: 'prores_hq', + recording_resolution: 'native', + recording_audio_codec: 'pcm_s24le', + recording_audio_channels: 2, + recording_container: 'mov', + proxy_enabled: true, + proxy_codec: 'h264', + proxy_resolution: '1920x1080', + proxy_video_bitrate: '8M', + proxy_audio_codec: 'aac', + proxy_audio_bitrate: '192k', + proxy_audio_channels: 2, + proxy_container: 'mp4', + }; + const row = { id: uuidv4(), status: 'stopped', ...defaults, ...fields }; + // Build INSERT dynamically so adding columns later means one place to update. + const cols = Object.keys(row); + const placeholders = cols.map((_, i) => `$${i + 1}`).join(', '); + const values = cols.map(k => { + const v = row[k]; + if (k === 'source_config') return v && typeof v === 'object' ? v : {}; + return v; + }); const result = await pool.query( - `INSERT INTO recorders ( - id, - name, - source_type, - source_config, - recording_codec, - recording_resolution, - proxy_enabled, - proxy_codec, - proxy_resolution, - project_id, - status, - created_at, - updated_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW()) - RETURNING *`, - [ - id, - name, - source_type, - source_config || {}, - recording_codec || 'prores_hq', - recording_resolution || 'native', - proxy_enabled !== false, - proxy_codec || 'libx264', - proxy_resolution || '1920x1080', - project_id || null, - 'stopped', - ] + `INSERT INTO recorders (${cols.join(', ')}, created_at, updated_at) + VALUES (${placeholders}, NOW(), NOW()) + RETURNING *`, + values ); res.status(201).json(result.rows[0]); @@ -206,41 +213,18 @@ router.patch('/:id', async (req, res, next) => { return res.status(409).json({ error: 'Cannot edit a recorder while it is recording — stop it first' }); } - const { - name, - source_type, - source_config, - recording_codec, - recording_resolution, - proxy_enabled, - proxy_codec, - proxy_resolution, - project_id, - } = req.body; - - const updates = []; - const params = []; - let n = 1; - - if (name !== undefined) { updates.push(`name = $${n++}`); params.push(name); } - if (source_type !== undefined) { updates.push(`source_type = $${n++}`); params.push(source_type); } - if (source_config !== undefined) { updates.push(`source_config = $${n++}`); params.push(source_config); } - if (recording_codec !== undefined) { updates.push(`recording_codec = $${n++}`); params.push(recording_codec); } - if (recording_resolution !== undefined){ updates.push(`recording_resolution = $${n++}`); params.push(recording_resolution); } - if (proxy_enabled !== undefined) { updates.push(`proxy_enabled = $${n++}`); params.push(proxy_enabled); } - if (proxy_codec !== undefined) { updates.push(`proxy_codec = $${n++}`); params.push(proxy_codec); } - if (proxy_resolution !== undefined) { updates.push(`proxy_resolution = $${n++}`); params.push(proxy_resolution); } - if (project_id !== undefined) { updates.push(`project_id = $${n++}`); params.push(project_id || null); } - - if (updates.length === 0) { + const fields = pickRecorderFields(req.body); + const cols = Object.keys(fields); + if (cols.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } - updates.push(`updated_at = NOW()`); + const setClause = cols.map((k, i) => `${k} = $${i + 1}`).join(', '); + const params = cols.map(k => fields[k]); params.push(id); const result = await pool.query( - `UPDATE recorders SET ${updates.join(', ')} WHERE id = $${n} RETURNING *`, + `UPDATE recorders SET ${setClause}, updated_at = NOW() WHERE id = $${params.length} RETURNING *`, params ); @@ -255,7 +239,6 @@ router.post('/:id/start', async (req, res, next) => { try { const { id } = req.params; - // Get recorder config from DB const recorderResult = await pool.query( 'SELECT * FROM recorders WHERE id = $1', [id] @@ -271,7 +254,6 @@ router.post('/:id/start', async (req, res, next) => { return res.status(400).json({ error: 'Recorder is already recording' }); } - // Get S3 config from environment const s3Endpoint = process.env.S3_ENDPOINT; const s3Bucket = process.env.S3_BUCKET; const s3AccessKey = process.env.S3_ACCESS_KEY; @@ -279,35 +261,32 @@ router.post('/:id/start', async (req, res, next) => { const mamApiUrl = process.env.MAM_API_URL || 'http://mam-api:3000'; const dockerNetwork = process.env.DOCKER_NETWORK || 'wild-dragon_wild-dragon'; - // Generate clip name with timestamp const clipName = generateClipName(recorder.name); - // live-asset: create the asset row right now (status='live') so the library - // shows the recording while it is happening. The capture container will - // tee an HLS stream into /live//. + // live-asset: create the asset row right now (status='live') so the + // library shows the recording while it is happening. const assetIdLive = uuidv4(); try { + const ext = recorder.recording_container || 'mov'; await pool.query( `INSERT INTO assets ( id, project_id, bin_id, filename, display_name, status, media_type, original_s3_key, created_at, updated_at ) VALUES ($1, $2, NULL, $3, $3, 'live', 'video', $4, NOW(), NOW())`, - [assetIdLive, recorder.project_id, clipName, `projects/${recorder.project_id}/masters/${clipName}.mov`] + [assetIdLive, recorder.project_id, clipName, `projects/${recorder.project_id}/masters/${clipName}.${ext}`] ); } catch (e) { console.warn('[recorders] could not pre-create live asset:', e.message); } - // Determine source config and whether this is a listener-mode recorder const sourceConfig = recorder.source_config || {}; const isListener = sourceConfig.mode === 'listener'; const sourceType = recorder.source_type; + const deviceIndex = recorder.device_index ?? sourceConfig.device ?? 0; - // Build port bindings for listener-mode SRT/RTMP containers const { portBindings, exposedPorts } = buildPortConfig(sourceType, sourceConfig); - // Build container environment — pass all source params so the capture - // service can auto-start recording on container startup + // Build container env — all codec controls flow through here. const env = [ `S3_ENDPOINT=${s3Endpoint}`, `S3_BUCKET=${s3Bucket}`, @@ -318,17 +297,34 @@ router.post('/:id/start', async (req, res, next) => { `RECORDER_ID=${id}`, `SOURCE_TYPE=${sourceType}`, `SOURCE_CONFIG=${JSON.stringify(sourceConfig)}`, - `RECORDING_CODEC=${recorder.recording_codec}`, - `RECORDING_RESOLUTION=${recorder.recording_resolution}`, - `PROXY_ENABLED=${recorder.proxy_enabled}`, - `PROXY_CODEC=${recorder.proxy_codec}`, - `PROXY_RESOLUTION=${recorder.proxy_resolution}`, + `DEVICE_INDEX=${deviceIndex}`, + + // Recording codec controls + `RECORDING_CODEC=${recorder.recording_codec || 'prores_hq'}`, + `RECORDING_RESOLUTION=${recorder.recording_resolution || 'native'}`, + `RECORDING_VIDEO_BITRATE=${recorder.recording_video_bitrate || ''}`, + `RECORDING_FRAMERATE=${recorder.recording_framerate || ''}`, + `RECORDING_AUDIO_CODEC=${recorder.recording_audio_codec || 'pcm_s24le'}`, + `RECORDING_AUDIO_BITRATE=${recorder.recording_audio_bitrate || ''}`, + `RECORDING_AUDIO_CHANNELS=${recorder.recording_audio_channels ?? 2}`, + `RECORDING_CONTAINER=${recorder.recording_container || 'mov'}`, + + // Proxy codec controls + `PROXY_ENABLED=${recorder.proxy_enabled !== false ? 'true' : 'false'}`, + `PROXY_CODEC=${recorder.proxy_codec || 'h264'}`, + `PROXY_RESOLUTION=${recorder.proxy_resolution || '1920x1080'}`, + `PROXY_VIDEO_BITRATE=${recorder.proxy_video_bitrate || '8M'}`, + `PROXY_FRAMERATE=${recorder.proxy_framerate || ''}`, + `PROXY_AUDIO_CODEC=${recorder.proxy_audio_codec || 'aac'}`, + `PROXY_AUDIO_BITRATE=${recorder.proxy_audio_bitrate || '192k'}`, + `PROXY_AUDIO_CHANNELS=${recorder.proxy_audio_channels ?? 2}`, + `PROXY_CONTAINER=${recorder.proxy_container || 'mp4'}`, + `PROJECT_ID=${recorder.project_id}`, `CLIP_NAME=${clipName}`, `ASSET_ID=${assetIdLive}`, ]; - // Add source-specific env vars for SRT/RTMP if (sourceType === 'srt' || sourceType === 'rtmp') { env.push(`LISTEN=${isListener ? '1' : '0'}`); if (isListener) { @@ -341,8 +337,6 @@ router.post('/:id/start', async (req, res, next) => { } } - // Build container config - // Stable network alias so mam-api can fetch live capture status by id const alias = `recorder-${id}`; const containerConfig = { Image: 'wild-dragon-capture:latest', @@ -362,7 +356,6 @@ router.post('/:id/start', async (req, res, next) => { Hostname: alias, }; - // Create container const createRes = await dockerApi('POST', '/containers/create', containerConfig); if (createRes.status !== 201) { @@ -373,8 +366,6 @@ router.post('/:id/start', async (req, res, next) => { } const containerId = createRes.data.Id; - - // Start container const startRes = await dockerApi('POST', `/containers/${containerId}/start`); if (startRes.status !== 204) { @@ -384,7 +375,6 @@ router.post('/:id/start', async (req, res, next) => { }); } - // Update recorder in DB const updateResult = await pool.query( `UPDATE recorders SET container_id = $1, status = $2, current_session_id = $3, updated_at = NOW() @@ -404,7 +394,6 @@ router.post('/:id/stop', async (req, res, next) => { try { const { id } = req.params; - // Get recorder from DB const recorderResult = await pool.query( 'SELECT * FROM recorders WHERE id = $1', [id] @@ -420,13 +409,11 @@ router.post('/:id/stop', async (req, res, next) => { return res.status(400).json({ error: 'No container running' }); } - // Stop container const stopRes = await dockerApi( 'POST', `/containers/${recorder.container_id}/stop` ); - // 204 = stopped, 304 = already stopped — both are acceptable if (stopRes.status !== 204 && stopRes.status !== 304) { return res.status(500).json({ error: 'Failed to stop container', @@ -434,7 +421,6 @@ router.post('/:id/stop', async (req, res, next) => { }); } - // Remove container — 204 = removed, 404 = already gone (both acceptable) const removeRes = await dockerApi( 'DELETE', `/containers/${recorder.container_id}` @@ -447,7 +433,6 @@ router.post('/:id/stop', async (req, res, next) => { }); } - // Update recorder in DB const updateResult = await pool.query( `UPDATE recorders SET container_id = NULL, status = $1, updated_at = NOW() @@ -467,7 +452,6 @@ router.get('/:id/status', async (req, res, next) => { try { const { id } = req.params; - // Get recorder from DB const recorderResult = await pool.query( 'SELECT * FROM recorders WHERE id = $1', [id] @@ -487,7 +471,6 @@ router.get('/:id/status', async (req, res, next) => { }); } - // Query Docker API for container status const inspectRes = await dockerApi( 'GET', `/containers/${recorder.container_id}/json` @@ -506,7 +489,6 @@ router.get('/:id/status', async (req, res, next) => { const now = Date.now(); const duration = Math.floor((now - startedAt) / 1000); - // Try to fetch live signal from the recorder's capture sidecar via network alias let signal = container.State.Running ? 'receiving' : 'stopped'; let signalKnown = false; let live = null; @@ -516,9 +498,7 @@ router.get('/:id/status', async (req, res, next) => { live = await captureRes.json(); if (live && live.signal) { signal = live.signal; signalKnown = true; } } - } catch (_) { - // Container may not be ready yet, or alias hasn't propagated. Leave signal as default. - } + } catch (_) { /* not ready yet */ } res.json({ status: container.State.Running ? 'recording' : 'stopped', @@ -527,9 +507,9 @@ router.get('/:id/status', async (req, res, next) => { signal, signalKnown, framesReceived: live ? live.framesReceived : null, - currentFps: live ? live.currentFps : null, - lastFrameAt: live ? live.lastFrameAt : null, - lastError: live ? live.lastError : null, + currentFps: live ? live.currentFps : null, + lastFrameAt: live ? live.lastFrameAt : null, + lastError: live ? live.lastError : null, }); } catch (err) { next(err); @@ -541,7 +521,6 @@ router.delete('/:id', async (req, res, next) => { try { const { id } = req.params; - // Get recorder from DB const recorderResult = await pool.query( 'SELECT * FROM recorders WHERE id = $1', [id] @@ -553,7 +532,6 @@ router.delete('/:id', async (req, res, next) => { const recorder = recorderResult.rows[0]; - // If recording, stop the container first if (recorder.container_id) { try { await dockerApi('POST', `/containers/${recorder.container_id}/stop`); @@ -563,7 +541,6 @@ router.delete('/:id', async (req, res, next) => { } } - // Delete from DB const deleteResult = await pool.query( 'DELETE FROM recorders WHERE id = $1 RETURNING *', [id]