diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js new file mode 100644 index 0000000..be7f384 --- /dev/null +++ b/services/mam-api/src/routes/recorders.js @@ -0,0 +1,394 @@ +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}`; +} + +// 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); + + // Build container config + const containerConfig = { + Image: 'wild-dragon-capture:latest', + Env: [ + `S3_ENDPOINT=${s3Endpoint}`, + `S3_BUCKET=${s3Bucket}`, + `S3_ACCESS_KEY=${s3AccessKey}`, + `S3_SECRET_KEY=${s3SecretKey}`, + `MAM_API_URL=${mamApiUrl}`, + `RECORDER_ID=${id}`, + `SOURCE_TYPE=${recorder.source_type}`, + `SOURCE_CONFIG=${JSON.stringify(recorder.source_config)}`, + `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}`, + ], + HostConfig: { + Privileged: true, + NetworkMode: dockerNetwork, + }, + Hostname: `recorder-${recorder.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`, + }; + + // 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` + ); + + if (stopRes.status !== 204) { + return res.status(500).json({ + error: 'Failed to stop container', + details: stopRes.data, + }); + } + + // Remove container + const removeRes = await dockerApi( + 'DELETE', + `/containers/${recorder.container_id}` + ); + + if (removeRes.status !== 204) { + 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); + + res.json({ + status: container.State.Running ? 'recording' : 'stopped', + duration, + containerId: recorder.container_id, + }); + } 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); + } +}); + +export default router;