2026-04-07 22:05:41 -04:00
|
|
|
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}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 08:21:03 -04:00
|
|
|
/**
|
|
|
|
|
* 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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 22:05:41 -04:00
|
|
|
// GET / - List all recorders
|
|
|
|
|
router.get('/', async (req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
'SELECT * FROM recorders ORDER BY created_at DESC'
|
|
|
|
|
);
|
2026-05-18 09:40:42 -04:00
|
|
|
// 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 {
|
|
|
|
|
const insp = await dockerApi('GET', `/containers/${r.container_id}/json`);
|
|
|
|
|
if (insp.status === 200 && insp.data && insp.data.State) {
|
|
|
|
|
r.started_at = insp.data.State.StartedAt;
|
|
|
|
|
}
|
|
|
|
|
} catch (_) { /* leave started_at undefined */ }
|
|
|
|
|
try {
|
|
|
|
|
const live = await pool.query(
|
|
|
|
|
`SELECT id FROM assets WHERE project_id = $1 AND display_name = $2 AND status = 'live' ORDER BY created_at DESC LIMIT 1`,
|
|
|
|
|
[r.project_id, r.current_session_id]
|
|
|
|
|
);
|
|
|
|
|
if (live.rows.length > 0) r.live_asset_id = live.rows[0].id;
|
|
|
|
|
} catch (_) { /* skip */ }
|
|
|
|
|
}
|
|
|
|
|
return r;
|
|
|
|
|
}));
|
|
|
|
|
res.json(rows);
|
|
|
|
|
|
2026-04-07 22:05:41 -04:00
|
|
|
} 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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-18 23:24:27 -04:00
|
|
|
// PATCH /:id - Edit recorder settings
|
|
|
|
|
// Blocked while recorder is actively recording to prevent config drift.
|
|
|
|
|
router.patch('/:id', async (req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
const { id } = req.params;
|
|
|
|
|
|
|
|
|
|
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(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) {
|
|
|
|
|
return res.status(400).json({ error: 'No fields to update' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updates.push(`updated_at = NOW()`);
|
|
|
|
|
params.push(id);
|
|
|
|
|
|
|
|
|
|
const result = await pool.query(
|
|
|
|
|
`UPDATE recorders SET ${updates.join(', ')} WHERE id = $${n} RETURNING *`,
|
|
|
|
|
params
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
res.json(result.rows[0]);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
next(err);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-07 22:05:41 -04:00
|
|
|
// 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);
|
|
|
|
|
|
2026-05-18 07:29:50 -04:00
|
|
|
// 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/<assetId>/.
|
|
|
|
|
const assetIdLive = (await import('uuid')).v4();
|
|
|
|
|
try {
|
|
|
|
|
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`]
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('[recorders] could not pre-create live asset:', e.message);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 08:21:03 -04:00
|
|
|
// 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}`,
|
2026-05-18 07:29:50 -04:00
|
|
|
`ASSET_ID=${assetIdLive}`,
|
2026-05-16 08:21:03 -04:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// 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}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 22:05:41 -04:00
|
|
|
// 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}`;
|
2026-04-07 22:05:41 -04:00
|
|
|
const containerConfig = {
|
|
|
|
|
Image: 'wild-dragon-capture:latest',
|
2026-05-16 08:21:03 -04:00
|
|
|
Env: env,
|
|
|
|
|
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
|
2026-04-07 22:05:41 -04:00
|
|
|
HostConfig: {
|
|
|
|
|
Privileged: true,
|
|
|
|
|
NetworkMode: dockerNetwork,
|
2026-05-16 08:21:03 -04:00
|
|
|
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
|
2026-05-18 07:29:50 -04:00
|
|
|
Binds: ['/mnt/NVME/MAM/wild-dragon-live:/live'],
|
2026-04-07 22:05:41 -04:00
|
|
|
},
|
2026-05-17 07:39:19 -04:00
|
|
|
NetworkingConfig: {
|
|
|
|
|
EndpointsConfig: {
|
|
|
|
|
[dockerNetwork]: { Aliases: [alias] },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
Hostname: alias,
|
2026-04-07 22:05:41 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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`
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-16 00:31:10 -04:00
|
|
|
// 204 = stopped, 304 = already stopped — both are acceptable
|
|
|
|
|
if (stopRes.status !== 204 && stopRes.status !== 304) {
|
2026-04-07 22:05:41 -04:00
|
|
|
return res.status(500).json({
|
|
|
|
|
error: 'Failed to stop container',
|
|
|
|
|
details: stopRes.data,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 00:31:10 -04:00
|
|
|
// Remove container — 204 = removed, 404 = already gone (both acceptable)
|
2026-04-07 22:05:41 -04:00
|
|
|
const removeRes = await dockerApi(
|
|
|
|
|
'DELETE',
|
|
|
|
|
`/containers/${recorder.container_id}`
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-16 00:31:10 -04:00
|
|
|
if (removeRes.status !== 204 && removeRes.status !== 404) {
|
2026-04-07 22:05:41 -04:00
|
|
|
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
|
2026-05-18 09:40:42 -04:00
|
|
|
let signal = container.State.Running ? 'receiving' : 'stopped';
|
|
|
|
|
let signalKnown = false;
|
2026-05-17 07:39:19 -04:00
|
|
|
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();
|
2026-05-18 09:40:42 -04:00
|
|
|
if (live && live.signal) { signal = live.signal; signalKnown = true; }
|
2026-05-17 07:39:19 -04:00
|
|
|
}
|
|
|
|
|
} catch (_) {
|
|
|
|
|
// Container may not be ready yet, or alias hasn't propagated. Leave signal as default.
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 22:05:41 -04:00
|
|
|
res.json({
|
|
|
|
|
status: container.State.Running ? 'recording' : 'stopped',
|
|
|
|
|
duration,
|
|
|
|
|
containerId: recorder.container_id,
|
2026-05-17 07:39:19 -04:00
|
|
|
signal,
|
2026-05-18 09:40:42 -04:00
|
|
|
signalKnown,
|
2026-05-17 07:39:19 -04:00
|
|
|
framesReceived: live ? live.framesReceived : null,
|
|
|
|
|
currentFps: live ? live.currentFps : null,
|
|
|
|
|
lastFrameAt: live ? live.lastFrameAt : null,
|
|
|
|
|
lastError: live ? live.lastError : null,
|
2026-04-07 22:05:41 -04:00
|
|
|
});
|
|
|
|
|
} 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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-17 18:39:09 -04:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-07 22:05:41 -04:00
|
|
|
export default router;
|