The Resi -> ProPresenter bridge, its installer and site kit, and the Companion module resi-studio-cues, as running at North Attleboro. Bridge (resi_to_propresenter.py) - Follows a Resi decoder's playhead and keeps ProPresenter's NextCue timer counting down to the next cue; optional cue name as a stage message, sent once per cue (ProPresenter flashes it on every send) and off by default. - Playhead estimate no longer learns a cadence from the partial first gap after a start, so a 30s Resi doesn't read as paused half the time. - Backs off on 429, holds 2 min before retrying a failed startup login, and logs Resi's actual reason (wrong password, USER_LOCKED, rate limit). - --setup asks for the login, decoder, ProPresenter and timer. install_bridge.sh installs it as a login service (outside ~/Documents, which launchd may not read); make_kit.sh builds the kit for another site. Companion module resi-studio-cues 0.1.4 (source/, package committed) - Same playhead fix; login and request hold-off (15s doubling to 5 min); a 401/403 on a fresh session is a permission, not a reason to sign in again; init never fails on a Resi problem, so Companion can't restart it into a login loop. Tests: test_playhead.py, test_stage_message.py, test_rate_limit.py and source/backoff_test.js run offline; test_end_to_end.py drives a real ProPresenter and refuses to run without an explicit flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
|