READY: START builds the week's Ableton sets (Sunday and special services) from Planning Center

Hold START on Monday: last weekend's songs go to Finished Songs (a changed one parks the version it
came from as z...), then each service PCO lists this week (weekend + Special Services / Morning
Worship...; First / Together Tuesday in Projects/FIRST TUESDAY) gets 'YYMMDD NA Project' built from
the newest weekend set: locators, tempo map and TEMPO clips, Waves Tune cues, songs from Finished
Songs (transposed Complex Pro when only another key exists), markers / guide / lighting cues on the
dedicated tracks, pads, routing and send A at 0 dB, audio copied in with an Ableton Project Info folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 12:38:09 -04:00
co-authored by Claude Opus 5.5
parent e2add3b27e
commit 0d655bff3c
10 changed files with 2027 additions and 7 deletions
+204 -1
View File
@@ -30,6 +30,8 @@ snap.ready, which the READY page keys draw (check type "ready" in status_panel.p
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
start every service PCO lists this week (Sunday and special services such as First / Together
Tuesday) has its Ableton set; the hold builds the missing ones (ableton_start.py)
What can be put right is put right by a hold (fix kind "ready", run_action below): re-sync
the lineup from PCO, put Resolume back on its deck, copy the tracks to a location that
@@ -1747,7 +1749,8 @@ def look(cfg, now=None, data_dir=None, sources=None):
resolume=lambda: http_json(rc.get("resolume_url", "").rstrip("/") + "/api/v1/composition", timeout=6),
macros=lambda paths: run_remote(cfg, rc["prop_host"], MACRO_SCRIPT % json.dumps({"paths": paths}), timeout=60),
companion=lambda n: companion_page(cfg, n), live=lambda: live_look(cfg, rc),
hits=lambda al: light_hits(cfg, rc, al),
hits=lambda al: light_hits(cfg, rc, al), services=lambda: upcoming_services(rc, now),
start=lambda svcs: start_look(cfg, rc, svcs),
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))
src.update(sources or {})
@@ -1772,6 +1775,7 @@ def look(cfg, now=None, data_dir=None, sources=None):
("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")),
("lineup", rc.get("lineup_url")), ("mics", True),
("track_bundle", rc.get("locations")), ("prop_bundle", rc.get("prop_bundle_host")),
("start", start_on(rc)),
("led_cross", rc.get("resolume_url") and rc.get("prop_host"))) if on], e)
if plan is not None:
prop = None
@@ -1846,6 +1850,12 @@ def look(cfg, now=None, data_dir=None, sources=None):
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"))
if start_on(rc):
try:
services = src["services"]()
lines["start"] = eval_start(rc, services, src["start"](services), now)
except Exception as e: # noqa: BLE001
failed(["start"], e)
mics = Line()
mics.info = mics_needed(rc, plan)
lines["mics"] = mics
@@ -1869,6 +1879,7 @@ class Poller(threading.Thread):
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)
IS_LIVE[0] = is_live
def run(self):
forced = True
@@ -1891,6 +1902,187 @@ class Poller(threading.Thread):
break
# --- START: the week's Ableton sets (ableton_start.py, run on the Tracks Mac) ----------------------------
# The services come from PCO: this location's weekend service type, and every special one beside it (same
# parent folder in PCO: "Special Services" holds First / Together Tuesday, "Morning Worship", "Forge &
# Flourish"...), skipping youth, kids and classes. A plan counts when it has songs with a key.
START_SOURCE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ableton_start.py")
START_TYPES = r"services|events|worship|forge|flourish"
START_SKIP = (r"youth|yth|kids|young adult|growth|baptism|guest|security|caf|preschool|nursery|toddler|elementary|"
r"academy|homeschool|audition|connect")
SERVICES = {} # the services START builds, kept a quarter of an hour (not asked of PCO every look)
IS_LIVE = [lambda: False] # the Poller's "a service is on": the hold copies gigabytes, never during one
START_SURVEY = r'''
import json, os
P = json.loads(%r)
out = {}
for s in P["services"]:
d = os.path.join(os.path.expanduser(P["projects"]), s["folder"], s["name"])
out[s["name"]] = {"exists": os.path.isdir(d) and any(n.endswith(".als") for n in os.listdir(d)),
"building": os.path.isdir(d + ".building")}
try:
st = json.load(open(os.path.expanduser("~/Library/Caches/status-panel-start.json")))
except Exception:
st = None
print(json.dumps({"survey": out, "status": st}))
'''
def start_on(rc):
"""START is where the sets are made: the location that sends its tracks to the others (or one set to)."""
on = rc.get("start")
return bool(rc.get("tracks_host") and rc.get("projects") and (on if on is not None else rc.get("locations")))
def start_window(rc, now):
"""Today up to the Thursday after this week's Sunday: on Monday that is Sunday and a First Tuesday."""
today = datetime.fromtimestamp(now).replace(hour=0, minute=0, second=0, microsecond=0)
return time.mktime(today.timetuple()), week_start(now, rc.get("reset_day", "mon")) + 10 * 86400
def plan_service(auth, st, plan_id, rc):
"""(songs, salvation) of a plan: the band's songs with their PCO key and tempo, in order; Salvation is the
song after the Salvation moment (a TAG) if there is one, else the last song (as the sets do it)."""
items, inc = pco_get(auth, "/service_types/%s/plans/%s/items?include=arrangement&per_page=100" % (st, plan_id))
bpm = {i["id"]: (i.get("attributes") or {}).get("bpm") for i in inc if i.get("type") == "Arrangement"}
skip = {norm(x) for x in rc.get("skip_songs", [])}
songs, sal, past = [], None, False
for it in items:
a = it.get("attributes") or {}
title = (a.get("title") or "").strip()
if re.search(r"salvation", title, re.I):
past = True
continue
if a.get("item_type") != "song" or a.get("service_position") not in ("during", "") or not a.get("key_name"):
continue
if norm(title) in skip:
continue
ref = ((it.get("relationships") or {}).get("arrangement") or {}).get("data") or {}
song = {"title": title, "key": a["key_name"], "bpm": bpm.get(ref.get("id"))}
if past or re.search(r"\bTAG\b", title, re.I):
sal = sal or song
continue
if not any(same(song["title"], x["title"]) and same_key(song["key"], x["key"]) for x in songs):
songs.append(song)
return songs, sal or (songs[-1] if songs else None)
def service_entry(rc, weekend, type_name, plan, t, songs, sal):
"""Where a service's set goes: "NA/261101 NA Project", "FIRST TUESDAY/261103 NA First Tuesday Project"."""
d = datetime.fromtimestamp(t)
date, loc = d.strftime("%y%m%d"), rc.get("site_folder", "")
fmt = rc.get("folder_format", "{date} {loc} Project")
a = plan.get("attributes") or {}
title = " ".join(re.sub(r"[/:\\]", "-", (a.get("title") or type_name.split("|")[-1]).strip()).split())
if weekend:
folder, name = loc, fmt.format(date=date, loc=loc)
else:
tues = re.search(r"tuesday", title + " " + type_name, re.I)
folder = rc.get("start_tuesday_folder", "FIRST TUESDAY") if tues else loc
name = fmt.format(date=date, loc="%s %s" % (loc, title))
label = "%s %d/%d" % (d.strftime("%a").upper(), d.month, d.day)
return {"date": date, "folder": folder, "name": name, "label": label, "title": title or type_name,
"songs": songs, "salvation": sal, "service_type": plan.get("_st"), "plan_id": plan.get("id")}
def upcoming_services(rc, now, fresh=False):
"""The services START builds sets for, soonest first."""
lo, hi = start_window(rc, now)
key = (lo, int(now // 900))
if not fresh and SERVICES.get("key") == key:
return SERVICES["list"]
auth, home = pco_login(rc)
try:
types, _ = pco_get(auth, "/service_types?per_page=100", pages=3)
except OSError as e:
raise ReadyError("PCO UNREACHABLE") from e
by_id = {x["id"]: x for x in types}
parent_of = lambda x: ((((x or {}).get("relationships") or {}).get("parent") or {}).get("data") or {}).get("id")
name_of = lambda x: ((x or {}).get("attributes") or {}).get("name") or ""
want = re.compile(rc.get("start_types", START_TYPES), re.I)
skip = re.compile(rc.get("start_skip_types", START_SKIP), re.I)
chosen = [home] + [x["id"] for x in types if x["id"] != home and parent_of(x) and parent_of(x) == parent_of(by_id.get(home))
and want.search(name_of(x)) and not skip.search(name_of(x))]
chosen += [str(x) for x in rc.get("start_service_types", []) if str(x) not in chosen]
out = []
for st in chosen:
try:
plans, _ = pco_get(auth, "/service_types/%s/plans?filter=future&order=sort_date&per_page=4" % st, pages=1)
except OSError as e:
raise ReadyError("PCO UNREACHABLE") from e
for p in plans:
t = parse_iso((p.get("attributes") or {}).get("sort_date"))
if t is None or not lo <= t < hi:
continue
songs, sal = plan_service(auth, st, p["id"], rc)
if songs:
out.append(service_entry(rc, st == home, name_of(by_id.get(st)), dict(p, _st=st), t, songs, sal))
out.sort(key=lambda x: (x["date"], x["name"]))
SERVICES.update(key=key, list=out)
return out
def start_params(rc, services, mode):
projects = rc.get("projects", "")
return {"mode": mode, "projects": projects, "site_folder": rc.get("site_folder", ""),
"folder_format": rc.get("folder_format", "{date} {loc} Project"), "finished_folders": rc.get("finished_folders", []),
"pads_folder": rc.get("pads_folder") or os.path.join(os.path.dirname(projects.rstrip("/")), "PADS"),
"tune_library": rc.get("tune_library", ""), "routing": rc.get("routing") or {},
"keep_groups": rc.get("start_keep_groups") or rc.get("ears_skip_groups") or ["MIDI GROUP", "RESI TRACKS"],
"pad_track": rc.get("pad_track", "AMBIENT PADS"), "tune_track": rc.get("tune_track", "WAVES TUNE"),
"lighting_track": rc.get("lighting_track", "LIGHTING CUES"), "sections_track": rc.get("sections_track", "SECTIONS"),
"guide_track": rc.get("guide_track", "+GUIDE"), "loopguide_track": rc.get("loopguide_track", "+LOOPGUIDE"),
"loop_cue_track": rc.get("loop_cue_track", "Loop Cue"), "tempo_track": rc.get("tempo_track", "TEMPO"),
"send_a": 1.0, "save_songs": rc.get("start_save_songs", True), "services": services}
def start_script(params):
"""ableton_start.py with its job; it keeps its own source, to start itself detached on the Tracks Mac."""
with open(START_SOURCE) as f:
src = f.read()
return "SOURCE = %r\n%s\nmain(json.loads(%r))\n" % (src, src, json.dumps(params))
def start_look(cfg, rc, services):
return run_remote(cfg, rc["tracks_host"], START_SURVEY % json.dumps({"projects": rc.get("projects", ""), "services": [
{"folder": x["folder"], "name": x["name"]} for x in services]}), timeout=30)
def eval_start(rc, services, look, now):
"""START: every service this week has its set (built, or being built); what the last build left to do."""
line = Line()
st = look.get("status") or {}
survey = look.get("survey") or {}
running = st.get("state") == "running" and now - (st.get("time") or 0) < 1800
if running:
line.fault(WARN, (st.get("step") or "BUILDING")[:24], "START is building: %s" % st.get("step"))
elif st.get("state") == "failed" and now - (st.get("time") or 0) < 7 * 86400:
line.fault(ERR, "START FAILED", "the last build stopped: %s" % st.get("step"))
for svc in services:
got = survey.get(svc["name"]) or {}
if not got.get("exists") and not running:
line.fault(ERR, "NO SET %s" % svc["label"], "%s has no Ableton set yet (%s/%s): hold START to build it" % (
svc["title"] or svc["label"], svc["folder"], svc["name"]))
if st.get("state") == "done" and now - (st.get("time") or 0) < 86400:
names = {x["name"] for x in services if (survey.get(x["name"]) or {}).get("exists")}
for res in st.get("results") or []:
if res.get("name") not in names:
continue
for note in res.get("notes") or []:
m = re.match(r"TRANSPOSE (.+?) \S+>\S+", note)
n = re.match(r"NEW SONG (.+?):", note)
if m:
line.fault(WARN, "CONSOLIDATE " + short(m.group(1), 12), "%s: %s" % (res["name"], note))
elif n:
line.fault(WARN, "NEW SONG " + short(n.group(1), 12), "%s: %s" % (res["name"], note))
else:
line.notes.append("%s: %s" % (res["name"], note))
line.info = " ".join(x["label"] for x in services) or "NO SERVICES"
return line
# --- Fixes (a hold on a READY key: fix kind "ready") ------------------------------------------------
def write_ltc(rc, songs, service_type, plan_id):
@@ -1978,6 +2170,17 @@ def run_action(cfg, action, chk=None, sleep=time.sleep):
return "NO AGENT" if r.get("no_agent") else "STILL NOT MOUNTED"
if action == "light_timing":
return fix_light_timing(cfg, rc)
if action == "start_week":
if IS_LIVE[0]():
return "SERVICE ON"
services = upcoming_services(rc, time.time(), fresh=True)
if not services:
return "NO SERVICES IN PCO"
r = run_remote(cfg, rc["tracks_host"], start_script(start_params(rc, services, "run")), timeout=60)
if r.get("busy"):
return "ALREADY BUILDING"
log.info("FIX START: building the sets for %s", ", ".join("%s (%s)" % (x["label"], x["name"]) for x in services))
return True if r.get("started") else "DID NOT START"
if action == "ableton_mix":
_, _, changes = mix_plan(rc, live_look(cfg, rc))
if not changes: