diff --git a/README.md b/README.md index 9b89259..66392ea 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Resi's API is undocumented and unsupported, so don't open a Resi ticket about th | `install_bridge.sh` | Installs it as a login service on the Mac it runs on, and runs `--setup` when needed. | | `make_kit.sh` | Builds `resi-propresenter-kit.zip` for another site (no site settings inside). | | `source/` | The Companion module `resi-studio-cues` (`dist/` is what ships, plus `manifest.json` and `HELP.md`). | -| `resi-studio-cues-0.1.4.tgz` | The current Companion package: import it in Companion → Modules. | +| `resi-studio-cues-0.1.5.tgz` | The current Companion package: import it in Companion → Modules. | Tests. All run offline against fakes, except the end-to-end one: @@ -99,6 +99,7 @@ python3 test_playhead.py # the playhead estimator, nine Resi profiles plu python3 test_stage_message.py # stage message sent once per cue, never when switched off python3 test_rate_limit.py # backs off when Resi says "too many requests" node source/backoff_test.js # Companion module: login hold-off, no crash loop on a locked account +node source/config_test.js # Companion module: settings always save, upgrade fills defaults, quieter cue refresh ``` `source/tick_test.js` is the Companion module's estimator test (run with JavaScriptCore's diff --git a/resi-studio-cues-0.1.4.tgz b/resi-studio-cues-0.1.4.tgz deleted file mode 100644 index 3560714..0000000 Binary files a/resi-studio-cues-0.1.4.tgz and /dev/null differ diff --git a/resi-studio-cues-0.1.5.tgz b/resi-studio-cues-0.1.5.tgz new file mode 100644 index 0000000..139b327 Binary files /dev/null and b/resi-studio-cues-0.1.5.tgz differ diff --git a/source/HELP.md b/source/HELP.md index e76cd3e..55c4739 100644 --- a/source/HELP.md +++ b/source/HELP.md @@ -241,3 +241,17 @@ seconds, and each restart signed in again from scratch, outside the hold-off. yet … will keep trying", starts the account poll, and the hold-off paces retries - once Resi answers, the configured decoder is picked up automatically - saving new credentials clears the old hold-off and starts over + +## Settings that always save (0.1.5) + +While the connection wasn't connected to Resi, its settings couldn't be saved at all: +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 one blocks +Save in Companion, including the login change that would have fixed the connection. + +- the saved decoder is always offered, as "(not connected to Resi yet)" until Resi answers +- an upgrade step fills any setting an older connection never saved with its default +- event details are read once per event instead of on every cue refresh, which is one + Resi request fewer every few seconds +- cue requests share the login/rate-limit hold-off, and "Loaded N cues" and repeated + cue-refresh failures are logged once, not every few seconds diff --git a/source/config_test.js b/source/config_test.js new file mode 100644 index 0000000..5a67021 --- /dev/null +++ b/source/config_test.js @@ -0,0 +1,143 @@ +// The settings form, the upgrade script and the cue refresh (0.1.5). +// +// Seen at North Attleboro: the connection's settings couldn't be saved at all while +// it wasn't connected -- the saved decoder showed as "??" (not in the list) and a +// cue refresh that had never been saved showed as 0 (below its minimum), and either +// one blocks Save, so the login that would have fixed it couldn't be changed. +// Runs the real module code against a fake Resi; touches nothing real. +// +// node config_test.js (defaults to dist/) +// node config_test.js path/to/dist + +'use strict'; +const Module = require('module'); +const path = require('path'); +const assert = require('assert'); + +const dist = path.resolve(process.argv[2] || path.join(__dirname, 'dist')); +const stubs = { + '@companion-module/base': { + InstanceBase: class { updateStatus() {} log() {} setVariableValues() {} }, + InstanceStatus: { Ok: 'ok', Connecting: 'c', ConnectionFailure: 'f', BadConfig: 'b' }, + runEntrypoint() {}, combineRgb() { return 0 }, Regex: {}, + }, + './variables.js': { UpdateVariableDefinitions() {} }, + './actions.js': { UpdateActions() {} }, + './feedbacks.js': { UpdateFeedbacks() {} }, + './presets.js': { UpdatePresets() {} }, +}; +const realLoad = Module._load; +Module._load = function (req, parent, isMain) { + if (stubs[req]) return stubs[req]; + return realLoad.apply(this, arguments); +}; + +let fetches = []; +let cues = [{ uuid: 'a', name: 'Welcome', position: '00:05:00' }, { uuid: 'b', name: 'Message', position: '00:30:00' }]; +let cueStatus = 200; +const reply = (status, body) => ({ + ok: status >= 200 && status < 300, status, + headers: { get: () => null, getSetCookie: () => [] }, + json: async () => body, text: async () => JSON.stringify(body), +}); +global.fetch = async (url) => { + const p = String(url).replace('https://central.resi.io', '').split('?')[0]; + fetches.push(p); + if (/\/events\/[^/]+$/.test(p)) return reply(200, { eventProfileId: 'P1', startTime: '2026-10-07T13:30:00Z' }); + if (p.endsWith('/cues')) return reply(cueStatus, cues); + return reply(404, {}); +}; + +const { ModuleInstance } = require(path.join(dist, 'main.js')); +const { UpgradeScripts } = require(path.join(dist, 'upgrades.js')); + +function fresh() { + const inst = Object.create(ModuleInstance.prototype); + inst.config = {}; + inst.secrets = { password: 'pw' }; + inst.venues = []; + inst.playersByVenueUuid = {}; + inst.updateStatus = () => {}; + inst.setVariableValues = () => {}; + inst.infos = []; + inst.warns = []; + inst.log = (lvl, m) => { (lvl === 'info' ? inst.infos : inst.warns).push(m); }; + return inst; +} + +let passed = 0; +async function test(name, fn) { await fn(); passed++; console.log(` ok ${name}`); } + +(async () => { + await test('the saved decoder is a valid choice even before Resi answers', () => { + const inst = fresh(); + inst.config = { defaultPlayerHardwareId: 'DEC1' }; + const field = inst.getConfigFields().find((f) => f.id === 'defaultPlayerHardwareId'); + const ids = field.choices.map((c) => c.id); + assert.ok(ids.includes('DEC1'), `choices were ${JSON.stringify(ids)}`); + assert.match(field.choices.find((c) => c.id === 'DEC1').label, /not connected/); + }); + + await test('once connected, the decoder is listed once, under its real name', () => { + const inst = fresh(); + inst.config = { defaultPlayerHardwareId: 'DEC1' }; + inst.venues = [{ uuid: 'V1', userName: 'North Attleboro' }]; + inst.playersByVenueUuid = { V1: [{ playerHardwareId: 'DEC1', userName: 'North Attleboro' }] }; + const field = inst.getConfigFields().find((f) => f.id === 'defaultPlayerHardwareId'); + const dec = field.choices.filter((c) => c.id === 'DEC1'); + assert.strictEqual(dec.length, 1); + assert.match(dec[0].label, /North Attleboro/); + }); + + await test('the upgrade fills settings an older connection never saved', () => { + const r = UpgradeScripts[0]({}, { config: { username: 'x', playerPollMs: 3000 }, actions: [], feedbacks: [] }); + assert.ok(r.updatedConfig, 'config should be updated'); + assert.strictEqual(r.updatedConfig.cuePollMs, 5000); + assert.strictEqual(r.updatedConfig.listPollMs, 120000); + assert.strictEqual(r.updatedConfig.positionTrimMs, 0); + assert.strictEqual(r.updatedConfig.playerPollMs, 3000, 'a saved value is kept'); + }); + + await test('the upgrade leaves a complete connection alone', () => { + const r = UpgradeScripts[0]({}, { + config: { playerPollMs: 3000, positionTrimMs: 0, cuePollMs: 5000, listPollMs: 120000 }, actions: [], feedbacks: [], + }); + assert.strictEqual(r.updatedConfig, null); + }); + + await test('event details are read once per event, and the cue log only speaks on a change', async () => { + const inst = fresh(); + inst.customerId = 'C'; + inst.currentEventId = 'EV1'; + inst.cookieHeader = 'livingas1=T'; + inst.lastLoginMs = Date.now(); + fetches = []; + for (let i = 0; i < 5; i++) await inst.refreshEventProfileAndCues(); + assert.strictEqual(fetches.filter((p) => p.endsWith('/events/EV1')).length, 1, 'event detail once'); + assert.strictEqual(fetches.filter((p) => p.endsWith('/cues')).length, 5, 'cues every refresh'); + assert.strictEqual(inst.infos.filter((m) => /Loaded/.test(m)).length, 1, `logged ${inst.infos.length} times`); + + cues = cues.concat({ uuid: 'c', name: 'Closing', position: '00:45:00' }); + await inst.refreshEventProfileAndCues(); + assert.strictEqual(inst.infos.filter((m) => /Loaded/.test(m)).length, 2, 'a changed list is logged'); + + inst.currentEventId = 'EV2'; + await inst.refreshEventProfileAndCues(); + assert.strictEqual(fetches.filter((p) => p.endsWith('/events/EV2')).length, 1, 'a new event is read'); + }); + + await test('a rate-limited cue request holds off every Resi call', async () => { + const inst = fresh(); + inst.customerId = 'C'; + inst.currentEventId = 'EV1'; + inst.cookieHeader = 'livingas1=T'; + inst.lastLoginMs = Date.now(); + inst.config = { username: 'a@b.c' }; + cueStatus = 429; + await assert.rejects(inst.refreshEventProfileAndCues(), /cues failed \(429\)/); + await assert.rejects(inst.ensureAuthenticated(false), /rate-limiting.*retrying in \d+s/); + cueStatus = 200; + }); + + console.log(`\n${passed} tests passed`); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/source/dist/config.js b/source/dist/config.js index b110c72..b124f25 100644 --- a/source/dist/config.js +++ b/source/dist/config.js @@ -48,7 +48,7 @@ function GetConfigFields() { { type: 'number', id: 'listPollMs', - label: 'User & decoder inf0 poll (ms)', + label: 'User & decoder info poll (ms)', width: 6, min: 10000, max: 3600000, diff --git a/source/dist/main.js b/source/dist/main.js index fc5e7a3..cba1fd0 100644 --- a/source/dist/main.js +++ b/source/dist/main.js @@ -89,7 +89,14 @@ class ModuleInstance extends base_1.InstanceBase { // Patch in current player choices for (const f of fields) { if (f.type === 'dropdown' && f.id === 'defaultPlayerHardwareId') { - f.choices = [{ id: '', label: '(none)' }, ...this.getPlayerChoices()]; + 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; @@ -605,21 +612,30 @@ class ModuleInstance extends base_1.InstanceBase { 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; + // 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) + 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) => ({ @@ -630,7 +646,11 @@ class ModuleInstance extends base_1.InstanceBase { positionMs: this.parseTimeToMs(c.position), })) .sort((a, b) => a.positionMs - b.positionMs); - this.log('info', `Loaded ${this.cues.length} cues for event ${this.currentEventId}`); + // 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), @@ -949,7 +969,11 @@ class ModuleInstance extends base_1.InstanceBase { } catch (err) { // Don’t hard-fail playback polling if cues refresh fails - this.log('warn', `Cue refresh failed: ${err?.message ?? err}`); + 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; diff --git a/source/dist/upgrades.js b/source/dist/upgrades.js index 7f01d4f..f272664 100644 --- a/source/dist/upgrades.js +++ b/source/dist/upgrades.js @@ -1,5 +1,22 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.UpgradeScripts = void 0; -exports.UpgradeScripts = []; -//# sourceMappingURL=upgrades.js.map \ No newline at end of file +/* 0.1.5: connections created before a setting existed have no value for it. Companion's + form shows such a number as 0 -- below the field's minimum -- and then refuses to save + any change to the connection. Fill each missing setting with its default, once. */ +const DEFAULTS = { playerPollMs: 1000, positionTrimMs: 0, cuePollMs: 5000, listPollMs: 120000 }; +function fillMissingDefaults(_context, props) { + const config = props.config; + let changed = false; + if (config) { + for (const [key, value] of Object.entries(DEFAULTS)) { + if (config[key] === undefined || config[key] === null || config[key] === '') { + config[key] = value; + changed = true; + } + } + } + return { updatedConfig: changed ? config : null, updatedActions: [], updatedFeedbacks: [] }; +} +exports.UpgradeScripts = [fillMissingDefaults]; +//# sourceMappingURL=upgrades.js.map diff --git a/source/manifest.json b/source/manifest.json index 590b6ed..cd2ae23 100644 --- a/source/manifest.json +++ b/source/manifest.json @@ -3,7 +3,7 @@ "name": "resi-studio-cues", "shortname": "resi-studio-cues", "description": "Live cue list, playhead and next-cue countdown from Resi Studio", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "repository": "git+https://github.com/reykroona/companion-module-resi-studio-cues.git", "bugs": "https://github.com/reykroona/companion-module-resi-studio-cues/issues",