Resi Cues becomes a real module, and Companion keeps the stage timer
Yesterday's version of this was a Python script under `modules/` with a LaunchAgent to keep it alive — which the guide rules out in as many words: everything a module does runs inside its server entry, no companion programs or daemons an operator starts by hand. It also could not put anything on a dashboard, which is half of what it was wanted for. Both halves are now in the right place. `modules/resi-cues/` is an ordinary module. Its tile is one number as large as the tile allows — the countdown to the next Resi cue, the cue's name above it, the playhead and the cue just passed underneath, and the whole cue list behind a tile switch. It also declares `"provides": ["timers"]` and serves `/timers` and `/timers/stream`, so the countdown and the playhead turn up among the ProPresenter timers in the Timers module without either side knowing about the other. The countdown ticks in the browser. The server sends the playhead with the instant it was true and whether playback is running, and the tile adds the elapsed time since; the Timers module extrapolates from `startedAt` the same way. So a snapshot only has to be sent when something structural changes — a new cue, a pause, a seek — rather than at 1 Hz to every open dashboard. `resi.js` holds the part worth reading twice. Resi republishes a decoder's position only every few seconds, on an irregular cadence, and each reading is already stale when it appears; a countdown built straight from it sits seconds behind and lurches. So every reading is kept as media time minus wall time and the largest recent value is trusted — the reading that arrived with the least delay — which a slow round trip cannot drag backwards. A reading outside the link's measured noise floor counts as a seek: drop the lock, snap, and re-read the cue list at once instead of on its timer. Playback is only called paused once the playhead has been still for longer than Resi's own cadence, because a fixed timeout mistakes a slow update for a stop and freezes the countdown mid-service. Driving the ProPresenter timer on the stage screen is Companion's job now. It is already a service on that machine, so nothing new has to be kept alive, and this module no longer has to reach ProPresenter at all — it only has to be right about Resi, which is the hard part. `resi.test.js` drives the estimator on a fake clock against nine simulated Resi profiles — cadences of one to twelve seconds, staleness to three, irregular timing, variable round trips — covering lock-on, a fired cue, a seek backwards, a pause, and a pause longer than the offset window. A punctual fixture proves nothing here: against one, three separate breakages of the estimator go unnoticed. `server.test.js` runs the real server entry against a canned Resi account and checks what the two consumers actually read, including the `/timers` shape field by field. Every mutation tried against either turns them red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
/* Resi Cues — talking to Resi, and working out where the decoder actually is.
|
||||
*
|
||||
* Two things live here, both free of the shell so they can be tested on their own
|
||||
* (`resi.test.js`): a thin client for the Resi Central API, and the playhead
|
||||
* estimator that turns its coarse, late readings into a usable position.
|
||||
*
|
||||
* The API is the undocumented one behind studio.resi.io. It is not supported by
|
||||
* Resi and can change without notice; that is the price of there being no public
|
||||
* way to read cue points at all.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const RESI = 'https://central.resi.io';
|
||||
|
||||
/* ── time ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** '01:02:03.500' → milliseconds, or null when it is not a position at all. */
|
||||
function positionToMs(position) {
|
||||
if (typeof position !== 'string') return null;
|
||||
const parts = position.split(':');
|
||||
if (parts.length !== 3) return null;
|
||||
const hh = Number(parts[0]);
|
||||
const mm = Number(parts[1]);
|
||||
const ss = Number(parts[2]);
|
||||
if (!Number.isFinite(hh) || !Number.isFinite(mm) || !Number.isFinite(ss)) return null;
|
||||
return Math.round((hh * 3600 + mm * 60 + ss) * 1000);
|
||||
}
|
||||
|
||||
/** milliseconds → 'HH:MM:SS.mmm', the shape Resi's own cue positions use. */
|
||||
function msToPosition(total) {
|
||||
const t = Math.max(0, Math.floor(total));
|
||||
const pad = (n, w) => String(n).padStart(w, '0');
|
||||
return `${pad(Math.floor(t / 3600000), 2)}:${pad(Math.floor((t % 3600000) / 60000), 2)}`
|
||||
+ `:${pad(Math.floor((t % 60000) / 1000), 2)}.${pad(t % 1000, 3)}`;
|
||||
}
|
||||
|
||||
/* ── the playhead estimator ────────────────────────────────────────────── */
|
||||
|
||||
const WINDOW_MS = 120000; // how long a reading stays useful
|
||||
const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek
|
||||
|
||||
/**
|
||||
* Resi republishes a decoder's `position` only every few seconds, on an irregular
|
||||
* cadence, and each reading is already a little stale when it appears. Shown raw,
|
||||
* a countdown built on it sits seconds behind and moves in lurches.
|
||||
*
|
||||
* So every reading is kept as *media time minus wall time*. While playback runs at
|
||||
* 1x that difference is constant, and the LARGEST recent value is the reading that
|
||||
* reached us with the least delay — the one to trust. The estimate is then simply
|
||||
* "now plus that offset", which advances smoothly by itself and cannot be dragged
|
||||
* backwards by a slow round trip.
|
||||
*
|
||||
* `now` is injected so the tests can drive a fake clock.
|
||||
*/
|
||||
function createPlayhead(options) {
|
||||
const opts = options || {};
|
||||
const trimMs = Number(opts.trimMs) || 0;
|
||||
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
||||
|
||||
let offsets = []; // { at, off } — off is mediaMs - wall time it was read
|
||||
let spreads = []; // how far apart honest readings sit: this link's noise
|
||||
let gaps = []; // how long between the playhead actually moving
|
||||
let base; // the trusted offset
|
||||
let lastMedia;
|
||||
let lastNewAt;
|
||||
let lastEst;
|
||||
let seeked = false;
|
||||
|
||||
function note(mediaMs) {
|
||||
if (!Number.isFinite(mediaMs)) return;
|
||||
const t = now();
|
||||
if (mediaMs !== lastMedia) {
|
||||
const off = mediaMs - t;
|
||||
// Anything inside the measured noise floor is noise; only a move clearly
|
||||
// beyond it is a real seek. Measured rather than assumed, and remembered
|
||||
// across a reset so the first reading after one is not judged blind.
|
||||
const noise = spreads.length ? Math.max.apply(null, spreads) : 0;
|
||||
const threshold = Math.max(JUMP_FLOOR_MS, noise * 1.5 + 4000);
|
||||
if (base !== undefined) {
|
||||
const delta = off - base;
|
||||
if (Math.abs(delta) > threshold) {
|
||||
offsets = []; // the window describes where we used to be
|
||||
seeked = true;
|
||||
// Only abandon the no-going-backwards guard for a move big enough that
|
||||
// snapping is obviously right; a marginal call must not make the
|
||||
// displayed playhead lurch backwards.
|
||||
if (Math.abs(delta) > 10000) lastEst = undefined;
|
||||
} else if (delta < 0) {
|
||||
spreads = spreads.concat(-delta).slice(-20);
|
||||
}
|
||||
}
|
||||
if (lastNewAt !== undefined) {
|
||||
const gap = t - lastNewAt;
|
||||
// The gap either side of a pause is not Resi's cadence — recording it
|
||||
// would inflate the estimate and make the next pause slow to notice.
|
||||
const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
|
||||
if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) {
|
||||
gaps = gaps.concat(gap).slice(-8);
|
||||
}
|
||||
}
|
||||
offsets = offsets.concat({ at: t, off });
|
||||
lastMedia = mediaMs;
|
||||
lastNewAt = t;
|
||||
}
|
||||
const kept = offsets.filter((o) => t - o.at <= WINDOW_MS);
|
||||
// Always keep the newest reading even once it ages out. If the window empties
|
||||
// during a long pause there is nothing left to compare against, and the resume
|
||||
// reads as an ordinary reading instead of the discontinuity it is.
|
||||
offsets = kept.length ? kept : offsets.slice(-1);
|
||||
base = offsets.length ? offsets.reduce((m, o) => (o.off > m ? o.off : m), -Infinity) : undefined;
|
||||
}
|
||||
|
||||
function read() {
|
||||
if (lastNewAt === undefined) return { estMs: null, playing: false, cadenceMs: 0 };
|
||||
const t = now();
|
||||
const cadenceMs = gaps.length ? Math.max.apply(null, gaps) : 0;
|
||||
// Only call it paused once the playhead has been still for clearly longer than
|
||||
// Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a
|
||||
// countdown that freezes mid-service is the worse failure of the two.
|
||||
const pauseAfter = Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000));
|
||||
const playing = base !== undefined && t - lastNewAt < pauseAfter;
|
||||
if (!playing) {
|
||||
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
|
||||
}
|
||||
let est = t + base + trimMs;
|
||||
if (lastEst !== undefined && est < lastEst) est = lastEst; // the base is a max
|
||||
lastEst = est;
|
||||
return { estMs: est, playing: true, cadenceMs };
|
||||
}
|
||||
|
||||
/** True once per detected discontinuity — the caller re-reads the cue list. */
|
||||
function takeSeek() {
|
||||
const was = seeked;
|
||||
seeked = false;
|
||||
return was;
|
||||
}
|
||||
|
||||
return { note, read, takeSeek };
|
||||
}
|
||||
|
||||
/* ── the Resi Central API ──────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Login sets a `livingas1` cookie which doubles as the bearer token, sent as
|
||||
* `Authorization: X-Bearer <cookie>`. Everything else is a plain GET.
|
||||
*/
|
||||
function createResiClient(options) {
|
||||
const opts = options || {};
|
||||
const doFetch = opts.fetch || globalThis.fetch;
|
||||
let cookieHeader = '';
|
||||
let token = '';
|
||||
let loggedInAt = 0;
|
||||
let customerId = '';
|
||||
let cacheBust = true;
|
||||
|
||||
function headers() {
|
||||
const h = { Accept: 'application/json' };
|
||||
if (cookieHeader) h.Cookie = cookieHeader;
|
||||
if (token) h.Authorization = `X-Bearer ${token}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ userName: opts.username, password: opts.password }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Resi login failed (${res.status})`);
|
||||
const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|
||||
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
|
||||
const jar = {};
|
||||
for (const raw of setCookies) {
|
||||
const first = String(raw).split(';')[0];
|
||||
const eq = first.indexOf('=');
|
||||
if (eq > 0) jar[first.slice(0, eq)] = first.slice(eq + 1);
|
||||
}
|
||||
token = jar.livingas1 || '';
|
||||
if (!token) throw new Error('Resi login returned no session cookie');
|
||||
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
|
||||
loggedInAt = Date.now();
|
||||
const me = await get('/api_v2.svc/users/me');
|
||||
customerId = (me && me.customerId) || '';
|
||||
if (!customerId) throw new Error('This Resi account has no customerId');
|
||||
return me;
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
if (!token || Date.now() - loggedInAt > 50 * 60 * 1000) await login();
|
||||
}
|
||||
|
||||
async function get(path) {
|
||||
const res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
await login();
|
||||
const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() });
|
||||
if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`);
|
||||
return retry.json();
|
||||
}
|
||||
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function venues() {
|
||||
await ensureSession();
|
||||
return (await get(`/api/v3/customers/${customerId}/venues`)) || [];
|
||||
}
|
||||
|
||||
/** A stale cached reading looks exactly like a laggy playhead, so ask for a fresh
|
||||
* one — and if the service objects to the parameter, stop sending it. */
|
||||
async function players(venueUuid) {
|
||||
await ensureSession();
|
||||
const path = `/api_v2.svc/users/${venueUuid}/players`;
|
||||
if (cacheBust) {
|
||||
try {
|
||||
return (await get(`${path}?_=${Date.now()}`)) || [];
|
||||
} catch (err) {
|
||||
if (!/\((4\d\d)\)/.test(String(err.message))) throw err;
|
||||
cacheBust = false;
|
||||
}
|
||||
}
|
||||
return (await get(path)) || [];
|
||||
}
|
||||
|
||||
async function event(eventId) {
|
||||
await ensureSession();
|
||||
return get(`/api/v3/customers/${customerId}/events/${eventId}`);
|
||||
}
|
||||
|
||||
async function cues(profileId, eventId) {
|
||||
await ensureSession();
|
||||
const raw = (await get(
|
||||
`/api_v2.svc/streamprofiles/${profileId}/events/${eventId}/cues?canISetCues=1`)) || [];
|
||||
return raw
|
||||
.map((c) => ({ name: String(c.name || ''), ms: positionToMs(c.position) }))
|
||||
.filter((c) => c.ms !== null)
|
||||
.sort((a, b) => a.ms - b.ms);
|
||||
}
|
||||
|
||||
return {
|
||||
login,
|
||||
ensureSession,
|
||||
venues,
|
||||
players,
|
||||
event,
|
||||
cues,
|
||||
get customerId() { return customerId; },
|
||||
};
|
||||
}
|
||||
|
||||
/** The cue after `estMs`, and the one before it. */
|
||||
function surroundingCues(cues, estMs) {
|
||||
let prev = null;
|
||||
let next = null;
|
||||
for (const c of cues || []) {
|
||||
if (c.ms > estMs) { next = c; break; }
|
||||
prev = c;
|
||||
}
|
||||
return { prev, next };
|
||||
}
|
||||
|
||||
module.exports = { positionToMs, msToPosition, createPlayhead, createResiClient, surroundingCues, RESI };
|
||||
Reference in New Issue
Block a user