dragonflight/services/mam-api/src/routes/recorders.js

489 lines
14 KiB
JavaScript
Raw Normal View History

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
2026-05-17 07:39:19 -04:00
// 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,
},
2026-05-17 07:39:19 -04:00
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);
2026-05-17 07:39:19 -04:00
// 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,
2026-05-17 07:39:19 -04:00
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;