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:
parent
f2b8d5dc4b
commit
bab24e156a
3 changed files with 178 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 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 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)",
|
||||
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 = labels[sig] || sig;
|
||||
el.style.color = colors[sig] || "var(--text-tertiary)";
|
||||
el.title = st.lastError || "";
|
||||
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,6 +677,69 @@
|
|||
}
|
||||
|
||||
// ── Save recorder ─────────────────────────
|
||||
|
||||
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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue