Files
ProdDash/modules/propresenter-timers/server.js
T
Waters ChurchandClaude Fable 5 b95aa7dc34 Timers: built-in LTC listener, configured in the admin page
The LTC listener now lives inside the module: it spawns the native
ltc-capture helper (moved into the module folder, compiled on first use,
exact-name device matching) for the admin-selected input device, extracts
the configured channel, and decodes SMPTE LTC in-process (ltc.js — the
engine, shared with tools/ltc-reader.js, which remains the remote-machine
option posting to /ltc).

Admin config: an "LTC listener" switch, an audio-device select populated
live from the new GET /ltc/devices route, and a 1-based channel number.
The switch off means LTC is gone entirely: no LTC entry in the Add-tile
picker, no LTC row in the Timers menu or All-timers grid, no stage/API
probes, and POST /ltc answers 409. Source precedence with it on:
listener-with-data, fresh remote reader, listener's own no-signal verdict,
then the legacy API/stage chain.

Shell (admin page): `select` fields learn `optionsRoute` — options fetched
from a module route at render, saved value kept selectable when the route
or device is gone. Documented in MODULE-GUIDE; backward compatible.
Coordinate with the shell lane before its next admin.js change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 17:21:48 -04:00

619 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* ProPresenter Timers — server part.
*
* Polls the ProPresenter openAPI for the configured timer list, the live
* timer values, and (when the connected build exposes it) LTC timecode, and
* fans one merged state object out to every tile over SSE. Browsers never
* talk to ProPresenter directly.
*
* GET /state → { state: <state> } (instant paint for new tiles)
* GET /stream → SSE, `state` events (change-detected, heartbeated)
* POST /ltc → remote LTC ingest: tools/ltc-reader.js on another
* machine reports { state, time, fps, df } here.
* GET /ltc/devices → this machine's audio input devices, for the admin
* page's device picker (select + optionsRoute).
*
* Endpoints used, verified against openapi.propresenter.com (API v1):
* GET /v1/timers → [ { id:{uuid,name,index}, allows_overrun,
* countdown:{duration} | count_down_to_time:{time_of_day,period}
* | elapsed:{start_time,end_time} } ]
* GET /v1/timers/current → [ { id:{uuid,name,index}, time:"HH:MM:SS", state } ]
* `time` may be negative ("-00:00:02") while overrunning, and some builds
* append hundredths ("00:00:01.00" appears in the spec's schema example).
* `state` enum is stopped|running|complete|overrunning|overran — but the
* spec's own response example says "overrun", so treat anything containing
* "overr" as overrun. The API exposes NO per-timer color anywhere.
* GET /v1/timecode/status → NOT in the published spec. The spec (fetched
* 2026-08, 0 mentions of "timecode", not in the /v1/status/updates
* allowlist either) documents no timecode/LTC route at all — verified
* live: ProPresenter 21.4 returns 404. We probe this candidate route
* once per connection anyway so a future ProPresenter that adds it
* lights up without a module update.
*
* Because the HTTP API has no timecode, LTC comes from the audio signal
* itself. The whole LTC feature sits behind the admin "LTC listener"
* switch — off means no LTC tile in the picker, no probes, and the ingest
* route answers 409. When on, the sources in precedence order:
* 1. the built-in listener (ltc-listener.js — this machine captures the
* admin-selected device/channel and decodes in-process) while it has
* actual data;
* 2. a fresh remote reader report (POST /ltc, tools/ltc-reader.js on the
* machine that can hear the LTC when this one can't);
* 3. the listener's own "no signal" verdict (healthy capture, silent
* feed — believed over the indirect sources below);
* 4. the HTTP timecode route if some future ProPresenter answers it;
* 5. a stage-display field labeled "LTC" (see stage-ws.js).
*
* ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path
* notes in ../propresenter-now-next/propresenter-core). So: requests run
* strictly sequentially, the next tick is scheduled only after the previous
* one finished, live values are the only every-tick call, and the timer list
* rides a slow lane. The endpoints do offer `?chunked` streaming, but polling
* matches how propresenter-core survives real-world ProPresenter quirks.
*/
/** Hot-path cadence — live timer values. */
const POLL_MS = 500;
/** Backoff while ProPresenter is unreachable. */
const RETRY_MS = 2500;
/** The configured-timer list refreshes every Nth tick (~2s at 500ms). */
const SLOW_EVERY = 4;
/** Abort a hung request well before it can stall the loop for good. */
const REQUEST_TIMEOUT_MS = 4000;
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000;
/** A reader report older than this no longer speaks for the LTC feed —
the reader posts at least 1×/s, so 6s of silence means it died. */
const INGEST_STALE_MS = 6000;
const { spawn } = require('child_process');
const { createStageLtcClient } = require('./stage-ws');
const { createLtcListener } = require('./ltc-listener');
/** Set by init(), read by the routes — both are rebuilt together on remount. */
let current = null;
function clearedState(enabled, ltcEnabled = false) {
return {
enabled,
ltcEnabled,
reachable: false,
lastError: '',
timers: [],
// supported: null = no verdict yet (probing, or ProPresenter
// unreachable); true once a timecode source is live; false when every
// source came up empty (note says why, when we know). status is the
// richer verdict (running|stopped|nosignal); fps/df only when the
// source knows them (the reader decodes both from the LTC bits).
ltc: { supported: null, time: '', receiving: false, status: '', fps: 0, df: false, source: '', note: '' },
};
}
module.exports = {
init({ config, log }) {
const streams = new Set();
// Endpoint config. Legacy flat host/port keys (saved before the endpoint
// field existed) can only still be present until the admin form is
// re-saved, so when they exist they win over the endpoint's default.
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
const host = String(config.host ?? ep.host ?? '');
const port = Number(config.port ?? ep.port) || 1025;
const password = String(config.password || '');
const enabled = Boolean(host && port);
const ltcEnabled = Boolean(config.ltcEnabled);
const ltcDevice = String(config.ltcDevice || '');
const ltcChannel = Math.max(1, Math.trunc(Number(config.ltcChannel)) || 1);
let latest = clearedState(enabled, ltcEnabled);
let lastFrame = '';
let stopped = false;
let pollTimer = null;
let tick = 0;
/** uuid → { name, index, type, allowsOverrun } from /v1/timers, in list order. */
let timerConfigs = new Map();
/** null = probe /v1/timecode/status on the next reachable tick. */
let ltcSupported = null;
let ltcLast = { time: '', receiving: false };
/** Live view of the stage-display websocket's LTC field (stage-ws.js). */
let stageLtc = { bound: false, time: '', receiving: false, note: '' };
/** Latest POST /ltc report from a remote reader, null until one lands. */
let ingest = null; // { state, time, fps, df, source, at }
let ingestExpiry = null;
/** Built-in listener's latest word: { state, time, fps, df } | { error }. */
let listenerLtc = null;
/** One LTC verdict from every source, in the precedence the header
comment explains. Text sources don't know fps — receiving/frozen/
empty maps onto the same status vocabulary the audio decode uses. */
function composeLtc() {
if (!ltcEnabled) {
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC listener disabled' };
}
const audioVerdict = (src, tag) => ({
supported: true,
source: tag,
time: src.time,
receiving: src.state === 'running',
status: src.state,
fps: src.fps,
df: src.df,
note: '',
});
// The built-in listener while it has actual data …
if (listenerLtc && !listenerLtc.error && listenerLtc.state !== 'nosignal') {
return audioVerdict(listenerLtc, 'listener');
}
// … else a fresh remote reader (during migrations both may run —
// real data beats no data) …
if (ingest && Date.now() - ingest.at < INGEST_STALE_MS) {
return audioVerdict(ingest, 'reader');
}
// … else a healthy-but-silent listener is believed over the indirect
// sources: no audio means the LTC really stopped.
if (listenerLtc && !listenerLtc.error) {
return audioVerdict(listenerLtc, 'listener');
}
const textStatus = (src) => (src.receiving ? 'running' : src.time ? 'stopped' : 'nosignal');
if (ltcSupported === true) {
return { supported: true, source: 'api', time: ltcLast.time, receiving: ltcLast.receiving, status: textStatus(ltcLast), fps: 0, df: false, note: '' };
}
if (stageLtc.bound) {
return { supported: true, source: 'stage', time: stageLtc.time, receiving: stageLtc.receiving, status: textStatus(stageLtc), fps: 0, df: false, note: '' };
}
if (ingest) {
// A reader reported once but went silent — likelier to be the
// story than anything the stage feed has to say.
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC reader offline' };
}
if (!listenerLtc?.error && ltcSupported === null && !stageLtc.note && enabled) {
return { supported: null, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: '' };
}
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: listenerLtc?.error || stageLtc.note || '' };
}
/** Timecode-shaped text — same shape rule as the other sources. */
const TC_SHAPE = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/;
const INGEST_STATES = new Set(['running', 'stopped', 'nosignal']);
/** Validated apply of one POST /ltc body. Returns '' or a client error. */
function applyIngest(body) {
if (!body || typeof body !== 'object') return 'body must be a JSON object';
const state = INGEST_STATES.has(body.state) ? body.state : '';
if (!state) return 'state must be one of running|stopped|nosignal';
const time = typeof body.time === 'string' && TC_SHAPE.test(body.time.trim()) ? body.time.trim() : '';
if (state === 'running' && !time) return 'a running report needs a timecode-shaped time';
ingest = {
state,
time,
fps: Number.isFinite(body.fps) && body.fps > 0 && body.fps <= 120 ? Number(body.fps) : 0,
df: body.df === true,
source: String(body.source || '').slice(0, 60),
at: Date.now(),
};
// When the reader dies mid-run the card must not stay green forever —
// re-broadcast once this report goes stale so the fallbacks take over.
clearTimeout(ingestExpiry);
ingestExpiry = setTimeout(broadcast, INGEST_STALE_MS + 100);
ingestExpiry.unref?.();
broadcast();
return '';
}
function url(target) {
const built = new URL(`http://${host}:${port}${target}`);
if (password) built.searchParams.set('password', password);
return built;
}
async function requestJson(target) {
const response = await fetch(url(target), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
const err = new Error(`ProPresenter ${response.status} ${response.statusText}`);
err.status = response.status;
throw err;
}
const text = await response.text();
return text ? JSON.parse(text) : null;
}
function timerType(entry) {
if (entry.countdown) return 'countdown';
if (entry.count_down_to_time) return 'countdown_to_time';
if (entry.elapsed) return 'elapsed';
return 'unknown';
}
function applyTimerList(list) {
if (!Array.isArray(list)) return;
const next = new Map();
for (const entry of list) {
const uuid = entry?.id?.uuid;
if (!uuid) continue;
next.set(uuid, {
name: String(entry.id.name || 'Timer'),
index: Number(entry.id.index) || 0,
type: timerType(entry),
allowsOverrun: Boolean(entry.allows_overrun),
});
}
timerConfigs = next;
}
/** Merge configured timers (order, type) with live values into one list. */
function mergeTimers(currentTimes) {
const live = new Map();
if (Array.isArray(currentTimes)) {
for (const entry of currentTimes) {
const uuid = entry?.id?.uuid;
if (!uuid) continue;
live.set(uuid, {
name: String(entry.id.name || ''),
time: typeof entry.time === 'string' ? entry.time : '',
state: typeof entry.state === 'string' ? entry.state : '',
});
}
}
const out = [];
for (const [uuid, cfg] of timerConfigs) {
const now = live.get(uuid);
live.delete(uuid);
out.push({
uuid,
name: (now && now.name) || cfg.name,
type: cfg.type,
allowsOverrun: cfg.allowsOverrun,
time: now ? now.time : '',
state: now ? now.state : '',
});
}
// Timers reported live but missing from the (possibly stale) config
// list still deserve a card — type unknown until the slow lane catches up.
for (const [uuid, now] of live) {
out.push({ uuid, name: now.name || 'Timer', type: 'unknown', allowsOverrun: false, time: now.time, state: now.state });
}
return out;
}
/**
* Liberal parse of a hypothetical timecode-status body (no spec documents
* its shape — see the header comment). Looks for an "HH:MM:SS:FF"-shaped
* string and a receiving/running boolean under likely key names.
*/
function parseLtc(body) {
const out = { time: '', receiving: false };
if (!body || typeof body !== 'object') return out;
const TC = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/;
for (const key of ['time', 'timecode', 'current_time', 'value']) {
if (typeof body[key] === 'string' && TC.test(body[key])) {
out.time = body[key];
break;
}
}
if (!out.time) {
for (const value of Object.values(body)) {
if (typeof value === 'string' && TC.test(value)) {
out.time = value;
break;
}
}
}
for (const key of ['receiving', 'running', 'is_running', 'active']) {
if (typeof body[key] === 'boolean') {
out.receiving = body[key];
break;
}
}
return out;
}
async function pollLtc() {
if (!ltcEnabled) return;
// A 404 verdict is retried once a minute — an endpoint appearing
// without an unreachable spell (ProPresenter hot-swapped in testing,
// or some future in-place upgrade) must not stay invisible forever.
if (ltcSupported === false && tick % 120 !== 0) return;
try {
const body = await requestJson('/v1/timecode/status');
if (ltcSupported === null) log('timecode endpoint answered — LTC card is live');
ltcSupported = true;
ltcLast = parseLtc(body);
} catch (err) {
// 404 = this ProPresenter has no timecode API (the expected case, as
// of API v1). Anything else is a real failure — let the tick handle it.
if (err && err.status === 404) {
ltcSupported = false;
ltcLast = { time: '', receiving: false };
return;
}
throw err;
}
}
function broadcast() {
latest = {
enabled,
ltcEnabled,
reachable: latest.reachable,
lastError: latest.lastError,
timers: latest.timers,
ltc: composeLtc(),
};
if (!streams.size) return;
let frame;
try {
frame = JSON.stringify(latest);
} catch {
return;
}
if (frame === lastFrame) return;
lastFrame = frame;
const payload = `event: state\ndata: ${frame}\n\n`;
for (const res of streams) {
try {
res.write(payload);
} catch {
streams.delete(res);
}
}
}
async function pollTick() {
if (stopped) return;
try {
tick += 1;
// Slow lane first on the very first tick so cards get names/types
// immediately; afterwards every SLOW_EVERY ticks.
if (tick % SLOW_EVERY === 1 || !timerConfigs.size) {
applyTimerList(await requestJson('/v1/timers'));
}
// THE HOT PATH — live values every tick.
const currentTimes = await requestJson('/v1/timers/current');
// Probe timecode once per connection; poll it only when it exists.
await pollLtc();
latest.timers = mergeTimers(currentTimes);
latest.reachable = true;
latest.lastError = '';
} catch (err) {
latest.reachable = false;
latest.lastError = err instanceof Error ? err.message : String(err);
// Re-probe timecode after a reconnect — the unreachable spell may
// have been a ProPresenter upgrade or a different machine.
ltcSupported = null;
ltcLast = { time: '', receiving: false };
}
broadcast();
if (stopped) return;
pollTimer = setTimeout(pollTick, latest.reachable ? POLL_MS : RETRY_MS);
}
let stageClient = null;
if (enabled) {
pollTick();
log(`polling timers on ${host}:${port}`);
if (ltcEnabled) {
stageClient = createStageLtcClient({
host,
port,
password: String(config.stagePassword || ''),
log,
// The HTTP poller knows every configured timer's uuid — anything
// else streaming timecode-shaped text can be heuristically bound.
isTimerUid: (uid) => timerConfigs.has(uid),
onUpdate(update) {
stageLtc = update;
broadcast();
},
});
}
} else {
log('no ProPresenter host configured — set one in /admin');
}
// The built-in listener is independent of ProPresenter — LTC-only
// setups (no host configured) still get the LTC card.
let ltcListener = null;
if (ltcEnabled) {
if (ltcDevice) {
ltcListener = createLtcListener({
device: ltcDevice,
channel: ltcChannel,
moduleDir: __dirname,
log,
onUpdate(update) {
listenerLtc = update;
broadcast();
},
});
} else {
listenerLtc = { error: 'LTC listener is on but no audio device is selected — pick one in Admin' };
broadcast();
}
}
const heartbeat = setInterval(() => {
for (const res of streams) {
try {
res.write(': ping\n\n');
} catch {
streams.delete(res);
}
}
}, HEARTBEAT_MS);
heartbeat.unref?.();
current = { streams, getLatest: () => latest, ingest: applyIngest };
return {
stop() {
stopped = true;
clearTimeout(pollTimer);
clearTimeout(ingestExpiry);
clearInterval(heartbeat);
stageClient?.stop();
ltcListener?.stop();
for (const res of streams) {
try { res.end(); } catch { /* already gone */ }
}
streams.clear();
if (current && current.streams === streams) current = null;
},
health() {
const ltcSource = { listener: ', LTC via listener', reader: ', LTC via remote reader', api: ', LTC via API', stage: ', LTC via stage display' };
if (!enabled) {
// The LTC listener/reader work regardless of ProPresenter — an
// LTC-only setup is half-configured but working.
if (latest.ltc.supported) return { status: 'ok', message: `No ProPresenter host configured — LTC only${ltcSource[latest.ltc.source] || ''}` };
return { status: 'error', message: 'No ProPresenter host configured' };
}
if (latest.reachable) {
const ltc = latest.ltc;
const ltcNote = !ltcEnabled
? ''
: ltc.supported
? (ltcSource[ltc.source] || '')
: (ltc.note ? ` (LTC: ${ltc.note})` : '');
return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` };
}
return {
status: 'error',
message: latest.lastError
? `ProPresenter unreachable: ${latest.lastError}`
: 'Trying to reach ProPresenter…',
};
},
};
},
/**
* The Add-tile picker's entries: one per discovered timer, plus "All
* timers" and the LTC card (moduleApi.variant carries the entry id —
* 'all', 'timer:<uuid>' or 'ltc'). Answered from the poller's latest
* state, never from a fresh upstream request — the picker must stay
* instant, and a dead ProPresenter must not stall it.
*/
tiles() {
const state = current ? current.getLatest() : null;
if (!state || (!state.enabled && !state.ltcEnabled)) return []; // unconfigured → classic single entry
const TYPE_LABEL = {
countdown: 'Countdown timer',
countdown_to_time: 'Countdown to a time of day',
elapsed: 'Elapsed timer',
unknown: 'Timer',
};
const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } };
return [
{
id: 'all',
name: 'All timers',
description: state.ltcEnabled
? 'Every timer and the LTC timecode, selectable per tile'
: 'Every timer, selectable per tile',
},
...state.timers.map((t) => ({
id: `timer:${t.uuid}`,
name: t.name,
description: TYPE_LABEL[t.type] || 'Timer',
...oneCard,
})),
// The LTC entry exists only while the admin switch is on.
...(state.ltcEnabled
? [{ id: 'ltc', name: 'LTC timecode', description: 'Incoming timecode (HH:MM:SS:FF)', ...oneCard }]
: []),
];
},
routes({ config }) {
const sendJson = (res, code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
};
return {
'GET /state': (req, res) => {
sendJson(res, 200, { state: current ? current.getLatest() : clearedState(false) });
},
// The admin device picker asks this machine what it can hear. The
// options shape matches the admin select's optionsRoute contract.
'GET /ltc/devices': (req, res) => {
const bin = require('path').join(__dirname, 'ltc-capture');
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
let err = '';
child.stderr.setEncoding('utf8');
child.stderr.on('data', (t) => { err += t; });
child.on('error', () => {
sendJson(res, 200, { options: [], error: 'LTC capture tool missing — see modules/propresenter-timers/ltc-capture.swift' });
});
child.on('exit', () => {
if (res.writableEnded) return;
const options = [];
for (const line of err.split('\n')) {
const m = line.match(/^ltc-capture:\s+(.*?)\s+(\d+) ch\s+(\d+) Hz\s+uid=/);
if (m) options.push({ value: m[1], label: `${m[1]} — ${m[2]} ch @ ${(Number(m[3]) / 1000)} kHz` });
}
sendJson(res, 200, { options });
});
},
// A remote LTC reader (tools/ltc-reader.js, on the machine that can
// hear the LTC when this one can't) reports here — the one route on
// this module that accepts writes, so it optionally carries a shared
// token (Admin → "LTC reader token").
'POST /ltc': (req, res) => {
if (!config.ltcEnabled) {
return void sendJson(res, 409, { error: 'LTC listener is disabled in Admin — enable it to accept reader reports' });
}
const token = String(config.ingestToken || '');
if (token) {
const given = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
if (given !== token) return void sendJson(res, 401, { error: 'bad or missing token' });
}
let body = '';
req.on('data', (chunk) => {
body += chunk;
if (body.length > 4096) {
sendJson(res, 413, { error: 'body too large' });
req.destroy();
}
});
req.on('end', () => {
if (!current) return void sendJson(res, 503, { error: 'module not mounted' });
let parsed;
try {
parsed = JSON.parse(body);
} catch {
return void sendJson(res, 400, { error: 'invalid JSON' });
}
const problem = current.ingest(parsed);
if (problem) return void sendJson(res, 400, { error: problem });
sendJson(res, 200, { ok: true });
});
},
'GET /stream': (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
const state = current ? current.getLatest() : clearedState(false);
res.write(`event: state\ndata: ${JSON.stringify(state)}\n\n`);
if (!current) return void res.end();
const { streams } = current;
// A viewer that stops reading (locked tablet, sleeping laptop) must
// not queue frames forever; keepalive probes make the OS notice.
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
streams.add(res);
req.on('close', () => streams.delete(res));
},
};
},
};