A failed login left no session, so the next 1s poll signed in again, and the next, for ever. Resi answers that with 429s, and since the retries never let up the account never got out of them: at North Attleboro the decoder picker stayed empty with "Resi login failed (429)". The picker made it worse by building a fresh client -- and signing in -- on every admin page load. - After a failed login, further attempts wait 15s, doubling to 5min (or as long as Resi's Retry-After asks); calls in between fail at once with the reason and how long until the next try. - The status says what happened: rate-limited, password rejected, or Resi unreachable. - The decoder picker reuses the running module's client when the login matches, otherwise one client per login, so it shares the hold-off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
317 lines
13 KiB
JavaScript
317 lines
13 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
|
|
const LOGIN_BACKOFF_MS = 15000; // first wait after a failed Resi login
|
|
const LOGIN_BACKOFF_MAX_MS = 300000; // longest wait between login attempts
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/* A failed login used to be retried on the very next poll -- once a second, for
|
|
ever. Resi answers that with 429s, and since the retries never let up, the
|
|
account never got out of them. So a failure now holds off further attempts,
|
|
doubling from 15s to 5min (or as long as Resi's Retry-After asks), and every
|
|
call in between fails at once with the same reason instead of signing in. */
|
|
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
|
let loginFailures = 0;
|
|
let loginBlockedUntil = 0;
|
|
let loginError = '';
|
|
|
|
async function login() {
|
|
if (now() < loginBlockedUntil) {
|
|
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
|
|
throw new Error(`${loginError} — retrying in ${wait}s`);
|
|
}
|
|
let res;
|
|
try {
|
|
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 }),
|
|
});
|
|
} catch (err) {
|
|
holdOff(`Resi unreachable (${(err && err.message) || err})`, 0);
|
|
throw new Error(loginError);
|
|
}
|
|
if (!res.ok) {
|
|
const reason = res.status === 429 ? 'Resi is rate-limiting this login (429)'
|
|
: res.status === 400 || res.status === 401 ? `Resi rejected the login (${res.status}) — check the password`
|
|
: `Resi login failed (${res.status})`;
|
|
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
|
|
holdOff(reason, retryAfter * 1000);
|
|
throw new Error(reason);
|
|
}
|
|
loginFailures = 0;
|
|
loginBlockedUntil = 0;
|
|
loginError = '';
|
|
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;
|
|
}
|
|
|
|
function holdOff(reason, askedMs) {
|
|
loginFailures++;
|
|
const backoff = Math.min(LOGIN_BACKOFF_MAX_MS, LOGIN_BACKOFF_MS * 2 ** (loginFailures - 1));
|
|
loginBlockedUntil = now() + Math.max(backoff, askedMs || 0);
|
|
loginError = reason;
|
|
}
|
|
|
|
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 };
|