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>
244 lines
9.3 KiB
Python
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")
|