feat(recorders): probe sources + reflect real signal in main status

Two things that together stop bogus URLs from masquerading as a recording:

PROBE BUTTON in the New Recorder panel. Before you commit to record, hit Probe Source - the capture container runs ffprobe with a 10s timeout against the URL and returns the parsed streams. UI shows green Signal Detected with codec/resolution/fps/audio, or red No Signal Detected with the actual ffprobe error message. For SDI it lists DeckLink devices. Listener-mode sources cannot be probed standalone (would block waiting for a publisher) and the UI says so.

MAIN STATUS LABEL ON THE RECORDING CARD now mirrors the live signal instead of hardcoding Recording. So a recorder pointed at a dead URL goes Connecting... -> Connection error (red) instead of looking like everything is fine. When frames actually start arriving the label flips to Recording (blue) and the dot turns blue. If a previously-good stream drops the label switches to Signal lost (red).

API:
* capture: POST /capture/probe runs ffprobe and returns { ok, streams, format, error? }
* mam-api: POST /api/v1/recorders/probe proxies through to the capture sidecar with a 15s outer timeout
This commit is contained in:
Zac Gaetano 2026-05-17 18:39:09 -04:00
parent f2b8d5dc4b
commit bab24e156a
3 changed files with 178 additions and 25 deletions

View file

@ -1,5 +1,5 @@
import express from 'express';
import { execSync } from 'child_process';
import { execSync, spawn } from 'child_process';
import captureManager from '../capture-manager.js';
const router = express.Router();
@ -60,6 +60,69 @@ router.get('/status', (req, res) => {
res.status(500).json({ error: 'Failed to get status' });
}
});
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 });
}
});
/**
* POST /start

View file

@ -470,4 +470,19 @@ router.delete('/:id', async (req, res, next) => {
}
});
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;

View file

@ -351,6 +351,7 @@
</div>
<div class="slide-panel-footer">
<button class="btn btn-ghost" onclick="closePanel()">Cancel</button>
<button class="btn btn-secondary" id="probeBtn">Probe source</button>
<button class="btn btn-primary" id="saveRecorderBtn">Create recorder</button>
</div>
</div>
@ -385,6 +386,7 @@
document.getElementById('closePanelBtn').onclick = closePanel;
document.getElementById('panelOverlay').onclick = closePanel;
document.getElementById('saveRecorderBtn').onclick = handleSaveRecorder;
document.getElementById('probeBtn').onclick = handleProbe;
document.getElementById('proxyToggle').onchange = e => {
document.getElementById('proxyFields').style.display = e.target.checked ? 'grid' : 'none';
};
@ -466,9 +468,9 @@
</div>
<div class="recorder-status-row">
<span class="status-dot ${statusDotClass}"></span>
<span class="text-sm" style="color:${isRecording ? 'var(--accent)' : rec.status === 'error' ? 'var(--status-red)' : 'var(--text-secondary)'}">
${isRecording ? 'Recording' : rec.status === 'error' ? 'Error' : 'Idle'}
<span class="status-dot ${statusDotClass}" id="statusDot-${rec.id}"></span>
<span class="text-sm" id="statusText-${rec.id}" style="color:${isRecording ? 'var(--accent)' : rec.status === 'error' ? 'var(--status-red)' : 'var(--text-secondary)'}">
${isRecording ? 'Connecting...' : rec.status === 'error' ? 'Error' : 'Idle'}
</span>
${isRecording ? `<span class="recorder-timer" id="timer-${rec.id}">00:00:00</span>` : ''}
</div>
@ -516,26 +518,36 @@
}
function updateSignalBadge(rid, st) {
const el = document.getElementById(`signal-${rid}`);
if (!el) return;
const sig = st.signal || "connecting";
const labels = {
connecting: "Connecting…",
receiving: `Receiving • ${st.framesReceived||0} fr • ${Math.round(st.currentFps||0)} fps`,
lost: "No signal — stream dropped",
error: "Connection error",
stopped: "Stopped",
};
const colors = {
connecting: "var(--status-yellow, oklch(82% 0.15 90))",
receiving: "var(--status-green, oklch(68% 0.18 148))",
lost: "var(--status-red, oklch(62% 0.22 25))",
error: "var(--status-red, oklch(62% 0.22 25))",
stopped: "var(--text-tertiary)",
};
el.textContent = labels[sig] || sig;
el.style.color = colors[sig] || "var(--text-tertiary)";
el.title = st.lastError || "";
const el = document.getElementById('signal-' + rid);
const sig = st.signal || 'connecting';
if (el) {
const detail = {
connecting: 'Waiting for stream...',
receiving: 'Receiving • ' + (st.framesReceived || 0) + ' fr • ' + Math.round(st.currentFps || 0) + ' fps',
lost: 'No signal — stream dropped',
error: st.lastError ? st.lastError : 'Connection error',
stopped: 'Stopped',
};
const col = {
connecting: 'var(--status-yellow, oklch(82% 0.15 90))',
receiving: 'var(--status-green, oklch(68% 0.18 148))',
lost: 'var(--status-red, oklch(62% 0.22 25))',
error: 'var(--status-red, oklch(62% 0.22 25))',
stopped: 'var(--text-tertiary)',
};
el.textContent = detail[sig] || sig;
el.style.color = col[sig] || 'var(--text-tertiary)';
el.title = st.lastError || '';
}
const mainTxt = document.getElementById('statusText-' + rid);
const mainDot = document.getElementById('statusDot-' + rid);
if (mainTxt && mainDot) {
const mainLabel = { connecting: 'Connecting...', receiving: 'Recording', lost: 'Signal lost', error: 'Connection error' }[sig] || 'Recording';
const mainCol = { connecting: 'var(--status-yellow, oklch(82% 0.15 90))', receiving: 'var(--accent)', lost: 'var(--status-red, oklch(62% 0.22 25))', error: 'var(--status-red, oklch(62% 0.22 25))' }[sig] || 'var(--accent)';
mainTxt.textContent = mainLabel;
mainTxt.style.color = mainCol;
mainDot.style.background = mainCol;
}
}
function formatDur(s) {
@ -665,7 +677,70 @@
}
// ── Save recorder ─────────────────────────
async function handleSaveRecorder() {
async function handleProbe() {
const btn = document.getElementById('probeBtn');
btn.disabled = true; btn.textContent = 'Probing...';
// Build payload from current form state
const type = pState.sourceType;
const payload = { source_type: type };
if (type === 'srt' && document.getElementById('srtUrl')) {
payload.source_url = document.getElementById('srtUrl').value.trim();
} else if (type === 'rtmp' && document.getElementById('rtmpUrl')) {
payload.source_url = document.getElementById('rtmpUrl').value.trim();
} else if (type === 'sdi') {
const d = document.getElementById('sdiDevice');
if (d) payload.device = parseInt(d.value || '0', 10);
}
try {
const r = await fetch('/api/v1/recorders/probe', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(payload),
});
const data = await r.json();
renderProbeResult(data);
} catch (err) {
renderProbeResult({ ok: false, error: 'Network error: ' + err.message });
} finally {
btn.disabled = false; btn.textContent = 'Probe source';
}
}
function renderProbeResult(d) {
let host = document.getElementById('probeResult');
if (!host) {
host = document.createElement('div');
host.id = 'probeResult';
host.style.cssText = 'margin-top:var(--sp-3);padding:var(--sp-3);border-radius:var(--r-md);border:1px solid var(--border);background:var(--bg-surface);font-size:var(--text-xs)';
const footer = document.querySelector('.slide-panel-footer');
footer.parentElement.insertBefore(host, footer);
}
if (!d.ok) {
host.style.borderColor = 'oklch(62% 0.22 25 / 0.5)';
host.style.background = 'oklch(62% 0.22 25 / 0.08)';
host.innerHTML = '<div style="color:var(--status-red);font-weight:500;margin-bottom:4px">No signal detected</div><div style="color:var(--text-secondary);white-space:pre-wrap">' + (d.error || 'Unknown error') + '</div>';
return;
}
host.style.borderColor = 'oklch(68% 0.18 148 / 0.5)';
host.style.background = 'oklch(68% 0.18 148 / 0.08)';
renderProbeOk(host, d);
}
function renderProbeOk(host, d) {
if (d.source_type === 'sdi') {
host.innerHTML = '<div style="color:var(--status-green);font-weight:500;margin-bottom:6px">DeckLink devices found</div><ul style="margin:0;padding-left:18px;color:var(--text-primary)">' + (d.devices || []).map(n => '<li>' + esc(n) + '</li>').join('') + '</ul>';
return;
}
const v = (d.streams || []).find(s => s.codec_type === 'video');
const a = (d.streams || []).find(s => s.codec_type === 'audio');
let html = '<div style="color:var(--status-green);font-weight:500;margin-bottom:6px">Signal detected</div><div style="color:var(--text-primary);line-height:1.6">';
if (v) html += '<div><strong>Video:</strong> ' + esc(v.codec_name || '?') + ' ' + (v.width || '?') + 'x' + (v.height || '?') + ' • ' + esc(v.r_frame_rate || v.avg_frame_rate || '?') + ' fps • ' + esc(v.pix_fmt || '?') + '</div>';
if (a) html += '<div><strong>Audio:</strong> ' + esc(a.codec_name || '?') + ' • ' + (a.sample_rate || '?') + ' Hz • ' + (a.channels || '?') + ' ch</div>';
html += '</div>';
host.innerHTML = html;
}
async function handleSaveRecorder() {
const name = document.getElementById('recName').value.trim();
if (!name) { toast('Enter a recorder name', '', 'warning'); return; }