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:
+204
-1
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user