While the connection wasn't connected, nothing in its settings could be saved. The saved decoder showed as "??" because the list only fills once Resi answers, and a cue refresh that had never been saved showed as 0, below its minimum. Either blocks Save, including the login change that would have fixed the connection. That's how North Attleboro's connection stayed on a locked login. - The saved decoder is always a choice, "(not connected to Resi yet)" until Resi answers. - An upgrade script fills settings an older connection never saved with their defaults (and leaves a complete one alone). - Event details are read once per event instead of on every cue refresh: one Resi request fewer every few seconds on a shared account. - Cue requests share the login/rate-limit hold-off; "Loaded N cues" and repeated cue-refresh failures are logged once, not every few seconds. - "inf0" in the decoder info poll label. source/config_test.js covers all of it (6 tests; the first fails on 0.1.4). backoff_test.js 5/5 and the estimator matrix still pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1100 lines
48 KiB
JavaScript
1100 lines
48 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') {
|
||
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
|