Files
christianandClaude Opus 5.5 1a658875bc 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>
2026-10-06 18:11:01 -04:00

244 lines
9.3 KiB
Python

#!/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")