Resi cue countdowns on ProPresenter and in Companion
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>
This commit is contained in:
+243
@@ -0,0 +1,243 @@
|
||||
# Resi Studio Cues (Bitfocus Companion Module)
|
||||
|
||||
This module logs into **studio.resi.io / central.resi.io** with a username + password, reads decoder/player playback position, loads cue points for the current event, and exposes variables/actions for cue timing and cue management.
|
||||
|
||||
The main goal is to drive things like a **countdown clock in ProPresenter** (time until next cue, next cue name, etc).
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Authentication
|
||||
- Logs in via `POST /api/v3/login?newToken=true`
|
||||
- Stores auth cookies (`refreshToken`, `livingas1`) and uses them on subsequent requests
|
||||
- Exposes status via:
|
||||
- Instance status (green/red in Companion)
|
||||
- Variables: `auth_status`, `last_error`
|
||||
|
||||
### Player polling (live)
|
||||
- Polls decoder/player position from:
|
||||
- `GET /api_v2.svc/users/{venueUuid}/players`
|
||||
- Updates variables like:
|
||||
- `current_position`
|
||||
- `current_position_sec`
|
||||
- `current_event_id`
|
||||
|
||||
### Cue loading (per event)
|
||||
When the current `eventId` changes, the module loads:
|
||||
- Event detail:
|
||||
- `GET /api/v3/customers/{customerId}/events/{eventId}`
|
||||
- Captures `startTime` for ToD (time-of-day) math
|
||||
- Cues:
|
||||
- `GET /api_v2.svc/streamprofiles/{eventProfileId}/events/{eventId}/cues?canISetCues=1`
|
||||
|
||||
Cues are stored locally and used to compute **next/prev** cue timing from the live playback position.
|
||||
|
||||
### Cue timing variables
|
||||
- Next cue:
|
||||
- `next_cue_name`
|
||||
- `next_cue_time_in_seconds`
|
||||
- `next_cue_time_in_hms`
|
||||
- `next_cue_position`
|
||||
- `next_cue_tod` (24-hour)
|
||||
- Previous cue:
|
||||
- `prev_cue_name`
|
||||
- `prev_cue_time_ago_in_seconds`
|
||||
- `prev_cue_time_ago_in_hms`
|
||||
- `prev_cue_position`
|
||||
- `prev_cue_tod` (24-hour)
|
||||
|
||||
### Cue management actions
|
||||
- Add cue (player position or custom time)
|
||||
- Lookup cue by name (stores into `lookup_*` variables)
|
||||
- Delete cue by name (looks up UUID, deletes by UUID)
|
||||
|
||||
---
|
||||
|
||||
## Installation / Testing
|
||||
|
||||
### Best (recommended): Developer Module via Git
|
||||
1. Push this repo to GitHub/GitLab (public or private).
|
||||
2. On the test system, add it as a Developer Module in Companion.
|
||||
3. Restart Companion.
|
||||
|
||||
### Offline / manual
|
||||
1. `yarn install`
|
||||
2. `yarn build`
|
||||
3. Zip the module folder (without `node_modules`) and provide to tester.
|
||||
4. Tester places it in Companion’s modules folder and restarts Companion.
|
||||
|
||||
(Exact folder paths vary by OS / Companion version.)
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Typical config fields:
|
||||
- **Username / Email**
|
||||
- **Password** (secret)
|
||||
- **Default Player** (optional)
|
||||
- **Polling intervals**
|
||||
- Playback position refresh (fast, e.g. 2000ms)
|
||||
- Account/venue refresh (slow, e.g. 60000ms)
|
||||
- Cue refresh while playing (optional, e.g. 15000ms)
|
||||
- **Fallback when no next cue** (optional)
|
||||
|
||||
> Note: dynamic "read-only" status display inside config is not supported by Companion config fields.
|
||||
> Use Instance Status + module variables for connection state.
|
||||
|
||||
---
|
||||
|
||||
## Actions
|
||||
|
||||
- **Refresh profile**: Reload `/users/me`
|
||||
- **Refresh lists**: Reload venues + players list
|
||||
- **Select decoder/player**: Choose which player to poll
|
||||
- **Add cue at player position (or custom time)**
|
||||
- Options:
|
||||
- Cue name
|
||||
- Private cue
|
||||
- Position source: player vs custom
|
||||
- Custom time string
|
||||
- Text fields support Companion variables (e.g. `$(internal:...)`)
|
||||
- **Lookup cue by name**
|
||||
- Writes result into:
|
||||
- `lookup_cue_found`, `lookup_cue_uuid`, `lookup_cue_position`, etc.
|
||||
- **Delete cue by name**
|
||||
- Finds matching cue(s), deletes by UUID via:
|
||||
- `DELETE /.../cues/{uuid}`
|
||||
|
||||
---
|
||||
|
||||
## Feedbacks (for button coloring)
|
||||
|
||||
Recommended “production” feedback set:
|
||||
1. **Connected (auth OK)** → Green
|
||||
2. **Error (auth/API)** → Red
|
||||
3. **Player active** (position > 0) → Blue
|
||||
4. **Approaching next cue** (threshold seconds) → Yellow
|
||||
5. **Playback frozen** (position not changing) → Red
|
||||
|
||||
Playback frozen requires:
|
||||
- variable `playback_last_change_epoch_ms` to be updated when playback position changes.
|
||||
|
||||
---
|
||||
|
||||
## Presets Included
|
||||
|
||||
### Status
|
||||
- Connected / Error indicator
|
||||
- Player active indicator
|
||||
- Playback frozen warning
|
||||
|
||||
### Cues
|
||||
- Next cue display (with T- countdown)
|
||||
- Previous cue display
|
||||
|
||||
### Maintenance
|
||||
- Refresh profile
|
||||
- Refresh venues/players list
|
||||
|
||||
### Cue actions
|
||||
- Add cue @ player position
|
||||
- Add cue @ custom time
|
||||
- Lookup cue by name
|
||||
- Delete cue by name
|
||||
|
||||
---
|
||||
|
||||
## Notes / Limitations
|
||||
|
||||
- The Resi API endpoints used here are not public/stable; breaking changes may occur.
|
||||
- Avoid polling cues every 2 seconds. Recommended cue refresh while playing is 10–30 seconds unless you have a specific need.
|
||||
- Companion config fields are not truly read-only, so status is exposed via variables + instance status.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap Ideas
|
||||
|
||||
- Optional periodic cue refresh while playing (rate-limited)
|
||||
- Extra feedback: "No player selected"
|
||||
- Extra feedback: "No next cue"
|
||||
- More presets for ProPresenter integration workflows
|
||||
|
||||
---
|
||||
|
||||
## Playhead accuracy (0.1.0)
|
||||
|
||||
Resi only republishes a decoder's `position` every few seconds, on an irregular
|
||||
cadence, and each reading is already slightly stale when it appears. Showing that
|
||||
number raw makes the countdown jump in steps and sit several seconds behind.
|
||||
|
||||
This build instead keeps a running estimate:
|
||||
|
||||
- every reading is stored as **media time minus wall time**; the largest recent value
|
||||
is the least-delayed reading and is the one trusted, so a slow round trip or an
|
||||
unlucky poll can no longer drag the countdown backwards
|
||||
- the display is recomputed **every second** from that offset, so the countdown ticks
|
||||
smoothly between Resi's updates instead of stepping
|
||||
- a reading that differs from the locked offset by more than the link's measured
|
||||
noise is treated as a **seek** - the lock is dropped, the estimate snaps, and the
|
||||
cue list is re-fetched immediately rather than on its timer
|
||||
- playback is only called **PAUSED** once the playhead has been still for clearly
|
||||
longer than Resi's own reporting cadence, so an irregular gap no longer freezes the
|
||||
countdown
|
||||
|
||||
`position_update_interval_sec` reports how often Resi is actually updating. That
|
||||
number is the floor on how fast a jump can possibly be noticed.
|
||||
|
||||
### Beating that floor
|
||||
|
||||
If cues are fired from Companion, the module does not have to wait for Resi to
|
||||
notice. Add one of these to the same button:
|
||||
|
||||
- **Resync playhead to cue** - give it the cue name; the estimate moves there at once
|
||||
- **Resync playhead to the NEXT cue**
|
||||
|
||||
The next real reading from Resi confirms or corrects it.
|
||||
|
||||
### Settings that matter
|
||||
|
||||
- **Player position poll (ms)** - 1000 is recommended. Accuracy no longer depends on
|
||||
this, but a faster poll notices a jump sooner.
|
||||
- **Cue list refresh (ms)** - 5000. Also forced immediately whenever a seek is seen.
|
||||
- **Position trim (ms)** - leave at 0. Only needed if the whole pipeline is
|
||||
consistently behind; positive values push the estimate forward.
|
||||
|
||||
## Startup fix (0.1.2)
|
||||
|
||||
On a Resi that publishes every ~30s, the module could start a few seconds before a
|
||||
publish and learn a 2s cadence from that partial first gap. It then threw out every
|
||||
real 30s gap, so `playback_state` flipped to **PAUSED** 15s into each update - the
|
||||
countdown froze and the next cue lagged, worst with cues close together.
|
||||
|
||||
- the partial gap after startup (or after a manual resync) is no longer learned
|
||||
- a single outlier gap can no longer lock out the real cadence
|
||||
- until the cadence is measured, PAUSED waits 45s instead of 15s
|
||||
|
||||
## Resi login back-off (0.1.3)
|
||||
|
||||
0.1.2 retried a failed Resi login on every player poll (once a second, for ever), and
|
||||
answered every 401/403 by signing in again. With a wrong password, a rate limit or a
|
||||
login that may not see decoders, that is thousands of sign-ins an hour: Resi replies
|
||||
with 429 and then locks the account (it locked one at North Attleboro).
|
||||
|
||||
- after any failed login, or a 429 on any request, every Resi call holds off: 15s,
|
||||
doubling to 5min, or as long as Resi's Retry-After asks
|
||||
- a 401/403 renews the session only if it is over a minute old; a refusal straight
|
||||
after signing in is a permission ("ask a Resi admin for access") and holds off
|
||||
- status and `last_error` say which: locked (USER_LOCKED), rate-limited, password
|
||||
rejected, no access, or unreachable
|
||||
- the log says it once per problem, not once a second
|
||||
- the cache-busting parameter is no longer dropped for good on a 401/403/429
|
||||
|
||||
## Start-up never crashes on a Resi problem (0.1.4)
|
||||
|
||||
In 0.1.3 a failed Resi login at start-up (here: a locked account) made the module's
|
||||
init fail. Companion counts that as a crash and restarts the module every few
|
||||
seconds, and each restart signed in again from scratch, outside the hold-off.
|
||||
|
||||
- init no longer fails when Resi refuses or is down: it logs "Not connected to Resi
|
||||
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
|
||||
@@ -0,0 +1,158 @@
|
||||
// A failed or refused Resi login must hold off, not retry on every poll.
|
||||
//
|
||||
// Seen at North Attleboro: with a bad password, a rate limit or a login without
|
||||
// decoder access, 0.1.2 signed in again on every 1s player poll. Resi answered with
|
||||
// 429s and then locked the account. Drives the real module code on a fake clock
|
||||
// against a fake Resi; touches nothing real.
|
||||
//
|
||||
// node backoff_test.js (defaults to dist/main.js)
|
||||
// node backoff_test.js path/to/main.js
|
||||
|
||||
'use strict';
|
||||
const Module = require('module');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const target = path.resolve(process.argv[2] || path.join(__dirname, 'dist/main.js'));
|
||||
const stubs = {
|
||||
'@companion-module/base': {
|
||||
InstanceBase: class { updateStatus() {} log() {} setVariableValues() {} },
|
||||
InstanceStatus: { Ok: 'ok', Connecting: 'c', ConnectionFailure: 'f', BadConfig: 'b' },
|
||||
runEntrypoint() {}, combineRgb() { return 0 }, Regex: {},
|
||||
},
|
||||
'./config.js': { GetConfigFields: () => [] },
|
||||
'./variables.js': { UpdateVariableDefinitions() {} },
|
||||
'./upgrades.js': { UpgradeScripts: [] },
|
||||
'./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 now = 1700000000000;
|
||||
Date.now = () => now;
|
||||
|
||||
let mode = 'limited'; // what the fake Resi does with a login
|
||||
let logins = 0;
|
||||
let venueCalls = 0;
|
||||
let playerCalls = 0;
|
||||
const reply = (status, body, headers) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: (k) => (headers || {})[k.toLowerCase()] ?? null, getSetCookie: () => (headers || {})['set-cookie'] || [] },
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
});
|
||||
global.fetch = async (url, opts) => {
|
||||
const p = String(url).replace('https://central.resi.io', '').split('?')[0];
|
||||
if (p === '/api/v3/login') {
|
||||
logins++;
|
||||
if (mode === 'limited') return reply(429, { message: 'Rate limit exceeded' });
|
||||
if (mode === 'locked') return reply(401, { code: 'USER_LOCKED', message: 'Unauthorized' });
|
||||
return reply(200, {}, { 'set-cookie': ['livingas1=T; Path=/'] });
|
||||
}
|
||||
if (p === '/api_v2.svc/users/me') return reply(200, { customerId: 'C' });
|
||||
if (p === '/api/v3/customers/C/venues') { venueCalls++; return reply(mode === 'noaccess' ? 403 : 200, [{ uuid: 'V1', name: 'NA' }]); }
|
||||
if (p.endsWith('/players')) {
|
||||
playerCalls++;
|
||||
return reply(mode === 'noaccess' ? 403 : 200,
|
||||
[{ playerHardwareId: 'DEC1', userName: 'NA', position: '00:10:00', eventId: null }]);
|
||||
}
|
||||
return reply(404, {});
|
||||
};
|
||||
|
||||
const { ModuleInstance } = require(target);
|
||||
|
||||
function fresh() {
|
||||
const inst = Object.create(ModuleInstance.prototype);
|
||||
inst.config = { username: 'a@b.c' };
|
||||
inst.secrets = { password: 'pw' };
|
||||
inst.lastLoginMs = 0;
|
||||
inst.selectedVenueUuid = 'V1';
|
||||
inst.selectedPlayerHardwareId = 'DEC1';
|
||||
inst.playerPollInFlight = false;
|
||||
inst.updateStatus = () => {};
|
||||
inst.setVariableValues = () => {};
|
||||
inst.warns = [];
|
||||
inst.log = (lvl, m) => { if (lvl === 'warn' || lvl === 'error') inst.warns.push(m); };
|
||||
// no real timers: the test drives the polls itself
|
||||
inst.startPolling = () => { inst.pollingStarted = true; };
|
||||
inst.startPlayerPolling = () => {};
|
||||
inst.startSmoothTick = () => {};
|
||||
inst.refreshCuesIfDue = async () => {};
|
||||
return inst;
|
||||
}
|
||||
|
||||
async function pollFor(inst, seconds) {
|
||||
for (let i = 0; i < seconds; i++) {
|
||||
now += 1000;
|
||||
await inst.pollPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
async function test(name, fn) { await fn(); passed++; console.log(` ok ${name}`); }
|
||||
|
||||
(async () => {
|
||||
await test('a rate-limited login is not retried every poll', async () => {
|
||||
mode = 'limited'; logins = 0;
|
||||
const inst = fresh();
|
||||
await pollFor(inst, 300); // five minutes of 1s polls
|
||||
assert.ok(logins <= 6, `signed in ${logins} times in five rate-limited minutes`);
|
||||
assert.ok(inst.warns.length <= 6, `logged ${inst.warns.length} warnings (one per poll would be 300)`);
|
||||
});
|
||||
|
||||
await test('a locked account says so and holds off', async () => {
|
||||
mode = 'locked'; logins = 0;
|
||||
const inst = fresh();
|
||||
await pollFor(inst, 120);
|
||||
assert.ok(logins <= 4, `signed in ${logins} times against a locked account`);
|
||||
assert.ok(inst.warns.some((w) => /USER_LOCKED/.test(w)), 'the lock is named in the log');
|
||||
});
|
||||
|
||||
await test('it recovers once Resi lets it in, and keeps one session', async () => {
|
||||
mode = 'limited'; logins = 0;
|
||||
const inst = fresh();
|
||||
await pollFor(inst, 30);
|
||||
mode = 'ok';
|
||||
await pollFor(inst, 330); // past the longest hold-off
|
||||
const after = logins;
|
||||
playerCalls = 0;
|
||||
await pollFor(inst, 30);
|
||||
assert.strictEqual(logins, after, 'a good session is not signed in again');
|
||||
assert.ok(playerCalls >= 29, `polls the decoder normally once in (${playerCalls} in 30s)`);
|
||||
});
|
||||
|
||||
await test('a login without decoder access is not signed in every poll', async () => {
|
||||
mode = 'noaccess'; logins = 0; playerCalls = 0;
|
||||
const inst = fresh();
|
||||
await pollFor(inst, 120);
|
||||
assert.ok(logins <= 3, `signed in ${logins} times in two minutes for a permission problem`);
|
||||
assert.ok(playerCalls <= 10, `asked for decoders ${playerCalls} times (a poll each would be 120)`);
|
||||
assert.ok(inst.warns.some((w) => /ask a Resi admin/.test(w)), 'says what to do about it');
|
||||
});
|
||||
|
||||
// Seen live with 0.1.3: a locked account made init() throw, Companion restarted the
|
||||
// module every few seconds, and every restart signed in again from scratch.
|
||||
await test('init survives a locked account, and recovers once it is unlocked', async () => {
|
||||
mode = 'locked'; logins = 0;
|
||||
const inst = fresh();
|
||||
inst.config.defaultPlayerHardwareId = 'DEC1';
|
||||
inst.venues = []; inst.playersByVenueUuid = {};
|
||||
await inst.init(inst.config, true, inst.secrets); // must not throw
|
||||
assert.ok(inst.pollingStarted, 'polling starts even though Resi refused');
|
||||
for (let i = 0; i < 60; i++) { now += 10000; await inst.pollOnce(false); } // ten minutes of account polls
|
||||
// 0s, 15s, 45s, 105s, 225s, 465s: six. The restart loop did about one every 6s.
|
||||
assert.ok(logins <= 6, `signed in ${logins} times in ten locked minutes`);
|
||||
mode = 'ok';
|
||||
now += 300000;
|
||||
await inst.pollOnce(false);
|
||||
assert.strictEqual(inst.selectedVenueUuid, 'V1', 'picks up the configured decoder by itself');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
})().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Walk every .js in a package and check that each require()/import target resolves,
|
||||
following Node's resolution rules against the vendored node_modules tree."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = os.path.abspath(sys.argv[1])
|
||||
BUILTIN = set("""assert async_hooks buffer child_process cluster console constants crypto dgram
|
||||
diagnostics_channel dns domain events fs http http2 https inspector module net os path
|
||||
perf_hooks process punycode querystring readline repl stream string_decoder sys timers tls
|
||||
trace_events tty url util v8 vm wasi worker_threads zlib""".split())
|
||||
|
||||
SPEC = re.compile(r"""(?:require\(|(?:^|[\s;{}])(?:import|export)[^;\n]*?\bfrom\s*|import\()\s*['"]([^'"]+)['"]""", re.M)
|
||||
DYNAMIC = re.compile(r"require\(\s*[^'\")\s]")
|
||||
COMMENTS = re.compile(r"/\*.*?\*/|^\s*//.*$", re.S | re.M)
|
||||
# Optional at runtime: loaded only under a config or platform this module never uses.
|
||||
OPTIONAL = {"re2", "expo-random", "tinybench", "uri-js", "neostandard"}
|
||||
SKIP_DIRS = ("test", "benchmark", "benchmarks", "example", "examples", ".github")
|
||||
|
||||
|
||||
def load_json(p):
|
||||
try:
|
||||
with open(p) as fh:
|
||||
return json.load(fh)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def resolve_file(base):
|
||||
for cand in (base, base + ".js", base + ".json", base + ".cjs", base + ".mjs", base + ".node"):
|
||||
if os.path.isfile(cand):
|
||||
return cand
|
||||
if os.path.isdir(base):
|
||||
pj = load_json(os.path.join(base, "package.json"))
|
||||
for key in ("main", "module"):
|
||||
if isinstance(pj.get(key), str):
|
||||
hit = resolve_file(os.path.join(base, pj[key]))
|
||||
if hit:
|
||||
return hit
|
||||
if pj.get("exports"):
|
||||
return base # exports map: trust it, too complex to model here
|
||||
for idx in ("index.js", "index.json", "index.cjs", "index.node"):
|
||||
if os.path.isfile(os.path.join(base, idx)):
|
||||
return os.path.join(base, idx)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_bare(spec, from_dir):
|
||||
d = from_dir
|
||||
while True:
|
||||
hit = resolve_file(os.path.join(d, "node_modules", *spec.split("/")))
|
||||
if hit:
|
||||
return hit
|
||||
# scoped or deep import into a package that exists
|
||||
parts = spec.split("/")
|
||||
pkg = "/".join(parts[:2]) if spec.startswith("@") else parts[0]
|
||||
pkg_dir = os.path.join(d, "node_modules", *pkg.split("/"))
|
||||
if os.path.isdir(pkg_dir) and len(parts) > (2 if spec.startswith("@") else 1):
|
||||
sub = resolve_file(os.path.join(pkg_dir, *parts[(2 if spec.startswith("@") else 1):]))
|
||||
if sub:
|
||||
return sub
|
||||
if load_json(os.path.join(pkg_dir, "package.json")).get("exports"):
|
||||
return pkg_dir
|
||||
if os.path.dirname(d) == d or len(d) <= len(ROOT) - 1:
|
||||
return None
|
||||
d = os.path.dirname(d)
|
||||
|
||||
|
||||
missing, dynamic, files, optional_hits = [], [], 0, set()
|
||||
for dirpath, dirnames, filenames in os.walk(ROOT):
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
||||
if dirpath.endswith((os.sep + "browser", os.sep + "types")):
|
||||
continue
|
||||
for fn in filenames:
|
||||
if not fn.endswith((".js", ".cjs", ".mjs")):
|
||||
continue
|
||||
if fn.endswith((".native.js", ".browser.js")) or fn.startswith("eslint.config"):
|
||||
continue
|
||||
path = os.path.join(dirpath, fn)
|
||||
files += 1
|
||||
try:
|
||||
src = COMMENTS.sub("", open(path, encoding="utf-8", errors="replace").read())
|
||||
except OSError:
|
||||
continue
|
||||
if DYNAMIC.search(src):
|
||||
dynamic.append(os.path.relpath(path, ROOT))
|
||||
for spec in set(SPEC.findall(src)):
|
||||
if spec.startswith("node:") or spec.split("/")[0] in BUILTIN:
|
||||
continue
|
||||
if spec in OPTIONAL:
|
||||
optional_hits.add((os.path.relpath(path, ROOT), spec))
|
||||
continue
|
||||
if spec.startswith("."):
|
||||
hit = resolve_file(os.path.normpath(os.path.join(dirpath, spec)))
|
||||
else:
|
||||
hit = resolve_bare(spec, dirpath)
|
||||
if not hit:
|
||||
missing.append((os.path.relpath(path, ROOT), spec))
|
||||
|
||||
print("scanned %d js files" % files)
|
||||
if dynamic:
|
||||
print("\n%d file(s) use a computed require() (not statically checkable):" % len(dynamic))
|
||||
for d in sorted(dynamic)[:12]:
|
||||
print(" ", d)
|
||||
if optional_hits:
|
||||
print("\n%d optional dep(s), not loaded on this platform/config:" % len(optional_hits))
|
||||
for f, spec in sorted(optional_hits):
|
||||
print(" %-64s -> %s" % (f, spec))
|
||||
if missing:
|
||||
print("\nUNRESOLVED (%d):" % len(missing))
|
||||
for f, s in sorted(set(missing)):
|
||||
print(" %-70s -> %s" % (f, s))
|
||||
sys.exit(1)
|
||||
print("\nevery static require/import resolves")
|
||||
Vendored
+184
@@ -0,0 +1,184 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.UpdateActions = UpdateActions;
|
||||
function UpdateActions(self) {
|
||||
self.setActionDefinitions({
|
||||
refresh_profile: {
|
||||
name: 'Refresh profile (users/me)',
|
||||
options: [],
|
||||
callback: async () => {
|
||||
await self.refreshProfileNow();
|
||||
},
|
||||
},
|
||||
refresh_lists: {
|
||||
name: 'Refresh venues/players list',
|
||||
options: [],
|
||||
callback: async () => {
|
||||
await self.refreshListsNow();
|
||||
},
|
||||
},
|
||||
add_cue_at_position: {
|
||||
name: 'Add cue at player position (or custom time)',
|
||||
options: [
|
||||
{
|
||||
id: 'name',
|
||||
type: 'textinput',
|
||||
label: 'Cue name',
|
||||
default: 'Web Cue',
|
||||
useVariables: true,
|
||||
},
|
||||
{
|
||||
id: 'privateCue',
|
||||
type: 'checkbox',
|
||||
label: 'Private cue',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
id: 'positionMode',
|
||||
type: 'dropdown',
|
||||
label: 'Position source',
|
||||
default: 'player',
|
||||
choices: [
|
||||
{ id: 'player', label: 'Player position (current)' },
|
||||
{ id: 'custom', label: 'Custom time' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'customPosition',
|
||||
type: 'textinput',
|
||||
label: 'Custom position (HH:MM:SS or HH:MM:SS.mmm)',
|
||||
default: '00:00:00.000',
|
||||
useVariables: true,
|
||||
},
|
||||
],
|
||||
callback: async (evt) => {
|
||||
const name = (await self.parseVariablesInString(String(evt.options.name ?? ''))).trim();
|
||||
const privateCue = Boolean(evt.options.privateCue);
|
||||
const positionMode = String(evt.options.positionMode ?? 'player');
|
||||
const customPosition = (await self.parseVariablesInString(String(evt.options.customPosition ?? ''))).trim();
|
||||
await self.addCueFromPosition({
|
||||
name,
|
||||
privateCue,
|
||||
positionMode,
|
||||
customPosition,
|
||||
});
|
||||
},
|
||||
},
|
||||
delete_cue_by_name: {
|
||||
name: 'Delete cue by name',
|
||||
options: [
|
||||
{
|
||||
id: 'cueName',
|
||||
type: 'textinput',
|
||||
label: 'Cue name',
|
||||
default: '',
|
||||
useVariables: true,
|
||||
},
|
||||
{
|
||||
id: 'matchMode',
|
||||
type: 'dropdown',
|
||||
label: 'Match mode',
|
||||
default: 'exact',
|
||||
choices: [
|
||||
{ id: 'exact', label: 'Exact (case-insensitive)' },
|
||||
{ id: 'contains', label: 'Contains (case-insensitive)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'deleteMode',
|
||||
type: 'dropdown',
|
||||
label: 'Delete',
|
||||
default: 'first',
|
||||
choices: [
|
||||
{ id: 'first', label: 'First match only' },
|
||||
{ id: 'all', label: 'All matches' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'onlyMine',
|
||||
type: 'checkbox',
|
||||
label: 'Only delete cues created by this user',
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
callback: async (evt) => {
|
||||
const cueName = await self.parseVariablesInString(String(evt.options.cueName ?? ''));
|
||||
const matchMode = String(evt.options.matchMode ?? 'exact');
|
||||
const deleteMode = String(evt.options.deleteMode ?? 'first');
|
||||
const onlyMine = Boolean(evt.options.onlyMine);
|
||||
await self.deleteCueByName({
|
||||
name: cueName,
|
||||
matchMode,
|
||||
deleteMode,
|
||||
onlyMine,
|
||||
});
|
||||
},
|
||||
},
|
||||
select_decoder: {
|
||||
name: 'Select decoder/player',
|
||||
options: [
|
||||
{
|
||||
id: 'playerId',
|
||||
type: 'dropdown',
|
||||
label: 'Decoder / Player',
|
||||
default: '',
|
||||
choices: self.getPlayerChoices(),
|
||||
},
|
||||
],
|
||||
callback: async (evt) => {
|
||||
await self.selectPlayer(String(evt.options.playerId || ''));
|
||||
},
|
||||
},
|
||||
resync_to_cue: {
|
||||
name: 'Resync playhead to cue (use on the button that fires it)',
|
||||
options: [
|
||||
{
|
||||
id: 'cueName',
|
||||
type: 'textinput',
|
||||
label: 'Cue name',
|
||||
default: '',
|
||||
useVariables: true,
|
||||
},
|
||||
],
|
||||
callback: async (evt) => {
|
||||
const cueName = await self.parseVariablesInString(String(evt.options.cueName ?? ''));
|
||||
self.resyncToCue(cueName);
|
||||
},
|
||||
},
|
||||
resync_to_next_cue: {
|
||||
name: 'Resync playhead to the NEXT cue',
|
||||
options: [],
|
||||
callback: async () => {
|
||||
self.resyncToNextCue();
|
||||
},
|
||||
},
|
||||
lookup_cue_by_name: {
|
||||
name: 'Lookup cue by name (stores in lookup_* variables)',
|
||||
options: [
|
||||
{
|
||||
id: 'cueName',
|
||||
type: 'textinput',
|
||||
label: 'Cue name',
|
||||
default: '',
|
||||
useVariables: true,
|
||||
},
|
||||
{
|
||||
id: 'matchMode',
|
||||
type: 'dropdown',
|
||||
label: 'Match mode',
|
||||
default: 'exact',
|
||||
choices: [
|
||||
{ id: 'exact', label: 'Exact (case-insensitive)' },
|
||||
{ id: 'contains', label: 'Contains (case-insensitive)' },
|
||||
],
|
||||
},
|
||||
],
|
||||
callback: async (evt) => {
|
||||
const cueName = await self.parseVariablesInString(String(evt.options.cueName ?? ''));
|
||||
const mode = String(evt.options.matchMode ?? 'exact');
|
||||
self.lookupCueByName(cueName, mode);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=actions.js.map
|
||||
Vendored
+65
@@ -0,0 +1,65 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.GetConfigFields = GetConfigFields;
|
||||
function GetConfigFields() {
|
||||
return [
|
||||
{
|
||||
type: 'textinput',
|
||||
id: 'username',
|
||||
label: 'Username / Email',
|
||||
width: 12,
|
||||
},
|
||||
{
|
||||
type: 'dropdown',
|
||||
id: 'defaultPlayerHardwareId',
|
||||
label: 'Default decoder/player on startup',
|
||||
width: 12,
|
||||
default: '',
|
||||
choices: [{ id: '', label: '(none)' }],
|
||||
},
|
||||
// NEW: polling intervals
|
||||
{
|
||||
type: 'number',
|
||||
id: 'playerPollMs',
|
||||
label: 'Player position poll (ms)',
|
||||
width: 6,
|
||||
min: 250,
|
||||
max: 30000,
|
||||
default: 1000,
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
id: 'positionTrimMs',
|
||||
label: 'Position trim (ms) - only if it still reads behind',
|
||||
width: 6,
|
||||
min: -30000,
|
||||
max: 30000,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
id: 'cuePollMs',
|
||||
label: 'Cue list refresh (ms)',
|
||||
width: 6,
|
||||
min: 1000,
|
||||
max: 60000,
|
||||
default: 5000,
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
id: 'listPollMs',
|
||||
label: 'User & decoder inf0 poll (ms)',
|
||||
width: 6,
|
||||
min: 10000,
|
||||
max: 3600000,
|
||||
default: 120000,
|
||||
},
|
||||
{
|
||||
type: 'secret-text',
|
||||
id: 'password',
|
||||
label: 'Password',
|
||||
width: 12,
|
||||
}
|
||||
];
|
||||
}
|
||||
//# sourceMappingURL=config.js.map
|
||||
Vendored
+113
@@ -0,0 +1,113 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.UpdateFeedbacks = UpdateFeedbacks;
|
||||
const base_1 = require("@companion-module/base");
|
||||
function UpdateFeedbacks(self) {
|
||||
self.setFeedbackDefinitions({
|
||||
resi_connected: {
|
||||
name: 'Resi: Connected (auth OK)',
|
||||
type: 'boolean',
|
||||
defaultStyle: {
|
||||
bgcolor: (0, base_1.combineRgb)(0, 200, 0),
|
||||
color: (0, base_1.combineRgb)(0, 0, 0),
|
||||
},
|
||||
options: [],
|
||||
callback: () => {
|
||||
const v = self.getVariableValue('auth_status') ?? '';
|
||||
return v === 'OK';
|
||||
},
|
||||
},
|
||||
resi_error: {
|
||||
name: 'Resi: Error (auth/API)',
|
||||
type: 'boolean',
|
||||
defaultStyle: {
|
||||
bgcolor: (0, base_1.combineRgb)(200, 0, 0),
|
||||
color: (0, base_1.combineRgb)(255, 255, 255),
|
||||
},
|
||||
options: [],
|
||||
callback: () => {
|
||||
const auth = (self.getVariableValue('auth_status') ?? '').trim();
|
||||
const err = (self.getVariableValue('last_error') ?? '').trim();
|
||||
// True when we have an error message OR auth isn't OK (but ignore "Missing credentials" if you prefer)
|
||||
return err.length > 0 || (auth.length > 0 && auth !== 'OK');
|
||||
},
|
||||
},
|
||||
player_active: {
|
||||
name: 'Resi: Player active (position > 0s)',
|
||||
type: 'boolean',
|
||||
defaultStyle: {
|
||||
bgcolor: (0, base_1.combineRgb)(0, 120, 255),
|
||||
color: (0, base_1.combineRgb)(255, 255, 255),
|
||||
},
|
||||
options: [],
|
||||
callback: () => {
|
||||
const s = self.getVariableValue('current_position_sec') ?? '0';
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) && n > 0;
|
||||
},
|
||||
},
|
||||
approaching_next_cue: {
|
||||
name: 'Resi: Approaching next cue (<= threshold seconds)',
|
||||
type: 'boolean',
|
||||
defaultStyle: {
|
||||
bgcolor: (0, base_1.combineRgb)(255, 180, 0),
|
||||
color: (0, base_1.combineRgb)(0, 0, 0),
|
||||
},
|
||||
options: [
|
||||
{
|
||||
id: 'threshold',
|
||||
type: 'number',
|
||||
label: 'Threshold (seconds)',
|
||||
default: 30,
|
||||
min: 1,
|
||||
max: 3600,
|
||||
},
|
||||
],
|
||||
callback: (fb) => {
|
||||
const nextName = (self.getVariableValue('next_cue_name') ?? '').trim();
|
||||
if (!nextName)
|
||||
return false;
|
||||
const s = self.getVariableValue('next_cue_time_in_seconds') ?? '';
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n))
|
||||
return false;
|
||||
const thr = Number(fb.options.threshold);
|
||||
return Number.isFinite(thr) ? n <= thr : false;
|
||||
},
|
||||
},
|
||||
playback_frozen: {
|
||||
name: 'Resi: Playback frozen (position not changing)',
|
||||
type: 'boolean',
|
||||
defaultStyle: {
|
||||
bgcolor: (0, base_1.combineRgb)(200, 0, 0),
|
||||
color: (0, base_1.combineRgb)(255, 255, 255),
|
||||
},
|
||||
options: [
|
||||
{
|
||||
id: 'staleSeconds',
|
||||
type: 'number',
|
||||
label: 'Frozen if unchanged for (seconds)',
|
||||
default: 6,
|
||||
min: 2,
|
||||
max: 120,
|
||||
},
|
||||
],
|
||||
callback: (fb) => {
|
||||
const staleSeconds = Number(fb.options.staleSeconds) || 6;
|
||||
const curStr = self.getVariableValue('current_position_sec') ?? '';
|
||||
const cur = Number(curStr);
|
||||
if (!Number.isFinite(cur))
|
||||
return false;
|
||||
const now = Date.now();
|
||||
const lastStr = self.getVariableValue('playback_last_change_epoch_ms') ?? '';
|
||||
const last = Number(lastStr);
|
||||
// If you haven't implemented playback_last_change_epoch_ms yet, we can't detect freezing reliably.
|
||||
if (!Number.isFinite(last) || last <= 0)
|
||||
return false;
|
||||
const ageSec = (now - last) / 1000;
|
||||
return ageSec >= staleSeconds;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=feedbacks.js.map
|
||||
Vendored
+1076
File diff suppressed because it is too large
Load Diff
Vendored
+303
@@ -0,0 +1,303 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.UpdatePresets = UpdatePresets;
|
||||
const base_1 = require("@companion-module/base");
|
||||
function UpdatePresets(self) {
|
||||
const presets = {};
|
||||
// Helper to reference THIS instance’s variables in button text
|
||||
// Companion variable syntax uses the instance label.
|
||||
const L = self.label;
|
||||
// ----------------------------
|
||||
// STATUS / HEALTH
|
||||
// ----------------------------
|
||||
presets['status_connected'] = {
|
||||
type: 'button',
|
||||
category: 'Resi • Status',
|
||||
name: 'Connected (green) / Error (red)',
|
||||
style: {
|
||||
text: `Resi\n$(${L}:auth_status)`,
|
||||
size: '14',
|
||||
color: (0, base_1.combineRgb)(255, 255, 255),
|
||||
bgcolor: (0, base_1.combineRgb)(40, 40, 40),
|
||||
show_topbar: true,
|
||||
},
|
||||
steps: [],
|
||||
feedbacks: [
|
||||
{
|
||||
feedbackId: 'resi_connected',
|
||||
options: {},
|
||||
},
|
||||
{
|
||||
feedbackId: 'resi_error',
|
||||
options: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
presets['status_player_active'] = {
|
||||
type: 'button',
|
||||
category: 'Resi • Status',
|
||||
name: 'Player Active',
|
||||
style: {
|
||||
text: `Player\n$(${L}:current_position)`,
|
||||
size: '14',
|
||||
color: (0, base_1.combineRgb)(255, 255, 255),
|
||||
bgcolor: (0, base_1.combineRgb)(40, 40, 40),
|
||||
show_topbar: true,
|
||||
},
|
||||
steps: [],
|
||||
feedbacks: [
|
||||
{
|
||||
feedbackId: 'player_active',
|
||||
options: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
presets['status_frozen'] = {
|
||||
type: 'button',
|
||||
category: 'Resi • Status',
|
||||
name: 'Playback Frozen Warning',
|
||||
style: {
|
||||
text: `FROZEN?\n$(${L}:current_position)`,
|
||||
size: '14',
|
||||
color: (0, base_1.combineRgb)(255, 255, 255),
|
||||
bgcolor: (0, base_1.combineRgb)(40, 40, 40),
|
||||
show_topbar: true,
|
||||
},
|
||||
steps: [],
|
||||
feedbacks: [
|
||||
{
|
||||
feedbackId: 'playback_frozen',
|
||||
options: {
|
||||
staleSeconds: 6,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
// ----------------------------
|
||||
// CUE DISPLAY (NO ACTIONS)
|
||||
// ----------------------------
|
||||
presets['cue_next_display'] = {
|
||||
type: 'button',
|
||||
category: 'Resi • Cues',
|
||||
name: 'Next Cue Display',
|
||||
style: {
|
||||
text: `NEXT:\n$(${L}:next_cue_name)\nT- $(${L}:next_cue_time_in_hms)`,
|
||||
size: '14',
|
||||
color: (0, base_1.combineRgb)(255, 255, 255),
|
||||
bgcolor: (0, base_1.combineRgb)(0, 0, 0),
|
||||
show_topbar: false,
|
||||
},
|
||||
steps: [],
|
||||
feedbacks: [
|
||||
{
|
||||
feedbackId: 'approaching_next_cue',
|
||||
options: { threshold: 30 },
|
||||
},
|
||||
],
|
||||
};
|
||||
presets['cue_prev_display'] = {
|
||||
type: 'button',
|
||||
category: 'Resi • Cues',
|
||||
name: 'Previous Cue Display',
|
||||
style: {
|
||||
text: `PREV:\n$(${L}:prev_cue_name)\n+ $(${L}:prev_cue_time_ago_in_hms)`,
|
||||
size: '14',
|
||||
color: (0, base_1.combineRgb)(255, 255, 255),
|
||||
bgcolor: (0, base_1.combineRgb)(0, 0, 0),
|
||||
show_topbar: false,
|
||||
},
|
||||
steps: [],
|
||||
feedbacks: [],
|
||||
};
|
||||
// ----------------------------
|
||||
// MAINTENANCE / REFRESH
|
||||
// ----------------------------
|
||||
presets['refresh_profile'] = {
|
||||
type: 'button',
|
||||
category: 'Resi • Maintenance',
|
||||
name: 'Refresh Profile',
|
||||
style: {
|
||||
text: 'Refresh\nProfile',
|
||||
size: '18',
|
||||
color: (0, base_1.combineRgb)(255, 255, 255),
|
||||
bgcolor: (0, base_1.combineRgb)(60, 60, 60),
|
||||
show_topbar: false,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
down: [
|
||||
{
|
||||
actionId: 'refresh_profile',
|
||||
options: {},
|
||||
},
|
||||
],
|
||||
up: [],
|
||||
},
|
||||
],
|
||||
feedbacks: [
|
||||
{ feedbackId: 'resi_connected', options: {} },
|
||||
{ feedbackId: 'resi_error', options: {} },
|
||||
],
|
||||
};
|
||||
presets['refresh_lists'] = {
|
||||
type: 'button',
|
||||
category: 'Resi • Maintenance',
|
||||
name: 'Refresh Venues/Players',
|
||||
style: {
|
||||
text: 'Refresh\nLists',
|
||||
size: '18',
|
||||
color: (0, base_1.combineRgb)(255, 255, 255),
|
||||
bgcolor: (0, base_1.combineRgb)(60, 60, 60),
|
||||
show_topbar: false,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
down: [
|
||||
{
|
||||
actionId: 'refresh_lists',
|
||||
options: {},
|
||||
},
|
||||
],
|
||||
up: [],
|
||||
},
|
||||
],
|
||||
feedbacks: [
|
||||
{ feedbackId: 'resi_connected', options: {} },
|
||||
{ feedbackId: 'resi_error', options: {} },
|
||||
],
|
||||
};
|
||||
// ----------------------------
|
||||
// CUE ACTIONS
|
||||
// ----------------------------
|
||||
presets['add_cue_player'] = {
|
||||
type: 'button',
|
||||
category: 'Resi • Cues',
|
||||
name: 'Add Cue @ Player Position',
|
||||
style: {
|
||||
text: 'Add Cue\n@ Player',
|
||||
size: '18',
|
||||
color: (0, base_1.combineRgb)(0, 0, 0),
|
||||
bgcolor: (0, base_1.combineRgb)(180, 180, 180),
|
||||
show_topbar: false,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
down: [
|
||||
{
|
||||
actionId: 'add_cue_at_position',
|
||||
options: {
|
||||
name: 'Web Cue',
|
||||
privateCue: false,
|
||||
positionMode: 'player',
|
||||
customPosition: '00:00:00.000',
|
||||
},
|
||||
},
|
||||
],
|
||||
up: [],
|
||||
},
|
||||
],
|
||||
feedbacks: [
|
||||
{ feedbackId: 'resi_connected', options: {} },
|
||||
{ feedbackId: 'resi_error', options: {} },
|
||||
],
|
||||
};
|
||||
presets['add_cue_custom'] = {
|
||||
type: 'button',
|
||||
category: 'Resi • Cues',
|
||||
name: 'Add Cue @ Custom Time',
|
||||
style: {
|
||||
text: 'Add Cue\n@ Custom',
|
||||
size: '18',
|
||||
color: (0, base_1.combineRgb)(0, 0, 0),
|
||||
bgcolor: (0, base_1.combineRgb)(180, 180, 180),
|
||||
show_topbar: false,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
down: [
|
||||
{
|
||||
actionId: 'add_cue_at_position',
|
||||
options: {
|
||||
// user can edit this preset, OR replace with a variable
|
||||
name: 'Custom Cue',
|
||||
privateCue: false,
|
||||
positionMode: 'custom',
|
||||
customPosition: '00:10:00.000',
|
||||
},
|
||||
},
|
||||
],
|
||||
up: [],
|
||||
},
|
||||
],
|
||||
feedbacks: [
|
||||
{ feedbackId: 'resi_connected', options: {} },
|
||||
{ feedbackId: 'resi_error', options: {} },
|
||||
],
|
||||
};
|
||||
presets['lookup_cue'] = {
|
||||
type: 'button',
|
||||
category: 'Resi • Cues',
|
||||
name: 'Lookup Cue (stores in lookup_* vars)',
|
||||
style: {
|
||||
text: 'Lookup\nCue',
|
||||
size: '18',
|
||||
color: (0, base_1.combineRgb)(255, 255, 255),
|
||||
bgcolor: (0, base_1.combineRgb)(0, 110, 200),
|
||||
show_topbar: false,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
down: [
|
||||
{
|
||||
actionId: 'lookup_cue_by_name',
|
||||
options: {
|
||||
// user edits this preset to their cue name or uses variables
|
||||
cueName: 'sermon start',
|
||||
matchMode: 'exact',
|
||||
},
|
||||
},
|
||||
],
|
||||
up: [],
|
||||
},
|
||||
],
|
||||
feedbacks: [
|
||||
{ feedbackId: 'resi_connected', options: {} },
|
||||
{ feedbackId: 'resi_error', options: {} },
|
||||
],
|
||||
};
|
||||
presets['delete_cue'] = {
|
||||
type: 'button',
|
||||
category: 'Resi • Cues',
|
||||
name: 'Delete Cue by Name',
|
||||
style: {
|
||||
text: 'DELETE\nCue',
|
||||
size: '18',
|
||||
color: (0, base_1.combineRgb)(255, 255, 255),
|
||||
bgcolor: (0, base_1.combineRgb)(140, 0, 0),
|
||||
show_topbar: false,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
down: [
|
||||
{
|
||||
actionId: 'delete_cue_by_name',
|
||||
options: {
|
||||
// user edits this preset to the cue they want to delete
|
||||
cueName: 'Companion Test',
|
||||
matchMode: 'exact',
|
||||
deleteMode: 'first',
|
||||
onlyMine: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
up: [],
|
||||
},
|
||||
],
|
||||
feedbacks: [
|
||||
{ feedbackId: 'resi_connected', options: {} },
|
||||
{ feedbackId: 'resi_error', options: {} },
|
||||
],
|
||||
};
|
||||
self.setPresetDefinitions(presets);
|
||||
}
|
||||
//# sourceMappingURL=presets.js.map
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.UpgradeScripts = void 0;
|
||||
exports.UpgradeScripts = [];
|
||||
//# sourceMappingURL=upgrades.js.map
|
||||
Vendored
+43
@@ -0,0 +1,43 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.UpdateVariableDefinitions = UpdateVariableDefinitions;
|
||||
function UpdateVariableDefinitions(instance) {
|
||||
const vars = [
|
||||
{ variableId: 'org_name', name: 'Organization name' },
|
||||
{ variableId: 'first_name', name: 'First name' },
|
||||
{ variableId: 'last_name', name: 'Last name' },
|
||||
{ variableId: 'email', name: 'Email' },
|
||||
{ variableId: 'auth_status', name: 'Auth status' },
|
||||
{ variableId: 'last_error', name: 'Last error' },
|
||||
{ variableId: 'last_poll', name: 'Last poll (local time)' },
|
||||
{ variableId: 'current_position', name: 'Current position (HH:MM:SS.ms)' },
|
||||
{ variableId: 'playback_state', name: 'Playback state (PLAYING/PAUSED)' },
|
||||
{ variableId: 'position_update_interval_sec', name: 'Seconds between Resi position updates' },
|
||||
{ variableId: 'player_fields', name: 'Fields Resi returns for this decoder (diagnostic)' },
|
||||
{ variableId: 'current_position_sec', name: 'Current position (seconds)' },
|
||||
{ variableId: 'current_event_id', name: 'Current event id (live from player)' },
|
||||
{ variableId: 'cue_count', name: 'Cue count' },
|
||||
{ variableId: 'cue_names', name: 'Cue names (comma separated)' },
|
||||
{ variableId: 'event_id', name: 'Current event id (cues loaded for)' },
|
||||
{ variableId: 'next_cue_name', name: 'Next cue name' },
|
||||
{ variableId: 'next_cue_time_in_seconds', name: 'Seconds until next cue' },
|
||||
{ variableId: 'next_cue_position', name: 'Next cue position (HH:MM:SS.ms)' },
|
||||
{ variableId: 'next_cue_tod', name: 'Next cue time-of-day (24 hour)' },
|
||||
{ variableId: 'prev_cue_name', name: 'Previous cue name' },
|
||||
{ variableId: 'prev_cue_time_ago_in_seconds', name: 'Seconds since previous cue' },
|
||||
{ variableId: 'prev_cue_position', name: 'Previous cue position (HH:MM:SS.ms)' },
|
||||
{ variableId: 'prev_cue_tod', name: 'Previous cue time-of-day (24 hour)' },
|
||||
{ variableId: 'next_cue_time_in_hms', name: 'Time until next cue (HH:MM:SS)' },
|
||||
{ variableId: 'prev_cue_time_ago_in_hms', name: 'Time since previous cue (HH:MM:SS)' },
|
||||
{ variableId: 'lookup_cue_query', name: 'Lookup cue query (name)' },
|
||||
{ variableId: 'lookup_cue_found', name: 'Lookup cue found (true/false)' },
|
||||
{ variableId: 'lookup_cue_name', name: 'Lookup cue name (matched)' },
|
||||
{ variableId: 'lookup_cue_uuid', name: 'Lookup cue uuid' },
|
||||
{ variableId: 'lookup_cue_position', name: 'Lookup cue position (HH:MM:SS.ms)' },
|
||||
{ variableId: 'lookup_cue_position_sec', name: 'Lookup cue position (seconds)' },
|
||||
{ variableId: 'lookup_cue_tod', name: 'Lookup cue time-of-day' },
|
||||
{ variableId: 'playback_last_change_epoch_ms', name: 'Playback last position change (epoch ms)' },
|
||||
];
|
||||
instance.setVariableDefinitions(vars);
|
||||
}
|
||||
//# sourceMappingURL=variables.js.map
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"id": "resi-studio-cues",
|
||||
"name": "resi-studio-cues",
|
||||
"shortname": "resi-studio-cues",
|
||||
"description": "Live cue list, playhead and next-cue countdown from Resi Studio",
|
||||
"version": "0.1.4",
|
||||
"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",
|
||||
"maintainers": [
|
||||
{
|
||||
"name": "Rey Kroona",
|
||||
"email": "reykroona@gmail.com"
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"type": "node18",
|
||||
"api": "nodejs-ipc",
|
||||
"apiVersion": "1.14.1",
|
||||
"entrypoint": "../dist/main.js"
|
||||
},
|
||||
"legacyIds": [],
|
||||
"manufacturer": "Rey Kroona",
|
||||
"products": [
|
||||
"Resi Studio Cues"
|
||||
],
|
||||
"keywords": [
|
||||
"resi",
|
||||
"living as one",
|
||||
"multisite",
|
||||
"cues",
|
||||
"countdown",
|
||||
"decoder"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble a fully-nested node_modules tree from the npm registry, without npm.
|
||||
|
||||
Fully nested (no hoisting) so Node's upward resolution can never pick a wrong
|
||||
version: every package's deps live inside its own node_modules/.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
CACHE = os.path.abspath("_npmcache")
|
||||
os.makedirs(CACHE, exist_ok=True)
|
||||
_meta = {}
|
||||
|
||||
|
||||
def meta(name):
|
||||
if name not in _meta:
|
||||
path = os.path.join(CACHE, name.replace("/", "_") + ".json")
|
||||
if not os.path.exists(path):
|
||||
url = "https://registry.npmjs.org/" + urllib.parse.quote(name, safe="")
|
||||
urllib.request.urlretrieve(url, path)
|
||||
with open(path) as fh:
|
||||
_meta[name] = json.load(fh)
|
||||
return _meta[name]
|
||||
|
||||
|
||||
def parse(v):
|
||||
core = v.split("-")[0].split("+")[0]
|
||||
return tuple(int(x) for x in core.split("."))
|
||||
|
||||
|
||||
def satisfies(version, rng):
|
||||
if "-" in version: # never auto-pick a prerelease
|
||||
return False
|
||||
rng = rng.strip()
|
||||
v = parse(version)
|
||||
if rng in ("", "*", "latest", "x"):
|
||||
return True
|
||||
if rng.startswith("^"):
|
||||
b = parse(rng[1:])
|
||||
if v < b:
|
||||
return False
|
||||
if b[0] > 0:
|
||||
return v[0] == b[0]
|
||||
if b[1] > 0:
|
||||
return v[0] == 0 and v[1] == b[1]
|
||||
return v[0] == 0 and v[1] == 0 and v[2] == b[2]
|
||||
if rng.startswith("~"):
|
||||
b = parse(rng[1:])
|
||||
return v >= b and v[0] == b[0] and v[1] == b[1]
|
||||
if rng.startswith(">="):
|
||||
return v >= parse(rng[2:])
|
||||
if rng[0].isdigit():
|
||||
return version == rng
|
||||
raise ValueError("unsupported range %r" % rng)
|
||||
|
||||
|
||||
def pick(name, rng):
|
||||
m = meta(name)
|
||||
ok = [v for v in m["versions"] if satisfies(v, rng)]
|
||||
if not ok:
|
||||
raise SystemExit("no version of %s satisfies %s" % (name, rng))
|
||||
best = max(ok, key=parse)
|
||||
return best, m["versions"][best]
|
||||
|
||||
|
||||
def install(name, rng, target_nm, chain, log):
|
||||
version, info = pick(name, rng)
|
||||
if (name, version) in chain:
|
||||
return # already provided by an ancestor
|
||||
dest = os.path.join(target_nm, *name.split("/"))
|
||||
if os.path.exists(dest):
|
||||
return
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
tgz = os.path.join(CACHE, "%s-%s.tgz" % (name.replace("/", "_"), version))
|
||||
if not os.path.exists(tgz):
|
||||
urllib.request.urlretrieve(info["dist"]["tarball"], tgz)
|
||||
tmp = dest + ".tmp"
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
with tarfile.open(tgz) as tf:
|
||||
tf.extractall(tmp)
|
||||
inner = os.path.join(tmp, "package")
|
||||
shutil.move(inner if os.path.isdir(inner) else tmp, dest)
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
log.append("%s%s@%s" % (" " * len(chain), name, version))
|
||||
|
||||
deps = info.get("dependencies") or {}
|
||||
if deps:
|
||||
child_nm = os.path.join(dest, "node_modules")
|
||||
for dname, drng in sorted(deps.items()):
|
||||
install(dname, drng, child_nm, chain | {(name, version)}, log)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
root_nm, name, rng = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
os.makedirs(root_nm, exist_ok=True)
|
||||
log = []
|
||||
install(name, rng, root_nm, frozenset(), log)
|
||||
print("\n".join(log))
|
||||
print("\n%d packages" % len(log))
|
||||
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
resi_probe.py — does the Resi cloud hand us live cue + playhead data for OUR account?
|
||||
|
||||
One read-only pass over the endpoints a Companion "cue countdown" module would need:
|
||||
|
||||
login POST /api/v3/login?newToken=true
|
||||
who am i GET /api_v2.svc/users/me
|
||||
venues GET /api/v3/customers/{customerId}/venues
|
||||
decoders GET /api_v2.svc/users/{venueUuid}/players <- playhead lives here
|
||||
event GET /api/v3/customers/{customerId}/events/{eventId}
|
||||
cues GET /api_v2.svc/streamprofiles/{eventProfileId}/events/{eventId}/cues
|
||||
|
||||
Nothing is created, changed or deleted. Your password is read with getpass (never
|
||||
echoed), sent only to https://central.resi.io, and never written to disk.
|
||||
|
||||
This talks to Resi's INTERNAL API — the one studio.resi.io itself uses. It is
|
||||
undocumented and unsupported; don't open a Resi ticket about it.
|
||||
|
||||
Usage:
|
||||
python3 resi_probe.py --user you@yourchurch.org
|
||||
python3 resi_probe.py --user you@yourchurch.org --watch 10 # 10s playhead watch
|
||||
python3 resi_probe.py --user you@yourchurch.org --save ./resi-capture
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BASE = "https://central.resi.io"
|
||||
|
||||
_jar = http.cookiejar.CookieJar()
|
||||
_opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(_jar))
|
||||
_token = None
|
||||
_save_dir = None
|
||||
|
||||
|
||||
def _cookie(name):
|
||||
for c in _jar:
|
||||
if c.name == name:
|
||||
return c.value
|
||||
return None
|
||||
|
||||
|
||||
def _headers():
|
||||
h = {"Accept": "application/json", "User-Agent": "resi-probe/1.0"}
|
||||
if _token:
|
||||
h["Authorization"] = "X-Bearer " + _token
|
||||
return h
|
||||
|
||||
|
||||
def _request(method, path, body=None):
|
||||
url = path if path.startswith("http") else BASE + path
|
||||
data = None
|
||||
h = _headers()
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
h["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, headers=h, method=method)
|
||||
try:
|
||||
with _opener.open(req, timeout=20) as resp:
|
||||
raw = resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
raise RuntimeError("HTTP %s on %s %s — %s" % (e.code, method, path, e.read()[:300].decode("utf-8", "replace")))
|
||||
except ssl.SSLError as e:
|
||||
raise RuntimeError("TLS failure (%s). If this Mac's Python has no CA bundle, run the "
|
||||
"'Install Certificates.command' that ships with your Python install." % e)
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError("Could not reach %s — %s" % (url, e.reason))
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError:
|
||||
return raw.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def get(path):
|
||||
out = _request("GET", path)
|
||||
if _save_dir is not None:
|
||||
name = urllib.parse.quote(path.split("?")[0].strip("/"), safe="") + ".json"
|
||||
with open(os.path.join(_save_dir, name), "w") as fh:
|
||||
json.dump(out, fh, indent=2, sort_keys=True)
|
||||
return out
|
||||
|
||||
|
||||
def login(username, password):
|
||||
global _token
|
||||
_request("POST", "/api/v3/login?newToken=true", {"userName": username, "password": password})
|
||||
_token = _cookie("livingas1")
|
||||
if not _token:
|
||||
raise RuntimeError("Login returned 2xx but set no 'livingas1' cookie — the auth shape has changed. "
|
||||
"Cookies seen: %s" % ", ".join(c.name for c in _jar))
|
||||
|
||||
|
||||
def ms(position):
|
||||
"""'01:02:03.500' -> milliseconds. Returns None on anything unparseable."""
|
||||
if not position:
|
||||
return None
|
||||
try:
|
||||
hh, mm_, rest = position.split(":")
|
||||
return int((int(hh) * 3600 + int(mm_) * 60 + float(rest)) * 1000)
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def hms(milliseconds):
|
||||
s, sign = int(abs(milliseconds) // 1000), "-" if milliseconds < 0 else ""
|
||||
return "%s%02d:%02d:%02d" % (sign, s // 3600, (s % 3600) // 60, s % 60)
|
||||
|
||||
|
||||
def main():
|
||||
global _save_dir
|
||||
ap = argparse.ArgumentParser(description="Read-only probe of the Resi internal API.")
|
||||
ap.add_argument("--user", help="Resi Studio login (email)")
|
||||
ap.add_argument("--watch", type=int, default=0, metavar="SEC",
|
||||
help="after the report, poll the playhead for SEC seconds to prove it advances")
|
||||
ap.add_argument("--save", metavar="DIR",
|
||||
help="write each raw JSON response to DIR (contains venue/decoder names and uuids)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.save:
|
||||
os.makedirs(args.save, exist_ok=True)
|
||||
_save_dir = args.save
|
||||
|
||||
username = args.user or input("Resi login (email): ").strip()
|
||||
password = getpass.getpass("Password (not echoed, not stored): ")
|
||||
|
||||
print("\n== auth ==")
|
||||
login(username, password)
|
||||
print(" login OK, bearer token acquired")
|
||||
|
||||
me = get("/api_v2.svc/users/me")
|
||||
customer_id = me.get("customerId")
|
||||
print(" user : %s" % me.get("userName"))
|
||||
print(" org : %s (customerId %s)" % (me.get("customerName"), customer_id))
|
||||
if not customer_id:
|
||||
sys.exit(" no customerId on this account — stop here, nothing else will resolve")
|
||||
|
||||
print("\n== venues ==")
|
||||
venues = get("/api/v3/customers/%s/venues" % customer_id) or []
|
||||
if not venues:
|
||||
print(" none returned. Either this login has no venue access, or the plan has no Multisite.")
|
||||
for v in venues:
|
||||
print(" %-40s %s" % (v.get("userName") or v.get("name"), v.get("uuid")))
|
||||
|
||||
print("\n== decoders / players ==")
|
||||
live = []
|
||||
for v in venues:
|
||||
players = []
|
||||
try:
|
||||
players = get("/api_v2.svc/users/%s/players" % v.get("uuid")) or []
|
||||
except RuntimeError as e:
|
||||
print(" [%s] players call failed: %s" % (v.get("userName"), e))
|
||||
continue
|
||||
if not players:
|
||||
print(" [%s] no players" % v.get("userName"))
|
||||
for p in players:
|
||||
print(" [%s] %-28s pos=%-14s eventId=%s"
|
||||
% (v.get("userName"), p.get("userName"), p.get("position"), p.get("eventId")))
|
||||
extra = sorted(set(p) - {"playerHardwareId", "userName", "position", "eventId"})
|
||||
if extra:
|
||||
print(" other fields on this player: %s" % ", ".join(extra))
|
||||
if p.get("eventId"):
|
||||
live.append((v, p))
|
||||
|
||||
if not live:
|
||||
print("\nNo decoder is on an event right now. Re-run this DURING a service —"
|
||||
"\nposition and eventId are what the whole countdown hangs on.")
|
||||
return
|
||||
|
||||
for v, p in live:
|
||||
print("\n== event + cues for %s / %s ==" % (v.get("userName"), p.get("userName")))
|
||||
event = get("/api/v3/customers/%s/events/%s" % (customer_id, p.get("eventId")))
|
||||
profile_id = event.get("eventProfileId")
|
||||
print(" event : %s" % event.get("name"))
|
||||
print(" startTime : %s" % event.get("startTime"))
|
||||
print(" profileId : %s" % profile_id)
|
||||
if not profile_id:
|
||||
print(" no eventProfileId — cannot fetch cues for this event")
|
||||
continue
|
||||
|
||||
cues = get("/api_v2.svc/streamprofiles/%s/events/%s/cues?canISetCues=1"
|
||||
% (profile_id, p.get("eventId"))) or []
|
||||
cues = sorted(cues, key=lambda c: ms(c.get("position")) or 0)
|
||||
print(" %d cue(s):" % len(cues))
|
||||
here = ms(p.get("position"))
|
||||
for c in cues:
|
||||
at = ms(c.get("position"))
|
||||
marker = " "
|
||||
if here is not None and at is not None:
|
||||
marker = "->" if at > here else " "
|
||||
print(" %s %-14s %s" % (marker, c.get("position"), c.get("name")))
|
||||
|
||||
if here is None:
|
||||
print("\n playhead unparseable (%r) — countdown not possible from this field"
|
||||
% p.get("position"))
|
||||
continue
|
||||
nxt = next((c for c in cues if (ms(c.get("position")) or 0) > here), None)
|
||||
print("\n THE BUTTON WOULD READ:")
|
||||
print(" Playback position : %s" % hms(here))
|
||||
if nxt:
|
||||
print(" Next cue : %s" % nxt.get("name"))
|
||||
print(" Next cue in : %s" % hms((ms(nxt.get("position")) or 0) - here))
|
||||
else:
|
||||
print(" Next cue : (none left on this event)")
|
||||
|
||||
if args.watch:
|
||||
v, p = live[0]
|
||||
print("\n== watching %s for %ds (does the playhead advance?) ==" % (p.get("userName"), args.watch))
|
||||
first = last = None
|
||||
deadline = time.time() + args.watch
|
||||
while time.time() < deadline:
|
||||
players = get("/api_v2.svc/users/%s/players" % v.get("uuid")) or []
|
||||
now = next((x.get("position") for x in players
|
||||
if x.get("playerHardwareId") == p.get("playerHardwareId")), None)
|
||||
print(" %s %s" % (time.strftime("%H:%M:%S"), now))
|
||||
if first is None:
|
||||
first = ms(now)
|
||||
last = ms(now)
|
||||
time.sleep(2)
|
||||
if first is not None and last is not None:
|
||||
moved = last - first
|
||||
print(" moved %s over ~%ds -> %s"
|
||||
% (hms(moved), args.watch,
|
||||
"PLAYING (a play/pause variable is derivable)" if moved > 0 else "frozen / paused"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except RuntimeError as exc:
|
||||
sys.exit("\nFAILED: %s" % exc)
|
||||
except KeyboardInterrupt:
|
||||
sys.exit("\ninterrupted")
|
||||
@@ -0,0 +1,272 @@
|
||||
// Drives the real pollPlayer() + estimator against a simulated Resi that
|
||||
// publishes `position` on a coarse cadence, already slightly stale.
|
||||
|
||||
var console = {
|
||||
log: function () { print(Array.prototype.join.call(arguments, ' ')) },
|
||||
error: function () { print('ERR ' + Array.prototype.join.call(arguments, ' ')) },
|
||||
warn: function () { print('WARN ' + Array.prototype.join.call(arguments, ' ')) },
|
||||
info: function () { print(Array.prototype.join.call(arguments, ' ')) },
|
||||
debug: function () {},
|
||||
};
|
||||
|
||||
var failures = [];
|
||||
function check(c, m) { if (!c) { failures.push(m); print(' FAIL ' + m) } }
|
||||
function eq(g, w, m) { check(g === w, m + ' (got ' + g + ', want ' + w + ')') }
|
||||
function near(g, w, tol, m) {
|
||||
check(Math.abs(g - w) <= tol, m + ' (got ' + Math.round(g) + ', want ' + Math.round(w) + ' +/-' + tol + ')');
|
||||
}
|
||||
|
||||
function StubInstanceBase() {}
|
||||
StubInstanceBase.prototype.updateStatus = function () {}
|
||||
StubInstanceBase.prototype.log = function () {}
|
||||
StubInstanceBase.prototype.setVariableValues = function () {}
|
||||
var stubs = {
|
||||
'@companion-module/base': {
|
||||
InstanceBase: StubInstanceBase,
|
||||
InstanceStatus: { Ok: 'ok', Connecting: 'c', ConnectionFailure: 'f', BadConfig: 'b' },
|
||||
runEntrypoint: function () {}, combineRgb: function () { return 0 }, Regex: {},
|
||||
},
|
||||
'./config.js': { GetConfigFields: function () { return [] } },
|
||||
'./variables.js': { UpdateVariableDefinitions: function () {} },
|
||||
'./upgrades.js': { UpgradeScripts: [] },
|
||||
'./actions.js': { UpdateActions: function () {} },
|
||||
'./feedbacks.js': { UpdateFeedbacks: function () {} },
|
||||
'./presets.js': { UpdatePresets: function () {} },
|
||||
};
|
||||
|
||||
// ---- the simulated world -------------------------------------------------
|
||||
var fakeNow = 1700000000000;
|
||||
Date.now = function () { return fakeNow };
|
||||
|
||||
// cadence / lag / jitter can be overridden: jsc tick_test.js -- 10000 3000 4000
|
||||
var ARGS = (typeof arguments !== 'undefined' ? arguments : []).map(Number);
|
||||
var DIAG = true;
|
||||
var RESI_CADENCE = ARGS[0] || 5000; // how often Resi refreshes `position`
|
||||
var RESI_LAG = ARGS.length > 1 ? ARGS[1] : 500; // how stale it already is when it appears
|
||||
var RESI_JITTER = ARGS[2] || 0; // how irregular the cadence is
|
||||
var NET_JITTER = ARGS[3] || 0; // how variable the request/response round trip is
|
||||
var START_INTO = ARGS[4] || 0; // how far into Resi's cycle the module starts polling
|
||||
var seed = 12345;
|
||||
function rnd() { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff }
|
||||
var nextCadence = RESI_CADENCE;
|
||||
var playing = true;
|
||||
var trueMedia = 8 * 60000 + 48000; // where the decoder REALLY is
|
||||
var published = trueMedia - RESI_LAG; // what the API currently returns
|
||||
var lastPublish = fakeNow - START_INTO;
|
||||
var extraPlayerFields = {};
|
||||
var TOL = RESI_LAG + 1200 + RESI_JITTER;
|
||||
|
||||
function pad(n, w) { return String(n).padStart(w, '0') }
|
||||
function fmt(ms) {
|
||||
var t = Math.max(0, Math.floor(ms));
|
||||
return pad(Math.floor(t / 3600000), 2) + ':' + pad(Math.floor((t % 3600000) / 60000), 2) + ':' +
|
||||
pad(Math.floor((t % 60000) / 1000), 2) + '.' + pad(t % 1000, 3);
|
||||
}
|
||||
|
||||
function advanceWorld(ms) {
|
||||
for (var i = 0; i < ms; i += 100) {
|
||||
fakeNow += 100;
|
||||
if (playing) trueMedia += 100;
|
||||
if (fakeNow - lastPublish >= nextCadence) {
|
||||
lastPublish = fakeNow;
|
||||
published = trueMedia - RESI_LAG;
|
||||
nextCadence = RESI_CADENCE + Math.round((rnd() - 0.5) * 2 * RESI_JITTER);
|
||||
if (nextCadence < 500) nextCadence = 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var fetch = function () {
|
||||
// the body is whatever Resi had at the moment the request left
|
||||
var one = { playerHardwareId: 'DEC1', userName: 'Lobby', position: fmt(published), eventId: 'EV1' };
|
||||
for (var k in extraPlayerFields) one[k] = extraPlayerFields[k];
|
||||
// ...and the round trip takes a while, during which the world moves on
|
||||
if (NET_JITTER) advanceWorld(100 + Math.round(rnd() * NET_JITTER));
|
||||
return Promise.resolve({ ok: true, json: function () { return Promise.resolve([one]) } });
|
||||
};
|
||||
|
||||
var tickFn = null, tickMs = null;
|
||||
var setInterval = function (fn, ms) { tickFn = fn; tickMs = ms; return {} };
|
||||
var clearInterval = function () {};
|
||||
|
||||
var src = readFile(typeof TARGET !== 'undefined' ? TARGET : 'build/pkg/resi-studio-cues/dist/main.js');
|
||||
var mod = { exports: {} };
|
||||
(new Function('module', 'exports', 'require', 'setInterval', 'clearInterval', 'console', 'fetch', src))(
|
||||
mod, mod.exports,
|
||||
function (id) { if (!stubs[id]) throw new Error('unstubbed require: ' + id); return stubs[id] },
|
||||
setInterval, clearInterval, console, fetch);
|
||||
|
||||
var inst = Object.create(mod.exports.ModuleInstance.prototype);
|
||||
var vars = {};
|
||||
inst.setVariableValues = function (o) { for (var k in o) vars[k] = o[k] };
|
||||
inst.log = function () {};
|
||||
inst.config = {};
|
||||
inst.selectedVenueUuid = 'V1';
|
||||
inst.selectedPlayerHardwareId = 'DEC1';
|
||||
inst.cookieHeader = 'stub';
|
||||
inst.currentEventId = 'EV1';
|
||||
inst.playerPollInFlight = false;
|
||||
inst.ensureAuthenticated = function () { return Promise.resolve() };
|
||||
inst.buildAuthHeaders = function () { return {} };
|
||||
inst.refreshCuesIfDue = function () { return Promise.resolve() };
|
||||
inst.cues = [
|
||||
{ uuid: 'a', name: 'Welcome', positionMs: 5 * 60000 },
|
||||
{ uuid: 'b', name: 'Campus Host', positionMs: 12 * 60000 + 30000 },
|
||||
{ uuid: 'c', name: 'Message', positionMs: 41 * 60000 },
|
||||
{ uuid: 'd', name: 'Closing', positionMs: 45 * 60000 },
|
||||
];
|
||||
|
||||
// true once Resi has actually REPORTED a position near `target`
|
||||
function reported_near(target) {
|
||||
return Number.isFinite(inst.__lastSampleMediaMs) && Math.abs(inst.__lastSampleMediaMs - target) < 8000;
|
||||
}
|
||||
|
||||
// one second of real operation: advance the world, poll, redraw
|
||||
// any PAUSED while the decoder is really playing is a false pause: it freezes the countdown
|
||||
var falsePauses = 0;
|
||||
async function second() {
|
||||
advanceWorld(1000); await inst.pollPlayer(); tickFn();
|
||||
if (playing && vars.playback_state === 'PAUSED') falsePauses++;
|
||||
}
|
||||
async function seconds(n) { for (var i = 0; i < n; i++) await second() }
|
||||
|
||||
function timestampChecks() {
|
||||
var now = Date.now();
|
||||
eq(inst.detectSampleTime({ lastUpdated: new Date(now - 2000).toISOString() }), now - 2000,
|
||||
'uses an ISO "lastUpdated" as the sample time');
|
||||
eq(inst.detectSampleTime({ reportedAt: now - 1500 }), now - 1500,
|
||||
'uses an epoch-ms "reportedAt" as the sample time');
|
||||
eq(inst.detectSampleTime({ startTime: new Date(now - 120000).toISOString() }), undefined,
|
||||
'ignores a timestamp too old to be this reading');
|
||||
eq(inst.detectSampleTime({ userName: 'Lobby', position: '00:01:02.000' }), undefined,
|
||||
'ignores fields that are not timestamps');
|
||||
}
|
||||
|
||||
(async function () {
|
||||
inst.startSmoothTick();
|
||||
eq(tickMs, 1000, 'display ticks every second');
|
||||
|
||||
// ---- accuracy while playing -----------------------------------------
|
||||
await seconds(40);
|
||||
var err = trueMedia - inst.__lastEstMs;
|
||||
check(err <= TOL + NET_JITTER * 0.35, 'estimate is not lagging (behind truth by ' + Math.round(err) + 'ms)');
|
||||
check(err >= -300, 'estimate does not run ahead of the real playhead (' + Math.round(err) + 'ms)');
|
||||
|
||||
// the point of the whole exercise: compare against the raw value over a
|
||||
// full publish cycle, not at one lucky instant
|
||||
var maxEstErr = 0, maxRawErr = 0;
|
||||
for (var w = 0; w < 30; w++) {
|
||||
await second();
|
||||
maxEstErr = Math.max(maxEstErr, trueMedia - inst.__lastEstMs);
|
||||
maxRawErr = Math.max(maxRawErr, trueMedia - published);
|
||||
}
|
||||
if (RESI_CADENCE >= 2000)
|
||||
check(maxRawErr >= RESI_CADENCE * 0.4,
|
||||
'sanity: the raw Resi value goes badly stale within a cycle (' + Math.round(maxRawErr) + 'ms)');
|
||||
check(maxEstErr <= TOL + NET_JITTER * 0.35,
|
||||
'the estimate stays close all cycle (worst ' + Math.round(maxEstErr) + 'ms vs raw ' + Math.round(maxRawErr) + 'ms)');
|
||||
|
||||
// ---- ticks every second, never backwards ------------------------------
|
||||
var prev = inst.__lastEstMs, stalls = 0, backwards = 0;
|
||||
for (var i = 0; i < 60; i++) {
|
||||
await second();
|
||||
if (inst.__lastEstMs === prev) stalls++;
|
||||
if (inst.__lastEstMs < prev) backwards++;
|
||||
prev = inst.__lastEstMs;
|
||||
}
|
||||
eq(stalls, 0, 'no stalled seconds across a minute of playback');
|
||||
eq(backwards, 0, 'the playhead never goes backwards');
|
||||
eq(vars.playback_state, 'PLAYING', 'reported as playing');
|
||||
check(trueMedia - inst.__lastEstMs <= TOL + NET_JITTER * 0.35, 'still accurate after a minute');
|
||||
|
||||
// ---- long run: the offset window is finite, so the best sample eventually
|
||||
// ages out of it; that must not drag the displayed playhead backwards
|
||||
var prev2 = inst.__lastEstMs, back2 = 0, worstBack = 0;
|
||||
for (var L = 0; L < 200; L++) {
|
||||
await second();
|
||||
if (inst.__lastEstMs < prev2) {
|
||||
back2++; worstBack = Math.max(worstBack, prev2 - inst.__lastEstMs);
|
||||
if (typeof DIAG !== 'undefined')
|
||||
print(' backward step ' + Math.round(prev2 - inst.__lastEstMs) + 'ms at L=' + L +
|
||||
' state=' + vars.playback_state + ' offsets=' + (inst.__offsets || []).length +
|
||||
' sinceNew=' + (Date.now() - inst.__lastNewSampleAt) + ' base=' + inst.__baseOffset);
|
||||
}
|
||||
prev2 = inst.__lastEstMs;
|
||||
}
|
||||
eq(back2, 0, 'no backward steps over 200s (worst was ' + Math.round(worstBack) + 'ms)');
|
||||
eq(falsePauses, 0, 'never reported PAUSED while playing, startup included');
|
||||
|
||||
// ---- a fired cue / forward seek --------------------------------------
|
||||
inst.lastCueRefreshMs = 999999;
|
||||
trueMedia = 40 * 60000 + 50000; // decoder jumps; Resi says nothing yet
|
||||
var jumpAt = fakeNow, sawIt = false;
|
||||
for (var q = 0; q < 60 && !sawIt; q++) { await second(); sawIt = reported_near(trueMedia) }
|
||||
check(sawIt, 'the jump is picked up at all');
|
||||
var detectMs = fakeNow - jumpAt;
|
||||
if (typeof DIAG !== 'undefined') print(' jump detected after ' + detectMs + 'ms');
|
||||
check(detectMs <= RESI_CADENCE + RESI_JITTER + 2000,
|
||||
'jump detected within one publish cycle (' + detectMs + 'ms)');
|
||||
eq(inst.lastCueRefreshMs, 0, 'a jump forces the cue list to refresh immediately');
|
||||
near(inst.__lastEstMs, trueMedia, TOL + RESI_CADENCE / 2, 'estimate resyncs to the new playhead at once');
|
||||
if (typeof DIAG !== 'undefined') print(' post-seek error: ' + Math.round(trueMedia - inst.__lastEstMs) + 'ms');
|
||||
check(trueMedia - inst.__lastEstMs >= -400,
|
||||
'estimate does not overshoot after a seek (' + Math.round(trueMedia - inst.__lastEstMs) + 'ms)');
|
||||
eq(vars.next_cue_name, 'Message', 'next cue is correct straight after the jump');
|
||||
|
||||
await seconds(15);
|
||||
eq(vars.next_cue_name, 'Closing', 'rolled over after passing Message');
|
||||
eq(vars.prev_cue_name, 'Message', 'passed cue became prev');
|
||||
|
||||
// ---- a backwards seek -------------------------------------------------
|
||||
trueMedia = 6 * 60000;
|
||||
for (var r = 0; r < 60 && !reported_near(trueMedia); r++) await second();
|
||||
near(inst.__lastEstMs, trueMedia, TOL + RESI_CADENCE / 2, 'follows a backwards seek');
|
||||
eq(vars.next_cue_name, 'Campus Host', 'cue targets recompute after seeking back');
|
||||
|
||||
// ---- pause ------------------------------------------------------------
|
||||
playing = false;
|
||||
// how long the module can legitimately take to call it a pause, given this feed
|
||||
var pauseWait = Math.ceil((Math.max(15000, Math.min(45000, (RESI_CADENCE + RESI_JITTER) * 2.5 + 6000))
|
||||
+ RESI_CADENCE + RESI_JITTER + 6000) / 1000);
|
||||
await seconds(pauseWait);
|
||||
eq(vars.playback_state, 'PAUSED', 'detects a real pause');
|
||||
var held = inst.__lastEstMs;
|
||||
await seconds(20);
|
||||
eq(inst.__lastEstMs, held, 'paused playhead holds instead of running on');
|
||||
|
||||
// ---- resume -----------------------------------------------------------
|
||||
playing = true;
|
||||
await seconds(25);
|
||||
eq(vars.playback_state, 'PLAYING', 'recovers after resume');
|
||||
near(inst.__lastEstMs, trueMedia, TOL + 1500, 'estimate re-locks after resume');
|
||||
|
||||
// ---- operator-driven resync (beats waiting for Resi to republish) ----
|
||||
var beforeEst = inst.__lastEstMs;
|
||||
eq(inst.resyncToCue('No Such Cue'), false, 'resync to an unknown cue is refused');
|
||||
eq(inst.__lastEstMs, beforeEst, 'a refused resync leaves the playhead alone');
|
||||
|
||||
inst.lastCueRefreshMs = 999999;
|
||||
eq(inst.resyncToCue('Campus Host'), true, 'resync to a named cue succeeds');
|
||||
eq(inst.__lastEstMs, 12 * 60000 + 30000, 'playhead snaps to that cue immediately');
|
||||
eq(vars.next_cue_name, 'Message', 'next cue updates in the same instant');
|
||||
eq(inst.lastCueRefreshMs, 0, 'resync also forces a cue-list refresh');
|
||||
|
||||
eq(inst.resyncToNextCue(), true, 'resync to the next cue succeeds');
|
||||
eq(inst.__lastEstMs, 41 * 60000, 'playhead snaps to the following cue');
|
||||
|
||||
// a real sample after a resync must still be taken seriously
|
||||
trueMedia = 30 * 60000; published = trueMedia - RESI_LAG; lastPublish = fakeNow;
|
||||
await seconds(2);
|
||||
for (var z = 0; z < 60 && !reported_near(trueMedia); z++) await second();
|
||||
near(inst.__lastEstMs, trueMedia, TOL + RESI_CADENCE / 2, 'a real reading overrides a resync');
|
||||
|
||||
timestampChecks();
|
||||
|
||||
var b = failures.length;
|
||||
eq(vars.next_cue_name, 'Not A Real Cue', 'deliberate failure');
|
||||
if (failures.length !== b + 1) { print('HARNESS BROKEN'); throw new Error('harness') }
|
||||
failures.pop();
|
||||
|
||||
if (failures.length) { print('\n' + failures.length + ' FAILED'); throw new Error('tests failed') }
|
||||
print('\nall estimator tests passed [cadence ' + RESI_CADENCE + 'ms, lag ' + RESI_LAG + 'ms, jitter +/-' + RESI_JITTER + 'ms]');
|
||||
})();
|
||||
Reference in New Issue
Block a user