/* 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 `. 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 };