Files
Nextcue/source/dist/main.js
T

1100 lines
48 KiB
JavaScript
Raw Normal View History

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ModuleInstance = void 0;
const base_1 = require("@companion-module/base");
const config_js_1 = require("./config.js");
const variables_js_1 = require("./variables.js");
const upgrades_js_1 = require("./upgrades.js");
const actions_js_1 = require("./actions.js");
const feedbacks_js_1 = require("./feedbacks.js");
const presets_js_1 = require("./presets.js");
class ModuleInstance extends base_1.InstanceBase {
config;
secrets;
cookieHeader = '';
refreshToken;
livingas1;
pollTimer;
pollInFlight = false;
lastLoginMs = 0;
loginInFlight;
customerId;
venues = [];
playersByVenueUuid = {};
cues = [];
selectedVenueUuid;
selectedPlayerHardwareId;
playerPollTimer;
playerPollInFlight = false;
currentEventId;
currentEventProfileId;
eventMediaStartEpochMs;
lastPlayerPosition;
lastCueRefreshMs = 0;
cueRefreshInFlight = false;
lastPositionSec;
constructor(internal) {
super(internal);
}
async init(config, _isFirstInit, secrets) {
this.config = config;
this.secrets = secrets;
this.updateStatus(base_1.InstanceStatus.Connecting, 'Initializing');
this.updateActions();
this.updateFeedbacks();
this.updatePresets();
this.updateVariableDefinitions();
// A Resi problem must never fail init: Companion treats that as a crash and
// restarts the module every few seconds, each restart a fresh process with no
// hold-off -- a sign-in every few seconds, which is how an account gets locked.
// So start what can be started, and let the polls (and their hold-off) retry.
try {
await this.startResi(false);
}
catch (err) {
this.log('warn', `Not connected to Resi yet: ${err?.message ?? err} — will keep trying`);
}
finally {
this.startPolling();
}
}
/** Sign in, load venues/decoders, and follow the configured decoder. */
async startResi(forceLogin) {
// Auth + customerId
await this.refreshProfileSmart(forceLogin);
// Load venues/players
await this.refreshVenuesAndPlayers();
// Auto-select default player if configured
if (this.config.defaultPlayerHardwareId) {
await this.selectPlayer(this.config.defaultPlayerHardwareId);
}
}
async configUpdated(config, secrets) {
this.config = config;
this.secrets = secrets;
// New credentials: start over, so the hold-off of the old ones doesn't apply.
this.clearHold();
this.cookieHeader = '';
try {
await this.startResi(true);
}
catch (err) {
this.log('warn', `Not connected to Resi yet: ${err?.message ?? err} — will keep trying`);
}
this.startPolling();
}
// Return config fields for web config
getConfigFields() {
const fields = (0, config_js_1.GetConfigFields)();
// Patch in current player choices
for (const f of fields) {
if (f.type === 'dropdown' && f.id === 'defaultPlayerHardwareId') {
const choices = [{ id: '', label: '(none)' }, ...this.getPlayerChoices()];
// The list only fills once Resi answers. Without the saved decoder in it,
// Companion marked the form invalid ("??") and refused to save anything --
// including the login that would have fixed the connection.
const saved = this.config?.defaultPlayerHardwareId;
if (saved && !choices.some((c) => c.id === saved))
choices.push({ id: saved, label: `${saved} (not connected to Resi yet)` });
f.choices = choices;
}
}
return fields;
}
updateActions() {
(0, actions_js_1.UpdateActions)(this);
}
updateFeedbacks() {
(0, feedbacks_js_1.UpdateFeedbacks)(this);
}
updatePresets() {
(0, presets_js_1.UpdatePresets)(this);
}
updateVariableDefinitions() {
(0, variables_js_1.UpdateVariableDefinitions)(this);
}
async refreshProfileSmart(forceLogin = false) {
try {
await this.ensureAuthenticated(forceLogin);
if (!this.cookieHeader)
return; // missing creds or login not possible yet
await this.fetchProfile();
this.updateStatus(base_1.InstanceStatus.Ok);
this.setVariableValues({ auth_status: 'OK', last_error: '' });
}
catch (err) {
const msg = err?.message ?? String(err);
this.setVariableValues({ auth_status: 'Auth/profile failed', last_error: msg });
this.log('error', `Refresh failed: ${msg}`);
this.updateStatus(base_1.InstanceStatus.ConnectionFailure, 'Refresh failed');
throw err;
}
}
async resiLogin(username, password) {
const res = await fetch('https://central.resi.io/api/v3/login?newToken=true', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ userName: username, password }),
});
if (!res.ok) {
let detail = '';
const j = (await res.json().catch(() => undefined));
if (j)
detail = [j?.code, j?.message ?? j?.error].filter(Boolean).join(' ') || JSON.stringify(j);
else
detail = await res.text().catch(() => '');
const e = new Error(`Login failed (${res.status}) ${detail}`.trim());
e.status = res.status;
e.retryAfterMs = (Number(res.headers.get('retry-after')) || 0) * 1000;
e.code = j?.code;
throw e;
}
// Node 18+ (Companion) supports getSetCookie()
const setCookies = res.headers.getSetCookie?.() ??
(res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
const cookies = {};
for (const sc of setCookies) {
const first = sc.split(';')[0];
const idx = first.indexOf('=');
if (idx > 0)
cookies[first.slice(0, idx)] = first.slice(idx + 1);
}
this.refreshToken = cookies.refreshToken;
this.livingas1 = cookies.livingas1;
const parts = [];
if (this.refreshToken)
parts.push(`refreshToken=${this.refreshToken}`);
if (this.livingas1)
parts.push(`livingas1=${this.livingas1}`);
this.cookieHeader = parts.join('; ');
}
async getUsersMe() {
const res = await fetch('https://central.resi.io/api_v2.svc/users/me', {
method: 'GET',
headers: {
Accept: 'application/json',
Cookie: this.cookieHeader,
...(this.livingas1 ? { authorization: `X-Bearer ${this.livingas1}` } : {}),
},
});
if (!res.ok) {
throw new Error(`/users/me failed (${res.status})`);
}
return res.json();
}
/* A failed login used to be retried on the very next poll -- once a second, for
ever -- and a 401/403 forced a fresh login every time. Resi answers that with
429s and, kept up, locks the account (it did, at North Attleboro). So any
failure now holds off every Resi call: 15s, doubling to 5min, or as long as
Resi's Retry-After asks. Calls in between fail at once with the reason. */
holdOff(reason, askedMs = 0) {
this.__holdFailures = (this.__holdFailures ?? 0) + 1;
const backoff = Math.min(300000, 15000 * 2 ** (this.__holdFailures - 1));
this.__holdUntil = Date.now() + Math.max(backoff, askedMs || 0);
this.__holdReason = reason;
this.updateStatus(base_1.InstanceStatus.ConnectionFailure, reason);
}
clearHold() {
this.__holdFailures = 0;
this.__holdUntil = 0;
this.__holdReason = '';
}
checkHold() {
if (Date.now() < (this.__holdUntil ?? 0)) {
const wait = Math.ceil((this.__holdUntil - Date.now()) / 1000);
throw new Error(`${this.__holdReason} — retrying in ${wait}s`);
}
}
/** A refusal straight after signing in is a permission; only an older session is renewed. */
sessionIsFresh() {
return Date.now() - (this.lastLoginMs ?? 0) < 60000;
}
/** 401/403/429 from any Resi request: renew an old session once, otherwise hold off. */
noteRefusal(status, what, retryAfterMs = 0) {
if (status === 429) {
this.holdOff('Resi is rate-limiting this account (429)', retryAfterMs);
return;
}
if ((status === 401 || status === 403) && !this.sessionIsFresh()) {
this.cookieHeader = ''; // the next call signs in again, once
return;
}
if (status === 401 || status === 403)
this.holdOff(`This Resi login isn't allowed to see ${what} (${status}) — ask a Resi admin for access`);
}
async ensureAuthenticated(force = false) {
this.checkHold();
if (force && this.sessionIsFresh() && this.cookieHeader)
force = false; // just signed in: signing in again won't change the answer
const username = this.config.username;
const password = this.secrets?.password;
if (!username || !password) {
this.updateStatus(base_1.InstanceStatus.BadConfig, 'Missing username/password');
this.setVariableValues({
auth_status: 'Missing credentials',
last_error: 'Enter username/password in config',
});
return;
}
// If we already have cookies and it hasn’t been too long, skip login
const ageMs = Date.now() - this.lastLoginMs;
const shouldRelogin = force || !this.cookieHeader || ageMs > 50 * 60 * 1000; // 50 minutes
if (!shouldRelogin)
return;
// De-dupe concurrent logins
if (!this.loginInFlight) {
this.loginInFlight = (async () => {
try {
this.updateStatus(base_1.InstanceStatus.Connecting, 'Logging in');
await this.resiLogin(username, password);
this.lastLoginMs = Date.now();
this.clearHold();
this.updateStatus(base_1.InstanceStatus.Ok, 'Connected');
this.setVariableValues({ auth_status: 'OK', last_error: '' });
}
catch (err) {
const msg = err?.message ?? String(err);
const reason = err?.code === 'USER_LOCKED' ? 'Resi has locked this account (USER_LOCKED)'
: err?.status === 429 ? 'Resi is rate-limiting this login (429)'
: err?.status === 400 || err?.status === 401 ? `Resi rejected the login (${err.status}) — check the password`
: err?.status ? `Resi login failed (${err.status})` : `Resi unreachable (${msg})`;
this.holdOff(reason, err?.retryAfterMs);
this.setVariableValues({ auth_status: 'Auth failed', last_error: `${reason} (${msg})` });
// Same words as the held-off calls that follow, so the log says it once.
throw new Error(reason);
}
})().finally(() => {
this.loginInFlight = undefined;
});
}
await this.loginInFlight;
}
async fetchProfile() {
this.updateStatus(base_1.InstanceStatus.Connecting, 'Fetching profile');
const me = await this.getUsersMe();
this.customerId = me.customerId;
this.setVariableValues({
org_name: me.customerName ?? '',
first_name: me.firstName ?? '',
last_name: me.lastName ?? '',
email: me.userName ?? '',
});
return me;
}
async refreshProfileNow() {
await this.refreshProfileSmart(true); // force login on manual refresh
this.setVariableValues({ last_poll: new Date().toLocaleString() });
}
getPlayerChoices() {
const choices = [];
for (const v of this.venues) {
const players = this.playersByVenueUuid[v.uuid] ?? [];
for (const p of players) {
choices.push({
id: p.playerHardwareId,
label: `${v.userName} → ${p.userName} (${p.playerHardwareId})`,
});
}
}
return choices;
}
async refreshListsNow() {
await this.refreshProfileSmart(false);
await this.refreshVenuesAndPlayers();
}
async selectPlayer(playerHardwareId) {
this.selectedPlayerHardwareId = playerHardwareId;
// find which venue it's in
this.selectedVenueUuid = undefined;
for (const v of this.venues) {
if ((this.playersByVenueUuid[v.uuid] ?? []).some((p) => p.playerHardwareId === playerHardwareId)) {
this.selectedVenueUuid = v.uuid;
break;
}
}
this.setVariableValues({
auth_status: this.selectedVenueUuid ? 'Player selected' : 'Player selected (venue not found)',
});
this.startPlayerPolling();
this.startSmoothTick();
}
// If the API hands us a timestamp for when this reading was taken, the sample's
// true age is known and the pipeline lag drops out. Name hint + a tight window
// so an event start time can never be mistaken for one.
detectSampleTime(p) {
const now = Date.now();
for (const k of Object.keys(p || {})) {
if (!/updat|time|stamp|sampl|asof|as_of|checkin|heartbeat|report|seen/i.test(k))
continue;
const v = p[k];
let t;
if (typeof v === 'number' && v > 1e12 && v < 4e12)
t = v;
else if (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(v)) {
const d = Date.parse(v);
if (!Number.isNaN(d))
t = d;
}
if (t !== undefined && Math.abs(now - t) < 60000)
return t;
}
return undefined;
}
notePlayheadSample(mediaMs, player) {
if (!Number.isFinite(mediaMs))
return;
const nowMs = Date.now();
if (player)
this.setVariableValues({ player_fields: Object.keys(player).sort().join(',') });
if (mediaMs !== this.__lastSampleMediaMs) {
const takenAt = (player ? this.detectSampleTime(player) : undefined) ?? nowMs;
// media time minus wall time. Constant while playing at 1x; the LARGEST
// recent value is the least-delayed reading, which is the one to trust.
const off = mediaMs - takenAt;
// How far apart honest readings sit: a slow round trip or a late poll
// makes a reading look older than it is, and that spread is this link's
// noise floor. Anything inside it is noise; only a change clearly beyond
// it is a real seek. Measured, not assumed, and kept across resets.
const spreads = this.__spreads ?? [];
const noise = spreads.length ? spreads.reduce((m, v) => (v > m ? v : m), 0) : 0;
const jumpThreshold = Math.max(5000, noise * 1.5 + 4000);
if (this.__baseOffset !== undefined) {
const delta = off - this.__baseOffset;
if (Math.abs(delta) > jumpThreshold) {
// A discontinuity: seek, fired cue, or resume after a pause.
// The window describes the old position and has to go.
this.__offsets = [];
this.__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)
this.__lastEstMs = undefined;
}
else if (delta < 0) {
this.__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 (this.__lastNewSampleAt !== undefined && (this.__sampleChanges ?? 0) > 0) {
const gap = nowMs - this.__lastNewSampleAt;
// 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 worstGap = (this.__gaps ?? []).reduce((m, g) => (g > m ? g : m), 0);
if (gap > 0 && gap < 60000) {
if (!worstGap || gap <= worstGap * 3 || this.__gapRejected) {
this.__gaps = (this.__gaps ?? []).concat(gap).slice(-8);
this.__gapRejected = false;
}
else {
this.__gapRejected = true;
}
}
}
if (this.__lastNewSampleAt !== undefined)
this.__sampleChanges = (this.__sampleChanges ?? 0) + 1;
this.__offsets = (this.__offsets ?? []).concat({ t: nowMs, off });
this.__lastSampleMediaMs = mediaMs;
this.__lastNewSampleAt = nowMs;
}
const kept = (this.__offsets ?? []).filter((o) => nowMs - o.t <= 120000);
// 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, so the resume
// reads as an ordinary reading instead of the discontinuity it is.
this.__offsets = kept.length ? kept : (this.__offsets ?? []).slice(-1);
this.__baseOffset = this.__offsets.length
? this.__offsets.reduce((m, o) => (o.off > m ? o.off : m), -Infinity)
: undefined;
}
applySmoothVars() {
if (this.__lastNewSampleAt === undefined)
return;
const nowMs = Date.now();
const gaps = this.__gaps ?? [];
const cadence = gaps.length ? gaps.reduce((m, g) => (g > m ? g : m), 0) : 0;
// Only call it paused once the playhead has been still for clearly longer
// than Resi's own (irregular) reporting cadence.
// Until a full gap has been measured the cadence is unknown; assume the slowest
// Resi plausibly is, or a 30s publisher looks paused 15s after every startup.
const pauseAfter = cadence ? Math.max(15000, Math.min(45000, cadence * 2.5 + 6000)) : 45000;
const sinceNew = nowMs - this.__lastNewSampleAt;
const playing = this.__baseOffset !== undefined && sinceNew < pauseAfter;
this.setVariableValues({
playback_state: playing ? 'PLAYING' : 'PAUSED',
position_update_interval_sec: cadence ? String(Math.round(cadence / 1000)) : '',
});
let est;
if (playing) {
const trim = Number(this.config.positionTrimMs ?? 0) || 0;
est = nowMs + this.__baseOffset + trim;
// the offset window is a max, so it can only dip when the best sample ages
// out -- never show the playhead running backwards because of that
if (this.__lastEstMs !== undefined && est < this.__lastEstMs)
est = this.__lastEstMs;
this.__lastEstMs = est;
}
else {
est = this.__lastEstMs ?? this.__lastSampleMediaMs;
}
this.computeAndSetCueVars(est);
this.setVariableValues({
current_position: this.msToHmsMs(est),
current_position_sec: String(Math.floor(est / 1000)),
});
}
msToHmsMs(total) {
const t = Math.max(0, Math.floor(total));
const hh = Math.floor(t / 3600000);
const mm = Math.floor((t % 3600000) / 60000);
const ss = Math.floor((t % 60000) / 1000);
const ms = t % 1000;
const pad = (n, w) => String(n).padStart(w, '0');
return `${pad(hh, 2)}:${pad(mm, 2)}:${pad(ss, 2)}.${pad(ms, 3)}`;
}
// Resi only republishes `position` every few seconds, so a fired cue is invisible
// until it does. If the operator fires it from Companion they already know where
// the playhead landed -- this takes their word for it and lets the next real
// sample confirm or correct it.
resyncToCue(cueName) {
const want = String(cueName ?? '').trim().toLowerCase();
const cue = (this.cues ?? []).find((c) => String(c.name ?? '').trim().toLowerCase() === want);
if (!cue) {
this.log('warn', `Resync: no cue named "${cueName}"`);
this.setVariableValues({ last_error: `Resync: no cue named "${cueName}"` });
return false;
}
this.resyncToPosition(cue.positionMs);
return true;
}
resyncToNextCue() {
const here = this.__lastEstMs ?? this.__lastSampleMediaMs;
if (!Number.isFinite(here))
return false;
const next = (this.cues ?? []).find((c) => c.positionMs > here);
if (!next) {
this.log('warn', 'Resync: there is no next cue to jump to');
return false;
}
this.resyncToPosition(next.positionMs);
return true;
}
resyncToPosition(mediaMs) {
if (!Number.isFinite(mediaMs))
return;
const nowMs = Date.now();
this.__offsets = [{ t: nowMs, off: mediaMs - nowMs }];
this.__baseOffset = mediaMs - nowMs;
this.__lastEstMs = undefined;
this.__lastSampleMediaMs = undefined; // the next poll counts as a fresh sample
this.__lastNewSampleAt = nowMs;
this.__sampleChanges = 0; // the gap from here to the next reading is partial too
this.lastCueRefreshMs = 0;
this.applySmoothVars();
}
startSmoothTick() {
if (this.__tickTimer)
clearInterval(this.__tickTimer);
this.__tickTimer = setInterval(() => {
try {
this.applySmoothVars();
}
catch (e) {
// a display tick must never kill the instance
}
}, 1000);
}
startPlayerPolling() {
if (this.playerPollTimer)
clearInterval(this.playerPollTimer);
const ms = Math.max(250, Number(this.config.playerPollMs ?? 1000) || 1000);
this.playerPollTimer = setInterval(() => {
void this.pollPlayer();
}, ms);
}
async pollPlayer() {
if (this.playerPollInFlight)
return;
this.playerPollInFlight = true;
try {
if (!this.selectedVenueUuid || !this.selectedPlayerHardwareId)
return;
await this.ensureAuthenticated(false);
if (!this.cookieHeader)
return;
// A stale cached reading looks exactly like a laggy playhead, so ask for a
// fresh one. If the service objects to the cache-busting parameter, drop it
// and never send it again rather than failing every poll.
const playersUrl = `https://central.resi.io/api_v2.svc/users/${this.selectedVenueUuid}/players`;
const noCacheHeaders = { ...this.buildAuthHeaders(), 'Cache-Control': 'no-cache', Pragma: 'no-cache' };
let res = await fetch(this.__noCacheBust ? playersUrl : `${playersUrl}?_=${Date.now()}`, {
method: 'GET',
headers: noCacheHeaders,
});
if (!res.ok && !this.__noCacheBust && res.status >= 400 && res.status < 500
&& ![401, 403, 429].includes(res.status)) {
this.__noCacheBust = true;
this.log('info', 'players: retrying without the cache-busting parameter');
res = await fetch(playersUrl, { method: 'GET', headers: noCacheHeaders });
}
if (!res.ok) {
this.noteRefusal(res.status, "this venue's decoders",
(Number(res.headers.get('retry-after')) || 0) * 1000);
throw new Error(`players poll failed (${res.status})`);
}
const players = (await res.json());
const p = players.find((x) => x.playerHardwareId === this.selectedPlayerHardwareId);
// Guard: player not found
if (!p) {
this.setVariableValues({
auth_status: 'Selected player not found',
last_error: 'Selected playerHardwareId not present in /players response',
});
return;
}
const posMs = this.parseTimeToMs(p.position ?? '00:00:00');
const eventId = p.eventId ?? undefined;
const posSec = Math.floor(posMs / 1000);
if (this.lastPositionSec === undefined || this.lastPositionSec !== posSec) {
this.lastPositionSec = posSec;
this.setVariableValues({
playback_last_change_epoch_ms: String(Date.now()),
});
}
this.setVariableValues({
current_position: p.position ?? '',
current_position_sec: String(posSec),
current_event_id: eventId ?? '',
last_error: '',
});
this.lastPlayerPosition = p.position ?? '';
// If event changed, refresh cues + capture event startTime -> eventMediaStartEpochMs
if (eventId && eventId !== this.currentEventId) {
this.currentEventId = eventId;
this.eventMediaStartEpochMs = undefined;
await this.refreshEventProfileAndCues();
}
// --- playhead estimator: record this sample (patched) ---
this.notePlayheadSample(posMs, p);
this.applySmoothVars();
if (this.__seeked) {
this.__seeked = false;
this.lastCueRefreshMs = 0; // make the cue list catch up now, not on the next tick
}
// --- end patch ---
this.__lastPollWarn = ''; // a later failure is news again
await this.refreshCuesIfDue();
}
catch (err) {
const msg = err?.message ?? String(err);
// Logged when the reason changes, not on every 1s poll of a held-off account.
const gist = msg.replace(/ — retrying in \d+s$/, '');
if (gist !== this.__lastPollWarn)
this.log('warn', `Player poll failed: ${msg}`);
this.__lastPollWarn = gist;
this.setVariableValues({
auth_status: 'Player poll failed',
last_error: msg,
});
}
finally {
this.playerPollInFlight = false;
}
}
async refreshEventProfileAndCues() {
if (!this.customerId || !this.currentEventId)
return;
await this.ensureAuthenticated(false);
if (!this.cookieHeader)
return;
// The event's details (profile, start time) don't change while it plays, so
// they're read once per event rather than on every cue refresh.
if (this.__eventDetailFor !== this.currentEventId || !this.currentEventProfileId) {
const evRes = await fetch(`https://central.resi.io/api/v3/customers/${this.customerId}/events/${this.currentEventId}`, { method: 'GET', headers: this.buildAuthHeaders() });
if (!evRes.ok) {
this.noteRefusal(evRes.status, 'this event', (Number(evRes.headers.get('retry-after')) || 0) * 1000);
throw new Error(`event detail failed (${evRes.status})`);
}
const ev = (await evRes.json());
this.currentEventProfileId = ev.eventProfileId;
// anchor ToD off event startTime
if (ev.startTime) {
const t = Date.parse(ev.startTime);
if (!Number.isNaN(t))
this.eventMediaStartEpochMs = t;
}
this.__eventDetailFor = this.currentEventId;
}
// cues
const cuesRes = await fetch(`https://central.resi.io/api_v2.svc/streamprofiles/${this.currentEventProfileId}/events/${this.currentEventId}/cues?canISetCues=1`, { method: 'GET', headers: this.buildAuthHeaders() });
if (!cuesRes.ok) {
this.noteRefusal(cuesRes.status, "this event's cues", (Number(cuesRes.headers.get('retry-after')) || 0) * 1000);
throw new Error(`cues failed (${cuesRes.status})`);
}
const cuesJson = (await cuesRes.json());
this.cues = cuesJson
.map((c) => ({
uuid: c.uuid,
name: c.name,
user: c.user,
privateCue: c.privateCue,
positionMs: this.parseTimeToMs(c.position),
}))
.sort((a, b) => a.positionMs - b.positionMs);
// Said when the list changes, not on every refresh (every few seconds).
const cueSig = `${this.currentEventId}|${JSON.stringify(this.cues.map((c) => [c.name, c.positionMs]))}`;
if (cueSig !== this.__cueSig)
this.log('info', `Loaded ${this.cues.length} cues for event ${this.currentEventId}`);
this.__cueSig = cueSig;
this.setVariableValues({
event_id: this.currentEventId ?? '',
cue_count: String(this.cues.length),
cue_names: this.cues.map((c) => c.name).join(', '),
});
}
startPolling() {
if (this.pollTimer)
clearInterval(this.pollTimer);
const ms = Math.max(10000, Number(this.config.listPollMs ?? 120000) || 120000);
this.pollTimer = setInterval(() => {
void this.pollOnce(false);
}, ms);
setTimeout(() => void this.pollOnce(false), 2000);
}
async pollOnce(isManual) {
if (this.pollInFlight)
return;
this.pollInFlight = true;
try {
await this.ensureAuthenticated(false);
if (!this.cookieHeader)
return;
try {
await this.fetchProfile();
}
catch (err) {
const status = err?.status ?? err?.response?.status;
const msg = err?.message ?? String(err);
if (String(msg).includes('401') || String(msg).includes('403') || status === 401 || status === 403) {
// Renewed only if the session is old enough to have expired; a
// refusal of a fresh one is a permission and holds off instead.
if (this.sessionIsFresh()) {
this.noteRefusal(String(msg).includes('401') ? 401 : 403, 'its own profile');
throw err;
}
await this.ensureAuthenticated(true);
await this.fetchProfile();
}
else {
throw err;
}
}
// Optionally keep lists fresh on the same interval:
await this.refreshVenuesAndPlayers();
// A start that failed (Resi down, account held off) left no decoder followed;
// pick the configured one up as soon as Resi answers.
if (this.config.defaultPlayerHardwareId && !this.selectedVenueUuid)
await this.selectPlayer(this.config.defaultPlayerHardwareId);
this.setVariableValues({
last_poll: new Date().toLocaleString(),
});
this.updateStatus(base_1.InstanceStatus.Ok);
}
catch (err) {
const msg = err?.message ?? String(err);
this.setVariableValues({
auth_status: isManual ? 'Manual refresh failed' : 'Poll failed',
last_error: msg,
last_poll: new Date().toLocaleString(),
});
this.updateStatus(base_1.InstanceStatus.ConnectionFailure, 'Poll failed');
}
finally {
this.pollInFlight = false;
}
}
buildAuthHeaders() {
return {
Accept: 'application/json',
Cookie: this.cookieHeader,
...(this.livingas1 ? { authorization: `X-Bearer ${this.livingas1}` } : {}),
};
}
parseTimeToMs(s) {
// Accept: "HH:MM:SS", "HH:MM:SS.mmm", "MM:SS", "MM:SS.mmm", "SS", "SS.mmm"
if (!s || typeof s !== 'string')
return 0;
const [timePart, msPartRaw] = s.trim().split('.');
const parts = timePart.split(':').map((p) => p.trim()).filter(Boolean);
const msPart = msPartRaw ? msPartRaw.padEnd(3, '0').slice(0, 3) : '0';
const ms = Number(msPart);
let hh = 0, mm = 0, ss = 0;
if (parts.length === 3) {
hh = Number(parts[0]);
mm = Number(parts[1]);
ss = Number(parts[2]);
}
else if (parts.length === 2) {
mm = Number(parts[0]);
ss = Number(parts[1]);
}
else if (parts.length === 1) {
ss = Number(parts[0]);
}
else {
return 0;
}
if (![hh, mm, ss, ms].every((n) => Number.isFinite(n)))
return 0;
return ((hh * 60 + mm) * 60 + ss) * 1000 + ms;
}
msToHms(ms) {
const total = Math.max(0, Math.floor(ms));
const hh = Math.floor(total / 3600000);
const mm = Math.floor((total % 3600000) / 60000);
const ss = Math.floor((total % 60000) / 1000);
const mmm = total % 1000;
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}.${String(mmm).padStart(3, '0')}`;
}
formatTod(epochMs) {
// 24-hour time, Companion host timezone
return new Date(epochMs).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
}
secondsToHms(totalSeconds) {
const s = Math.max(0, Math.floor(totalSeconds));
const hh = Math.floor(s / 3600);
const mm = Math.floor((s % 3600) / 60);
const ss = s % 60;
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
}
computeAndSetCueVars(posMs) {
if (!Number.isFinite(posMs)) {
this.log('warn', `posMs is not finite. current_position parse failed.`);
this.setVariableValues({
next_cue_name: '',
next_cue_time_in_seconds: '',
next_cue_time_in_hms: '',
next_cue_position: '',
next_cue_tod: '',
prev_cue_name: '',
prev_cue_time_ago_in_seconds: '',
prev_cue_time_ago_in_hms: '',
prev_cue_position: '',
prev_cue_tod: '',
});
return;
}
if (!this.cues || this.cues.length === 0) {
this.setVariableValues({
next_cue_name: '',
next_cue_time_in_seconds: '',
next_cue_time_in_hms: '',
next_cue_position: '',
next_cue_tod: '',
prev_cue_name: '',
prev_cue_time_ago_in_seconds: '',
prev_cue_time_ago_in_hms: '',
prev_cue_position: '',
prev_cue_tod: '',
});
return;
}
// Find next cue (first > posMs) and prev cue (last <= posMs)
let next;
let prev;
for (const c of this.cues) {
if (c.positionMs > posMs) {
next = c;
break;
}
prev = c;
}
const vars = {};
// --- NEXT ---
if (next) {
const secsUntil = Math.max(0, Math.ceil((next.positionMs - posMs) / 1000));
vars.next_cue_name = next.name ?? '';
vars.next_cue_time_in_seconds = String(secsUntil);
vars.next_cue_time_in_hms = this.secondsToHms(secsUntil);
vars.next_cue_position = this.msToHms(next.positionMs);
vars.next_cue_tod = this.eventMediaStartEpochMs
? this.formatTod(this.eventMediaStartEpochMs + next.positionMs)
: '';
}
else {
// Optional fallback if user wants a default countdown when there is no "next cue"
const fallback = Number(this.config.noNextCueSeconds ?? 0);
const secs = Number.isFinite(fallback) && fallback > 0 ? Math.floor(fallback) : NaN;
vars.next_cue_name = '';
vars.next_cue_time_in_seconds = Number.isFinite(secs) ? String(secs) : '';
vars.next_cue_time_in_hms = Number.isFinite(secs) ? this.secondsToHms(secs) : '';
vars.next_cue_position = '';
vars.next_cue_tod = '';
}
// --- PREV ---
if (prev) {
const secsAgo = Math.max(0, Math.floor((posMs - prev.positionMs) / 1000));
vars.prev_cue_name = prev.name ?? '';
vars.prev_cue_time_ago_in_seconds = String(secsAgo);
vars.prev_cue_time_ago_in_hms = this.secondsToHms(secsAgo);
vars.prev_cue_position = this.msToHms(prev.positionMs);
vars.prev_cue_tod = this.eventMediaStartEpochMs
? this.formatTod(this.eventMediaStartEpochMs + prev.positionMs)
: '';
}
else {
vars.prev_cue_name = '';
vars.prev_cue_time_ago_in_seconds = '';
vars.prev_cue_time_ago_in_hms = '';
vars.prev_cue_position = '';
vars.prev_cue_tod = '';
}
this.setVariableValues(vars);
}
async refreshVenuesAndPlayers() {
if (!this.customerId)
return;
await this.ensureAuthenticated(false);
if (!this.cookieHeader)
return;
// venues
const venuesRes = await fetch(`https://central.resi.io/api/v3/customers/${this.customerId}/venues`, {
method: 'GET',
headers: this.buildAuthHeaders(),
});
if (!venuesRes.ok) {
this.noteRefusal(venuesRes.status, 'its venues', (Number(venuesRes.headers.get('retry-after')) || 0) * 1000);
throw new Error(`venues failed (${venuesRes.status})`);
}
const venuesJson = (await venuesRes.json());
this.venues = venuesJson;
// players per venue
this.playersByVenueUuid = {};
for (const v of this.venues) {
const playersRes = await fetch(`https://central.resi.io/api_v2.svc/users/${v.uuid}/players`, {
method: 'GET',
headers: this.buildAuthHeaders(),
});
if (!playersRes.ok)
continue;
const playersJson = (await playersRes.json());
this.playersByVenueUuid[v.uuid] = playersJson.map((p) => ({
playerHardwareId: p.playerHardwareId,
userName: p.userName,
position: p.position,
eventId: p.eventId,
}));
}
// refresh action dropdown choices
this.updateActions();
}
lookupCueByName(queryRaw, mode = 'exact') {
const query = (queryRaw ?? '').trim();
this.setVariableValues({ lookup_cue_query: query });
if (!query) {
this.setVariableValues({
lookup_cue_found: 'false',
lookup_cue_name: '',
lookup_cue_uuid: '',
lookup_cue_position: '',
lookup_cue_position_sec: '',
lookup_cue_tod: '',
});
return;
}
const norm = (s) => s.trim().toLowerCase();
const match = mode === 'contains'
? this.cues.find((c) => norm(c.name).includes(norm(query)))
: this.cues.find((c) => norm(c.name) === norm(query));
if (!match) {
this.setVariableValues({
lookup_cue_found: 'false',
lookup_cue_name: '',
lookup_cue_uuid: '',
lookup_cue_position: '',
lookup_cue_position_sec: '',
lookup_cue_tod: '',
});
return;
}
this.setVariableValues({
lookup_cue_found: 'true',
lookup_cue_name: match.name ?? '',
lookup_cue_uuid: match.uuid ?? '',
lookup_cue_position: this.msToHms(match.positionMs),
lookup_cue_position_sec: String(Math.floor(match.positionMs / 1000)),
lookup_cue_tod: this.eventMediaStartEpochMs ? this.formatTod(this.eventMediaStartEpochMs + match.positionMs) : '',
});
}
isValidHmsMs(s) {
// Accept HH:MM:SS or HH:MM:SS.mmm (hours can be 1+ digits)
return /^\d+:[0-5]\d:[0-5]\d(\.\d{1,3})?$/.test(s.trim());
}
normalizeHmsMs(s) {
// Ensure we always send HH:MM:SS.mmm
const trimmed = (s ?? '').trim();
const [timePart, msRaw] = trimmed.split('.');
const ms = (msRaw ?? '0').padEnd(3, '0').slice(0, 3);
// If they typed "4:00:00", keep it (hours can be 1 digit) but add .mmm
return `${timePart}.${ms}`;
}
async refreshCuesIfDue() {
// Must have an event loaded
if (!this.currentEventId || !this.currentEventProfileId)
return;
// Don’t overlap cue refreshes
if (this.cueRefreshInFlight)
return;
// How often to refresh cues while playing (ms)
// If you want “every player poll”, set this to your playerPollMs,
// but recommended: 10000–30000.
const intervalMs = Math.max(1000, Number(this.config.cuePollMs ?? 5000) || 5000);
const now = Date.now();
if (now - this.lastCueRefreshMs < intervalMs)
return;
this.cueRefreshInFlight = true;
this.lastCueRefreshMs = now;
try {
await this.refreshEventProfileAndCues();
}
catch (err) {
// Don’t hard-fail playback polling if cues refresh fails
const msg = String(err?.message ?? err);
const gist = msg.replace(/ — retrying in \d+s$/, '');
if (gist !== this.__lastCueWarn)
this.log('warn', `Cue refresh failed: ${msg}`);
this.__lastCueWarn = gist;
}
finally {
this.cueRefreshInFlight = false;
}
}
/**
* Adds a cue to the currently-selected event, at either current player position or a custom time.
*/
async addCueFromPosition(opts) {
const cueName = (opts.name ?? '').trim();
if (!cueName)
throw new Error('Cue name is required');
if (!this.currentEventId || !this.currentEventProfileId) {
throw new Error('No active event loaded yet. Select a player/event first.');
}
const user = (this.config.username ?? '').trim();
if (!user)
throw new Error('Missing username (needed for cue user field)');
let position = '';
if (opts.positionMode === 'player') {
// ✅ THIS is what “uses” lastPlayerPosition
const p = (this.lastPlayerPosition ?? '').trim();
if (!p)
throw new Error('Current player position is not available yet');
if (!this.isValidHmsMs(p))
throw new Error(`Current player position is invalid: ${p}`);
position = this.normalizeHmsMs(p);
}
else {
const custom = (opts.customPosition ?? '').trim();
if (!custom)
throw new Error('Custom position is required');
if (!this.isValidHmsMs(custom))
throw new Error('Custom position must be like HH:MM:SS or HH:MM:SS.mmm');
position = this.normalizeHmsMs(custom);
}
await this.ensureAuthenticated(false);
if (!this.cookieHeader)
return;
const url = `https://central.resi.io/api_v2.svc/streamprofiles/${this.currentEventProfileId}/events/${this.currentEventId}/cues`;
const body = { position, name: cueName, privateCue: !!opts.privateCue, user };
const res = await fetch(url, {
method: 'POST',
headers: { ...this.buildAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Add cue failed (${res.status}) ${text}`.trim());
}
this.log('info', `Added cue "${cueName}" at ${position}`);
await this.refreshEventProfileAndCues();
}
async deleteCueByName(opts) {
const query = (opts.name ?? '').trim();
if (!query)
throw new Error('Cue name is required');
if (!this.currentEventId || !this.currentEventProfileId) {
throw new Error('No active event loaded yet. Select a player/event first.');
}
// Make sure we have current cues loaded
if (!this.cues || this.cues.length === 0) {
await this.refreshEventProfileAndCues();
}
const norm = (s) => s.trim().toLowerCase();
const qn = norm(query);
// Note: your this.cues is { uuid, name, positionMs } (no user field),
// so if you want onlyMine filtering, we need to store "user" too when loading cues.
// For now, onlyMine will be best-effort if you extend cues to include user.
const myUser = (this.config.username ?? '').trim().toLowerCase();
// If you haven't extended cue storage with user, this will just skip that filter.
const cuesAny = this.cues;
const matches = cuesAny.filter((c) => {
const nameOk = opts.matchMode === 'contains' ? norm(c.name ?? '').includes(qn) : norm(c.name ?? '') === qn;
if (!nameOk)
return false;
if (opts.onlyMine) {
// works only if c.user exists
if (typeof c.user !== 'string')
return false;
return c.user.trim().toLowerCase() === myUser;
}
return true;
});
if (matches.length === 0) {
throw new Error(`No cue matched "${query}"`);
}
const toDelete = opts.deleteMode === 'all' ? matches : [matches[0]];
await this.ensureAuthenticated(false);
let deleted = 0;
for (const c of toDelete) {
const uuid = String(c.uuid ?? '').trim();
if (!uuid)
continue;
const url = `https://central.resi.io/api_v2.svc/streamprofiles/${this.currentEventProfileId}/events/${this.currentEventId}/cues/${uuid}`;
const res = await fetch(url, {
method: 'DELETE',
headers: this.buildAuthHeaders(),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Delete cue failed (${res.status}) ${text}`.trim());
}
deleted++;
}
this.log('info', `Deleted ${deleted} cue(s) matching "${query}"`);
await this.refreshEventProfileAndCues();
}
async destroy() {
if (this.pollTimer)
clearInterval(this.pollTimer);
if (this.playerPollTimer)
clearInterval(this.playerPollTimer);
this.playerPollTimer = undefined;
if (this.__tickTimer)
clearInterval(this.__tickTimer);
this.__tickTimer = undefined;
this.pollTimer = undefined;
this.log('debug', 'destroy');
}
}
exports.ModuleInstance = ModuleInstance;
(0, base_1.runEntrypoint)(ModuleInstance, upgrades_js_1.UpgradeScripts);
//# sourceMappingURL=main.js.map