From 3ccaea00762455a9a05bdd66671d4d9df386d308 Mon Sep 17 00:00:00 2001 From: christian Date: Mon, 19 Oct 2026 10:26:13 -0400 Subject: [PATCH] LTC timecode goes to the other campuses' plans too: Apollo Beach, Southcoast, Woonsocket The TIMECODE key now reads each campus's plan for the same weekend, pairs its songs with North Attleboro's by title in North Attleboro's order (a song out of order, or one NA does not play, is left alone and said so), checks their LTC notes, and the hold writes the timecode into every campus's plan. ready.ltc_campuses lists the campuses (name, two letters, PCO service type). Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- config.json | 17 +++++++++ readiness.py | 80 ++++++++++++++++++++++++++++++++++++----- schema.py | 5 +++ tests/test_readiness.py | 45 +++++++++++++++++++++++ tests/test_schema.py | 2 +- 6 files changed, 140 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 080380d..6ed17ec 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ everything on the page at once. | LIGHTS | the `LIGHTING CUES` track has a clip under every song | looks again | | MIDI | the Tracks Mac's MIDI links (FX LIGHTS, TRK LIVEBOX, TRK STUDIO B) and Ableton running | reconnects the MIDI link that dropped | | PROP | the ProPresenter playlist for the plan's date has every PCO item. **MC n/m SLIDES**: PCO lists more announcements than there are slides. **MC SLIDES OLD?**: the slide notes still list last week's announcements. **BAPTISM: NO SAMANTHA**: a person PCO lists has no slide | looks again | -| TIMECODE | every song's presentation has a timeline in ProPresenter, and PCO's `LTC` note for every song is the timecode the tracks actually play at that song's start. That timecode is worked out from the set: where its locator ("2 - REJOICE AGAIN - Bb") sits, through the tempo map, on the `LTC` track's file (`LTC_00000000_..._30fps`). **NO LTC** / **LTC WRONG** name the song. | writes the tracks' timecode into each song's `LTC` note in Planning Center where it is missing or different (nothing is written when they already match), then looks again | +| TIMECODE | every song's presentation has a timeline in ProPresenter, and PCO's `LTC` note for every song is the timecode the tracks actually play at that song's start; the other campuses' plans (`ready.ltc_campuses`: Apollo Beach, Southcoast, Woonsocket) get the same timecode for the same songs in this campus's order, and a song that is missing or out of order there is left alone. That timecode is worked out from the set: where its locator ("2 - REJOICE AGAIN - Bb") sits, through the tempo map, on the `LTC` track's file (`LTC_00000000_..._30fps`). **NO LTC** / **LTC WRONG** name the song. | writes the tracks' timecode into each song's `LTC` note in Planning Center where it is missing or different (nothing is written when they already match), then looks again | | BKGDS | every song has at least one slide with a background | looks again | | LED CROSS | every Resolume column Sunday's playlist calls up has content: each slide's macro (read from the ProPresenter Mac's `Macros` and `.pro` files) sends RossTalk `CC page/row/col` to Companion, that button (and the buttons it presses, e.g. page 17 → page 6 "Arena") sends `/composition/columns/N/connect`; **COL 11 EMPTY** names the column and the items that use it. Also: Resolume on the **Service** deck. | selects the Service deck, then looks again (an empty column is filled in Resolume) | | LINEUP TV | ProdDash's Band Lineup shows this plan, nobody who is not on it or who declined, and everyone on the Band and Communication teams | re-syncs the board from Planning Center | diff --git a/config.json b/config.json index ac21096..8acc426 100644 --- a/config.json +++ b/config.json @@ -909,6 +909,23 @@ "AB", "SC" ], + "ltc_campuses": [ + { + "name": "Apollo Beach", + "service_type": "1113969", + "short": "AB" + }, + { + "name": "Southcoast", + "service_type": "1272185", + "short": "SC" + }, + { + "name": "Woonsocket", + "service_type": "730831", + "short": "WS" + } + ], "ltc_note": "LTC", "ltc_track": "LTC", "mic_positions": { diff --git a/readiness.py b/readiness.py index 54a5cd4..10c1f09 100644 --- a/readiness.py +++ b/readiness.py @@ -201,9 +201,12 @@ def pick_plan(plans, now): return (upcoming[0][1], upcoming[0][0]) if upcoming else (None, None) -def pco_plan(rc, now): - """This weekend's plan: {"id", "title", "dates", "time", "items": [...], "team": [...]}.""" +def pco_plan(rc, now, service_type=None, team=True): + """This weekend's plan: {"id", "title", "dates", "time", "items": [...], "team": [...]}. + `service_type` reads another campus's plan for the same weekend (its team is not needed).""" auth, st = pco_login(rc) + if service_type: + st = str(service_type) try: future, _ = pco_get(auth, "/service_types/%s/plans?filter=future&order=sort_date&per_page=4" % st, pages=1) past, _ = pco_get(auth, "/service_types/%s/plans?filter=past&order=-sort_date&per_page=2" % st, pages=1) @@ -227,7 +230,7 @@ def pco_plan(rc, now): out_items.append({"id": it.get("id"), "note_ids": note_ids, "title": a.get("title") or "", "type": a.get("item_type") or "", "position": a.get("service_position") or "", "description": a.get("description") or "", "notes": ns}) - members, inc = pco_get(auth, "/service_types/%s/plans/%s/team_members?include=team&per_page=100" % (st, pid)) + members, inc = pco_get(auth, "/service_types/%s/plans/%s/team_members?include=team&per_page=100" % (st, pid)) if team else ([], []) teams = {i["id"]: (i.get("attributes") or {}).get("name") for i in inc if i.get("type") == "Team"} team = [] for m in members: @@ -637,6 +640,46 @@ def song_ltc(rc, plan, look): return out +def match_in_order(na_songs, campus_songs): + """Pair a campus's songs with the home campus's by title, keeping the home order: a campus song + whose place breaks that order (or that the home campus does not play) is skipped, that song only. + Returns ([(campus item, home song)], [skipped titles]).""" + pairs, skipped, last = [], [], -1 + for it in campus_songs: + idx = next((i for i, s in enumerate(na_songs) if i > last and same(it["title"], s["title"])), None) + if idx is None: + skipped.append(it["title"]) + continue + pairs.append((it, na_songs[idx])) + last = idx + return pairs, skipped + + +def campus_ltc(rc, na_songs, campus, plan): + """One other campus's plan against the home timecode: its songs in home order, each with the + timecode it should carry and what its LTC note says now.""" + note = rc.get("ltc_note", "LTC") + pairs, skipped = match_in_order(na_songs, plan_songs(rc, plan)) + songs = [{"item": it, "title": it["title"], "tracks": s["tracks"], "pco": (it["notes"].get(note) or "").strip()} for it, s in pairs] + return {"name": campus.get("name") or campus.get("short") or str(campus.get("service_type")), "short": campus.get("short") or (campus.get("name") or "?")[:2].upper(), + "service_type": str(campus.get("service_type")), "plan_id": plan["id"], "dates": plan.get("dates", ""), "songs": songs, "skipped": skipped} + + +def eval_campus_ltc(rc, result, line): + """The other campus's notes must say the home timecode for every matched song.""" + tag = result["short"] + for s in result["songs"]: + if not s["pco"]: + line.fault(ERR, "%s NO LTC %s" % (tag, short(s["title"], 12)), "%s: %s has no %s note; the tracks start it at %s" % ( + result["name"], s["title"], rc.get("ltc_note", "LTC"), s["tracks"])) + elif s["pco"] != s["tracks"]: + line.fault(ERR, "%s LTC WRONG %s" % (tag, short(s["title"], 10)), "%s: %s says %s, the tracks start it at %s" % ( + result["name"], s["title"], s["pco"], s["tracks"])) + if result["skipped"]: + line.notes.append("%s: not in this campus's order, left alone: %s" % (result["name"], ", ".join(result["skipped"]))) + return line + + def eval_ltc(rc, plan, look, line): """PCO's LTC note for every song must be the timecode the tracks play at that song's start.""" for s in song_ltc(rc, plan, look): @@ -950,14 +993,14 @@ class Line: """One checklist line: its faults [(level, msg, detail)] and, when it is fine, what to show.""" def __init__(self): - self.faults, self.info = [], "" + self.faults, self.info, self.notes = [], "", [] def fault(self, level, msg, detail=""): if all(m != msg for _, m, _ in self.faults): self.faults.append((level, msg, detail)) def as_dict(self): - return {"faults": [[lv, m] for lv, m, _ in self.faults], "details": [d for _, _, d in self.faults if d], + return {"faults": [[lv, m] for lv, m, _ in self.faults], "details": [d for _, _, d in self.faults if d] + list(self.notes), "info": self.info} @@ -966,7 +1009,7 @@ def look(cfg, now=None, data_dir=None, sources=None): `sources` replaces the readers (tests).""" rc = cfg.get("ready") or {} now = now or time.time() - src = dict(pco=lambda: pco_plan(rc, now), prop=lambda plan: prop_look(rc, plan), + src = dict(pco=lambda: pco_plan(rc, now), pco_campus=lambda st: pco_plan(rc, now, st, team=False), prop=lambda plan: prop_look(rc, plan), ableton=lambda plan: ableton_look(cfg, rc, plan), lineup=lambda: http_json(rc.get("lineup_url", "").rstrip("/") + "/state", timeout=6), resolume=lambda: http_json(rc.get("resolume_url", "").rstrip("/") + "/api/v1/composition", timeout=6), @@ -1023,9 +1066,23 @@ def look(cfg, now=None, data_dir=None, sources=None): lines.update(eval_ableton(rc, plan, al)) if rc.get("ltc_note", "LTC"): eval_ltc(rc, plan, al, lines.setdefault("timecode", Line())) - LAST["ltc"] = [dict(s, item={"id": s["item"]["id"], "note_ids": s["item"]["note_ids"]}) - for s in song_ltc(rc, plan, al)] + na_songs = song_ltc(rc, plan, al) + LAST["ltc"] = [dict(s, item={"id": s["item"]["id"], "note_ids": s["item"]["note_ids"]}) for s in na_songs] LAST["service_type"], LAST["plan_id"] = plan.get("service_type"), plan["id"] + others = [] + for campus in rc.get("ltc_campuses") or []: + if not campus.get("service_type"): + continue + try: + cplan = src["pco_campus"](campus["service_type"]) + res = campus_ltc(rc, na_songs, campus, cplan) + eval_campus_ltc(rc, res, lines["timecode"]) + others.append(dict(res, songs=[dict(s, item={"id": s["item"]["id"], "note_ids": s["item"]["note_ids"]}) for s in res["songs"]])) + except Exception as e: # noqa: BLE001 - one campus's plan down must not hide the home one + tag = campus.get("short") or (campus.get("name") or "?")[:2].upper() + msg = str(e) if isinstance(e, ReadyError) else "PCO UNREACHABLE" + lines["timecode"].fault(WARN, "%s %s" % (tag, msg), "%s: %s" % (campus.get("name") or tag, e)) + LAST["ltc_other"] = others if rc.get("locations"): lines["track_bundle"] = eval_track_bundle(rc, al) except Exception as e: # noqa: BLE001 @@ -1140,7 +1197,12 @@ def run_action(cfg, action, chk=None, sleep=time.sleep): comp = http_json(base, timeout=6) or {} return True # an empty column is filled by hand in Resolume if action == "ltc_to_pco": - return write_ltc(rc, LAST.get("ltc") or [], LAST.get("service_type"), LAST.get("plan_id")) + result = write_ltc(rc, LAST.get("ltc") or [], LAST.get("service_type"), LAST.get("plan_id")) + for other in LAST.get("ltc_other") or []: + r = write_ltc(rc, other["songs"], other["service_type"], other["plan_id"]) + if r is not True and result is True: + result = "%s: %s" % (other["short"], r) + return result if action == "copy_tracks": plan = LAST.get("plan") if not plan: diff --git a/schema.py b/schema.py index 113f4c3..81878f4 100644 --- a/schema.py +++ b/schema.py @@ -359,6 +359,11 @@ field("ready.pco_module", "str", "pco-plan", "The ProdDash module whose Planning advanced=True) field("ready.pco_service_type", "str", None, "The Planning Center service type; blank = ProdDash's first.", "Ready") field("ready.skip_songs", "list", [], "PCO songs that have no tracks or slides of their own.", "Ready") +field("ready.ltc_campuses", "list", [], "Other campuses that play the same songs: their PCO plans get this campus's " + "timecode too, song by song in this campus's order (a song out of order is left alone).", "Ready") +field("ready.ltc_campuses[].name", "str", None, "The campus.", "Ready", required=True) +field("ready.ltc_campuses[].short", "str", None, "Two letters for the key (AB).", "Ready", required=True) +field("ready.ltc_campuses[].service_type", "str", None, "Its Planning Center service type.", "Ready", required=True) field("ready.ltc_note", "str", "LTC", "The PCO note category that holds a song's timecode (checked against the " "tracks, written from them by a hold); blank = not checked.", "Ready", advanced=True) diff --git a/tests/test_readiness.py b/tests/test_readiness.py index 1a4bb35..e53db96 100644 --- a/tests/test_readiness.py +++ b/tests/test_readiness.py @@ -550,3 +550,48 @@ class TestSpotifyWhileMuted(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class TestOtherCampuses(unittest.TestCase): + """The other campuses play the same songs: their plans get North Attleboro's timecode, in its order.""" + CAMPUS = {"name": "Apollo Beach", "short": "AB", "service_type": "1113969"} + + def campus_plan(self, titles, notes=None): + p = plan() + p["id"], p["service_type"] = "ab-1", "1113969" + p["items"] = [item(t, "song", notes=(notes or {}).get(t)) for t in titles] + return p + + def test_songs_match_in_home_order_and_out_of_order_ones_are_skipped(self): + na = rd.song_ltc(RC, plan(), ableton()) + titles = [s["title"] for s in na] + self.assertGreaterEqual(len(titles), 3) + # the campus plays song 2 first, then 1, then 3: song 1 breaks the order and is skipped; a song NA does not play is skipped too + cp = self.campus_plan([titles[1], titles[0], titles[2], "Something Else"]) + res = rd.campus_ltc(RC, na, self.CAMPUS, cp) + self.assertEqual([s["title"] for s in res["songs"]], [titles[1], titles[2]]) + self.assertEqual(res["skipped"], [titles[0], "Something Else"]) + self.assertEqual(res["songs"][0]["tracks"], na[1]["tracks"]) + line = rd.eval_campus_ltc(RC, res, rd.Line()) + self.assertEqual([m for _, m, _ in line.faults], ["AB NO LTC " + rd.short(titles[1], 12), "AB NO LTC " + rd.short(titles[2], 12)]) + self.assertTrue(any("left alone" in d for d in line.as_dict()["details"])) + right = self.campus_plan(titles, notes={t: {"LTC": s["tracks"]} for t, s in zip(titles, na)}) + self.assertEqual(rd.eval_campus_ltc(RC, rd.campus_ltc(RC, na, self.CAMPUS, right), rd.Line()).faults, []) + + def test_the_fix_writes_every_campus(self): + na = rd.song_ltc(RC, plan(), ableton()) + cp = self.campus_plan([s["title"] for s in na]) + res = rd.campus_ltc(RC, na, self.CAMPUS, cp) + rd.LAST["ltc"], rd.LAST["service_type"], rd.LAST["plan_id"] = na, "275974", "92013595" + rd.LAST["ltc_other"] = [res] + calls = [] + real_http, real_get, real_login = rd.http_json, rd.pco_get, rd.pco_login + rd.pco_login = lambda rc: ("Basic x", "275974") + rd.pco_get = lambda auth, path, pages=10: ([{"id": "77", "attributes": {"name": "LTC"}}], []) + rd.http_json = lambda url, headers=None, method="GET", data=None, timeout=10: calls.append((method, url)) or {} + try: + cfg = dict(CONFIG, ready=dict(RC, ltc_campuses=[self.CAMPUS])) + self.assertTrue(rd.run_action(cfg, "ltc_to_pco", sleep=lambda s: None)) + finally: + rd.http_json, rd.pco_get, rd.pco_login = real_http, real_get, real_login + self.assertEqual(len([c for c in calls if "/1113969/" in c[1] and c[0] == "POST"]), len(res["songs"])) # one note per campus song diff --git a/tests/test_schema.py b/tests/test_schema.py index e08d2df..2527e17 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -180,7 +180,7 @@ class TestSchemaMatchesCode(unittest.TestCase): ui_only = {"site.id", "site.name", "site.ingest_hostname", "site.ingest_ips", "site.contacts", "site.editing_code", "site.settings_port", "site.dvs_helper", "site.solo_dir", "hosts.*.name", "hosts.*.role", "hosts.*.notes", "hosts.*.pingable", "hosts.*.runs", "hosts.*.networks", "hosts.*.manual_done", - "gear", "gear.*.location", "gear.*.notes", "gear.*.kind", "gear.*.address", "atem.source_folder", "fixes.*.template", "fixes.*.params", + "gear", "gear.*.location", "gear.*.notes", "gear.*.kind", "gear.*.address", "ready.ltc_campuses[].name", "ready.ltc_campuses[].short", "ready.ltc_campuses[].service_type", "atem.source_folder", "fixes.*.template", "fixes.*.params", "tiles[].checks[].template", "tiles[].checks[].params", "spotify.name", "site", "timing", "fixes", "hosts", "tiles", "flap", "auto_fix", "network", "variables", "companion", "atem", "spotify", "restart_page", "service_windows", "restart_page.keys", "tiles[].checks", "auto_fix.windows",