1076 lines
46 KiB
JavaScript
1076 lines
46 KiB
JavaScript
"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') {
|
|||
|
|
f.choices = [{ id: '', label: '(none)' }, ...this.getPlayerChoices()];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
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;
|
|||
|
|
const evRes = await fetch(`https://central.resi.io/api/v3/customers/${this.customerId}/events/${this.currentEventId}`, { method: 'GET', headers: this.buildAuthHeaders() });
|
|||
|
|
if (!evRes.ok)
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
// 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)
|
|||
|
|
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);
|
|||
|
|
this.log('info', `Loaded ${this.cues.length} cues for event ${this.currentEventId}`);
|
|||
|
|
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
|
|||
|
|
this.log('warn', `Cue refresh failed: ${err?.message ?? err}`);
|
|||
|
|
}
|
|||
|
|
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
|