import express from 'express'; import http from 'http'; import pool from '../db/pool.js'; import { requireAuth } from '../middleware/auth.js'; import { v4 as uuidv4 } from 'uuid'; const router = express.Router(); router.use(requireAuth); // Docker API helper function 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); if (body) req.write(JSON.stringify(body)); req.end(); }); } // Helper function to generate clip name with timestamp function generateClipName(recorderName) { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); const seconds = String(now.getSeconds()).padStart(2, '0'); return `${recorderName}_${year}${month}${day}_${hours}${minutes}${seconds}`; } /** * 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 = {}; const exposedPorts = {}; if (sourceConfig && sourceConfig.mode === 'listener') { if (sourceType === 'srt') { const port = String(sourceConfig.listen_port || 9000); const proto = `${port}/udp`; portBindings[proto] = [{ HostPort: port }]; exposedPorts[proto] = {}; } else if (sourceType === 'rtmp') { const port = String(sourceConfig.listen_port || 1935); const proto = `${port}/tcp`; portBindings[proto] = [{ HostPort: port }]; exposedPorts[proto] = {}; } } return { portBindings, exposedPorts }; } // GET / - List all recorders router.get('/', async (req, res, next) => { try { const result = await pool.query( 'SELECT * FROM recorders ORDER BY created_at DESC' ); res.json(result.rows); } catch (err) { next(err); } }); // 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; if (!name || !source_type) { return res .status(400) .json({ error: 'Name and source_type are required' }); } const id = uuidv4(); 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', ] ); res.status(201).json(result.rows[0]); } catch (err) { next(err); } }); // GET /:id - Get single recorder router.get('/:id', async (req, res, next) => { try { const { id } = req.params; const result = await pool.query( 'SELECT * FROM recorders WHERE id = $1', [id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Recorder not found' }); } res.json(result.rows[0]); } catch (err) { next(err); } }); // POST /:id/start - Start recording 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] ); if (recorderResult.rows.length === 0) { return res.status(404).json({ error: 'Recorder not found' }); } const recorder = recorderResult.rows[0]; if (recorder.status === 'recording') { 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; const s3SecretKey = process.env.S3_SECRET_KEY; 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); // 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; // 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 const env = [ `S3_ENDPOINT=${s3Endpoint}`, `S3_BUCKET=${s3Bucket}`, `S3_ACCESS_KEY=${s3AccessKey}`, `S3_SECRET_KEY=${s3SecretKey}`, `S3_REGION=${process.env.S3_REGION || 'us-east-1'}`, `MAM_API_URL=${mamApiUrl}`, `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}`, `PROJECT_ID=${recorder.project_id}`, `CLIP_NAME=${clipName}`, ]; // Add source-specific env vars for SRT/RTMP if (sourceType === 'srt' || sourceType === 'rtmp') { env.push(`LISTEN=${isListener ? '1' : '0'}`); if (isListener) { env.push(`LISTEN_PORT=${sourceConfig.listen_port || (sourceType === 'srt' ? 9000 : 1935)}`); if (sourceType === 'rtmp' && sourceConfig.stream_key) { env.push(`STREAM_KEY=${sourceConfig.stream_key}`); } } else if (sourceConfig.url) { env.push(`SOURCE_URL=${sourceConfig.url}`); } } // 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', Env: env, ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined, HostConfig: { Privileged: true, NetworkMode: dockerNetwork, PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined, }, NetworkingConfig: { EndpointsConfig: { [dockerNetwork]: { Aliases: [alias] }, }, }, Hostname: alias, }; // Create container const createRes = await dockerApi('POST', '/containers/create', containerConfig); if (createRes.status !== 201) { return res.status(500).json({ error: 'Failed to create container', details: createRes.data, }); } const containerId = createRes.data.Id; // Start container const startRes = await dockerApi('POST', `/containers/${containerId}/start`); if (startRes.status !== 204) { return res.status(500).json({ error: 'Failed to start container', details: startRes.data, }); } // Update recorder in DB const updateResult = await pool.query( `UPDATE recorders SET container_id = $1, status = $2, current_session_id = $3, updated_at = NOW() WHERE id = $4 RETURNING *`, [containerId, 'recording', clipName, id] ); res.json(updateResult.rows[0]); } catch (err) { next(err); } }); // POST /:id/stop - Stop recording 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] ); if (recorderResult.rows.length === 0) { return res.status(404).json({ error: 'Recorder not found' }); } const recorder = recorderResult.rows[0]; if (!recorder.container_id) { 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', details: stopRes.data, }); } // Remove container — 204 = removed, 404 = already gone (both acceptable) const removeRes = await dockerApi( 'DELETE', `/containers/${recorder.container_id}` ); if (removeRes.status !== 204 && removeRes.status !== 404) { return res.status(500).json({ error: 'Failed to remove container', details: removeRes.data, }); } // Update recorder in DB const updateResult = await pool.query( `UPDATE recorders SET container_id = NULL, status = $1, updated_at = NOW() WHERE id = $2 RETURNING *`, ['stopped', id] ); res.json(updateResult.rows[0]); } catch (err) { next(err); } }); // GET /:id/status - Get live status 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] ); if (recorderResult.rows.length === 0) { return res.status(404).json({ error: 'Recorder not found' }); } const recorder = recorderResult.rows[0]; if (!recorder.container_id) { return res.json({ status: recorder.status, duration: 0, containerId: null, }); } // Query Docker API for container status const inspectRes = await dockerApi( 'GET', `/containers/${recorder.container_id}/json` ); if (inspectRes.status !== 200) { return res.json({ status: 'unknown', duration: 0, containerId: recorder.container_id, }); } const container = inspectRes.data; const startedAt = new Date(container.State.StartedAt).getTime(); 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 ? 'connecting' : 'stopped'; let live = null; try { const captureRes = await fetch(`http://recorder-${id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) }); if (captureRes.ok) { live = await captureRes.json(); if (live && live.signal) signal = live.signal; } } catch (_) { // Container may not be ready yet, or alias hasn't propagated. Leave signal as default. } res.json({ status: container.State.Running ? 'recording' : 'stopped', duration, containerId: recorder.container_id, signal, framesReceived: live ? live.framesReceived : null, currentFps: live ? live.currentFps : null, lastFrameAt: live ? live.lastFrameAt : null, lastError: live ? live.lastError : null, }); } catch (err) { next(err); } }); // DELETE /:id - Delete recorder 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] ); if (recorderResult.rows.length === 0) { return res.status(404).json({ error: 'Recorder not found' }); } const recorder = recorderResult.rows[0]; // If recording, stop the container first if (recorder.container_id) { try { await dockerApi('POST', `/containers/${recorder.container_id}/stop`); await dockerApi('DELETE', `/containers/${recorder.container_id}`); } catch (err) { console.error('Error stopping container during delete:', err); } } // Delete from DB const deleteResult = await pool.query( 'DELETE FROM recorders WHERE id = $1 RETURNING *', [id] ); res.json({ message: 'Recorder deleted', recorder: deleteResult.rows[0] }); } catch (err) { next(err); } }); router.post('/probe', async (req, res, next) => { try { const r = await fetch('http://capture:3001/capture/probe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(req.body || {}), signal: AbortSignal.timeout(15000), }); const data = await r.json().catch(() => ({})); res.status(r.status).json(data); } catch (err) { next(err); } }); export default router;