"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