On a Resi that publishes every ~30s, the board could start a few seconds before a publish and learn a 2s cadence from that partial first gap. Every honest 30s gap after that was thrown out as a pause, so playback read as paused 15s into every update: the countdown froze, and the next cue -- and the name on the Timers card -- stopped moving on. - The partial gap right after startup is no longer learned, and one outlier gap can no longer lock out the real cadence. - Until a cadence is measured, a pause is only called after 45s, not 15s. - The Timers card is titled with the cue's name; its status line now says "Next cue" / "Held" instead of repeating the name. - server.test.js awaited nothing, so an async test raced the next one and failed on main; tests now run one at a time. Same fix as the Resi -> ProPresenter bridge and the Companion module 0.1.2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
278 lines
11 KiB
JavaScript
278 lines
11 KiB
JavaScript
/* 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 changes = 0; // readings that differed from the one before
|
|
let rejected = false; // the last gap looked like a pause and was skipped
|
|
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);
|
|
}
|
|
}
|
|
// The first reading lands at an arbitrary point in Resi's cycle, so the gap
|
|
// from it to the next one is only a fraction of the real cadence. Learning
|
|
// from it locked in a 2s cadence that rejected every honest 30s gap.
|
|
if (lastNewAt !== undefined && changes > 0) {
|
|
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.
|
|
// But a pause is one gap; twice in a row is the cadence having changed.
|
|
const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
|
|
if (gap > 0 && gap < 60000) {
|
|
if (!worst || gap <= worst * 3 || rejected) {
|
|
gaps = gaps.concat(gap).slice(-8);
|
|
rejected = false;
|
|
} else {
|
|
rejected = true;
|
|
}
|
|
}
|
|
}
|
|
if (lastNewAt !== undefined) changes++;
|
|
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.
|
|
// Until a full gap has been measured the cadence is unknown; assume the slowest
|
|
// Resi plausibly is, or a 30s publisher reads as paused 15s after every start.
|
|
const pauseAfter = cadenceMs ? Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000)) : 45000;
|
|
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 };
|