2026-10-14 20:28:43 -04:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""The READY page: the weekly worship checklist, checked against Planning Center.
|
|
|
|
|
|
|
|
|
|
|
|
Every couple of minutes this looks at this weekend's plan in Planning Center (PCO) and at
|
|
|
|
|
|
what is actually in place for it, and writes one verdict per checklist line into
|
|
|
|
|
|
snap.ready, which the READY page keys draw (check type "ready" in status_panel.py):
|
|
|
|
|
|
|
|
|
|
|
|
tracks the weekend's Ableton set exists and has a song group for every PCO song
|
|
|
|
|
|
mix_foh every song is mixed (a song whose tracks are all at 0 dB is not) and its
|
|
|
|
|
|
stems go out to the console (Ext. Out), not to Master
|
|
|
|
|
|
lights the lighting cue track has a clip under every song
|
|
|
|
|
|
prop the ProPresenter playlist has every PCO item; lists in PCO item descriptions
|
|
|
|
|
|
match the slides (MC announcements: one slide each; baptisms: every name)
|
|
|
|
|
|
timecode every song's presentation has a timeline, and PCO's LTC note for it is the timecode
|
|
|
|
|
|
the tracks play at that song's start (worked out from the set's LTC clip and tempo map)
|
|
|
|
|
|
backgrounds every song's slides have a background (ProPresenter labels a slide with
|
|
|
|
|
|
its media's file name)
|
2026-10-15 14:43:49 -04:00
|
|
|
|
led_cross every Resolume column Sunday's slides call up holds content, on the Service deck
|
|
|
|
|
|
(slide -> macro -> RossTalk "CC page/row/col" to Companion -> Resolume column)
|
2026-10-14 20:28:43 -04:00
|
|
|
|
lineup the Band Lineup board (ProdDash) shows this plan and exactly the people on it
|
|
|
|
|
|
mics (a tap-to-confirm key) which mics this weekend needs, from PCO
|
|
|
|
|
|
track_bundle every location has this weekend's Ableton project folder on the shared drive
|
|
|
|
|
|
prop_bundle a ProPresenter bundle was saved to the locations' folder this week
|
|
|
|
|
|
|
|
|
|
|
|
What can be put right is put right by a hold (fix kind "ready", run_action below): re-sync
|
2026-10-15 14:43:49 -04:00
|
|
|
|
the lineup from PCO, put Resolume back on its deck, copy the tracks to a location that
|
2026-10-14 20:28:43 -04:00
|
|
|
|
has none, or simply look again now.
|
|
|
|
|
|
|
|
|
|
|
|
Nothing here writes to Companion, ProPresenter or Ableton's set; the Ableton set is read
|
|
|
|
|
|
from disk on the Tracks Mac (nice'd, and only when it changed), and the whole look is
|
|
|
|
|
|
skipped while a HyperDeck is recording.
|
|
|
|
|
|
|
|
|
|
|
|
Stdlib only; runs on the macOS /usr/bin/python3 (3.9).
|
|
|
|
|
|
"""
|
|
|
|
|
|
import base64
|
|
|
|
|
|
import calendar
|
|
|
|
|
|
import json
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import os
|
|
|
|
|
|
import re
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
import threading
|
|
|
|
|
|
import time
|
|
|
|
|
|
import urllib.parse
|
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger("status-panel")
|
|
|
|
|
|
|
|
|
|
|
|
OK, WARN, ERR = "ok", "warn", "err"
|
|
|
|
|
|
DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
|
|
|
|
|
MEDIA = re.compile(r"\.(mp4|mov|m4v|mxf|mpg|mpeg|avi|webm|jpg|jpeg|png|gif|heic|tif|tiff)$", re.I)
|
|
|
|
|
|
|
|
|
|
|
|
WAKE = threading.Event() # set by a hold: look again now instead of waiting for the interval
|
|
|
|
|
|
LAST = {} # the latest look (run_action needs the plan's date)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ReadyError(Exception):
|
|
|
|
|
|
"""A source could not be read; the message is what the key shows."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Names ----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def norm(text):
|
|
|
|
|
|
"""A title as words to compare: "Can't Help But Praise LM - [ Full ]" -> "CANT HELP BUT PRAISE LM",
|
|
|
|
|
|
"3- CAN'T HELP BUT PRAISE - Eb" -> "CANT HELP BUT PRAISE", "Rejoice Again (Live)" -> "REJOICE AGAIN"."""
|
|
|
|
|
|
s = (text or "").upper().replace("’", "'")
|
|
|
|
|
|
s = re.sub(r"\[.*?\]|\(.*?\)", " ", s)
|
|
|
|
|
|
s = re.sub(r"^\s*\d+\s*-\s*", "", s) # a locator's running number
|
|
|
|
|
|
s = re.sub(r"\s*-\s*[A-G](#|B)?M?\s*$", "", s) # a locator's key: " - Bb", "BLOOD- B"
|
|
|
|
|
|
s = re.sub(r"[^A-Z0-9 ]", "", s)
|
|
|
|
|
|
return " ".join(s.split())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def same(a, b):
|
|
|
|
|
|
"""The same song or item: equal, or one is the other plus more words ("COVERED BY THE BLOOD TAG")."""
|
|
|
|
|
|
a, b = norm(a), norm(b)
|
|
|
|
|
|
if not a or not b:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return a == b or a.startswith(b + " ") or b.startswith(a + " ")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def short(title, n=16):
|
|
|
|
|
|
t = norm(title) or (title or "").upper()
|
|
|
|
|
|
return t if len(t) <= n else t[:n - 1].rstrip() + "."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def bullets(text):
|
|
|
|
|
|
"""The "- item" lines of a description."""
|
|
|
|
|
|
return [m.strip() for m in re.findall(r"^\s*[-•*]\s*(.+?)\s*$", text or "", re.M) if m.strip()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def listed_names(text):
|
|
|
|
|
|
"""People named in a description like "730 930 None / 1130 Samantha Lopez, Bob Ray": the names,
|
|
|
|
|
|
without service times or "None"."""
|
|
|
|
|
|
names = []
|
|
|
|
|
|
for line in (text or "").splitlines():
|
|
|
|
|
|
line = re.sub(r"\b\d{3,4}\b", " ", line) # service times: 730, 1130
|
|
|
|
|
|
for part in re.split(r",|&|/|\band\b", line):
|
|
|
|
|
|
words = [w for w in part.split() if w.lower() not in ("none", "n/a", "na", "tbd", "-")]
|
|
|
|
|
|
if words and words[0][:1].isalpha():
|
|
|
|
|
|
names.append(" ".join(words))
|
|
|
|
|
|
return names
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Time -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def parse_iso(text):
|
|
|
|
|
|
"""PCO's "2026-10-18T13:30:00Z" as unix time (None if it is not one)."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
return datetime.fromisoformat((text or "").replace("Z", "+00:00")).timestamp()
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def week_start(now, day="mon"):
|
|
|
|
|
|
"""Local midnight at the start of the checklist's week (taps from before it no longer count)."""
|
|
|
|
|
|
d = datetime.fromtimestamp(now)
|
|
|
|
|
|
back = (d.weekday() - DAYS.index(day)) % 7
|
|
|
|
|
|
start = datetime(d.year, d.month, d.day) - timedelta(days=back)
|
|
|
|
|
|
return time.mktime(start.timetuple())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- HTTP and SSH ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def http_json(url, headers=None, method="GET", data=None, timeout=10):
|
|
|
|
|
|
req = urllib.request.Request(url, headers=headers or {}, method=method, data=data)
|
|
|
|
|
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
|
|
|
|
body = r.read()
|
|
|
|
|
|
return json.loads(body) if body.strip() else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ssh_argv(cfg, host):
|
|
|
|
|
|
"""The same login the watchdog's checks and fixes use (status_panel.ssh_cmd)."""
|
|
|
|
|
|
import status_panel
|
|
|
|
|
|
return status_panel.ssh_cmd(cfg, host)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_remote(cfg, host, script, timeout=60):
|
|
|
|
|
|
"""Run a Python script on another Mac (on stdin, at low priority); its last line is JSON."""
|
|
|
|
|
|
r = subprocess.run(ssh_argv(cfg, host) + ["nice -n 19 /usr/bin/python3 -"], input=script,
|
|
|
|
|
|
capture_output=True, text=True, timeout=timeout)
|
|
|
|
|
|
if r.returncode == 255:
|
|
|
|
|
|
raise ReadyError("%s UNREACHABLE" % host.upper())
|
|
|
|
|
|
lines = [ln for ln in r.stdout.strip().splitlines() if ln.strip()]
|
|
|
|
|
|
if r.returncode != 0 or not lines:
|
|
|
|
|
|
raise ReadyError("%s SCRIPT FAILED" % host.upper()) if not lines else ReadyError(lines[-1][:40])
|
|
|
|
|
|
return json.loads(lines[-1])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Planning Center -----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
PCO = "https://api.planningcenteronline.com/services/v2"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def pco_login(rc):
|
|
|
|
|
|
"""(Authorization header, service type) from ProdDash's own PCO settings on this Mac, so the
|
|
|
|
|
|
login lives in one place."""
|
|
|
|
|
|
path = os.path.expanduser(rc.get("pco_credentials", "~/Library/Application Support/ProdDash/modules.json"))
|
|
|
|
|
|
try:
|
|
|
|
|
|
mods = json.load(open(path))
|
|
|
|
|
|
except (OSError, ValueError):
|
|
|
|
|
|
raise ReadyError("NO PCO LOGIN")
|
|
|
|
|
|
c = (mods.get(rc.get("pco_module", "pco-plan")) or {}).get("config") or {}
|
|
|
|
|
|
if c.get("accessToken"):
|
|
|
|
|
|
auth = "Bearer " + c["accessToken"]
|
|
|
|
|
|
elif c.get("appId") and c.get("secret"):
|
|
|
|
|
|
auth = "Basic " + base64.b64encode(("%s:%s" % (c["appId"], c["secret"])).encode()).decode()
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise ReadyError("NO PCO LOGIN")
|
|
|
|
|
|
st = rc.get("pco_service_type") or (c.get("serviceTypes") or [None])[0] or c.get("serviceTypeId")
|
|
|
|
|
|
if not st:
|
|
|
|
|
|
raise ReadyError("NO SERVICE TYPE")
|
|
|
|
|
|
return auth, str(st)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def pco_get(auth, path, pages=10):
|
|
|
|
|
|
"""One PCO list (data, included), following "next" for at most `pages` pages (the plan lists
|
|
|
|
|
|
want one page: following them would walk the church's whole history)."""
|
|
|
|
|
|
url, data, included = PCO + path, [], []
|
|
|
|
|
|
while url and pages > 0:
|
|
|
|
|
|
d = http_json(url, {"Authorization": auth}, timeout=15)
|
|
|
|
|
|
data += d.get("data") if isinstance(d.get("data"), list) else [d.get("data")]
|
|
|
|
|
|
included += d.get("included") or []
|
|
|
|
|
|
url, pages = (d.get("links") or {}).get("next"), pages - 1
|
|
|
|
|
|
return data, included
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def pick_plan(plans, now):
|
|
|
|
|
|
"""Today's plan, else the next one (from 12 hours ago on) - the plan ProdDash's lineup picks too."""
|
|
|
|
|
|
dated = []
|
|
|
|
|
|
for p in plans:
|
|
|
|
|
|
t = parse_iso((p.get("attributes") or {}).get("sort_date"))
|
|
|
|
|
|
if t is not None:
|
|
|
|
|
|
dated.append((t, p))
|
|
|
|
|
|
today = datetime.fromtimestamp(now).date()
|
|
|
|
|
|
for t, p in dated:
|
|
|
|
|
|
if datetime.fromtimestamp(t).date() == today:
|
|
|
|
|
|
return p, t
|
|
|
|
|
|
upcoming = sorted(((t, p) for t, p in dated if t >= now - 12 * 3600), key=lambda tp: tp[0])
|
|
|
|
|
|
return (upcoming[0][1], upcoming[0][0]) if upcoming else (None, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def pco_plan(rc, now):
|
|
|
|
|
|
"""This weekend's plan: {"id", "title", "dates", "time", "items": [...], "team": [...]}."""
|
|
|
|
|
|
auth, st = pco_login(rc)
|
|
|
|
|
|
try:
|
|
|
|
|
|
future, _ = pco_get(auth, "/service_types/%s/plans?filter=future&order=sort_date&per_page=4" % st, pages=1)
|
|
|
|
|
|
past, _ = pco_get(auth, "/service_types/%s/plans?filter=past&order=-sort_date&per_page=2" % st, pages=1)
|
|
|
|
|
|
except OSError as e:
|
|
|
|
|
|
raise ReadyError("PCO UNREACHABLE") from e
|
|
|
|
|
|
plan, t = pick_plan(future + past, now)
|
|
|
|
|
|
if plan is None:
|
|
|
|
|
|
raise ReadyError("NO PLAN IN PCO")
|
|
|
|
|
|
pid = plan["id"]
|
|
|
|
|
|
items, inc = pco_get(auth, "/service_types/%s/plans/%s/items?include=item_notes&per_page=100" % (st, pid))
|
|
|
|
|
|
notes = {i["id"]: i.get("attributes") or {} for i in inc if i.get("type") == "ItemNote"}
|
|
|
|
|
|
out_items = []
|
|
|
|
|
|
for it in items:
|
|
|
|
|
|
a = it.get("attributes") or {}
|
|
|
|
|
|
ns, note_ids = {}, {}
|
|
|
|
|
|
for ref in ((it.get("relationships") or {}).get("item_notes") or {}).get("data") or []:
|
|
|
|
|
|
n = notes.get(ref["id"])
|
|
|
|
|
|
if n:
|
|
|
|
|
|
ns[n.get("category_name") or ""] = n.get("content") or ""
|
|
|
|
|
|
note_ids[n.get("category_name") or ""] = ref["id"]
|
|
|
|
|
|
out_items.append({"id": it.get("id"), "note_ids": note_ids, "title": a.get("title") or "", "type": a.get("item_type") or "",
|
|
|
|
|
|
"position": a.get("service_position") or "", "description": a.get("description") or "",
|
|
|
|
|
|
"notes": ns})
|
|
|
|
|
|
members, inc = pco_get(auth, "/service_types/%s/plans/%s/team_members?include=team&per_page=100" % (st, pid))
|
|
|
|
|
|
teams = {i["id"]: (i.get("attributes") or {}).get("name") for i in inc if i.get("type") == "Team"}
|
|
|
|
|
|
team = []
|
|
|
|
|
|
for m in members:
|
|
|
|
|
|
a = m.get("attributes") or {}
|
|
|
|
|
|
tref = ((m.get("relationships") or {}).get("team") or {}).get("data") or {}
|
|
|
|
|
|
team.append({"name": a.get("name") or "", "position": a.get("team_position_name") or "",
|
|
|
|
|
|
"team": teams.get(tref.get("id"), ""), "status": a.get("status") or ""})
|
|
|
|
|
|
a = plan.get("attributes") or {}
|
|
|
|
|
|
return {"id": pid, "service_type": st, "title": a.get("title") or "", "dates": a.get("dates") or "", "time": t,
|
|
|
|
|
|
"items": out_items, "team": team}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def plan_songs(rc, plan):
|
|
|
|
|
|
"""The songs the band plays: PCO songs during the service, minus any the site skips."""
|
|
|
|
|
|
skip = {norm(s) for s in rc.get("skip_songs", [])}
|
|
|
|
|
|
return [it for it in plan["items"] if it["type"] == "song" and it["position"] in ("during", "")
|
|
|
|
|
|
and norm(it["title"]) not in skip]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def declined(member):
|
|
|
|
|
|
return member["status"] in ("D", "Declined")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- ProPresenter ----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def flatten_playlists(nodes, prefix=()):
|
|
|
|
|
|
"""[(api path "2/3", name)] for every playlist in the /v1/playlists tree."""
|
|
|
|
|
|
out = []
|
|
|
|
|
|
for i, node in enumerate(nodes or []):
|
|
|
|
|
|
idx = (node.get("id") or {}).get("index", i)
|
|
|
|
|
|
path = prefix + (str(idx),)
|
|
|
|
|
|
if node.get("field_type") == "group":
|
|
|
|
|
|
out += flatten_playlists(node.get("children"), path)
|
|
|
|
|
|
else:
|
|
|
|
|
|
out.append(("/".join(path), (node.get("id") or {}).get("name") or ""))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def prop_look(rc, plan):
|
|
|
|
|
|
"""{"playlist": name, "items": [{"name", "type", "pres": {...} or None}]} for the plan's playlist."""
|
|
|
|
|
|
base = rc.get("prop_url", "").rstrip("/")
|
|
|
|
|
|
if not base:
|
|
|
|
|
|
raise ReadyError("NO PROP ADDRESS")
|
|
|
|
|
|
try:
|
|
|
|
|
|
tree = http_json(base + "/v1/playlists", timeout=6)
|
|
|
|
|
|
except (OSError, ValueError) as e:
|
|
|
|
|
|
raise ReadyError("PROP UNREACHABLE") from e
|
|
|
|
|
|
dates = (plan.get("dates") or "").lower()
|
|
|
|
|
|
found = [(p, n) for p, n in flatten_playlists(tree) if dates and dates in n.lower()]
|
|
|
|
|
|
if not found:
|
|
|
|
|
|
return {"playlist": None, "items": []}
|
|
|
|
|
|
path, name = found[-1]
|
|
|
|
|
|
data = http_json(base + "/v1/playlist/" + "/".join(urllib.parse.quote(x) for x in path.split("/")), timeout=6)
|
|
|
|
|
|
items, cache = [], {}
|
|
|
|
|
|
for it in (data or {}).get("items") or []:
|
|
|
|
|
|
uuid = (it.get("presentation_info") or {}).get("presentation_uuid")
|
|
|
|
|
|
pres = None
|
|
|
|
|
|
if uuid:
|
|
|
|
|
|
if uuid not in cache:
|
|
|
|
|
|
try:
|
|
|
|
|
|
p = (http_json(base + "/v1/presentation/" + uuid, timeout=6) or {}).get("presentation") or {}
|
|
|
|
|
|
slides = [s for g in p.get("groups") or [] for s in g.get("slides") or []]
|
2026-10-15 14:43:49 -04:00
|
|
|
|
cache[uuid] = {"name": (p.get("id") or {}).get("name"), "type": p.get("type"), "path": p.get("presentation_path"),
|
2026-10-14 20:28:43 -04:00
|
|
|
|
"timeline": bool(p.get("has_timeline")), "cues": p.get("total_cues") or 0,
|
|
|
|
|
|
"slides": [{"enabled": s.get("enabled", True), "label": s.get("label") or "",
|
|
|
|
|
|
"text": s.get("text") or "", "notes": s.get("notes") or ""} for s in slides]}
|
|
|
|
|
|
except (OSError, ValueError):
|
|
|
|
|
|
cache[uuid] = None
|
|
|
|
|
|
pres = cache[uuid]
|
|
|
|
|
|
items.append({"name": (it.get("id") or {}).get("name") or "", "type": it.get("type") or "",
|
|
|
|
|
|
"pco": bool(it.get("is_pco")), "pres": pres})
|
|
|
|
|
|
return {"playlist": name, "items": items}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def match_prop(pco_items, prop_items):
|
|
|
|
|
|
"""{index of a PCO item: its ProPresenter item}. By name; a PCO item the name does not find takes
|
|
|
|
|
|
the playlist item in the same place when the playlist came from PCO (ProPresenter names linked
|
|
|
|
|
|
items after the presentation: PCO "Message" is ProPresenter "Message Bumper")."""
|
|
|
|
|
|
want = [it for it in pco_items if it["type"] != "header"]
|
|
|
|
|
|
have = [it for it in prop_items if it["type"] != "header"]
|
|
|
|
|
|
found, used = {}, set()
|
|
|
|
|
|
for i, it in enumerate(want):
|
|
|
|
|
|
for j, p in enumerate(have):
|
|
|
|
|
|
if j not in used and same(it["title"], p["name"]):
|
|
|
|
|
|
found[i] = p
|
|
|
|
|
|
used.add(j)
|
|
|
|
|
|
break
|
|
|
|
|
|
if len(want) == len(have):
|
|
|
|
|
|
for i in range(len(want)):
|
|
|
|
|
|
if i not in found and i not in used and have[i]["pco"]:
|
|
|
|
|
|
found[i] = have[i]
|
|
|
|
|
|
used.add(i)
|
|
|
|
|
|
return want, found
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def eval_prop(rc, plan, prop):
|
|
|
|
|
|
"""The prop, timecode and backgrounds lines."""
|
|
|
|
|
|
out = {"prop": Line(), "timecode": Line(), "backgrounds": Line()}
|
|
|
|
|
|
if prop["playlist"] is None:
|
|
|
|
|
|
for line in out.values():
|
|
|
|
|
|
line.fault(ERR, "NO PLAYLIST %s" % short(plan.get("dates"), 14), "ProPresenter has no playlist named for %s" % plan.get("dates"))
|
|
|
|
|
|
return out
|
|
|
|
|
|
want, found = match_prop(plan["items"], prop["items"])
|
|
|
|
|
|
songs = {id(s) for s in plan_songs(rc, plan)}
|
|
|
|
|
|
lists = rc.get("prop_lists", [])
|
|
|
|
|
|
for i, it in enumerate(want):
|
|
|
|
|
|
p = found.get(i)
|
|
|
|
|
|
if p is None:
|
|
|
|
|
|
out["prop"].fault(ERR, "MISSING " + short(it["title"]), "%s is in PCO but not in the ProPresenter playlist" % it["title"])
|
|
|
|
|
|
continue
|
|
|
|
|
|
pres = p["pres"]
|
|
|
|
|
|
if pres is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
slides = [s for s in pres["slides"] if s["enabled"]]
|
|
|
|
|
|
for rule in lists:
|
|
|
|
|
|
if rule.get("match", "").lower() not in it["title"].lower():
|
|
|
|
|
|
continue
|
|
|
|
|
|
tag = rule.get("short") or short(it["title"], 10)
|
|
|
|
|
|
if rule.get("kind") == "count":
|
|
|
|
|
|
want_n = bullets(it["description"])
|
|
|
|
|
|
if want_n and len(slides) < len(want_n):
|
|
|
|
|
|
out["prop"].fault(ERR, "%s %d/%d SLIDES" % (tag, len(slides), len(want_n)),
|
|
|
|
|
|
"PCO lists %d for %s, ProPresenter has %d slides" % (len(want_n), it["title"], len(slides)))
|
|
|
|
|
|
noted = {norm(b) for s in slides for b in bullets(s["notes"])}
|
|
|
|
|
|
if want_n and noted and not {norm(b) for b in want_n} <= noted:
|
|
|
|
|
|
out["prop"].fault(WARN, "%s SLIDES OLD?" % tag,
|
|
|
|
|
|
"%s: the slide notes list %s; PCO lists %s" % (
|
|
|
|
|
|
it["title"], ", ".join(sorted(noted)), ", ".join(want_n)))
|
|
|
|
|
|
elif rule.get("kind") == "names":
|
|
|
|
|
|
seen = " ".join((s["label"] + " " + s["text"]).lower() for s in slides)
|
|
|
|
|
|
for name in listed_names(it["description"]):
|
|
|
|
|
|
first = name.split()[0].lower()
|
|
|
|
|
|
if not re.search(r"\b%s\b" % re.escape(first), seen):
|
|
|
|
|
|
out["prop"].fault(ERR, "%s: NO %s" % (tag, short(name.split()[0], 10)),
|
|
|
|
|
|
"%s: PCO lists %s, no slide in %s names them" % (it["title"], name, pres["name"]))
|
|
|
|
|
|
if id(it) in songs:
|
|
|
|
|
|
if not pres["timeline"] or not pres["cues"]:
|
|
|
|
|
|
out["timecode"].fault(ERR, "NO TC " + short(it["title"]), "%s has no timeline in ProPresenter" % pres["name"])
|
|
|
|
|
|
# A background plays on until the next one, so one is enough; none at all is the gap.
|
|
|
|
|
|
if not any(MEDIA.search(s["label"].strip()) for s in slides):
|
|
|
|
|
|
out["backgrounds"].fault(ERR, "NO BG " + short(it["title"]), "no slide of %s has a background" % (
|
|
|
|
|
|
pres["name"] or p["name"]))
|
|
|
|
|
|
n_songs = len(songs)
|
|
|
|
|
|
out["prop"].info = "%d ITEMS" % len(want)
|
|
|
|
|
|
out["timecode"].info = "%d SONGS" % n_songs
|
|
|
|
|
|
out["backgrounds"].info = "%d SONGS" % n_songs
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Ableton (read on the Tracks Mac) ------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
ALS_SCRIPT = r'''
|
|
|
|
|
|
import glob, gzip, json, os, re, sys
|
|
|
|
|
|
import xml.etree.ElementTree as ET
|
|
|
|
|
|
P = json.loads(%r)
|
|
|
|
|
|
def live_set():
|
|
|
|
|
|
logs = sorted(glob.glob(os.path.expanduser("~/Library/Preferences/Ableton/Live*/Log.txt")), key=os.path.getmtime)
|
|
|
|
|
|
path = None
|
|
|
|
|
|
if logs:
|
|
|
|
|
|
with open(logs[-1], "rb") as f:
|
|
|
|
|
|
text = f.read().decode("utf-8", "replace")
|
|
|
|
|
|
for m in re.finditer(r'info: Loading document "(.*)"', text):
|
|
|
|
|
|
if "/Templates/" not in m.group(1):
|
|
|
|
|
|
path = m.group(1)
|
|
|
|
|
|
return path
|
|
|
|
|
|
def folder(loc):
|
|
|
|
|
|
return os.path.join(os.path.expanduser(P["projects"]), loc, P["folder"].format(date=P["date"], loc=loc))
|
|
|
|
|
|
def newest_als(d):
|
|
|
|
|
|
found = [os.path.join(d, n) for n in os.listdir(d) if n.endswith(".als") and not n.startswith(".")] if os.path.isdir(d) else []
|
|
|
|
|
|
return max(found, key=os.path.getmtime) if found else None
|
|
|
|
|
|
def summary(path):
|
|
|
|
|
|
root = ET.parse(gzip.open(path)).getroot()
|
|
|
|
|
|
ls = root.find("LiveSet")
|
|
|
|
|
|
tracks = []
|
|
|
|
|
|
for t in list(ls.find("Tracks")):
|
|
|
|
|
|
dc = t.find("DeviceChain")
|
|
|
|
|
|
g = t.find("TrackGroupId")
|
|
|
|
|
|
vol = dc.find("Mixer/Volume/Manual") if dc is not None else None
|
|
|
|
|
|
tgt = dc.find("AudioOutputRouting/Target") if dc is not None else None
|
|
|
|
|
|
up = dc.find("AudioOutputRouting/UpperDisplayString") if dc is not None else None
|
|
|
|
|
|
lo = dc.find("AudioOutputRouting/LowerDisplayString") if dc is not None else None
|
|
|
|
|
|
clips = []
|
|
|
|
|
|
for c in t.findall(".//ArrangerAutomation/Events/AudioClip") + t.findall(".//ArrangerAutomation/Events/MidiClip"):
|
|
|
|
|
|
dis = c.find("Disabled")
|
|
|
|
|
|
if dis is not None and dis.get("Value") == "true":
|
|
|
|
|
|
continue
|
|
|
|
|
|
s, e = c.find("CurrentStart"), c.find("CurrentEnd")
|
|
|
|
|
|
notes = len(c.findall(".//MidiNoteEvent")) if c.tag == "MidiClip" else None
|
|
|
|
|
|
clips.append([float(s.get("Value")), float(e.get("Value")), notes])
|
|
|
|
|
|
tracks.append({"id": t.get("Id"), "kind": t.tag, "name": t.find("Name/EffectiveName").get("Value"),
|
|
|
|
|
|
"group": g.get("Value") if g is not None else "-1",
|
|
|
|
|
|
"vol": float(vol.get("Value")) if vol is not None else None,
|
|
|
|
|
|
"ext": bool(tgt is not None and tgt.get("Value", "").startswith("AudioOut/External")),
|
|
|
|
|
|
"out": " ".join(x.get("Value") for x in (up, lo) if x is not None and x.get("Value")),
|
|
|
|
|
|
"clips": clips})
|
|
|
|
|
|
locs = [{"name": l.find("Name").get("Value"), "time": float(l.find("Time").get("Value"))}
|
|
|
|
|
|
for l in ls.findall("Locators/Locators/Locator")]
|
|
|
|
|
|
mt = ls.find("MasterTrack")
|
|
|
|
|
|
tempo = mt.find("DeviceChain/Mixer/Tempo")
|
|
|
|
|
|
target = tempo.find("AutomationTarget").get("Id")
|
|
|
|
|
|
events = []
|
|
|
|
|
|
for env in mt.findall("AutomationEnvelopes/Envelopes/AutomationEnvelope"):
|
|
|
|
|
|
if env.find("EnvelopeTarget/PointeeId").get("Value") == target:
|
|
|
|
|
|
events = [[float(e.get("Time")), float(e.get("Value"))] for e in env.findall("Automation/Events/FloatEvent")]
|
|
|
|
|
|
ltc = None
|
|
|
|
|
|
for t in list(ls.find("Tracks")):
|
|
|
|
|
|
if t.find("Name/EffectiveName").get("Value").strip().upper() == P["ltc_track"].strip().upper():
|
|
|
|
|
|
for c in t.findall(".//ArrangerAutomation/Events/AudioClip"):
|
|
|
|
|
|
g = lambda p: c.find(p).get("Value") if c.find(p) is not None else None
|
|
|
|
|
|
ltc = {"start": float(g("CurrentStart")), "name": g("Name"), "warped": g("IsWarped") == "true",
|
|
|
|
|
|
"offset": float(g("Loop/LoopStart") or 0)}
|
|
|
|
|
|
break
|
|
|
|
|
|
return {"tracks": tracks, "locators": locs, "tempo": {"manual": float(tempo.find("Manual").get("Value")), "events": events},
|
|
|
|
|
|
"ltc": ltc}
|
|
|
|
|
|
out = {"live_set": live_set(), "bundles": {}}
|
|
|
|
|
|
path = newest_als(folder(P["site"]))
|
|
|
|
|
|
out["set"] = path
|
|
|
|
|
|
if path:
|
|
|
|
|
|
st = os.stat(path)
|
|
|
|
|
|
key = [path, st.st_mtime, st.st_size, 2] # 2: the summary's layout
|
|
|
|
|
|
cache = os.path.expanduser("~/Library/Caches/status-panel-ready-als.json")
|
|
|
|
|
|
data = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
c = json.load(open(cache))
|
|
|
|
|
|
if c.get("key") == key:
|
|
|
|
|
|
data = c["data"]
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if data is None:
|
|
|
|
|
|
data = summary(path)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(cache + ".tmp", "w") as f:
|
|
|
|
|
|
json.dump({"key": key, "data": data}, f)
|
|
|
|
|
|
os.replace(cache + ".tmp", cache)
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
out["mtime"] = st.st_mtime
|
|
|
|
|
|
out["summary"] = data
|
|
|
|
|
|
for loc in P["locations"]:
|
|
|
|
|
|
d = folder(loc)
|
|
|
|
|
|
out["bundles"][loc] = {"folder": d, "als": bool(newest_als(d)), "copying": os.path.isdir(d + ".copying")}
|
|
|
|
|
|
print(json.dumps(out))
|
|
|
|
|
|
'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ableton_params(rc, plan):
|
|
|
|
|
|
return {"projects": rc.get("projects", ""), "site": rc.get("site_folder", ""), "folder": rc.get("folder_format", "{date} {loc} Project"),
|
|
|
|
|
|
"date": datetime.fromtimestamp(plan["time"]).strftime("%y%m%d"), "locations": rc.get("locations", []),
|
|
|
|
|
|
"ltc_track": rc.get("ltc_track", "LTC")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ableton_look(cfg, rc, plan):
|
|
|
|
|
|
host = rc.get("tracks_host")
|
|
|
|
|
|
if not host or not rc.get("projects") or not rc.get("site_folder"):
|
|
|
|
|
|
raise ReadyError("NO TRACKS SETUP")
|
|
|
|
|
|
return run_remote(cfg, host, ALS_SCRIPT % json.dumps(ableton_params(rc, plan)), timeout=rc.get("remote_timeout", 90))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def song_groups(summary):
|
|
|
|
|
|
"""{top-level group track name: [its audio tracks, at any depth]}."""
|
|
|
|
|
|
tracks = summary["tracks"]
|
|
|
|
|
|
by_id = {t["id"]: t for t in tracks}
|
|
|
|
|
|
|
|
|
|
|
|
def top(t):
|
|
|
|
|
|
seen = set()
|
|
|
|
|
|
while t["group"] not in ("-1", None) and t["group"] in by_id and t["id"] not in seen:
|
|
|
|
|
|
seen.add(t["id"])
|
|
|
|
|
|
t = by_id[t["group"]]
|
|
|
|
|
|
return t
|
|
|
|
|
|
groups = {t["name"]: [] for t in tracks if t["kind"] == "GroupTrack" and t["group"] in ("-1", None)}
|
|
|
|
|
|
for t in tracks:
|
|
|
|
|
|
if t["kind"] == "AudioTrack" and t["group"] not in ("-1", None):
|
|
|
|
|
|
g = top(t)
|
|
|
|
|
|
if g is not t and g["name"] in groups:
|
|
|
|
|
|
groups[g["name"]].append(t)
|
|
|
|
|
|
return groups
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def eval_ableton(rc, plan, look):
|
|
|
|
|
|
out = {"tracks": Line(), "mix_foh": Line(), "lights": Line()}
|
|
|
|
|
|
date = ableton_params(rc, plan)["date"]
|
|
|
|
|
|
if not look.get("set"):
|
|
|
|
|
|
for name, line in out.items():
|
|
|
|
|
|
line.fault(ERR, "NO SET %s" % date, "no %s in %s/%s" % (
|
|
|
|
|
|
rc.get("folder_format", "{date} {loc} Project").format(date=date, loc=rc.get("site_folder")),
|
|
|
|
|
|
rc.get("projects"), rc.get("site_folder")))
|
|
|
|
|
|
return out
|
|
|
|
|
|
summary = look["summary"]
|
|
|
|
|
|
groups = song_groups(summary)
|
|
|
|
|
|
lighting = next((t for t in summary["tracks"] if norm(t["name"]) == norm(rc.get("lighting_track", "LIGHTING CUES"))), None)
|
|
|
|
|
|
cues = [c for c in (lighting or {}).get("clips", []) if c[2]] if lighting else []
|
|
|
|
|
|
songs = plan_songs(rc, plan)
|
|
|
|
|
|
unmixed = 0
|
|
|
|
|
|
for it in songs:
|
|
|
|
|
|
gname = next((g for g in groups if same(it["title"], g)), None)
|
|
|
|
|
|
if gname is None:
|
|
|
|
|
|
out["tracks"].fault(ERR, "NO TRACKS " + short(it["title"]), "%s has no song group in %s" % (
|
|
|
|
|
|
it["title"], os.path.basename(look["set"])))
|
|
|
|
|
|
continue
|
|
|
|
|
|
audio = [t for t in groups[gname] if t["clips"]]
|
|
|
|
|
|
if not audio:
|
|
|
|
|
|
out["tracks"].fault(ERR, "EMPTY " + short(it["title"]), "the %s group has no audio clips" % gname)
|
|
|
|
|
|
continue
|
|
|
|
|
|
vols = [t["vol"] for t in audio if t["vol"] is not None]
|
|
|
|
|
|
if vols and all(abs(v - 1.0) < 1e-6 for v in vols):
|
|
|
|
|
|
unmixed += 1
|
|
|
|
|
|
out["mix_foh"].fault(WARN, "NOT MIXED " + short(it["title"]), "every one of the %d tracks of %s is at 0 dB" % (len(vols), gname))
|
|
|
|
|
|
stray = [t for t in audio if not t["ext"]]
|
|
|
|
|
|
if stray:
|
|
|
|
|
|
out["mix_foh"].fault(ERR, "TO MASTER " + short(it["title"]), "%s: %s go to %s, not out to the console" % (
|
|
|
|
|
|
gname, ", ".join(t["name"] for t in stray[:4]), stray[0]["out"] or "Master"))
|
|
|
|
|
|
start = min(c[0] for t in audio for c in t["clips"])
|
|
|
|
|
|
end = max(c[1] for t in audio for c in t["clips"])
|
|
|
|
|
|
if lighting is None:
|
|
|
|
|
|
pass
|
|
|
|
|
|
elif not any(c[0] < end and c[1] > start for c in cues):
|
|
|
|
|
|
out["lights"].fault(ERR, "NO LIGHTS " + short(it["title"]), "no clip on %s between beats %g and %g (%s)" % (
|
|
|
|
|
|
lighting["name"], start, end, gname))
|
|
|
|
|
|
if lighting is None:
|
|
|
|
|
|
out["lights"].fault(ERR, "NO CUE TRACK", "no track called %s in the set" % rc.get("lighting_track", "LIGHTING CUES"))
|
|
|
|
|
|
live = look.get("live_set")
|
|
|
|
|
|
if live and os.path.basename(live) != os.path.basename(look["set"]):
|
|
|
|
|
|
out["tracks"].fault(WARN, "LIVE HAS OLD SET", "Ableton last opened %s, not %s" % (os.path.basename(live), os.path.basename(look["set"])))
|
|
|
|
|
|
saved = datetime.fromtimestamp(look.get("mtime") or 0).strftime("%a %H:%M").upper()
|
|
|
|
|
|
out["tracks"].info = "%d SONGS" % len(songs)
|
|
|
|
|
|
out["mix_foh"].info = "SAVED " + saved
|
|
|
|
|
|
out["lights"].info = "%d CUES" % len(cues)
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def eval_track_bundle(rc, look):
|
|
|
|
|
|
line = Line()
|
|
|
|
|
|
for loc, b in sorted((look.get("bundles") or {}).items()):
|
|
|
|
|
|
if b.get("copying"):
|
|
|
|
|
|
line.fault(WARN, "COPYING TO " + loc, "the copy to %s is still running" % loc)
|
|
|
|
|
|
elif not b.get("als"):
|
|
|
|
|
|
line.fault(ERR, "NOT SENT " + loc, "no set in %s" % b.get("folder"))
|
|
|
|
|
|
line.info = " ".join(sorted(look.get("bundles") or {})) or "NO LOCATIONS"
|
|
|
|
|
|
return line
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def seconds_at(tempo, beat):
|
|
|
|
|
|
"""Seconds from the arrangement's start to `beat`, through the tempo automation (a step where two
|
|
|
|
|
|
points share a time, a ramp between two points)."""
|
|
|
|
|
|
pts = sorted(((t, v) for t, v in tempo.get("events") or [] if t >= 0), key=lambda p: p[0])
|
|
|
|
|
|
if not pts:
|
|
|
|
|
|
return beat * 60.0 / tempo["manual"]
|
|
|
|
|
|
first = [v for t, v in tempo.get("events") or [] if t < 0]
|
|
|
|
|
|
pts = [(0.0, first[-1] if first else pts[0][1])] + pts
|
|
|
|
|
|
secs = 0.0
|
|
|
|
|
|
for (t1, v1), (t2, v2) in zip(pts, pts[1:]):
|
|
|
|
|
|
if beat <= t1:
|
|
|
|
|
|
break
|
|
|
|
|
|
end = min(beat, t2)
|
|
|
|
|
|
if end > t1:
|
|
|
|
|
|
if abs(v2 - v1) < 1e-9:
|
|
|
|
|
|
secs += (end - t1) * 60.0 / v1
|
|
|
|
|
|
else:
|
|
|
|
|
|
v_end = v1 + (v2 - v1) * (end - t1) / (t2 - t1) # a ramp: integrate 60/bpm
|
|
|
|
|
|
secs += 60.0 * (end - t1) / (v_end - v1) * __import__("math").log(v_end / v1)
|
|
|
|
|
|
last_t, last_v = pts[-1]
|
|
|
|
|
|
if beat > last_t:
|
|
|
|
|
|
secs += (beat - last_t) * 60.0 / last_v
|
|
|
|
|
|
return secs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ltc_clip_info(name):
|
|
|
|
|
|
"""(start seconds, fps) from an LTC file's name: "LTC_00000000_60mins_30fps_48000x24" -> (0, 30)."""
|
|
|
|
|
|
m = re.search(r"(\d{2})(\d{2})(\d{2})(\d{2})", name or "")
|
|
|
|
|
|
f = re.search(r"(\d+(?:\.\d+)?)\s*fps", name or "", re.I)
|
|
|
|
|
|
fps = float(f.group(1)) if f else 30.0
|
|
|
|
|
|
start = (int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3)) + int(m.group(4)) / fps) if m else 0.0
|
|
|
|
|
|
return start, fps
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def timecode(seconds, fps):
|
|
|
|
|
|
frames = int(round(seconds * fps))
|
|
|
|
|
|
whole = int(round(fps))
|
|
|
|
|
|
return "%02d:%02d:%02d:%02d" % (frames // (whole * 3600), frames // (whole * 60) % 60, frames // whole % 60, frames % whole)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def song_ltc(rc, plan, look):
|
|
|
|
|
|
"""[{item, title, start beat, tracks: "HH:MM:SS:FF", pco: what PCO's LTC note says}] for every PCO song the set has.
|
|
|
|
|
|
A song starts at its locator ("2 - REJOICE AGAIN - Bb"), else at its group's first clip."""
|
|
|
|
|
|
summary = (look or {}).get("summary") or {}
|
|
|
|
|
|
clip = summary.get("ltc")
|
|
|
|
|
|
if not clip or not summary.get("tempo"):
|
|
|
|
|
|
return []
|
|
|
|
|
|
file_start, fps = ltc_clip_info(clip["name"])
|
|
|
|
|
|
clip_secs = seconds_at(summary["tempo"], clip["start"])
|
|
|
|
|
|
groups = song_groups(summary)
|
|
|
|
|
|
note = rc.get("ltc_note", "LTC")
|
|
|
|
|
|
out = []
|
|
|
|
|
|
for it in plan_songs(rc, plan):
|
|
|
|
|
|
loc = next((l for l in summary.get("locators") or [] if same(it["title"], l["name"])), None)
|
|
|
|
|
|
if loc is not None:
|
|
|
|
|
|
beat = loc["time"]
|
|
|
|
|
|
else:
|
|
|
|
|
|
gname = next((g for g in groups if same(it["title"], g)), None)
|
|
|
|
|
|
clips = [c for t in groups.get(gname, []) for c in t["clips"]]
|
|
|
|
|
|
if not clips:
|
|
|
|
|
|
continue
|
|
|
|
|
|
beat = min(c[0] for c in clips)
|
|
|
|
|
|
secs = seconds_at(summary["tempo"], beat) - clip_secs + clip.get("offset", 0) + file_start
|
|
|
|
|
|
out.append({"item": it, "title": it["title"], "beat": beat, "tracks": timecode(secs, fps),
|
|
|
|
|
|
"pco": (it["notes"].get(note) or "").strip()})
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def eval_ltc(rc, plan, look, line):
|
|
|
|
|
|
"""PCO's LTC note for every song must be the timecode the tracks play at that song's start."""
|
|
|
|
|
|
for s in song_ltc(rc, plan, look):
|
|
|
|
|
|
if not s["pco"]:
|
|
|
|
|
|
line.fault(ERR, "NO LTC " + short(s["title"]), "%s: PCO has no %s note; the tracks start it at %s" % (
|
|
|
|
|
|
s["title"], rc.get("ltc_note", "LTC"), s["tracks"]))
|
|
|
|
|
|
elif s["pco"] != s["tracks"]:
|
|
|
|
|
|
line.fault(ERR, "LTC WRONG " + short(s["title"]), "%s: PCO says %s, the tracks start it at %s" % (
|
|
|
|
|
|
s["title"], s["pco"], s["tracks"]))
|
|
|
|
|
|
return line
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
COPY_SCRIPT = r'''
|
|
|
|
|
|
import json, os, shlex, subprocess
|
|
|
|
|
|
P = json.loads(%r)
|
|
|
|
|
|
def folder(loc):
|
|
|
|
|
|
return os.path.join(os.path.expanduser(P["projects"]), loc, P["folder"].format(date=P["date"], loc=loc))
|
|
|
|
|
|
src = folder(P["site"])
|
|
|
|
|
|
done = []
|
|
|
|
|
|
for loc in P["locations"]:
|
|
|
|
|
|
dst = folder(loc)
|
|
|
|
|
|
if os.path.exists(dst) or os.path.isdir(dst + ".copying") or not os.path.isdir(src):
|
|
|
|
|
|
continue
|
|
|
|
|
|
old = os.path.basename(src) + ".als"
|
|
|
|
|
|
new = os.path.basename(dst) + ".als"
|
|
|
|
|
|
# Copied under a temporary name and renamed at the end, so a half-copied project never looks sent.
|
|
|
|
|
|
cmd = ("mkdir -p %%s && rsync -a --exclude Backup/ %%s/ %%s/ && if [ -f %%s ]; then mv %%s %%s; fi && "
|
|
|
|
|
|
"[ ! -e %%s ] && mv %%s %%s") %% tuple(map(shlex.quote, (dst + ".copying", src, dst + ".copying",
|
|
|
|
|
|
dst + ".copying/" + old, dst + ".copying/" + old, dst + ".copying/" + new, dst, dst + ".copying", dst)))
|
|
|
|
|
|
subprocess.Popen(["/usr/bin/nohup", "/bin/sh", "-c", cmd], stdout=open("/tmp/status-panel-copy-%%s.log" %% loc, "w"),
|
|
|
|
|
|
stderr=subprocess.STDOUT, start_new_session=True)
|
|
|
|
|
|
done.append(loc)
|
|
|
|
|
|
print(json.dumps({"started": done, "source": os.path.isdir(src)}))
|
|
|
|
|
|
'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- The lineup TV (ProdDash's Band Lineup) -------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def name_score(shown, full):
|
|
|
|
|
|
"""How surely a name typed on the board is this PCO person: 3 the same, 2 "Alex Zinni" / "AlexG" / "DIEGO"
|
|
|
|
|
|
(first name, nickname, last initial), 0 not them."""
|
|
|
|
|
|
b = " ".join(re.sub(r"[^a-z ]", "", (shown or "").lower()).split())
|
|
|
|
|
|
f = " ".join(re.sub(r"[^a-z ]", "", (full or "").lower()).split())
|
|
|
|
|
|
if not b or not f:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
if b == f:
|
|
|
|
|
|
return 3
|
|
|
|
|
|
first, last = f.split()[0], f.split()[-1]
|
|
|
|
|
|
words = b.split()
|
|
|
|
|
|
alike = lambda w: w == first or (len(w) >= 3 and (first.startswith(w) or w.startswith(first)))
|
|
|
|
|
|
if len(words) == 1:
|
|
|
|
|
|
w = words[0]
|
|
|
|
|
|
if w == first + last or w == first + last[0]:
|
|
|
|
|
|
return 2
|
|
|
|
|
|
return 1 if alike(w) else 0
|
|
|
|
|
|
return 2 if alike(words[0]) and words[-1] in (last, last[0]) else 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def best_person(shown, people):
|
|
|
|
|
|
"""The PCO person a board name means, or None (a bare first name shared by two people means nobody)."""
|
|
|
|
|
|
scored = sorted(((name_score(shown, m["name"]), m) for m in people), key=lambda sm: -sm[0])
|
|
|
|
|
|
if not scored or scored[0][0] == 0:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if len(scored) > 1 and scored[1][0] == scored[0][0] and scored[0][1]["name"] != scored[1][1]["name"]:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return scored[0][1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def eval_lineup(rc, plan, state):
|
|
|
|
|
|
line = Line()
|
|
|
|
|
|
st = (state or {}).get("state") or {}
|
|
|
|
|
|
shown = [s["person"]["name"] for s in st.get("slots") or [] if s.get("person") and s["person"].get("name")]
|
|
|
|
|
|
if str((st.get("plan") or {}).get("id")) != str(plan["id"]):
|
|
|
|
|
|
line.fault(ERR, "OLD PLAN", "the board shows %s, PCO's plan is %s" % ((st.get("plan") or {}).get("dates") or "nothing", plan["dates"]))
|
|
|
|
|
|
if st.get("lastError"):
|
|
|
|
|
|
line.fault(WARN, "SYNC ERROR", st["lastError"])
|
|
|
|
|
|
on_plan = [m for m in plan["team"] if not declined(m)]
|
|
|
|
|
|
gone = [m for m in plan["team"] if declined(m)]
|
|
|
|
|
|
teams = {t.lower() for t in rc.get("lineup_teams", ["Band", "Communication"])}
|
|
|
|
|
|
skip = {p.lower() for p in rc.get("lineup_skip_positions", [])}
|
|
|
|
|
|
seen = set()
|
|
|
|
|
|
for name in shown:
|
|
|
|
|
|
m = best_person(name, on_plan)
|
|
|
|
|
|
if m is not None:
|
|
|
|
|
|
seen.add(m["name"])
|
|
|
|
|
|
elif best_person(name, gone) is not None:
|
|
|
|
|
|
line.fault(ERR, "%s DECLINED" % short(name, 14), "%s is on the board but declined in PCO" % name)
|
|
|
|
|
|
else:
|
|
|
|
|
|
line.fault(ERR, "%s NOT ON PLAN" % short(name, 14), "%s is on the board but not on this plan" % name)
|
|
|
|
|
|
for m in on_plan:
|
|
|
|
|
|
if m["team"].lower() in teams and m["position"].lower() not in skip and m["name"] not in seen:
|
|
|
|
|
|
line.fault(ERR, "%s MISSING" % short(m["name"], 14), "%s (%s, %s) is on the plan but not on the board" % (
|
|
|
|
|
|
m["name"], m["team"], m["position"]))
|
|
|
|
|
|
line.info = "%d NAMES" % len(shown)
|
|
|
|
|
|
return line
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-15 14:43:49 -04:00
|
|
|
|
# --- Resolume: the columns Sunday's slides call up -------------------------------------------
|
|
|
|
|
|
# A ProPresenter slide fires a macro (the .pro holds the macro's UUID); the macro sends RossTalk
|
|
|
|
|
|
# "CC page/row/column" to Companion; that Companion button (and the buttons it presses in turn)
|
|
|
|
|
|
# sends Resolume "/composition/columns/N/connect". Every column found that way must hold content.
|
2026-10-14 20:28:43 -04:00
|
|
|
|
|
2026-10-15 14:43:49 -04:00
|
|
|
|
MACRO_SCRIPT = r"""
|
|
|
|
|
|
import json, os, subprocess
|
|
|
|
|
|
P = json.loads(%r)
|
|
|
|
|
|
def show_dir():
|
|
|
|
|
|
try:
|
|
|
|
|
|
d = subprocess.run(["defaults", "read", "com.renewedvision.propresenter", "applicationShowDirectory"],
|
|
|
|
|
|
capture_output=True, text=True, timeout=5).stdout.strip()
|
|
|
|
|
|
if d:
|
|
|
|
|
|
return os.path.expanduser(d)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return os.path.expanduser("~/Documents/ProPresenter")
|
|
|
|
|
|
def varint(b, i):
|
|
|
|
|
|
s = r = 0
|
|
|
|
|
|
while True:
|
|
|
|
|
|
x = b[i]; i += 1; r |= (x & 0x7f) << s
|
|
|
|
|
|
if not x & 0x80:
|
|
|
|
|
|
return r, i
|
|
|
|
|
|
s += 7
|
|
|
|
|
|
def fields(b):
|
|
|
|
|
|
out, i = [], 0
|
|
|
|
|
|
while i < len(b):
|
|
|
|
|
|
k, i = varint(b, i); num, wt = k >> 3, k & 7
|
|
|
|
|
|
if wt == 0: v, i = varint(b, i)
|
|
|
|
|
|
elif wt == 1: v, i = b[i:i + 8], i + 8
|
|
|
|
|
|
elif wt == 5: v, i = b[i:i + 4], i + 4
|
|
|
|
|
|
elif wt == 2:
|
|
|
|
|
|
n, i = varint(b, i); v = b[i:i + n]; i += n
|
|
|
|
|
|
else: raise ValueError(wt)
|
|
|
|
|
|
out.append((num, wt, v))
|
2026-10-14 20:28:43 -04:00
|
|
|
|
return out
|
2026-10-15 14:43:49 -04:00
|
|
|
|
def get(b, *path):
|
|
|
|
|
|
for p in path:
|
|
|
|
|
|
if not isinstance(b, bytes):
|
|
|
|
|
|
return None
|
|
|
|
|
|
f = [v for n, w, v in fields(b) if n == p]
|
|
|
|
|
|
if not f:
|
|
|
|
|
|
return None
|
|
|
|
|
|
b = f[0]
|
|
|
|
|
|
return b if isinstance(b, bytes) else None
|
|
|
|
|
|
macros, raw = {}, {}
|
|
|
|
|
|
for num, wt, v in fields(open(os.path.join(show_dir(), "Configuration", "Macros"), "rb").read()):
|
|
|
|
|
|
if num != 2 or wt != 2:
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
uuid = get(v, 1, 1).decode(); name = (get(v, 2) or b"").decode()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
cc = []
|
|
|
|
|
|
for n, w, act in fields(v):
|
|
|
|
|
|
comm = get(act, 32) if n == 4 and w == 2 else None
|
|
|
|
|
|
if comm is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
dev, fmt = (get(comm, 1, 2) or b"").decode(), (get(comm, 2) or b"").decode()
|
|
|
|
|
|
for n2, w2, cmd in fields(comm):
|
|
|
|
|
|
if n2 == 4 and w2 == 2:
|
|
|
|
|
|
cc.append([dev, fmt, (get(cmd, 2) or b"").decode()])
|
|
|
|
|
|
macros[uuid] = {"name": name, "cc": cc}
|
|
|
|
|
|
raw[uuid] = v
|
|
|
|
|
|
for u, v in raw.items(): # a macro that runs other macros
|
|
|
|
|
|
macros[u]["calls"] = [o for o in raw if o != u and o.encode() in v]
|
|
|
|
|
|
used = {}
|
|
|
|
|
|
for p in P["paths"]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
b = open(p, "rb").read()
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
used[p] = None
|
|
|
|
|
|
continue
|
|
|
|
|
|
used[p] = [u for u in macros if u.encode() in b]
|
|
|
|
|
|
print(json.dumps({"macros": macros, "used": used}))
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
COLUMN = re.compile(r"/composition/columns/(\d+)/connect")
|
|
|
|
|
|
LOCATION = re.compile(r"^\s*(\d+)/(\d+)/(\d+)\s*$")
|
|
|
|
|
|
PRESSES = ("button_pressrelease", "button_press", "button_release", "button_pressrelease_condition")
|
2026-10-14 20:28:43 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-10-15 14:43:49 -04:00
|
|
|
|
def companion_page(cfg, n):
|
|
|
|
|
|
"""One Companion page as Companion exports it (read-only)."""
|
|
|
|
|
|
comp = cfg.get("companion") or {}
|
|
|
|
|
|
url = "http://%s:%d/int/export/page/%d?includeSecrets=false" % (comp.get("host", "127.0.0.1"), comp.get("port", 8000), n)
|
|
|
|
|
|
with urllib.request.urlopen(url, timeout=6) as r:
|
|
|
|
|
|
raw = r.read()
|
|
|
|
|
|
try:
|
|
|
|
|
|
raw = __import__("gzip").decompress(raw)
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return json.loads(raw).get("page") or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def button_columns(get_page, location, seen=None):
|
|
|
|
|
|
"""The Resolume columns a Companion button connects, following the buttons it presses."""
|
|
|
|
|
|
seen = set() if seen is None else seen
|
|
|
|
|
|
m = LOCATION.match(location or "")
|
|
|
|
|
|
if not m or location in seen or len(seen) > 40:
|
|
|
|
|
|
return set()
|
|
|
|
|
|
seen.add(location)
|
|
|
|
|
|
page, row, col = (int(x) for x in m.groups())
|
|
|
|
|
|
ctl = (((get_page(page) or {}).get("controls") or {}).get(str(row)) or {}).get(str(col)) or {}
|
|
|
|
|
|
cols = set()
|
|
|
|
|
|
|
|
|
|
|
|
def walk(actions):
|
|
|
|
|
|
for a in actions or []:
|
|
|
|
|
|
opts = {k: (v.get("value") if isinstance(v, dict) else v) for k, v in (a.get("options") or {}).items()}
|
|
|
|
|
|
for v in opts.values():
|
|
|
|
|
|
for hit in COLUMN.findall(str(v)):
|
|
|
|
|
|
cols.add(int(hit))
|
|
|
|
|
|
if "olumn" in str(a.get("definitionId")) and str(opts.get("column", "")).isdigit():
|
|
|
|
|
|
cols.add(int(opts["column"]))
|
|
|
|
|
|
if a.get("connectionId") == "internal" and a.get("definitionId") in PRESSES:
|
|
|
|
|
|
cols.update(button_columns(get_page, str(opts.get("location", "")), seen))
|
|
|
|
|
|
for kids in (a.get("children") or {}).values():
|
|
|
|
|
|
walk(kids)
|
|
|
|
|
|
for step in (ctl.get("steps") or {}).values():
|
|
|
|
|
|
for actions in (step.get("action_sets") or {}).values():
|
|
|
|
|
|
walk(actions)
|
|
|
|
|
|
return cols
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def macro_columns(macros, uuid, get_page, seen=None):
|
|
|
|
|
|
"""The columns one ProPresenter macro calls up (through the Companion buttons it presses)."""
|
|
|
|
|
|
seen = set() if seen is None else seen
|
|
|
|
|
|
if uuid in seen or uuid not in macros:
|
|
|
|
|
|
return set()
|
|
|
|
|
|
seen.add(uuid)
|
|
|
|
|
|
cols = set()
|
|
|
|
|
|
for dev, fmt, value in macros[uuid].get("cc") or []:
|
|
|
|
|
|
if fmt.strip().upper().startswith("CC"):
|
|
|
|
|
|
cols |= button_columns(get_page, value.replace(":", "/"))
|
|
|
|
|
|
for other in macros[uuid].get("calls") or []:
|
|
|
|
|
|
cols |= macro_columns(macros, other, get_page, seen)
|
|
|
|
|
|
return cols
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def column_has_content(comp, n):
|
|
|
|
|
|
"""Column n (1-based) holds a clip on some layer that a column trigger plays."""
|
|
|
|
|
|
for layer in comp.get("layers") or []:
|
|
|
|
|
|
clips = layer.get("clips") or []
|
|
|
|
|
|
if n - 1 < len(clips):
|
|
|
|
|
|
c = clips[n - 1]
|
|
|
|
|
|
ignore = (c.get("ignorecolumntrigger") or {}).get("value") # "Layer Determined" / "On" / "Off"
|
|
|
|
|
|
if ((c.get("connected") or {}).get("value") or "Empty") != "Empty" and ignore not in ("On", True):
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def eval_columns(rc, prop, found, get_page, comp):
|
|
|
|
|
|
"""Every Resolume column the plan's ProPresenter items call up holds content, on the right deck."""
|
2026-10-14 20:28:43 -04:00
|
|
|
|
line = Line()
|
|
|
|
|
|
deck = next((d for d in comp.get("decks") or [] if (d.get("selected") or {}).get("value")), None)
|
|
|
|
|
|
want = rc.get("resolume_deck", "")
|
|
|
|
|
|
if want and (deck is None or ((deck.get("name") or {}).get("value") or "") != want):
|
2026-10-15 14:43:49 -04:00
|
|
|
|
line.fault(ERR, "WRONG DECK", "Resolume is on deck %s, not %s: the columns would be another deck's" % (
|
|
|
|
|
|
((deck or {}).get("name") or {}).get("value"), want))
|
|
|
|
|
|
macros, used = found.get("macros") or {}, found.get("used") or {}
|
|
|
|
|
|
wanted = {} # column -> [items that call it up]
|
|
|
|
|
|
for it in prop.get("items") or []:
|
|
|
|
|
|
path = (it.get("pres") or {}).get("path")
|
|
|
|
|
|
for uuid in used.get(path) or []:
|
|
|
|
|
|
for col in macro_columns(macros, uuid, get_page):
|
|
|
|
|
|
wanted.setdefault(col, []).append(it["name"])
|
|
|
|
|
|
columns = comp.get("columns") or []
|
|
|
|
|
|
for col in sorted(wanted):
|
|
|
|
|
|
name = ((columns[col - 1].get("name") or {}).get("value") if col <= len(columns) else "") or ""
|
|
|
|
|
|
who = ", ".join(dict.fromkeys(wanted[col]))
|
|
|
|
|
|
if col > len(columns):
|
|
|
|
|
|
line.fault(ERR, "COL %d MISSING" % col, "%s call up column %d; Resolume has %d columns" % (who, col, len(columns)))
|
|
|
|
|
|
elif not column_has_content(comp, col):
|
|
|
|
|
|
line.fault(ERR, "COL %d EMPTY" % col, "column %d (%s) is empty in Resolume; %s call it up" % (col, name, who))
|
|
|
|
|
|
line.info = "%d COLUMNS OK" % len(wanted) if wanted else "NO COLUMNS USED"
|
2026-10-14 20:28:43 -04:00
|
|
|
|
return line
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Bundles, mics ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
BUNDLE_SCRIPT = r'''
|
|
|
|
|
|
import glob, json, os
|
|
|
|
|
|
P = json.loads(%r)
|
|
|
|
|
|
files = glob.glob(os.path.join(os.path.expanduser(P["folder"]), P["pattern"]))
|
|
|
|
|
|
print(json.dumps(sorted([[os.path.basename(f), os.path.getmtime(f)] for f in files], key=lambda x: -x[1])[:5]))
|
|
|
|
|
|
'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def eval_prop_bundle(rc, files, since):
|
|
|
|
|
|
line = Line()
|
|
|
|
|
|
fresh = [f for f in files if f[1] >= since]
|
|
|
|
|
|
if not fresh:
|
|
|
|
|
|
last = datetime.fromtimestamp(files[0][1]).strftime("%-m/%-d") if files else "NEVER"
|
|
|
|
|
|
line.fault(ERR, "NOT SENT (LAST %s)" % last, "no %s in %s saved since Monday" % (rc.get("prop_bundle_pattern"), rc.get("prop_bundle_folder")))
|
|
|
|
|
|
else:
|
|
|
|
|
|
line.info = "SENT " + datetime.fromtimestamp(fresh[0][1]).strftime("%a %H:%M").upper()
|
|
|
|
|
|
return line
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mics_needed(rc, plan):
|
|
|
|
|
|
"""What the mic table needs this weekend, from PCO: "4 VOX MC TIM"."""
|
|
|
|
|
|
vocal = {p.lower() for p in rc.get("vocal_positions", ["Vocalist", "[Leader]"])}
|
|
|
|
|
|
singers = {m["name"] for m in plan["team"] if not declined(m) and m["position"].lower() in vocal}
|
|
|
|
|
|
words = ["%d VOX" % len(singers)] if singers else []
|
|
|
|
|
|
for position, mic in sorted((rc.get("mic_positions") or {}).items()):
|
|
|
|
|
|
if any(m["position"].lower() == position.lower() and not declined(m) for m in plan["team"]):
|
|
|
|
|
|
words.append(mic)
|
|
|
|
|
|
return " ".join(words)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- The whole look ------------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
class Line:
|
|
|
|
|
|
"""One checklist line: its faults [(level, msg, detail)] and, when it is fine, what to show."""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self.faults, self.info = [], ""
|
|
|
|
|
|
|
|
|
|
|
|
def fault(self, level, msg, detail=""):
|
|
|
|
|
|
if all(m != msg for _, m, _ in self.faults):
|
|
|
|
|
|
self.faults.append((level, msg, detail))
|
|
|
|
|
|
|
|
|
|
|
|
def as_dict(self):
|
|
|
|
|
|
return {"faults": [[lv, m] for lv, m, _ in self.faults], "details": [d for _, _, d in self.faults if d],
|
|
|
|
|
|
"info": self.info}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def look(cfg, now=None, data_dir=None, sources=None):
|
|
|
|
|
|
"""Everything the READY page shows, as {"time", "plan", "items": {item: {"faults", "details", "info"}}}.
|
|
|
|
|
|
`sources` replaces the readers (tests)."""
|
|
|
|
|
|
rc = cfg.get("ready") or {}
|
|
|
|
|
|
now = now or time.time()
|
|
|
|
|
|
src = dict(pco=lambda: pco_plan(rc, now), prop=lambda plan: prop_look(rc, plan),
|
|
|
|
|
|
ableton=lambda plan: ableton_look(cfg, rc, plan),
|
|
|
|
|
|
lineup=lambda: http_json(rc.get("lineup_url", "").rstrip("/") + "/state", timeout=6),
|
|
|
|
|
|
resolume=lambda: http_json(rc.get("resolume_url", "").rstrip("/") + "/api/v1/composition", timeout=6),
|
2026-10-15 14:43:49 -04:00
|
|
|
|
macros=lambda paths: run_remote(cfg, rc["prop_host"], MACRO_SCRIPT % json.dumps({"paths": paths}), timeout=60),
|
|
|
|
|
|
companion=lambda n: companion_page(cfg, n),
|
2026-10-14 20:28:43 -04:00
|
|
|
|
prop_bundle=lambda: run_remote(cfg, rc["prop_bundle_host"], BUNDLE_SCRIPT % json.dumps(
|
|
|
|
|
|
{"folder": rc.get("prop_bundle_folder", ""), "pattern": rc.get("prop_bundle_pattern", "*.probundle")}), timeout=30))
|
|
|
|
|
|
src.update(sources or {})
|
|
|
|
|
|
lines = {}
|
|
|
|
|
|
|
|
|
|
|
|
def failed(names, err):
|
|
|
|
|
|
msg = str(err) if isinstance(err, ReadyError) else "CHECK FAILED"
|
|
|
|
|
|
if not isinstance(err, ReadyError):
|
|
|
|
|
|
log.warning("READY %s: %s", "/".join(names), err)
|
|
|
|
|
|
for n in names:
|
|
|
|
|
|
lines.setdefault(n, Line()).fault(WARN, msg, "%s: %s" % (msg, err))
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
plan = src["pco"]()
|
|
|
|
|
|
except Exception as e: # noqa: BLE001 - one source down must not hide the others
|
|
|
|
|
|
plan = None
|
|
|
|
|
|
failed([n for n, on in (("tracks", rc.get("tracks_host")), ("mix_foh", rc.get("tracks_host")),
|
|
|
|
|
|
("lights", rc.get("tracks_host")), ("prop", rc.get("prop_url")),
|
|
|
|
|
|
("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")),
|
|
|
|
|
|
("lineup", rc.get("lineup_url")), ("mics", True),
|
2026-10-15 14:43:49 -04:00
|
|
|
|
("track_bundle", rc.get("locations")), ("prop_bundle", rc.get("prop_bundle_host")),
|
|
|
|
|
|
("led_cross", rc.get("resolume_url") and rc.get("prop_host"))) if on], e)
|
2026-10-14 20:28:43 -04:00
|
|
|
|
if plan is not None:
|
2026-10-15 14:43:49 -04:00
|
|
|
|
prop = None
|
2026-10-14 20:28:43 -04:00
|
|
|
|
if rc.get("prop_url"):
|
|
|
|
|
|
try:
|
2026-10-15 14:43:49 -04:00
|
|
|
|
prop = src["prop"](plan)
|
|
|
|
|
|
lines.update(eval_prop(rc, plan, prop))
|
2026-10-14 20:28:43 -04:00
|
|
|
|
except Exception as e: # noqa: BLE001
|
2026-10-15 14:43:49 -04:00
|
|
|
|
failed(["prop", "timecode", "backgrounds"] + (["led_cross"] if rc.get("resolume_url") and rc.get("prop_host") else []), e)
|
|
|
|
|
|
if rc.get("resolume_url") and rc.get("prop_host") and prop is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
pages = {}
|
|
|
|
|
|
|
|
|
|
|
|
def get_page(n):
|
|
|
|
|
|
if n not in pages:
|
|
|
|
|
|
try:
|
|
|
|
|
|
pages[n] = src["companion"](n)
|
|
|
|
|
|
except (OSError, ValueError):
|
|
|
|
|
|
pages[n] = {}
|
|
|
|
|
|
return pages[n]
|
|
|
|
|
|
paths = sorted({(it.get("pres") or {}).get("path") for it in prop["items"]} - {None})
|
|
|
|
|
|
lines["led_cross"] = eval_columns(rc, prop, src["macros"](paths), get_page, src["resolume"]())
|
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
failed(["led_cross"], e if isinstance(e, ReadyError) else ReadyError("RESOLUME UNREACHABLE"))
|
2026-10-14 20:28:43 -04:00
|
|
|
|
if rc.get("tracks_host"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
al = src["ableton"](plan)
|
|
|
|
|
|
lines.update(eval_ableton(rc, plan, al))
|
|
|
|
|
|
if rc.get("ltc_note", "LTC"):
|
|
|
|
|
|
eval_ltc(rc, plan, al, lines.setdefault("timecode", Line()))
|
|
|
|
|
|
LAST["ltc"] = [dict(s, item={"id": s["item"]["id"], "note_ids": s["item"]["note_ids"]})
|
|
|
|
|
|
for s in song_ltc(rc, plan, al)]
|
|
|
|
|
|
LAST["service_type"], LAST["plan_id"] = plan.get("service_type"), plan["id"]
|
|
|
|
|
|
if rc.get("locations"):
|
|
|
|
|
|
lines["track_bundle"] = eval_track_bundle(rc, al)
|
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
failed(["tracks", "mix_foh", "lights"] + (["track_bundle"] if rc.get("locations") else []), e)
|
|
|
|
|
|
if rc.get("lineup_url"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
lines["lineup"] = eval_lineup(rc, plan, src["lineup"]())
|
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
failed(["lineup"], e if isinstance(e, ReadyError) else ReadyError("LINEUP UNREACHABLE"))
|
|
|
|
|
|
mics = Line()
|
|
|
|
|
|
mics.info = mics_needed(rc, plan)
|
|
|
|
|
|
lines["mics"] = mics
|
|
|
|
|
|
if rc.get("prop_bundle_host"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
lines["prop_bundle"] = eval_prop_bundle(rc, src["prop_bundle"](), week_start(now, rc.get("reset_day", "mon")))
|
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
failed(["prop_bundle"], e)
|
|
|
|
|
|
out = {"time": now, "items": {k: v.as_dict() for k, v in lines.items()}}
|
|
|
|
|
|
if plan is not None:
|
|
|
|
|
|
out["plan"] = {"id": plan["id"], "title": plan["title"], "dates": plan["dates"], "time": plan["time"]}
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Poller(threading.Thread):
|
|
|
|
|
|
"""Looks every "interval_seconds" (or at once after a hold), never while `is_live()` - a
|
|
|
|
|
|
recording HyperDeck means a service, and the Tracks and ProPresenter Macs are busy."""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, cfg, snap, stop, data_dir=None, is_live=lambda: False):
|
|
|
|
|
|
super().__init__(name="ready", daemon=True)
|
|
|
|
|
|
self.cfg, self.snap, self.stop, self.data_dir, self.is_live = cfg, snap, stop, data_dir, is_live
|
|
|
|
|
|
self.interval = (cfg.get("ready") or {}).get("interval_seconds", 120)
|
|
|
|
|
|
|
|
|
|
|
|
def run(self):
|
|
|
|
|
|
forced = True
|
|
|
|
|
|
while not self.stop.is_set():
|
|
|
|
|
|
if forced or not self.is_live():
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = look(self.cfg, data_dir=self.data_dir)
|
|
|
|
|
|
self.snap.ready = result
|
|
|
|
|
|
for k in ("time", "items", "plan"):
|
|
|
|
|
|
LAST.pop(k, None)
|
|
|
|
|
|
LAST.update(result)
|
|
|
|
|
|
except Exception: # noqa: BLE001 - the panel carries on without the READY page
|
|
|
|
|
|
log.exception("READY look failed")
|
|
|
|
|
|
WAKE.clear()
|
|
|
|
|
|
forced = False
|
|
|
|
|
|
deadline = time.time() + self.interval
|
|
|
|
|
|
while not self.stop.is_set() and time.time() < deadline:
|
|
|
|
|
|
if WAKE.wait(1.0):
|
|
|
|
|
|
forced = True
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Fixes (a hold on a READY key: fix kind "ready") ------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def write_ltc(rc, songs, service_type, plan_id):
|
|
|
|
|
|
"""Write the tracks' timecode into PCO's LTC note of every song where it is missing or different."""
|
|
|
|
|
|
todo = [s for s in songs if s["pco"] != s["tracks"]]
|
|
|
|
|
|
if not todo:
|
|
|
|
|
|
return True
|
|
|
|
|
|
if not (service_type and plan_id):
|
|
|
|
|
|
return "NO PLAN YET"
|
|
|
|
|
|
auth, _ = pco_login(rc)
|
|
|
|
|
|
note = rc.get("ltc_note", "LTC")
|
|
|
|
|
|
base = "%s/service_types/%s/plans/%s/items" % (PCO, service_type, plan_id)
|
|
|
|
|
|
headers = {"Authorization": auth, "Content-Type": "application/json"}
|
|
|
|
|
|
category = None
|
|
|
|
|
|
for s in todo:
|
|
|
|
|
|
note_id = s["item"]["note_ids"].get(note)
|
|
|
|
|
|
if note_id:
|
|
|
|
|
|
body = {"data": {"type": "ItemNote", "id": note_id, "attributes": {"content": s["tracks"]}}}
|
|
|
|
|
|
http_json("%s/%s/item_notes/%s" % (base, s["item"]["id"], note_id), headers, "PATCH",
|
|
|
|
|
|
json.dumps(body).encode(), timeout=15)
|
|
|
|
|
|
else:
|
|
|
|
|
|
if category is None:
|
|
|
|
|
|
cats, _ = pco_get(auth, "/service_types/%s/item_note_categories" % service_type, pages=3)
|
|
|
|
|
|
category = next((c["id"] for c in cats if ((c.get("attributes") or {}).get("name") or "").lower() == note.lower()), "")
|
|
|
|
|
|
if not category:
|
|
|
|
|
|
return "NO %s IN PCO" % note.upper()
|
|
|
|
|
|
body = {"data": {"type": "ItemNote", "attributes": {"content": s["tracks"], "item_note_category_id": category}}}
|
|
|
|
|
|
http_json("%s/%s/item_notes" % (base, s["item"]["id"]), headers, "POST", json.dumps(body).encode(), timeout=15)
|
|
|
|
|
|
log.info("FIX PCO %s note for %s: %s -> %s", note, s["title"], s["pco"] or "(none)", s["tracks"])
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_action(cfg, action, chk=None, sleep=time.sleep):
|
|
|
|
|
|
"""One "ready" fix. True when it worked; a string says what to do instead."""
|
|
|
|
|
|
rc = cfg.get("ready") or {}
|
|
|
|
|
|
try:
|
|
|
|
|
|
if action == "recheck":
|
|
|
|
|
|
return True
|
|
|
|
|
|
if action == "lineup_sync":
|
|
|
|
|
|
base = rc.get("lineup_url", "").rstrip("/")
|
|
|
|
|
|
before = ((http_json(base + "/state", timeout=6) or {}).get("state") or {}).get("lastFetched")
|
|
|
|
|
|
http_json(base + "/sync", method="POST", data=b"{}", headers={"Content-Type": "application/json"}, timeout=10)
|
|
|
|
|
|
for _ in range(30):
|
|
|
|
|
|
sleep(1)
|
|
|
|
|
|
st = (http_json(base + "/state", timeout=6) or {}).get("state") or {}
|
|
|
|
|
|
if st.get("lastFetched") != before:
|
|
|
|
|
|
log.info("FIX lineup synced from PCO%s", (": " + st["lastError"]) if st.get("lastError") else "")
|
|
|
|
|
|
return "SYNC ERROR" if st.get("lastError") else True
|
|
|
|
|
|
return "SYNC SLOW"
|
|
|
|
|
|
if action == "led_cross":
|
|
|
|
|
|
base = rc.get("resolume_url", "").rstrip("/") + "/api/v1/composition"
|
|
|
|
|
|
comp = http_json(base, timeout=6) or {}
|
|
|
|
|
|
want = rc.get("resolume_deck", "")
|
|
|
|
|
|
for i, d in enumerate(comp.get("decks") or []):
|
|
|
|
|
|
if want and ((d.get("name") or {}).get("value") == want) and not (d.get("selected") or {}).get("value"):
|
|
|
|
|
|
http_json("%s/decks/%d/select" % (base, i + 1), method="POST", timeout=6)
|
|
|
|
|
|
log.info("FIX Resolume deck %s selected", want)
|
|
|
|
|
|
sleep(1)
|
|
|
|
|
|
comp = http_json(base, timeout=6) or {}
|
2026-10-15 14:43:49 -04:00
|
|
|
|
return True # an empty column is filled by hand in Resolume
|
2026-10-14 20:28:43 -04:00
|
|
|
|
if action == "ltc_to_pco":
|
|
|
|
|
|
return write_ltc(rc, LAST.get("ltc") or [], LAST.get("service_type"), LAST.get("plan_id"))
|
|
|
|
|
|
if action == "copy_tracks":
|
|
|
|
|
|
plan = LAST.get("plan")
|
|
|
|
|
|
if not plan:
|
|
|
|
|
|
return "NO PLAN YET"
|
|
|
|
|
|
r = run_remote(cfg, rc["tracks_host"], COPY_SCRIPT % json.dumps(ableton_params(rc, plan)), timeout=60)
|
|
|
|
|
|
if not r.get("source"):
|
|
|
|
|
|
return "NO SET TO COPY"
|
|
|
|
|
|
log.info("FIX tracks copy started to %s", ", ".join(r["started"]) or "nowhere (all there)")
|
|
|
|
|
|
return True
|
|
|
|
|
|
log.warning("FIX ready: unknown action %r", action)
|
|
|
|
|
|
return False
|
|
|
|
|
|
except ReadyError as e:
|
|
|
|
|
|
return str(e)
|
|
|
|
|
|
except (OSError, ValueError) as e:
|
|
|
|
|
|
log.warning("FIX ready %s failed: %s", action, e)
|
|
|
|
|
|
return "PCO WRITE FAILED" if action == "ltc_to_pco" else False
|
|
|
|
|
|
finally:
|
|
|
|
|
|
WAKE.set() # look again now, so the key shows the result
|