fix: library + caller-only recorders + live signal indicator

Three problems blocked the end-to-end flow:

1) Library always rendered empty because /assets returns {assets,total} but
   index.html (and capture.html) assumed r.data was an array. Fixed in
   api.js by unwrapping r.data.assets centrally; total is kept on r.total.

2) SRT/RTMP caller mode pulled audio only. ffmpeg opened the network input
   before the H264 SPS arrived, marked the video stream as pix_fmt=none,
   and silently dropped it from the stream map. Added -probesize 32M
   -analyzeduration 10M -fflags +genpts and explicit -map 0✌️0?/0🅰️0? so
   each track survives independently of when it appears.

3) Hitting Record gave no feedback about whether a stream was actually
   arriving. capture-manager now parses ffmpeg progress lines (frame=...
   fps=...) and tracks framesReceived, currentFps, lastFrameAt, lastError.
   getStatus() returns a derived signal enum (connecting | receiving |
   lost | error | stopped). The recorder controller gives each spawned
   container a stable network alias `recorder-<id>` and the GET
   /recorders/:id/status endpoint proxies the live capture status through.
   recorders.html polls that every 2s and renders the badge under each
   active card with the running frame/fps counter or the ffmpeg error.

Also:
* recorders.html: dropped the listener-mode UI entirely. All new recorders
  are caller-mode (pull). The MAM is no longer offered as an RTMP/SRT
  server. Legacy listener records still render but read-only.
This commit is contained in:
Zac Gaetano 2026-05-17 07:39:19 -04:00
parent 3154cce37c
commit ac1878452f
4 changed files with 159 additions and 97 deletions

View file

@ -11,6 +11,11 @@ class CaptureManager {
sessionId: null, sessionId: null,
processes: {}, processes: {},
currentSession: {}, currentSession: {},
// Live signal metrics derived from ffmpeg stderr
framesReceived: 0,
currentFps: 0,
lastFrameAt: null,
lastError: null,
}; };
} }
@ -32,7 +37,7 @@ class CaptureManager {
url += (url.includes('?') ? '&' : '?') + 'mode=caller'; url += (url.includes('?') ? '&' : '?') + 'mode=caller';
} }
} }
return { inputArgs: ['-i', url], isNetwork: true }; return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', url], isNetwork: true };
} }
if (sourceType === 'rtmp') { if (sourceType === 'rtmp') {
@ -40,11 +45,11 @@ class CaptureManager {
const port = listenPort || 1935; const port = listenPort || 1935;
const key = streamKey || 'stream'; const key = streamKey || 'stream';
return { return {
inputArgs: ['-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`], inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`],
isNetwork: true, isNetwork: true,
}; };
} }
return { inputArgs: ['-i', sourceUrl], isNetwork: true }; return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
} }
// Default: SDI via DeckLink // Default: SDI via DeckLink
@ -105,6 +110,8 @@ class CaptureManager {
// ProRes hires — fragmented moov for pipe-safe output on network sources // ProRes hires — fragmented moov for pipe-safe output on network sources
const hiresCodecArgs = isNetwork const hiresCodecArgs = isNetwork
? [ ? [
'-map', '0:v:0?',
'-map', '0:a:0?',
'-c:v', 'prores_ks', '-c:v', 'prores_ks',
'-profile:v', '3', '-profile:v', '3',
'-c:a', 'pcm_s24le', '-c:a', 'pcm_s24le',
@ -133,7 +140,19 @@ class CaptureManager {
const uploads = { hires: hiresUpload }; const uploads = { hires: hiresUpload };
hiresProcess.stderr.on('data', (data) => { hiresProcess.stderr.on('data', (data) => {
console.error(`[HIRES] ${data}`); const text = data.toString();
console.error(`[HIRES] ${text}`);
// Track stream signal: ffmpeg prints "frame= 123 fps= 30 ..." every ~1s
const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/);
if (m) {
this.state.framesReceived = parseInt(m[1], 10);
this.state.currentFps = parseFloat(m[2]);
this.state.lastFrameAt = new Date().toISOString();
}
// Surface fatal-looking lines for the status endpoint
if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
this.state.lastError = text.trim().slice(0, 240);
}
}); });
// SDI only: spawn a second FFmpeg process for the proxy. // SDI only: spawn a second FFmpeg process for the proxy.
@ -166,6 +185,10 @@ class CaptureManager {
this.state.recording = true; this.state.recording = true;
this.state.sessionId = sessionId; this.state.sessionId = sessionId;
this.state.processes = processes; this.state.processes = processes;
this.state.framesReceived = 0;
this.state.currentFps = 0;
this.state.lastFrameAt = null;
this.state.lastError = null;
this.state.currentSession = { this.state.currentSession = {
sessionId, sessionId,
projectId, projectId,
@ -254,6 +277,14 @@ class CaptureManager {
const now = new Date(); const now = new Date();
const duration = Math.round((now - startTime) / 1000); const duration = Math.round((now - startTime) / 1000);
const lastFrameAt = this.state.lastFrameAt;
const msSinceFrame = lastFrameAt ? (Date.now() - new Date(lastFrameAt).getTime()) : null;
let signal = 'connecting';
if (this.state.framesReceived > 0) {
signal = (msSinceFrame !== null && msSinceFrame < 5000) ? 'receiving' : 'lost';
} else if (this.state.lastError) {
signal = 'error';
}
return { return {
recording: true, recording: true,
sessionId: this.state.sessionId, sessionId: this.state.sessionId,
@ -264,6 +295,12 @@ class CaptureManager {
binId: this.state.currentSession.binId, binId: this.state.currentSession.binId,
duration, duration,
startedAt: this.state.currentSession.startedAt, startedAt: this.state.currentSession.startedAt,
signal,
framesReceived: this.state.framesReceived,
currentFps: this.state.currentFps,
lastFrameAt,
msSinceFrame,
lastError: this.state.lastError,
}; };
} }

View file

@ -240,6 +240,8 @@ router.post('/:id/start', async (req, res, next) => {
} }
// Build container config // Build container config
// Stable network alias so mam-api can fetch live capture status by id
const alias = `recorder-${id}`;
const containerConfig = { const containerConfig = {
Image: 'wild-dragon-capture:latest', Image: 'wild-dragon-capture:latest',
Env: env, Env: env,
@ -249,7 +251,12 @@ router.post('/:id/start', async (req, res, next) => {
NetworkMode: dockerNetwork, NetworkMode: dockerNetwork,
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined, PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
}, },
Hostname: `recorder-${recorder.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`, NetworkingConfig: {
EndpointsConfig: {
[dockerNetwork]: { Aliases: [alias] },
},
},
Hostname: alias,
}; };
// Create container // Create container
@ -396,10 +403,28 @@ router.get('/:id/status', async (req, res, next) => {
const now = Date.now(); const now = Date.now();
const duration = Math.floor((now - startedAt) / 1000); const duration = Math.floor((now - startedAt) / 1000);
// 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({ res.json({
status: container.State.Running ? 'recording' : 'stopped', status: container.State.Running ? 'recording' : 'stopped',
duration, duration,
containerId: recorder.container_id, containerId: recorder.container_id,
signal,
framesReceived: live ? live.framesReceived : null,
currentFps: live ? live.currentFps : null,
lastFrameAt: live ? live.lastFrameAt : null,
lastError: live ? live.lastError : null,
}); });
} catch (err) { } catch (err) {
next(err); next(err);

View file

@ -73,7 +73,14 @@ async function captureApi(path, options = {}) {
async function getAssets(filters = {}) { async function getAssets(filters = {}) {
const params = new URLSearchParams(filters); const params = new URLSearchParams(filters);
const path = `/assets${params.toString() ? '?' + params.toString() : ''}`; const path = `/assets${params.toString() ? '?' + params.toString() : ''}`;
return api(path); const r = await api(path);
// API returns { assets, total }. Callers expect r.data to be the array,
// so unwrap here — but preserve total as a sibling for any caller that wants it.
if (r.success && r.data && typeof r.data === 'object' && Array.isArray(r.data.assets)) {
r.total = r.data.total;
r.data = r.data.assets;
}
return r;
} }
async function getAsset(assetId) { async function getAsset(assetId) {
@ -253,7 +260,12 @@ async function stopRecording() {
* Get recent captures uses assets API ordered by created_at desc. * Get recent captures uses assets API ordered by created_at desc.
*/ */
async function getRecentCaptures(limit = 10) { async function getRecentCaptures(limit = 10) {
return api(`/assets?limit=${limit}`); const r = await api(`/assets?limit=${limit}`);
if (r.success && r.data && typeof r.data === 'object' && Array.isArray(r.data.assets)) {
r.total = r.data.total;
r.data = r.data.assets;
}
return r;
} }
/** Not available in current capture service — returns empty */ /** Not available in current capture service — returns empty */

View file

@ -3,11 +3,11 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recorders — Wild Dragon</title> <title>Recorders — Z-AMPP</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/common.css"> <link rel="stylesheet" href="css/common.css?v=3">
<style> <style>
/* Recorder grid */ /* Recorder grid */
.recorder-grid { .recorder-grid {
@ -191,10 +191,8 @@
<!-- Sidebar --> <!-- Sidebar -->
<nav class="sidebar" aria-label="Main navigation"> <nav class="sidebar" aria-label="Main navigation">
<div class="sidebar-brand"> <div class="sidebar-brand">
<div class="sidebar-brand-mark"> <img src="img/dragon-mark.png" alt="Z-AMPP" class="sidebar-logo">
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><path d="M8 1L2 5v6l6 4 6-4V5L8 1zm0 2.2L12 6v4l-4 2.7L4 10V6l4-2.8z"/></svg> <span class="sidebar-brand-name">Z-AMPP</span>
</div>
<span class="sidebar-brand-name">Wild Dragon</span>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<a href="index.html" class="nav-item"> <a href="index.html" class="nav-item">
@ -298,9 +296,9 @@
<label class="form-label" for="recResolution">Resolution</label> <label class="form-label" for="recResolution">Resolution</label>
<select id="recResolution"> <select id="recResolution">
<option value="native">Native (source)</option> <option value="native">Native (source)</option>
<option value="1920x1080">1920x1080</option> <option value="1920x1080">1920×1080</option>
<option value="1280x720">1280x720</option> <option value="1280x720">1280×720</option>
<option value="3840x2160">3840x2160</option> <option value="3840x2160">3840×2160</option>
</select> </select>
</div> </div>
</div> </div>
@ -361,11 +359,27 @@
<script src="js/api.js"></script> <script src="js/api.js"></script>
<script> <script>
const pState = { recorders: [], timers: {}, sourceType: 'srt', mode: 'listener', projects: [] }; const pState = { recorders: [], timers: {}, sourceType: 'srt', mode: 'caller', projects: [], signals: {} };
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
await Promise.all([loadRecorders(), loadProjects()]); await Promise.all([loadRecorders(), loadProjects()]);
setInterval(loadRecorders, 5000); setInterval(loadRecorders, 5000);
setInterval(pollRecordingSignals, 2000);
// Poll live signal info for every recorder currently in `recording` state
async function pollRecordingSignals() {
const active = pState.recorders.filter(r => r.status === "recording");
await Promise.all(active.map(async (rec) => {
try {
const resp = await fetch(`/api/v1/recorders/${rec.id}/status`, { credentials: "include" });
if (resp.ok) {
const j = await resp.json();
pState.signals[rec.id] = j;
updateSignalBadge(rec.id, j);
}
} catch (_) {}
}));
}
document.getElementById('newRecorderBtn').onclick = openPanel; document.getElementById('newRecorderBtn').onclick = openPanel;
document.getElementById('closePanelBtn').onclick = closePanel; document.getElementById('closePanelBtn').onclick = closePanel;
@ -378,7 +392,7 @@
updateSourceFields(); updateSourceFields();
}); });
// Load / render // ── Load / render ─────────────────────────
async function loadRecorders() { async function loadRecorders() {
const r = await getRecorders(); const r = await getRecorders();
if (!r.success) return; if (!r.success) return;
@ -404,26 +418,27 @@
const statusDotClass = isRecording ? 'status-dot--recording' : rec.status === 'error' ? 'status-dot--error' : 'status-dot--idle'; const statusDotClass = isRecording ? 'status-dot--recording' : rec.status === 'error' ? 'status-dot--error' : 'status-dot--idle';
let sourceDisplay = ''; let sourceDisplay = '';
if (cfg.mode === 'listener') { if (cfg.url) {
const port = cfg.listen_port || (sourceTypeKey === 'srt' ? 9000 : 1935);
sourceDisplay = `Listen :${port}`;
} else if (cfg.url) {
sourceDisplay = cfg.url; sourceDisplay = cfg.url;
} else if (cfg.device !== undefined) { } else if (cfg.device !== undefined) {
sourceDisplay = `DeckLink ${cfg.device}`; sourceDisplay = `DeckLink ${cfg.device}`;
} else if (cfg.mode === 'listener') {
// legacy/listener data - display read-only but no longer creatable from UI
const port = cfg.listen_port || (sourceTypeKey === 'srt' ? 49001 : 41936);
sourceDisplay = `(legacy listener :${port})`;
} }
let connectBanner = ''; let connectBanner = '';
if (!isRecording && cfg.mode === 'listener') { if (!isRecording && cfg.mode === 'listener') {
const serverIp = location.hostname || '10.0.0.25'; const serverIp = location.hostname || '10.0.0.25';
if (sourceTypeKey === 'srt') { if (sourceTypeKey === 'srt') {
const port = cfg.listen_port || 9000; const port = cfg.listen_port || 49001;
connectBanner = `<div class="info-banner recorder-connect-info"> connectBanner = `<div class="info-banner recorder-connect-info">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg> <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
<span>Push to <code>srt://${serverIp}:${port}?mode=caller</code></span> <span>Push to <code>srt://${serverIp}:${port}?mode=caller</code></span>
</div>`; </div>`;
} else if (sourceTypeKey === 'rtmp') { } else if (sourceTypeKey === 'rtmp') {
const port = cfg.listen_port || 1935; const port = cfg.listen_port || 41936;
const key = cfg.stream_key || 'stream'; const key = cfg.stream_key || 'stream';
connectBanner = `<div class="info-banner recorder-connect-info"> connectBanner = `<div class="info-banner recorder-connect-info">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg> <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
@ -457,6 +472,7 @@
</span> </span>
${isRecording ? `<span class="recorder-timer" id="timer-${rec.id}">00:00:00</span>` : ''} ${isRecording ? `<span class="recorder-timer" id="timer-${rec.id}">00:00:00</span>` : ''}
</div> </div>
${isRecording ? `<div class="recorder-status-row" style="font-size:var(--text-xs);"><span id="signal-${rec.id}" style="color:var(--text-tertiary)">Connecting…</span></div>` : ''}
<div class="recorder-source"> <div class="recorder-source">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M2 4h10M2 8h7M2 12h5"/></svg> <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" width="12" height="12"><path d="M2 4h10M2 8h7M2 12h5"/></svg>
@ -480,6 +496,7 @@
</div>`; </div>`;
}).join(''); }).join('');
// Start timers for recording recorders
pState.recorders.filter(r => r.status === 'recording').forEach(rec => { pState.recorders.filter(r => r.status === 'recording').forEach(rec => {
if (!pState.timers[rec.id]) { if (!pState.timers[rec.id]) {
const startedAt = rec.started_at ? new Date(rec.started_at) : new Date(); const startedAt = rec.started_at ? new Date(rec.started_at) : new Date();
@ -498,12 +515,35 @@
}); });
} }
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 || "";
}
function formatDur(s) { function formatDur(s) {
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60; const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
return [h, m, sec].map(v => String(v).padStart(2,'0')).join(':'); return [h, m, sec].map(v => String(v).padStart(2,'0')).join(':');
} }
// Controls // ── Controls ──────────────────────────────
async function handleStart(id) { async function handleStart(id) {
const r = await startRecorder(id); const r = await startRecorder(id);
if (r.success) { toast('Recording started', '', 'success'); loadRecorders(); } if (r.success) { toast('Recording started', '', 'success'); loadRecorders(); }
@ -523,7 +563,7 @@
else toast('Delete failed', r.error, 'error'); else toast('Delete failed', r.error, 'error');
} }
// Panel // ── Panel ─────────────────────────────────
function openPanel() { function openPanel() {
document.getElementById('recorderPanel').classList.add('open'); document.getElementById('recorderPanel').classList.add('open');
document.getElementById('panelOverlay').classList.add('open'); document.getElementById('panelOverlay').classList.add('open');
@ -535,10 +575,10 @@
document.getElementById('panelOverlay').classList.remove('open'); document.getElementById('panelOverlay').classList.remove('open');
} }
// Source type // ── Source type ───────────────────────────
function setSourceType(type) { function setSourceType(type) {
pState.sourceType = type; pState.sourceType = type;
pState.mode = 'listener'; pState.mode = 'caller';
document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === type)); document.querySelectorAll('.source-type-btn').forEach(b => b.classList.toggle('active', b.dataset.type === type));
updateSourceFields(); updateSourceFields();
} }
@ -560,31 +600,15 @@
} else if (type === 'srt') { } else if (type === 'srt') {
container.innerHTML = ` container.innerHTML = `
<div class="form-group"> <div id="srtCallerFields">
<label class="form-label">Mode</label>
<div class="mode-row">
<button class="mode-btn active" data-mode="listener" onclick="setMode('listener')">Listener - encoder pushes here</button>
<button class="mode-btn" data-mode="caller" onclick="setMode('caller')">Caller - pull from source</button>
</div>
</div>
<div id="srtListenerFields">
<div class="form-group">
<label class="form-label" for="srtPort">Listen port (UDP)</label>
<input type="number" id="srtPort" value="9000" min="1024" max="65535">
<div class="form-hint">Encoders connect to this port on the server</div>
</div>
<div id="srtConnectInfo" class="info-banner">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
<span>Encoder connect string: <code id="srtConnectStr">srt://10.0.0.25:9000?mode=caller</code></span>
</div>
</div>
<div id="srtCallerFields" style="display:none;">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="srtUrl">Source URL</label> <label class="form-label" for="srtUrl">Source URL</label>
<input type="url" id="srtUrl" placeholder="srt://192.168.1.100:4200?mode=caller"> <input type="url" id="srtUrl" placeholder="srt://192.168.1.100:4200">
<div class="form-hint">The recorder will connect out to this URL (caller mode). <code>?mode=caller</code> is appended automatically.</div>
</div> </div>
</div>`; </div>`;
// Wire port input to update banner
setTimeout(() => { setTimeout(() => {
const portIn = document.getElementById('srtPort'); const portIn = document.getElementById('srtPort');
if (portIn) portIn.addEventListener('input', () => { if (portIn) portIn.addEventListener('input', () => {
@ -595,33 +619,11 @@
} else if (type === 'rtmp') { } else if (type === 'rtmp') {
container.innerHTML = ` container.innerHTML = `
<div class="form-group"> <div id="rtmpCallerFields">
<label class="form-label">Mode</label>
<div class="mode-row">
<button class="mode-btn active" data-mode="listener" onclick="setMode('listener')">Listener - encoder pushes here</button>
<button class="mode-btn" data-mode="caller" onclick="setMode('caller')">Caller - pull from source</button>
</div>
</div>
<div id="rtmpListenerFields">
<div class="form-row">
<div class="form-group">
<label class="form-label" for="rtmpPort">Listen port (TCP)</label>
<input type="number" id="rtmpPort" value="1935" min="1024" max="65535">
</div>
<div class="form-group">
<label class="form-label" for="rtmpKey">Stream key</label>
<input type="text" id="rtmpKey" value="stream" placeholder="stream">
</div>
</div>
<div id="rtmpConnectInfo" class="info-banner">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
<span>Push to: <code id="rtmpConnectStr">rtmp://10.0.0.25:1935/live/stream</code></span>
</div>
</div>
<div id="rtmpCallerFields" style="display:none;">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="rtmpUrl">Source URL</label> <label class="form-label" for="rtmpUrl">Source URL</label>
<input type="url" id="rtmpUrl" placeholder="rtmp://192.168.1.100/live/key"> <input type="url" id="rtmpUrl" placeholder="rtmp://server/live/streamkey">
<div class="form-hint">The recorder will pull this RTMP stream. Must be an existing published stream on an RTMP server.</div>
</div> </div>
</div>`; </div>`;
@ -630,7 +632,7 @@
const keyIn = document.getElementById('rtmpKey'); const keyIn = document.getElementById('rtmpKey');
const update = () => { const update = () => {
const el = document.getElementById('rtmpConnectStr'); const el = document.getElementById('rtmpConnectStr');
if (el) el.textContent = `rtmp://${location.hostname || '10.0.0.25'}:${portIn?.value || 1935}/live/${keyIn?.value || 'stream'}`; if (el) el.textContent = `rtmp://${location.hostname || '10.0.0.25'}:${portIn?.value || 41936}/live/${keyIn?.value || 'stream'}`;
}; };
portIn?.addEventListener('input', update); portIn?.addEventListener('input', update);
keyIn?.addEventListener('input', update); keyIn?.addEventListener('input', update);
@ -638,20 +640,12 @@
} }
} }
function setMode(mode) { function setMode(_mode) {
pState.mode = mode; // Listener mode UI was removed - all recorders are caller (pull) mode now.
document.querySelectorAll('.mode-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === mode)); pState.mode = 'caller';
const type = pState.sourceType;
if (type === 'srt') {
document.getElementById('srtListenerFields').style.display = mode === 'listener' ? '' : 'none';
document.getElementById('srtCallerFields').style.display = mode === 'caller' ? '' : 'none';
} else if (type === 'rtmp') {
document.getElementById('rtmpListenerFields').style.display = mode === 'listener' ? '' : 'none';
document.getElementById('rtmpCallerFields').style.display = mode === 'caller' ? '' : 'none';
}
} }
// Projects for recorder destination // ── Projects for recorder destination ─────
async function loadProjects() { async function loadProjects() {
const r = await getProjects(); const r = await getProjects();
if (!r.success) return; if (!r.success) return;
@ -670,7 +664,7 @@
if (r.success) r.data.forEach(b => binSel.innerHTML += `<option value="${b.id}">${esc(b.name)}</option>`); if (r.success) r.data.forEach(b => binSel.innerHTML += `<option value="${b.id}">${esc(b.name)}</option>`);
} }
// Save recorder // ── Save recorder ─────────────────────────
async function handleSaveRecorder() { async function handleSaveRecorder() {
const name = document.getElementById('recName').value.trim(); const name = document.getElementById('recName').value.trim();
if (!name) { toast('Enter a recorder name', '', 'warning'); return; } if (!name) { toast('Enter a recorder name', '', 'warning'); return; }
@ -686,18 +680,12 @@
if (type === 'sdi') { if (type === 'sdi') {
sourceConfig.device = parseInt(document.getElementById('sdiDevice')?.value || '0'); sourceConfig.device = parseInt(document.getElementById('sdiDevice')?.value || '0');
} else if (type === 'srt') { } else if (type === 'srt') {
sourceConfig.mode = mode; sourceConfig.mode = 'caller';
if (mode === 'listener') sourceConfig.listen_port = parseInt(document.getElementById('srtPort')?.value || '9000'); sourceConfig.url = document.getElementById('srtUrl')?.value;
else sourceConfig.url = document.getElementById('srtUrl')?.value;
} else if (type === 'rtmp') { } else if (type === 'rtmp') {
sourceConfig.mode = mode; sourceConfig.mode = 'caller';
if (mode === 'listener') {
sourceConfig.listen_port = parseInt(document.getElementById('rtmpPort')?.value || '1935');
sourceConfig.stream_key = document.getElementById('rtmpKey')?.value || 'stream';
} else {
sourceConfig.url = document.getElementById('rtmpUrl')?.value; sourceConfig.url = document.getElementById('rtmpUrl')?.value;
} }
}
const proxy = document.getElementById('proxyToggle').checked; const proxy = document.getElementById('proxyToggle').checked;
const proxyConfig = proxy ? { const proxyConfig = proxy ? {