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
|
2026-10-22 10:03:49 -04:00
|
|
|
|
tune the Waves Tune clip that starts each song (a program change from the "Waves Tune - midi
|
|
|
|
|
|
Program Change" clips) is the song's key in PCO
|
|
|
|
|
|
routing every stem goes where this location's template says (Keys -> Ext. Out 5/6 Keys), read live
|
|
|
|
|
|
from Ableton over AbletonOSC, so tracks just dragged in count before the set is saved
|
|
|
|
|
|
ears no song stem's in-ear send (send A) is at -inf: dragged-in songs come in that way
|
2026-10-22 10:18:46 -04:00
|
|
|
|
light_timing every blinder flash and FX cue on the lighting track lands on the hit in the drum stem under it
|
|
|
|
|
|
(a hit a few ms before or after the cue, with the cue itself on a quiet spot, is named)
|
2026-10-22 10:03:49 -04:00
|
|
|
|
keys every song is in the key PCO gives it: the set's locator for it ("3- JEHOVAH - B"), the pad
|
|
|
|
|
|
clip under it ("B - WARM - CHURCHFRONT PADS") and a Finished Songs folder ("JEHOVAH [B]")
|
2026-10-14 20:28:43 -04:00
|
|
|
|
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
|
2026-10-22 10:03:49 -04:00
|
|
|
|
import socket
|
|
|
|
|
|
import struct
|
2026-10-14 20:28:43 -04:00
|
|
|
|
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):
|
2026-10-21 14:40:18 -04:00
|
|
|
|
"""Run a Python script on another Mac (on stdin, at low priority), or here when `host` is this Mac
|
|
|
|
|
|
(NA Ingest's Google Drive is current; NA ProP's lagged a day behind, 2026-10-21); its last line is JSON."""
|
|
|
|
|
|
here = ((cfg.get("hosts") or {}).get(host) or {}).get("self")
|
|
|
|
|
|
argv = ["/usr/bin/nice", "-n", "19", "/usr/bin/python3", "-"] if here else ssh_argv(cfg, host) + ["nice -n 19 /usr/bin/python3 -"]
|
|
|
|
|
|
r = subprocess.run(argv, input=script, capture_output=True, text=True, timeout=timeout)
|
2026-10-14 20:28:43 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 10:26:13 -04:00
|
|
|
|
def pco_plan(rc, now, service_type=None, team=True):
|
|
|
|
|
|
"""This weekend's plan: {"id", "title", "dates", "time", "items": [...], "team": [...]}.
|
2026-10-21 13:42:22 -04:00
|
|
|
|
`service_type` reads another location's plan for the same weekend (its team is not needed)."""
|
2026-10-14 20:28:43 -04:00
|
|
|
|
auth, st = pco_login(rc)
|
2026-10-19 10:26:13 -04:00
|
|
|
|
if service_type:
|
|
|
|
|
|
st = str(service_type)
|
2026-10-14 20:28:43 -04:00
|
|
|
|
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 "",
|
2026-10-22 10:03:49 -04:00
|
|
|
|
"notes": ns, "key": a.get("key_name") or ""})
|
2026-10-19 10:26:13 -04:00
|
|
|
|
members, inc = pco_get(auth, "/service_types/%s/plans/%s/team_members?include=team&per_page=100" % (st, pid)) if team else ([], [])
|
2026-10-14 20:28:43 -04:00
|
|
|
|
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
|
2026-10-22 10:03:49 -04:00
|
|
|
|
pads = []
|
|
|
|
|
|
for t in list(ls.find("Tracks")):
|
|
|
|
|
|
if t.find("Name/EffectiveName").get("Value").strip().upper() == P["pad_track"].strip().upper():
|
|
|
|
|
|
for c in t.findall(".//ArrangerAutomation/Events/AudioClip"):
|
|
|
|
|
|
dis = c.find("Disabled")
|
|
|
|
|
|
if dis is None or dis.get("Value") != "true":
|
|
|
|
|
|
pads.append([float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value")),
|
|
|
|
|
|
c.find("Name").get("Value")])
|
|
|
|
|
|
tune = []
|
|
|
|
|
|
for t in list(ls.find("Tracks")):
|
|
|
|
|
|
if t.find("Name/EffectiveName").get("Value").strip().upper() == P["tune_track"].strip().upper():
|
|
|
|
|
|
for c in t.findall(".//ArrangerAutomation/Events/MidiClip"):
|
|
|
|
|
|
dis = c.find("Disabled")
|
|
|
|
|
|
if dis is None or dis.get("Value") != "true":
|
|
|
|
|
|
pg = c.find("ProgramChange")
|
|
|
|
|
|
tune.append([float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value")),
|
|
|
|
|
|
c.find("Name").get("Value"), int(pg.get("Value")) if pg is not None else None])
|
2026-10-22 10:18:46 -04:00
|
|
|
|
cues, hit_src = [], []
|
|
|
|
|
|
for t in list(ls.find("Tracks")):
|
|
|
|
|
|
name = t.find("Name/EffectiveName").get("Value").strip()
|
|
|
|
|
|
if name.upper() == P["lighting_track"].strip().upper():
|
|
|
|
|
|
for c in t.findall(".//ArrangerAutomation/Events/MidiClip"):
|
|
|
|
|
|
if c.find("Disabled") is not None and c.find("Disabled").get("Value") == "true":
|
|
|
|
|
|
continue
|
|
|
|
|
|
start, end = float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value"))
|
|
|
|
|
|
offset = float(c.find("Loop/LoopStart").get("Value"))
|
|
|
|
|
|
for kt in c.iter("KeyTrack"):
|
|
|
|
|
|
k = int(kt.find("MidiKey").get("Value"))
|
|
|
|
|
|
if k not in P["hit_notes"]:
|
|
|
|
|
|
continue
|
|
|
|
|
|
for n in kt.iter("MidiNoteEvent"):
|
|
|
|
|
|
b = start + float(n.get("Time")) - offset
|
|
|
|
|
|
if n.get("IsEnabled") != "false" and start <= b < end:
|
|
|
|
|
|
cues.append([b, k])
|
|
|
|
|
|
stem = re.sub(r"^\s*\d+\s*-\s*", "", re.sub(r"\[.*?\]", " ", name.upper())).strip()
|
|
|
|
|
|
if t.tag == "AudioTrack" and any(stem.startswith(h.upper()) for h in P["hit_stems"]):
|
|
|
|
|
|
for c in t.findall(".//ArrangerAutomation/Events/AudioClip"):
|
|
|
|
|
|
# a deactivated clip counts: the drummer plays live, and the stem still says where the hits are
|
|
|
|
|
|
fr = c.find("SampleRef/FileRef")
|
|
|
|
|
|
rel = fr.find("RelativePath").get("Value") if fr.find("RelativePath") is not None else ""
|
|
|
|
|
|
absp = fr.find("Path").get("Value") if fr.find("Path") is not None else ""
|
|
|
|
|
|
f = os.path.join(os.path.dirname(path), rel) if rel and os.path.exists(os.path.join(os.path.dirname(path), rel)) else absp
|
|
|
|
|
|
hit_src.append([t.get("Id"), float(c.find("CurrentStart").get("Value")), float(c.find("CurrentEnd").get("Value")),
|
|
|
|
|
|
f, c.find("IsWarped").get("Value") == "true", float(c.find("Loop/LoopStart").get("Value"))])
|
2026-10-14 20:28:43 -04:00
|
|
|
|
return {"tracks": tracks, "locators": locs, "tempo": {"manual": float(tempo.find("Manual").get("Value")), "events": events},
|
2026-10-22 10:18:46 -04:00
|
|
|
|
"ltc": ltc, "pads": pads, "tune": tune, "cues": cues, "hit_src": hit_src}
|
2026-10-14 20:28:43 -04:00
|
|
|
|
out = {"live_set": live_set(), "bundles": {}}
|
|
|
|
|
|
path = newest_als(folder(P["site"]))
|
|
|
|
|
|
out["set"] = path
|
|
|
|
|
|
if path:
|
|
|
|
|
|
st = os.stat(path)
|
2026-10-22 10:18:46 -04:00
|
|
|
|
key = [path, st.st_mtime, st.st_size, P["pad_track"], P["tune_track"], P["lighting_track"], P["hit_notes"], P["hit_stems"], 6]
|
2026-10-14 20:28:43 -04:00
|
|
|
|
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
|
2026-10-22 10:03:49 -04:00
|
|
|
|
out["finished"] = {}
|
2026-10-22 10:18:46 -04:00
|
|
|
|
out["light_names"] = {}
|
|
|
|
|
|
llib = os.path.expanduser(P["light_library"]) if P["light_library"] else ""
|
|
|
|
|
|
for n in sorted(os.listdir(llib)) if llib and os.path.isdir(llib) else []:
|
|
|
|
|
|
m = re.match(r"^(\d+)\s+(.+)\.alc$", n)
|
|
|
|
|
|
if m:
|
|
|
|
|
|
out["light_names"][m.group(1)] = m.group(2)
|
2026-10-22 10:03:49 -04:00
|
|
|
|
lib = os.path.expanduser(P["tune_library"]) if P["tune_library"] else ""
|
|
|
|
|
|
out["tune_library"] = {}
|
|
|
|
|
|
for n in sorted(os.listdir(lib)) if lib and os.path.isdir(lib) else []:
|
|
|
|
|
|
if n.endswith(".alc"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
pg = ET.parse(gzip.open(os.path.join(lib, n))).getroot().find(".//MidiClip/ProgramChange")
|
|
|
|
|
|
out["tune_library"][n[:-4]] = int(pg.get("Value")) if pg is not None else None
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
for f in P["finished"]:
|
|
|
|
|
|
f = os.path.expanduser(f)
|
|
|
|
|
|
out["finished"][f] = sorted(n for n in os.listdir(f) if "[" in n) if os.path.isdir(f) else None
|
2026-10-14 20:28:43 -04:00
|
|
|
|
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", []),
|
2026-10-22 10:03:49 -04:00
|
|
|
|
"ltc_track": rc.get("ltc_track", "LTC"), "pad_track": rc.get("pad_track", "AMBIENT PADS"),
|
|
|
|
|
|
"finished": rc.get("finished_folders", []), "tune_track": rc.get("tune_track", "WAVES TUNE"),
|
2026-10-22 10:18:46 -04:00
|
|
|
|
"tune_library": rc.get("tune_library", ""), "lighting_track": rc.get("lighting_track", "LIGHTING CUES"),
|
|
|
|
|
|
"hit_notes": rc.get("light_hit_notes", list(range(30, 40))), "hit_stems": rc.get("hit_stems", ["DRUMS"]),
|
|
|
|
|
|
"light_library": rc.get("light_library", "")}
|
2026-10-14 20:28:43 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 10:03:49 -04:00
|
|
|
|
# --- Ableton, live (AbletonOSC on the Tracks Mac) ------------------------------------------
|
|
|
|
|
|
# AbletonOSC answers on port 11001 of whichever computer spoke to it last, and sends its listeners'
|
|
|
|
|
|
# updates (Companion's volume and meter feedback) there too. So the set is read from the Tracks Mac itself
|
|
|
|
|
|
# (127.0.0.1), and straight after, NA Ingest asks it for the tempo: from then on it answers Companion again.
|
|
|
|
|
|
|
|
|
|
|
|
LIVE_SCRIPT = r"""
|
|
|
|
|
|
import json, socket, struct, time
|
|
|
|
|
|
P = json.loads(%r)
|
|
|
|
|
|
def pad(b):
|
|
|
|
|
|
return b + b"\0" * (4 - len(b) %% 4)
|
|
|
|
|
|
def msg(addr, *args):
|
|
|
|
|
|
tags, data = ",", b""
|
|
|
|
|
|
for a in args:
|
|
|
|
|
|
if isinstance(a, bool):
|
|
|
|
|
|
tags += "T" if a else "F"
|
|
|
|
|
|
elif isinstance(a, int):
|
|
|
|
|
|
tags, data = tags + "i", data + struct.pack(">i", a)
|
|
|
|
|
|
elif isinstance(a, float):
|
|
|
|
|
|
tags, data = tags + "f", data + struct.pack(">f", a)
|
|
|
|
|
|
else:
|
|
|
|
|
|
tags, data = tags + "s", data + pad(str(a).encode())
|
|
|
|
|
|
return pad(addr.encode()) + pad(tags.encode()) + data
|
|
|
|
|
|
def parse(b):
|
|
|
|
|
|
def string(i):
|
|
|
|
|
|
j = b.index(b"\0", i)
|
|
|
|
|
|
return b[i:j].decode("utf-8", "replace"), (j // 4 + 1) * 4
|
|
|
|
|
|
addr, i = string(0)
|
|
|
|
|
|
tags, i = string(i)
|
|
|
|
|
|
out = []
|
|
|
|
|
|
for t in tags[1:]:
|
|
|
|
|
|
if t == "i":
|
|
|
|
|
|
out.append(struct.unpack(">i", b[i:i + 4])[0]); i += 4
|
|
|
|
|
|
elif t == "f":
|
|
|
|
|
|
out.append(struct.unpack(">f", b[i:i + 4])[0]); i += 4
|
|
|
|
|
|
elif t == "s":
|
|
|
|
|
|
s, i = string(i); out.append(s)
|
|
|
|
|
|
elif t in "TF":
|
|
|
|
|
|
out.append(t == "T")
|
|
|
|
|
|
elif t == "N":
|
|
|
|
|
|
out.append(None)
|
|
|
|
|
|
return addr, out
|
|
|
|
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
|
|
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 << 21)
|
|
|
|
|
|
try:
|
|
|
|
|
|
s.bind(("127.0.0.1", 11001))
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
print(json.dumps({"error": "OSC PORT BUSY"})); raise SystemExit
|
|
|
|
|
|
LIVE = ("127.0.0.1", 11000)
|
|
|
|
|
|
def ask(reqs, timeout=4.0):
|
|
|
|
|
|
# send [(addr, args)]; the replies keyed by (addr, track index)
|
|
|
|
|
|
got = {}
|
|
|
|
|
|
for k in range(0, len(reqs), 40):
|
|
|
|
|
|
for a, args in reqs[k:k + 40]:
|
|
|
|
|
|
s.sendto(msg(a, *args), LIVE)
|
|
|
|
|
|
time.sleep(0.05)
|
|
|
|
|
|
want = len(reqs)
|
|
|
|
|
|
end = time.time() + timeout
|
|
|
|
|
|
s.settimeout(0.3)
|
|
|
|
|
|
while len(got) < want and time.time() < end:
|
|
|
|
|
|
try:
|
|
|
|
|
|
b, _ = s.recvfrom(65536)
|
|
|
|
|
|
except socket.timeout:
|
|
|
|
|
|
continue
|
|
|
|
|
|
a, args = parse(b)
|
|
|
|
|
|
if any(a == r[0] for r in reqs):
|
|
|
|
|
|
got[(a, args[0] if args and a.startswith("/live/track/") else None)] = args
|
|
|
|
|
|
return got
|
|
|
|
|
|
out = {}
|
|
|
|
|
|
g = ask([("/live/song/get/num_tracks", ()), ("/live/song/get/is_playing", ())])
|
|
|
|
|
|
n = (g.get(("/live/song/get/num_tracks", None)) or [None])[0]
|
|
|
|
|
|
if n is None:
|
|
|
|
|
|
print(json.dumps({"error": "ABLETON NOT ANSWERING"})); raise SystemExit
|
|
|
|
|
|
out["playing"] = bool((g.get(("/live/song/get/is_playing", None)) or [False])[0])
|
|
|
|
|
|
props = ["track.name", "track.is_foldable", "track.is_grouped", "track.has_audio_output"]
|
|
|
|
|
|
td = ask([("/live/song/get/track_data", (0, n) + tuple(props))]).get(("/live/song/get/track_data", None)) or []
|
|
|
|
|
|
tracks = [dict(zip(("name", "group", "grouped", "audio"), td[i * 4:i * 4 + 4]), index=i) for i in range(len(td) // 4)]
|
|
|
|
|
|
audio = [t for t in tracks if t["audio"] and not t["group"]]
|
|
|
|
|
|
reqs = []
|
|
|
|
|
|
for t in audio:
|
|
|
|
|
|
reqs += [("/live/track/get/output_routing_type", (t["index"],)), ("/live/track/get/output_routing_channel", (t["index"],)),
|
|
|
|
|
|
("/live/track/get/send", (t["index"], P["send_index"]))]
|
|
|
|
|
|
g = ask(reqs, timeout=8.0)
|
|
|
|
|
|
for t in audio:
|
|
|
|
|
|
i = t["index"]
|
|
|
|
|
|
t["type"] = (g.get(("/live/track/get/output_routing_type", i)) or [None, None])[1]
|
|
|
|
|
|
t["channel"] = (g.get(("/live/track/get/output_routing_channel", i)) or [None, None])[1]
|
|
|
|
|
|
snd = g.get(("/live/track/get/send", i))
|
|
|
|
|
|
t["send"] = snd[2] if snd and len(snd) > 2 else None
|
|
|
|
|
|
out["tracks"] = tracks
|
|
|
|
|
|
if P.get("apply"):
|
|
|
|
|
|
names = {t["index"]: t["name"] for t in tracks}
|
|
|
|
|
|
done, skipped = [], []
|
|
|
|
|
|
for ch in P["apply"]:
|
|
|
|
|
|
i = ch["index"]
|
|
|
|
|
|
if names.get(i) != ch["name"]:
|
|
|
|
|
|
skipped.append(ch["name"]) # the set changed since the look: leave it for the next one
|
|
|
|
|
|
continue
|
|
|
|
|
|
if ch.get("type"):
|
|
|
|
|
|
s.sendto(msg("/live/track/set/output_routing_type", i, ch["type"]), LIVE)
|
|
|
|
|
|
time.sleep(0.15)
|
|
|
|
|
|
if ch.get("channel") is not None and ch.get("type") is not None:
|
|
|
|
|
|
s.sendto(msg("/live/track/set/output_routing_channel", i, ch["channel"]), LIVE)
|
|
|
|
|
|
if ch.get("send") is not None:
|
|
|
|
|
|
s.sendto(msg("/live/track/set/send", i, P["send_index"], float(ch["send"])), LIVE)
|
|
|
|
|
|
done.append(ch["name"])
|
|
|
|
|
|
time.sleep(0.05)
|
|
|
|
|
|
out["done"], out["skipped"] = done, skipped
|
|
|
|
|
|
print(json.dumps(out))
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def osc_message(addr, *args):
|
|
|
|
|
|
"""One OSC message (strings and ints), for the hand-back to Companion."""
|
|
|
|
|
|
def pad(b):
|
|
|
|
|
|
return b + b"\0" * (4 - len(b) % 4)
|
|
|
|
|
|
tags = "," + "".join("i" if isinstance(a, int) else "s" for a in args)
|
|
|
|
|
|
data = b"".join(struct.pack(">i", a) if isinstance(a, int) else pad(str(a).encode()) for a in args)
|
|
|
|
|
|
return pad(addr.encode()) + pad(tags.encode()) + data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def hand_back(cfg, host):
|
|
|
|
|
|
"""AbletonOSC answers the last computer that spoke to it: speak to it from here, where Companion listens."""
|
|
|
|
|
|
ip = (((cfg.get("hosts") or {}).get(host) or {}).get("ssh") or "").rpartition("@")[2]
|
|
|
|
|
|
if not ip:
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
|
|
|
|
s.sendto(osc_message("/live/song/get/tempo"), (ip, 11000))
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def live_look(cfg, rc, apply=None):
|
|
|
|
|
|
host = rc.get("tracks_host")
|
|
|
|
|
|
try:
|
|
|
|
|
|
return run_remote(cfg, host, LIVE_SCRIPT % json.dumps({"send_index": rc.get("ears_send", 0), "apply": apply}),
|
|
|
|
|
|
timeout=30)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
hand_back(cfg, host)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def stem_name(name):
|
|
|
|
|
|
"""A track as the template knows it: "50-Keys 1 [2026-10-21 132128]" -> "KEYS 1"."""
|
|
|
|
|
|
s = re.sub(r"\[.*?\]", " ", (name or "").upper())
|
|
|
|
|
|
s = re.sub(r"^\s*\d+\s*-\s*", "", s)
|
|
|
|
|
|
return " ".join(s.split())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def template_for(routing, name):
|
|
|
|
|
|
"""Where a stem goes: its own entry, else that of its family ("EG 13" goes where "EG 1".."EG 12" all go)."""
|
|
|
|
|
|
n = stem_name(name)
|
|
|
|
|
|
if n in routing:
|
|
|
|
|
|
return routing[n]
|
|
|
|
|
|
base = re.sub(r"\s*\d+$", "", n)
|
|
|
|
|
|
fam = {tuple(v) for k, v in routing.items() if re.sub(r"\s*\d+$", "", k) == base}
|
|
|
|
|
|
return list(fam.pop()) if len(fam) == 1 else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mix_plan(rc, live):
|
|
|
|
|
|
"""(routing Line, ears Line, changes): what is routed away from the template, which song stems have
|
|
|
|
|
|
their in-ear send at -inf, and the changes that put both right."""
|
|
|
|
|
|
routing, ears = Line(), Line()
|
|
|
|
|
|
if live.get("error"):
|
|
|
|
|
|
for line in (routing, ears):
|
|
|
|
|
|
line.fault(WARN, live["error"], "%s: AbletonOSC on the Tracks Mac did not answer" % live["error"])
|
|
|
|
|
|
return routing, ears, []
|
|
|
|
|
|
table = {stem_name(k): v for k, v in (rc.get("routing") or {}).items()}
|
|
|
|
|
|
skip = {norm(g) for g in rc.get("ears_skip_groups", [])}
|
|
|
|
|
|
changes, top, n_route, n_ears, new = {}, None, 0, 0, []
|
|
|
|
|
|
for t in live.get("tracks") or []:
|
|
|
|
|
|
if t["group"] and not t["grouped"]:
|
|
|
|
|
|
top = t["name"]
|
|
|
|
|
|
continue
|
|
|
|
|
|
if not t["grouped"]:
|
|
|
|
|
|
top = None
|
|
|
|
|
|
if t["group"] or not t["audio"] or t.get("type") is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
want = template_for(table, t["name"]) if table else None
|
|
|
|
|
|
if want and [t["type"], t["channel"]] != list(want):
|
|
|
|
|
|
changes.setdefault(t["index"], {"index": t["index"], "name": t["name"]}).update(type=want[0], channel=want[1])
|
|
|
|
|
|
routing.fault(ERR, "%s TO %s" % (short(stem_name(t["name"]), 12), (t["channel"] or t["type"]).upper()[:10]),
|
|
|
|
|
|
"%s (%s) goes to %s %s; the template says %s %s" % (t["name"], top or "top level", t["type"],
|
|
|
|
|
|
t["channel"], want[0], want[1]))
|
|
|
|
|
|
elif want:
|
|
|
|
|
|
n_route += 1
|
|
|
|
|
|
elif table and top and norm(top) not in skip:
|
|
|
|
|
|
new.append(t["name"])
|
|
|
|
|
|
if top and norm(top) not in skip and t.get("send") is not None:
|
|
|
|
|
|
if t["send"] <= 0.0:
|
|
|
|
|
|
changes.setdefault(t["index"], {"index": t["index"], "name": t["name"]})["send"] = 1.0
|
|
|
|
|
|
ears.fault(ERR, "EARS OFF " + short(top, 12), "%s in %s: send A is at -inf" % (t["name"], top))
|
|
|
|
|
|
else:
|
|
|
|
|
|
n_ears += 1
|
|
|
|
|
|
if new:
|
|
|
|
|
|
routing.fault(WARN, "NEW STEM " + short(stem_name(new[0]), 12), "not in the routing template (add them in the "
|
|
|
|
|
|
"settings): " + ", ".join(new[:12]))
|
|
|
|
|
|
routing.info = "%d ROUTED" % n_route if table else "NO TEMPLATE"
|
|
|
|
|
|
ears.info = "%d SENDS ON" % n_ears
|
|
|
|
|
|
if live.get("playing"):
|
|
|
|
|
|
routing.notes.append("Ableton is playing")
|
|
|
|
|
|
return routing, ears, sorted(changes.values(), key=lambda change: change["index"])
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 10:18:46 -04:00
|
|
|
|
# --- Lighting cue timing -------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
HIT_SCRIPT = r"""
|
|
|
|
|
|
import aifc, audioop, json, wave
|
|
|
|
|
|
P = json.loads(%r)
|
|
|
|
|
|
FRAME = 0.005
|
|
|
|
|
|
def look(path, sec, span):
|
|
|
|
|
|
aiff = path.lower().endswith((".aif", ".aiff"))
|
|
|
|
|
|
f = aifc.open(path, "rb") if aiff else wave.open(path, "rb")
|
|
|
|
|
|
try:
|
|
|
|
|
|
sr, w, ch = f.getframerate(), f.getsampwidth(), f.getnchannels()
|
|
|
|
|
|
a = max(0, int((sec - span - 0.03) * sr))
|
|
|
|
|
|
f.setpos(a)
|
|
|
|
|
|
hop, n = int(FRAME * sr), int((2 * span + 0.03) / FRAME)
|
|
|
|
|
|
data = f.readframes(hop * (n + 1))
|
|
|
|
|
|
finally:
|
|
|
|
|
|
f.close()
|
|
|
|
|
|
if aiff:
|
|
|
|
|
|
data = audioop.byteswap(data, w) # AIFF is big-endian
|
|
|
|
|
|
size = hop * w * ch
|
|
|
|
|
|
rms = [audioop.rms(data[i * size:(i + 1) * size], w) for i in range(n)]
|
|
|
|
|
|
rise = [max(0, rms[i] - sum(rms[max(0, i - 4):i]) / max(1, min(4, i))) for i in range(n)]
|
|
|
|
|
|
times = [a / sr + i * FRAME for i in range(n)]
|
|
|
|
|
|
near = [i for i in range(n) if abs(times[i] - sec) <= span]
|
|
|
|
|
|
at = max([rise[i] for i in near if abs(times[i] - sec) <= 0.012] or [0])
|
|
|
|
|
|
best = max(near, key=lambda i: rise[i]) if near else None
|
|
|
|
|
|
return [round(times[best] - sec, 4), rise[best], at] if best is not None else None
|
|
|
|
|
|
out = []
|
|
|
|
|
|
for path, sec, span in P["cues"]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
out.append(look(path, sec, span))
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
out.append(str(e)[:60])
|
|
|
|
|
|
print(json.dumps(out))
|
|
|
|
|
|
"""
|
|
|
|
|
|
HITS = {} # (set, its mtime) -> the hit list, so an unchanged set is not listened to again
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cue_times(rc, summary):
|
|
|
|
|
|
"""[(beat, note, song group, file, second in the file, search span)] for every flash and FX cue over a drum stem."""
|
|
|
|
|
|
groups = song_groups(summary)
|
|
|
|
|
|
owner = {t["id"]: g for g, members in groups.items() for t in members}
|
|
|
|
|
|
tempo = summary["tempo"]
|
|
|
|
|
|
out, seen, wanted = [], set(), set(rc.get("light_hit_notes", range(30, 40)))
|
|
|
|
|
|
for beat, note in sorted(summary.get("cues") or []):
|
|
|
|
|
|
if beat in seen or note not in wanted:
|
|
|
|
|
|
continue # a flash and an FX on the same 16th: one hit
|
|
|
|
|
|
src = next((h for h in summary.get("hit_src") or [] if h[1] <= beat < h[2] and not h[4]), None)
|
|
|
|
|
|
if not src:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(beat)
|
|
|
|
|
|
sec = seconds_at(tempo, beat) - seconds_at(tempo, src[1]) + src[5]
|
|
|
|
|
|
sixteenth = seconds_at(tempo, beat + 0.25) - seconds_at(tempo, beat)
|
|
|
|
|
|
out.append((beat, note, owner.get(src[0], ""), src[3], sec, min(0.08, 0.45 * sixteenth)))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def bar_of(beat):
|
|
|
|
|
|
"""Live's position: beat 1315.75 -> "329.4.4" (4/4)."""
|
|
|
|
|
|
return "%d.%d.%d" % (beat // 4 + 1, beat % 4 + 1, (beat % 1) * 4 + 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def eval_light_timing(rc, look, hits):
|
|
|
|
|
|
"""Cues whose hit is clearly a few ms off: the strongest hit near the cue is at least `light_tolerance_ms`
|
|
|
|
|
|
away, is a real hit for that song, and is clearly stronger than what is under the cue itself."""
|
|
|
|
|
|
line = Line()
|
|
|
|
|
|
if not look.get("set"):
|
|
|
|
|
|
line.fault(ERR, "NO SET", "no set to check")
|
|
|
|
|
|
return line
|
|
|
|
|
|
cues = cue_times(rc, look["summary"])
|
|
|
|
|
|
if not cues:
|
|
|
|
|
|
line.info = "NO CUES ON DRUMS"
|
|
|
|
|
|
return line
|
|
|
|
|
|
names = look.get("light_names") or {}
|
|
|
|
|
|
tol = rc.get("light_tolerance_ms", 25) / 1000.0
|
|
|
|
|
|
by_file = {}
|
|
|
|
|
|
for c, h in zip(cues, hits):
|
|
|
|
|
|
if isinstance(h, list):
|
|
|
|
|
|
by_file.setdefault(c[3], []).append(h[1])
|
|
|
|
|
|
typical = {f: sorted(v)[len(v) // 2] for f, v in by_file.items()}
|
|
|
|
|
|
off, broken = [], set()
|
|
|
|
|
|
for c, h in zip(cues, hits):
|
|
|
|
|
|
if not isinstance(h, list):
|
|
|
|
|
|
broken.add(os.path.basename(c[3]))
|
|
|
|
|
|
continue
|
|
|
|
|
|
dt, best, at = h
|
|
|
|
|
|
if abs(dt) > tol and best >= 0.3 * typical.get(c[3], 0) and best > 0 and (at == 0 or best / at >= 1.5):
|
|
|
|
|
|
off.append((c, dt))
|
|
|
|
|
|
said = ["%s bar %s: %s is %d ms %s the hit" % (song or "?", bar_of(beat), names.get(str(note), "note %d" % note),
|
|
|
|
|
|
abs(dt) * 1000, "before" if dt > 0 else "after")
|
|
|
|
|
|
for (beat, note, song, _, _, _), dt in off]
|
|
|
|
|
|
if off:
|
|
|
|
|
|
line.fault(WARN, "%d CUES OFF" % len(off) if len(off) > 1 else "CUE OFF %s" % bar_of(off[0][0][0]), said[0])
|
|
|
|
|
|
line.notes += said[1:]
|
|
|
|
|
|
for f in sorted(broken):
|
|
|
|
|
|
line.notes.append("could not read %s" % f)
|
|
|
|
|
|
line.info = "%d CUES ON TIME" % (len(cues) - len(off) - sum(1 for h in hits if not isinstance(h, list)))
|
|
|
|
|
|
return line
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def light_hits(cfg, rc, look):
|
|
|
|
|
|
"""The hits under every cue, listened to on the Tracks Mac (once per saved set)."""
|
|
|
|
|
|
key = (look.get("set"), look.get("mtime"))
|
|
|
|
|
|
if key not in HITS:
|
|
|
|
|
|
cues = cue_times(rc, look["summary"])
|
|
|
|
|
|
hits = run_remote(cfg, rc["tracks_host"], HIT_SCRIPT % json.dumps({"cues": [[c[3], c[4], c[5]] for c in cues]}),
|
|
|
|
|
|
timeout=rc.get("remote_timeout", 90)) if cues else []
|
|
|
|
|
|
HITS.clear()
|
|
|
|
|
|
HITS[key] = hits
|
|
|
|
|
|
return HITS[key]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 10:03:49 -04:00
|
|
|
|
# --- Song keys --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
NOTES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
|
|
|
|
|
KEY_AT_END = re.compile(r"-\s*([A-G](?:#|b|♯|♭)?m?)\s*$") # a locator: "3- JEHOVAH - B"
|
|
|
|
|
|
KEY_AT_START = re.compile(r"^\s*([A-G](?:#|b|♯|♭)?m?)(?=\s|-|_|$)") # a pad: "D# - WARM - CHURCHFRONT PADS"
|
|
|
|
|
|
FINISHED = re.compile(r"^(.*?)\s*\[\s*([A-G](?:#|b|♯|♭)?m?)\s*\]\s*(?:-\s*CUSTOM)?\s*$", re.I) # "JEHOVAH [Db]"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def key_of(text):
|
|
|
|
|
|
"""A key as (pitch class, minor): "Eb" and "D#" are the same key; "C#m" is minor; None if it is not a key."""
|
|
|
|
|
|
m = re.match(r"^\s*([A-Ga-g])(#|b|♯|♭)?\s*(m|min|minor)?\s*$", text or "")
|
|
|
|
|
|
if not m:
|
|
|
|
|
|
return None
|
|
|
|
|
|
pc = NOTES[m.group(1).upper()] + {"#": 1, "♯": 1, "b": -1, "♭": -1}.get(m.group(2) or "", 0)
|
|
|
|
|
|
return pc % 12, bool(m.group(3))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def same_key(a, b, mode=True):
|
|
|
|
|
|
"""Two keys that sound the same (a pad has no mode: mode=False compares the root alone)."""
|
|
|
|
|
|
ka, kb = key_of(a), key_of(b)
|
|
|
|
|
|
return bool(ka and kb) and ka[0] == kb[0] and (not mode or ka[1] == kb[1])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def finished_versions(look, title):
|
|
|
|
|
|
"""The keys Finished Songs has `title` in: an exact name if there is one, else a longer one
|
|
|
|
|
|
("CAN'T HELP BUT PRAISE" for "Can't Help But Praise (Live)"). Unfinished and z-parked folders do not count."""
|
|
|
|
|
|
exact, near = [], []
|
|
|
|
|
|
for names in (look.get("finished") or {}).values():
|
|
|
|
|
|
for n in names or []:
|
|
|
|
|
|
m = FINISHED.match(n)
|
|
|
|
|
|
if not m or "UNFINISHED" in n.upper():
|
|
|
|
|
|
continue
|
|
|
|
|
|
(exact if norm(m.group(1)) == norm(title) else near if same(m.group(1), title) else []).append((n, m.group(2)))
|
|
|
|
|
|
return exact or near
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def eval_keys(rc, plan, look):
|
|
|
|
|
|
"""Every song in the key PCO gives it, by the set's locator, the pad under the song and Finished Songs."""
|
|
|
|
|
|
line = Line()
|
|
|
|
|
|
if not look.get("set"):
|
|
|
|
|
|
date = ableton_params(rc, plan)["date"]
|
|
|
|
|
|
line.fault(ERR, "NO SET %s" % date, "no set for %s, so no keys to check" % date)
|
|
|
|
|
|
return line
|
|
|
|
|
|
summary = look["summary"]
|
|
|
|
|
|
groups = song_groups(summary)
|
|
|
|
|
|
pads = summary.get("pads") or []
|
|
|
|
|
|
folders = [os.path.basename(f.rstrip("/")) for f in look.get("finished") or {}]
|
|
|
|
|
|
seen, ok = set(), 0
|
|
|
|
|
|
for it in plan_songs(rc, plan):
|
|
|
|
|
|
title, want = it["title"], it.get("key") or ""
|
|
|
|
|
|
name = short(title, 14)
|
|
|
|
|
|
if not key_of(want):
|
|
|
|
|
|
line.fault(WARN, "NO KEY IN PCO " + short(title), "Planning Center has no key for %s" % title)
|
|
|
|
|
|
continue
|
|
|
|
|
|
gname = next((g for g in groups if same(title, g)), None)
|
|
|
|
|
|
if (gname or norm(title), key_of(want)) in seen: # "Owe You Praise TAG" is Owe You Praise again
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add((gname or norm(title), key_of(want)))
|
|
|
|
|
|
bad, checked = False, 0
|
|
|
|
|
|
loc = next((l for l in summary.get("locators") or [] if same(title, l["name"]) and KEY_AT_END.search(l["name"])), None)
|
|
|
|
|
|
if loc:
|
|
|
|
|
|
have = KEY_AT_END.search(loc["name"]).group(1)
|
|
|
|
|
|
checked += 1
|
|
|
|
|
|
if not same_key(have, want):
|
|
|
|
|
|
bad = True
|
|
|
|
|
|
line.fault(ERR, "%s %s NOT %s" % (name, have, want), "Planning Center has %s in %s; the set's locator \"%s\" says %s" % (
|
|
|
|
|
|
title, want, loc["name"].strip(), have))
|
|
|
|
|
|
audio = [c for t in groups.get(gname, []) for c in t["clips"]] if gname else []
|
|
|
|
|
|
if audio:
|
|
|
|
|
|
start, end = min(c[0] for c in audio), max(c[1] for c in audio)
|
|
|
|
|
|
under = [p for p in pads if p[0] < end and p[1] > start and KEY_AT_START.match(p[2] or "")]
|
|
|
|
|
|
if under:
|
|
|
|
|
|
pad = max(under, key=lambda p: min(p[1], end) - max(p[0], start))
|
|
|
|
|
|
have = KEY_AT_START.match(pad[2]).group(1)
|
|
|
|
|
|
checked += 1
|
|
|
|
|
|
if not same_key(have, want, mode=False):
|
|
|
|
|
|
bad = True
|
|
|
|
|
|
line.fault(ERR, "%s PAD %s NOT %s" % (name, have, want), "the pad under %s is %s (%s), but the song is in %s" % (
|
|
|
|
|
|
gname, have, pad[2], want))
|
|
|
|
|
|
if folders:
|
|
|
|
|
|
versions = finished_versions(look, title)
|
|
|
|
|
|
if not versions:
|
|
|
|
|
|
line.notes.append("%s is not in %s" % (title, " or ".join(folders)))
|
|
|
|
|
|
elif any(same_key(k, want) for _, k in versions):
|
|
|
|
|
|
checked += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
line.fault(WARN, "%s ONLY %s" % (name, "/".join(sorted({k for _, k in versions}))),
|
|
|
|
|
|
"%s has %s, none in %s" % (" / ".join(folders), ", ".join(n for n, _ in versions), want))
|
|
|
|
|
|
bad = True
|
|
|
|
|
|
if not checked and not bad:
|
|
|
|
|
|
line.fault(WARN, "KEY UNCHECKED " + short(title), "no locator with a key, no pad under it and nothing in Finished Songs "
|
|
|
|
|
|
"for %s (%s in PCO)" % (title, want))
|
|
|
|
|
|
elif not bad:
|
|
|
|
|
|
ok += 1
|
|
|
|
|
|
line.info = "%d KEYS OK" % ok
|
|
|
|
|
|
return line
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def tune_key(name):
|
|
|
|
|
|
"""The key a Waves Tune clip sets: "Ebmaj" -> "Eb", "C#min" -> "C#m"; None for "OFF | Chromatic" or "LOOP CUE"."""
|
|
|
|
|
|
m = re.match(r"^\s*([A-G](?:#|b)?)\s*(maj|min|m)?\b", name or "")
|
|
|
|
|
|
return (m.group(1) + ("m" if m.group(2) in ("min", "m") else "")) if m else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def same_tune(clip_key, want):
|
|
|
|
|
|
"""Waves Tune corrects to a scale: a minor song is fine on its relative major (Em on Gmaj)."""
|
|
|
|
|
|
if same_key(clip_key, want):
|
|
|
|
|
|
return True
|
|
|
|
|
|
kc, kw = key_of(clip_key), key_of(want)
|
|
|
|
|
|
return bool(kc and kw) and kw[1] and not kc[1] and (kw[0] + 3) % 12 == kc[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def eval_tune(rc, plan, look):
|
|
|
|
|
|
"""The Waves Tune clip at each song's start is the song's PCO key (and the program it sends is that
|
|
|
|
|
|
key's program in the clip library, so a renamed clip is caught)."""
|
|
|
|
|
|
line = Line()
|
|
|
|
|
|
if not look.get("set"):
|
|
|
|
|
|
date = ableton_params(rc, plan)["date"]
|
|
|
|
|
|
line.fault(ERR, "NO SET %s" % date, "no set for %s" % date)
|
|
|
|
|
|
return line
|
|
|
|
|
|
summary = look["summary"]
|
|
|
|
|
|
track = rc.get("tune_track", "WAVES TUNE")
|
|
|
|
|
|
clips = sorted(summary.get("tune") or [], key=lambda c: c[0])
|
|
|
|
|
|
if not clips:
|
|
|
|
|
|
line.fault(ERR, "NO TUNE CLIPS", "no clips on the %s track" % track)
|
|
|
|
|
|
return line
|
|
|
|
|
|
library = look.get("tune_library") or {}
|
|
|
|
|
|
groups = song_groups(summary)
|
|
|
|
|
|
seen, ok = set(), 0
|
|
|
|
|
|
for it in plan_songs(rc, plan):
|
|
|
|
|
|
title, want = it["title"], it.get("key") or ""
|
|
|
|
|
|
gname = next((g for g in groups if same(title, g)), None)
|
|
|
|
|
|
audio = [c for t in groups.get(gname, []) for c in t["clips"]] if gname else []
|
|
|
|
|
|
if not audio or not key_of(want) or gname in seen:
|
|
|
|
|
|
continue # TRACKS and SONG KEYS say what is missing
|
|
|
|
|
|
seen.add(gname)
|
|
|
|
|
|
start = min(c[0] for c in audio)
|
|
|
|
|
|
at = [c for c in clips if c[0] <= start + 1]
|
|
|
|
|
|
bar = "bar %d" % (int(start // 4) + 1)
|
|
|
|
|
|
name = short(title, 14)
|
|
|
|
|
|
if not at:
|
|
|
|
|
|
line.fault(ERR, "NO TUNE " + name, "no %s clip at or before %s, where %s starts" % (track, bar, gname))
|
|
|
|
|
|
continue
|
|
|
|
|
|
clip = at[-1]
|
|
|
|
|
|
have = tune_key(clip[2])
|
|
|
|
|
|
lib = next((n for n in library if tune_key(n) and same_tune(tune_key(n), want) and same_key(tune_key(n), have or "")), None)
|
|
|
|
|
|
right = next((n for n in library if tune_key(n) and same_tune(tune_key(n), want)), "%smaj" % want)
|
|
|
|
|
|
if not have or not same_tune(have, want):
|
|
|
|
|
|
line.fault(ERR, "%s TUNE %s" % (name, have or clip[2][:6].upper()),
|
|
|
|
|
|
"%s starts at %s on the \"%s\" clip, but the song is in %s: put the %s clip there instead" % (
|
|
|
|
|
|
title, bar, clip[2], want, right))
|
|
|
|
|
|
elif lib and library.get(lib) is not None and clip[3] is not None and clip[3] != library[lib]:
|
|
|
|
|
|
line.fault(ERR, "%s TUNE PGM" % name, "the \"%s\" clip at %s sends program %d, but %s is program %d" % (
|
|
|
|
|
|
clip[2], bar, clip[3], lib, library[lib]))
|
|
|
|
|
|
else:
|
|
|
|
|
|
ok += 1
|
|
|
|
|
|
line.info = "%d SONGS IN KEY" % ok
|
|
|
|
|
|
return line
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 20:28:43 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 10:26:13 -04:00
|
|
|
|
def match_in_order(na_songs, campus_songs):
|
2026-10-21 13:42:22 -04:00
|
|
|
|
"""Pair a location's songs with the home location's by title, keeping the home order: a location song
|
|
|
|
|
|
whose place breaks that order (or that the home location does not play) is skipped, that song only.
|
|
|
|
|
|
Returns ([(location item, home song)], [skipped titles])."""
|
2026-10-19 10:26:13 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 10:30:43 -04:00
|
|
|
|
def campus_ltc(rc, na_plan, na_songs, campus, plan):
|
2026-10-21 13:42:22 -04:00
|
|
|
|
"""One other location's plan against the home timecode: its songs in the home plan's order, each
|
2026-10-19 10:30:43 -04:00
|
|
|
|
with the timecode the home set gives that song (None while the set has none) and what its LTC
|
|
|
|
|
|
note says now."""
|
2026-10-19 10:26:13 -04:00
|
|
|
|
note = rc.get("ltc_note", "LTC")
|
2026-10-19 10:30:43 -04:00
|
|
|
|
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()})
|
2026-10-19 10:26:13 -04:00
|
|
|
|
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):
|
2026-10-21 13:42:22 -04:00
|
|
|
|
"""The other location's notes must say the home timecode for every matched song."""
|
2026-10-19 10:26:13 -04:00
|
|
|
|
tag = result["short"]
|
|
|
|
|
|
for s in result["songs"]:
|
2026-10-19 10:30:43 -04:00
|
|
|
|
if s["tracks"] is None: # the home set has no timecode for it yet: the home line says so
|
|
|
|
|
|
continue
|
2026-10-19 10:26:13 -04:00
|
|
|
|
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"]:
|
2026-10-21 13:42:22 -04:00
|
|
|
|
line.notes.append("%s: not in this location's order, left alone: %s" % (result["name"], ", ".join(result["skipped"])))
|
2026-10-19 10:26:13 -04:00
|
|
|
|
return line
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 20:28:43 -04:00
|
|
|
|
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]))
|
|
|
|
|
|
'''
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-21 14:40:18 -04:00
|
|
|
|
def prop_bundle_folder(rc, plan):
|
|
|
|
|
|
"""Where this weekend's bundle goes: "{date}" is the weekend's folder (Location Assets/261025, the
|
|
|
|
|
|
user 2026-10-21: "it should be looking at the latest weekends folder in location assets")."""
|
|
|
|
|
|
return str(rc.get("prop_bundle_folder", "")).replace("{date}", datetime.fromtimestamp(plan["time"]).strftime("%y%m%d"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def eval_prop_bundle(rc, files, since, folder=None):
|
2026-10-14 20:28:43 -04:00
|
|
|
|
line = Line()
|
2026-10-21 14:40:18 -04:00
|
|
|
|
if "{date}" in str(rc.get("prop_bundle_folder", "")):
|
|
|
|
|
|
# This weekend's own folder: any bundle in it is this weekend's.
|
|
|
|
|
|
if not files:
|
|
|
|
|
|
line.fault(ERR, "NOT IN " + os.path.basename(str(folder or "").rstrip("/")), "no %s in %s" % (rc.get("prop_bundle_pattern"), folder))
|
|
|
|
|
|
else:
|
|
|
|
|
|
line.info = "SENT " + datetime.fromtimestamp(files[0][1]).strftime("%a %H:%M").upper()
|
|
|
|
|
|
return line
|
2026-10-14 20:28:43 -04:00
|
|
|
|
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):
|
2026-10-19 10:26:13 -04:00
|
|
|
|
self.faults, self.info, self.notes = [], "", []
|
2026-10-14 20:28:43 -04:00
|
|
|
|
|
|
|
|
|
|
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):
|
2026-10-19 10:26:13 -04:00
|
|
|
|
return {"faults": [[lv, m] for lv, m, _ in self.faults], "details": [d for _, _, d in self.faults if d] + list(self.notes),
|
2026-10-14 20:28:43 -04:00
|
|
|
|
"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()
|
2026-10-19 10:26:13 -04:00
|
|
|
|
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),
|
2026-10-14 20:28:43 -04:00
|
|
|
|
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),
|
2026-10-22 10:03:49 -04:00
|
|
|
|
companion=lambda n: companion_page(cfg, n), live=lambda: live_look(cfg, rc),
|
2026-10-22 10:18:46 -04:00
|
|
|
|
hits=lambda al: light_hits(cfg, rc, al),
|
2026-10-21 14:40:18 -04:00
|
|
|
|
prop_bundle=lambda plan: run_remote(cfg, rc["prop_bundle_host"], BUNDLE_SCRIPT % json.dumps(
|
|
|
|
|
|
{"folder": prop_bundle_folder(rc, plan), "pattern": rc.get("prop_bundle_pattern", "*.probundle")}), timeout=30))
|
2026-10-14 20:28:43 -04:00
|
|
|
|
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")),
|
2026-10-22 10:03:49 -04:00
|
|
|
|
("lights", rc.get("tracks_host")), ("keys", rc.get("tracks_host")),
|
2026-10-22 10:18:46 -04:00
|
|
|
|
("tune", rc.get("tracks_host")), ("light_timing", rc.get("tracks_host")),
|
|
|
|
|
|
("prop", rc.get("prop_url")),
|
2026-10-22 10:03:49 -04:00
|
|
|
|
("routing", rc.get("ableton_osc")), ("ears", rc.get("ableton_osc")),
|
2026-10-14 20:28:43 -04:00
|
|
|
|
("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))
|
2026-10-22 10:03:49 -04:00
|
|
|
|
lines["keys"] = eval_keys(rc, plan, al)
|
|
|
|
|
|
lines["tune"] = eval_tune(rc, plan, al)
|
2026-10-22 10:18:46 -04:00
|
|
|
|
if rc.get("light_timing_check", True):
|
|
|
|
|
|
try:
|
|
|
|
|
|
lines["light_timing"] = eval_light_timing(rc, al, src["hits"](al) if al.get("set") else [])
|
|
|
|
|
|
except Exception as e: # noqa: BLE001 - a stem it cannot read must not hide the rest
|
|
|
|
|
|
failed(["light_timing"], e)
|
2026-10-14 20:28:43 -04:00
|
|
|
|
if rc.get("ltc_note", "LTC"):
|
|
|
|
|
|
eval_ltc(rc, plan, al, lines.setdefault("timecode", Line()))
|
2026-10-19 10:26:13 -04:00
|
|
|
|
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]
|
2026-10-14 20:28:43 -04:00
|
|
|
|
LAST["service_type"], LAST["plan_id"] = plan.get("service_type"), plan["id"]
|
2026-10-19 10:26:13 -04:00
|
|
|
|
others = []
|
2026-10-19 10:30:43 -04:00
|
|
|
|
for campus in (rc.get("ltc_campuses") or []) if na_songs else []: # nothing to give the others yet
|
2026-10-19 10:26:13 -04:00
|
|
|
|
if not campus.get("service_type"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
cplan = src["pco_campus"](campus["service_type"])
|
2026-10-19 10:30:43 -04:00
|
|
|
|
res = campus_ltc(rc, plan, na_songs, campus, cplan)
|
2026-10-19 10:26:13 -04:00
|
|
|
|
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"]]))
|
2026-10-21 13:42:22 -04:00
|
|
|
|
except Exception as e: # noqa: BLE001 - one location's plan down must not hide the home one
|
2026-10-19 10:26:13 -04:00
|
|
|
|
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
|
2026-10-14 20:28:43 -04:00
|
|
|
|
if rc.get("locations"):
|
|
|
|
|
|
lines["track_bundle"] = eval_track_bundle(rc, al)
|
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
2026-10-22 10:18:46 -04:00
|
|
|
|
failed(["tracks", "mix_foh", "lights", "keys", "tune", "light_timing"] + (["track_bundle"] if rc.get("locations") else []), e)
|
2026-10-22 10:03:49 -04:00
|
|
|
|
if rc.get("tracks_host") and rc.get("ableton_osc"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
lines["routing"], lines["ears"], _ = mix_plan(rc, src["live"]())
|
|
|
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
|
|
failed(["routing", "ears"], e)
|
2026-10-14 20:28:43 -04:00
|
|
|
|
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:
|
2026-10-21 14:40:18 -04:00
|
|
|
|
lines["prop_bundle"] = eval_prop_bundle(rc, src["prop_bundle"](plan), week_start(now, rc.get("reset_day", "mon")),
|
|
|
|
|
|
prop_bundle_folder(rc, plan))
|
2026-10-14 20:28:43 -04:00
|
|
|
|
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."""
|
2026-10-19 10:30:43 -04:00
|
|
|
|
todo = [s for s in songs if s["tracks"] and s["pco"] != s["tracks"]]
|
2026-10-14 20:28:43 -04:00
|
|
|
|
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":
|
2026-10-19 10:26:13 -04:00
|
|
|
|
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
|
2026-10-14 20:28:43 -04:00
|
|
|
|
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
|
2026-10-22 10:03:49 -04:00
|
|
|
|
if action == "ableton_mix":
|
|
|
|
|
|
_, _, changes = mix_plan(rc, live_look(cfg, rc))
|
|
|
|
|
|
if not changes:
|
|
|
|
|
|
return True
|
|
|
|
|
|
r = live_look(cfg, rc, apply=changes)
|
|
|
|
|
|
if r.get("error"):
|
|
|
|
|
|
return r["error"]
|
|
|
|
|
|
log.info("FIX Ableton: %s put to the template / send A to 0 dB%s", ", ".join(r.get("done") or []) or "nothing",
|
|
|
|
|
|
("; left (the set changed): " + ", ".join(r["skipped"])) if r.get("skipped") else "")
|
|
|
|
|
|
return True if r.get("done") else "SET CHANGED"
|
2026-10-14 20:28:43 -04:00
|
|
|
|
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
|