Files
christianandClaude Fable 5.1 f1cd42a437 Other campuses' LTC: match against North Attleboro's plan order, not only the songs with timecode
With the weekend's set not on Tracks yet, every campus song looked out of order. The campuses now
pair with the home plan itself; a song the home set has no timecode for yet is matched but neither
faulted nor written, and the campuses are not looked at at all until the home set gives some timecode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 10:30:43 -04:00

1230 lines
59 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)
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)
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
the lineup from PCO, put Resolume back on its deck, copy the tracks to a location that
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, service_type=None, team=True):
"""This weekend's plan: {"id", "title", "dates", "time", "items": [...], "team": [...]}.
`service_type` reads another campus's plan for the same weekend (its team is not needed)."""
auth, st = pco_login(rc)
if service_type:
st = str(service_type)
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)) if team else ([], [])
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 []]
cache[uuid] = {"name": (p.get("id") or {}).get("name"), "type": p.get("type"), "path": p.get("presentation_path"),
"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 match_in_order(na_songs, campus_songs):
"""Pair a campus's songs with the home campus's by title, keeping the home order: a campus song
whose place breaks that order (or that the home campus does not play) is skipped, that song only.
Returns ([(campus item, home song)], [skipped titles])."""
pairs, skipped, last = [], [], -1
for it in campus_songs:
idx = next((i for i, s in enumerate(na_songs) if i > last and same(it["title"], s["title"])), None)
if idx is None:
skipped.append(it["title"])
continue
pairs.append((it, na_songs[idx]))
last = idx
return pairs, skipped
def campus_ltc(rc, na_plan, na_songs, campus, plan):
"""One other campus's plan against the home timecode: its songs in the home plan's order, each
with the timecode the home set gives that song (None while the set has none) and what its LTC
note says now."""
note = rc.get("ltc_note", "LTC")
pairs, skipped = match_in_order(plan_songs(rc, na_plan), plan_songs(rc, plan))
songs = []
for it, home in pairs:
tc = next((s["tracks"] for s in na_songs if same(home["title"], s["title"])), None)
songs.append({"item": it, "title": it["title"], "tracks": tc, "pco": (it["notes"].get(note) or "").strip()})
return {"name": campus.get("name") or campus.get("short") or str(campus.get("service_type")), "short": campus.get("short") or (campus.get("name") or "?")[:2].upper(),
"service_type": str(campus.get("service_type")), "plan_id": plan["id"], "dates": plan.get("dates", ""), "songs": songs, "skipped": skipped}
def eval_campus_ltc(rc, result, line):
"""The other campus's notes must say the home timecode for every matched song."""
tag = result["short"]
for s in result["songs"]:
if s["tracks"] is None: # the home set has no timecode for it yet: the home line says so
continue
if not s["pco"]:
line.fault(ERR, "%s NO LTC %s" % (tag, short(s["title"], 12)), "%s: %s has no %s note; the tracks start it at %s" % (
result["name"], s["title"], rc.get("ltc_note", "LTC"), s["tracks"]))
elif s["pco"] != s["tracks"]:
line.fault(ERR, "%s LTC WRONG %s" % (tag, short(s["title"], 10)), "%s: %s says %s, the tracks start it at %s" % (
result["name"], s["title"], s["pco"], s["tracks"]))
if result["skipped"]:
line.notes.append("%s: not in this campus's order, left alone: %s" % (result["name"], ", ".join(result["skipped"])))
return line
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
# --- 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.
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))
return out
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")
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."""
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):
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"
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, self.notes = [], "", []
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] + list(self.notes),
"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), pco_campus=lambda st: pco_plan(rc, now, st, team=False), 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),
macros=lambda paths: run_remote(cfg, rc["prop_host"], MACRO_SCRIPT % json.dumps({"paths": paths}), timeout=60),
companion=lambda n: companion_page(cfg, n),
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),
("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)
if plan is not None:
prop = None
if rc.get("prop_url"):
try:
prop = src["prop"](plan)
lines.update(eval_prop(rc, plan, prop))
except Exception as e: # noqa: BLE001
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"))
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()))
na_songs = song_ltc(rc, plan, al)
LAST["ltc"] = [dict(s, item={"id": s["item"]["id"], "note_ids": s["item"]["note_ids"]}) for s in na_songs]
LAST["service_type"], LAST["plan_id"] = plan.get("service_type"), plan["id"]
others = []
for campus in (rc.get("ltc_campuses") or []) if na_songs else []: # nothing to give the others yet
if not campus.get("service_type"):
continue
try:
cplan = src["pco_campus"](campus["service_type"])
res = campus_ltc(rc, plan, na_songs, campus, cplan)
eval_campus_ltc(rc, res, lines["timecode"])
others.append(dict(res, songs=[dict(s, item={"id": s["item"]["id"], "note_ids": s["item"]["note_ids"]}) for s in res["songs"]]))
except Exception as e: # noqa: BLE001 - one campus's plan down must not hide the home one
tag = campus.get("short") or (campus.get("name") or "?")[:2].upper()
msg = str(e) if isinstance(e, ReadyError) else "PCO UNREACHABLE"
lines["timecode"].fault(WARN, "%s %s" % (tag, msg), "%s: %s" % (campus.get("name") or tag, e))
LAST["ltc_other"] = others
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["tracks"] and 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 {}
return True # an empty column is filled by hand in Resolume
if action == "ltc_to_pco":
result = write_ltc(rc, LAST.get("ltc") or [], LAST.get("service_type"), LAST.get("plan_id"))
for other in LAST.get("ltc_other") or []:
r = write_ltc(rc, other["songs"], other["service_type"], other["plan_id"])
if r is not True and result is True:
result = "%s: %s" % (other["short"], r)
return result
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