2026-04-07 21:58:30 -04:00
|
|
|
import express from 'express';
|
2026-05-17 18:39:09 -04:00
|
|
|
import { execSync, spawn } from 'child_process';
|
2026-04-07 21:58:30 -04:00
|
|
|
import captureManager from '../capture-manager.js';
|
|
|
|
|
|
|
|
|
|
const router = express.Router();
|
|
|
|
|
|
|
|
|
|
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GET /devices
|
|
|
|
|
* List available DeckLink devices
|
|
|
|
|
*/
|
|
|
|
|
router.get('/devices', (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const devices = [];
|
|
|
|
|
let output = '';
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
output = execSync('ffmpeg -f decklink -list_devices 1 -i dummy 2>&1', {
|
|
|
|
|
encoding: 'utf-8',
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// ffmpeg returns non-zero, but stderr is still captured
|
|
|
|
|
output = error.stderr ? error.stderr.toString() : error.toString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse ffmpeg output for DeckLink device names
|
|
|
|
|
// Format: [decklink @ ...] "DeckLink Quad 2" (input #0)
|
|
|
|
|
const lines = output.split('\n');
|
|
|
|
|
let deviceIndex = 0;
|
|
|
|
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
const match = line.match(/^\s*\[decklink[^\]]*\]\s+"([^"]+)"/);
|
|
|
|
|
if (match) {
|
|
|
|
|
devices.push({
|
|
|
|
|
index: deviceIndex,
|
|
|
|
|
name: match[1],
|
|
|
|
|
});
|
|
|
|
|
deviceIndex++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.json({ devices });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error listing devices:', error);
|
|
|
|
|
res.status(500).json({ error: 'Failed to list devices' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GET /status
|
|
|
|
|
* Get current capture status
|
|
|
|
|
*/
|
|
|
|
|
router.get('/status', (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const status = captureManager.getStatus();
|
|
|
|
|
res.json(status);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error getting status:', error);
|
|
|
|
|
res.status(500).json({ error: 'Failed to get status' });
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-17 18:39:09 -04:00
|
|
|
router.post('/probe', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { source_type = 'sdi', source_url, listen = false, device } = req.body || {};
|
|
|
|
|
|
|
|
|
|
if (source_type === 'sdi') {
|
|
|
|
|
try {
|
|
|
|
|
const raw = execSync('ffmpeg -hide_banner -f decklink -list_devices 1 -i dummy 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
|
|
|
|
const devices = [];
|
|
|
|
|
for (const line of raw.split('\n')) {
|
|
|
|
|
const m = line.match(/\[decklink[^\]]*\]\s+"([^"]+)"/);
|
|
|
|
|
if (m) devices.push(m[1]);
|
|
|
|
|
}
|
|
|
|
|
return res.json({ ok: true, source_type, devices });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const out = (err.stderr || err.stdout || err.toString()).toString();
|
|
|
|
|
return res.json({ ok: false, source_type, error: out.slice(0, 800) });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (listen) {
|
|
|
|
|
return res.json({ ok: false, source_type, error: 'Listener-mode sources cannot be probed standalone. Start the recorder and watch the signal indicator.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!source_url) return res.status(400).json({ error: 'source_url is required' });
|
|
|
|
|
|
|
|
|
|
let url = source_url;
|
|
|
|
|
if (source_type === 'srt' && !/mode=/.test(url)) {
|
|
|
|
|
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const args = ['-hide_banner','-v','error','-probesize','32M','-analyzeduration','8M','-rw_timeout','7000000','-i', url, '-show_streams','-show_format','-of','json'];
|
|
|
|
|
const ff = spawn('ffprobe', args);
|
|
|
|
|
let stdout = '', stderr = '';
|
|
|
|
|
ff.stdout.on('data', (c) => { stdout += c; });
|
|
|
|
|
ff.stderr.on('data', (c) => { stderr += c; });
|
|
|
|
|
const killer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 10000);
|
|
|
|
|
ff.on('close', (code) => {
|
|
|
|
|
clearTimeout(killer);
|
|
|
|
|
if (code !== 0) {
|
|
|
|
|
return res.json({ ok: false, source_type, source_url, error: (stderr || 'ffprobe failed').slice(0, 800) });
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(stdout);
|
|
|
|
|
const streams = (parsed.streams || []).map(s => ({
|
|
|
|
|
index: s.index, codec_type: s.codec_type, codec_name: s.codec_name,
|
|
|
|
|
width: s.width, height: s.height, pix_fmt: s.pix_fmt,
|
|
|
|
|
r_frame_rate: s.r_frame_rate, avg_frame_rate: s.avg_frame_rate,
|
|
|
|
|
sample_rate: s.sample_rate, channels: s.channels,
|
|
|
|
|
channel_layout: s.channel_layout, bit_rate: s.bit_rate,
|
|
|
|
|
}));
|
|
|
|
|
return res.json({ ok: true, source_type, source_url,
|
|
|
|
|
format: { format_name: parsed.format && parsed.format.format_name, duration: parsed.format && parsed.format.duration, bit_rate: parsed.format && parsed.format.bit_rate },
|
|
|
|
|
streams });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
return res.json({ ok: false, source_type, source_url, error: 'Could not parse ffprobe output: ' + err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Probe error:', error);
|
|
|
|
|
res.status(500).json({ error: error.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-07 21:58:30 -04:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* POST /start
|
|
|
|
|
* Start a new capture session
|
2026-05-16 08:20:10 -04:00
|
|
|
*
|
|
|
|
|
* Body (SDI):
|
|
|
|
|
* { project_id, clip_name, device, bin_id?, source_type? }
|
|
|
|
|
*
|
|
|
|
|
* Body (SRT/RTMP caller):
|
|
|
|
|
* { project_id, clip_name, source_type, source_url, bin_id? }
|
|
|
|
|
*
|
|
|
|
|
* Body (SRT/RTMP listener):
|
|
|
|
|
* { project_id, clip_name, source_type, listen: true, listen_port?, stream_key?, bin_id? }
|
2026-04-07 21:58:30 -04:00
|
|
|
*/
|
|
|
|
|
router.post('/start', async (req, res) => {
|
|
|
|
|
try {
|
2026-05-16 08:20:10 -04:00
|
|
|
const {
|
|
|
|
|
project_id,
|
|
|
|
|
bin_id,
|
|
|
|
|
clip_name,
|
|
|
|
|
device,
|
|
|
|
|
source_type = 'sdi',
|
|
|
|
|
source_url,
|
|
|
|
|
listen = false,
|
|
|
|
|
listen_port,
|
|
|
|
|
stream_key,
|
|
|
|
|
} = req.body;
|
|
|
|
|
|
|
|
|
|
if (!project_id || !clip_name) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
error: 'Missing required fields: project_id, clip_name',
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-07 21:58:30 -04:00
|
|
|
|
2026-05-16 08:20:10 -04:00
|
|
|
// Source-specific validation
|
|
|
|
|
if (source_type === 'sdi') {
|
|
|
|
|
if (device === undefined || device === null) {
|
|
|
|
|
return res.status(400).json({ error: 'SDI source requires: device' });
|
|
|
|
|
}
|
|
|
|
|
} else if (source_type === 'srt' || source_type === 'rtmp') {
|
|
|
|
|
if (!listen && !source_url) {
|
|
|
|
|
return res.status(400).json({
|
|
|
|
|
error: `${source_type.toUpperCase()} caller mode requires: source_url`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2026-04-07 21:58:30 -04:00
|
|
|
return res.status(400).json({
|
2026-05-16 08:20:10 -04:00
|
|
|
error: `Unknown source_type: ${source_type}. Must be sdi, srt, or rtmp`,
|
2026-04-07 21:58:30 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const session = await captureManager.start({
|
|
|
|
|
projectId: project_id,
|
2026-05-16 00:30:25 -04:00
|
|
|
binId: bin_id || null,
|
2026-04-07 21:58:30 -04:00
|
|
|
clipName: clip_name,
|
|
|
|
|
device,
|
2026-05-16 08:20:10 -04:00
|
|
|
sourceType: source_type,
|
|
|
|
|
sourceUrl: source_url,
|
|
|
|
|
listen,
|
|
|
|
|
listenPort: listen_port,
|
|
|
|
|
streamKey: stream_key,
|
2026-04-07 21:58:30 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.json(session);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error starting capture:', error);
|
|
|
|
|
res.status(500).json({ error: error.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* POST /stop
|
|
|
|
|
* Stop the current capture session
|
|
|
|
|
* Body: { session_id }
|
|
|
|
|
*/
|
|
|
|
|
router.post('/stop', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { session_id } = req.body;
|
|
|
|
|
|
|
|
|
|
if (!session_id) {
|
|
|
|
|
return res.status(400).json({ error: 'Missing required field: session_id' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const completedSession = await captureManager.stop(session_id);
|
|
|
|
|
|
2026-05-16 08:20:10 -04:00
|
|
|
// Register asset with mam-api.
|
|
|
|
|
// If proxyKey is null (SRT/RTMP source), set needsProxy=true so the
|
|
|
|
|
// worker generates a proxy from the hires file asynchronously.
|
2026-04-07 21:58:30 -04:00
|
|
|
try {
|
|
|
|
|
const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
projectId: completedSession.projectId,
|
|
|
|
|
binId: completedSession.binId,
|
|
|
|
|
clipName: completedSession.clipName,
|
2026-05-16 08:20:10 -04:00
|
|
|
sourceType: completedSession.sourceType,
|
2026-04-07 21:58:30 -04:00
|
|
|
hiresKey: completedSession.hiresKey,
|
|
|
|
|
proxyKey: completedSession.proxyKey,
|
2026-05-16 08:20:10 -04:00
|
|
|
needsProxy: completedSession.proxyKey === null,
|
2026-04-07 21:58:30 -04:00
|
|
|
duration: completedSession.duration,
|
|
|
|
|
capturedAt: completedSession.startedAt,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!mamResponse.ok) {
|
|
|
|
|
console.warn(
|
|
|
|
|
`MAM API registration returned ${mamResponse.status}: ${await mamResponse.text()}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} catch (mamError) {
|
|
|
|
|
console.warn('Failed to register asset with MAM API:', mamError.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.json(completedSession);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error stopping capture:', error);
|
|
|
|
|
res.status(500).json({ error: error.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export default router;
|