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 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 10:26:13 -04:00
co-authored by Claude Fable 5.1
parent 87bfe7c162
commit 3ccaea0076
6 changed files with 140 additions and 11 deletions
+1 -1
View File
@@ -150,7 +150,7 @@ everything on the page at once.
| LIGHTS | the `LIGHTING CUES` track has a clip under every song | looks again | | 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 | | 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 | | 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 | | 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) | | 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 | | 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 |
+17
View File
@@ -909,6 +909,23 @@
"AB", "AB",
"SC" "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_note": "LTC",
"ltc_track": "LTC", "ltc_track": "LTC",
"mic_positions": { "mic_positions": {
+71 -9
View File
@@ -201,9 +201,12 @@ def pick_plan(plans, now):
return (upcoming[0][1], upcoming[0][0]) if upcoming else (None, None) return (upcoming[0][1], upcoming[0][0]) if upcoming else (None, None)
def pco_plan(rc, now): def pco_plan(rc, now, service_type=None, team=True):
"""This weekend's plan: {"id", "title", "dates", "time", "items": [...], "team": [...]}.""" """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) auth, st = pco_login(rc)
if service_type:
st = str(service_type)
try: try:
future, _ = pco_get(auth, "/service_types/%s/plans?filter=future&order=sort_date&per_page=4" % st, pages=1) 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) 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 "", 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 "", "position": a.get("service_position") or "", "description": a.get("description") or "",
"notes": ns}) "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"} teams = {i["id"]: (i.get("attributes") or {}).get("name") for i in inc if i.get("type") == "Team"}
team = [] team = []
for m in members: for m in members:
@@ -637,6 +640,46 @@ def song_ltc(rc, plan, look):
return out 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): 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.""" """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): 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.""" """One checklist line: its faults [(level, msg, detail)] and, when it is fine, what to show."""
def __init__(self): def __init__(self):
self.faults, self.info = [], "" self.faults, self.info, self.notes = [], "", []
def fault(self, level, msg, detail=""): def fault(self, level, msg, detail=""):
if all(m != msg for _, m, _ in self.faults): if all(m != msg for _, m, _ in self.faults):
self.faults.append((level, msg, detail)) self.faults.append((level, msg, detail))
def as_dict(self): 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} "info": self.info}
@@ -966,7 +1009,7 @@ def look(cfg, now=None, data_dir=None, sources=None):
`sources` replaces the readers (tests).""" `sources` replaces the readers (tests)."""
rc = cfg.get("ready") or {} rc = cfg.get("ready") or {}
now = now or time.time() 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), ableton=lambda plan: ableton_look(cfg, rc, plan),
lineup=lambda: http_json(rc.get("lineup_url", "").rstrip("/") + "/state", timeout=6), 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), 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)) lines.update(eval_ableton(rc, plan, al))
if rc.get("ltc_note", "LTC"): if rc.get("ltc_note", "LTC"):
eval_ltc(rc, plan, al, lines.setdefault("timecode", Line())) eval_ltc(rc, plan, al, lines.setdefault("timecode", Line()))
LAST["ltc"] = [dict(s, item={"id": s["item"]["id"], "note_ids": s["item"]["note_ids"]}) na_songs = song_ltc(rc, plan, al)
for s in 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"] 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"): if rc.get("locations"):
lines["track_bundle"] = eval_track_bundle(rc, al) lines["track_bundle"] = eval_track_bundle(rc, al)
except Exception as e: # noqa: BLE001 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 {} comp = http_json(base, timeout=6) or {}
return True # an empty column is filled by hand in Resolume return True # an empty column is filled by hand in Resolume
if action == "ltc_to_pco": 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": if action == "copy_tracks":
plan = LAST.get("plan") plan = LAST.get("plan")
if not plan: if not plan:
+5
View File
@@ -359,6 +359,11 @@ field("ready.pco_module", "str", "pco-plan", "The ProdDash module whose Planning
advanced=True) advanced=True)
field("ready.pco_service_type", "str", None, "The Planning Center service type; blank = ProdDash's first.", "Ready") 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.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 " 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.", "tracks, written from them by a hold); blank = not checked.",
"Ready", advanced=True) "Ready", advanced=True)
+45
View File
@@ -550,3 +550,48 @@ class TestSpotifyWhileMuted(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.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
+1 -1
View File
@@ -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", 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", "site.settings_port", "site.dvs_helper", "site.solo_dir", "hosts.*.name", "hosts.*.role",
"hosts.*.notes", "hosts.*.pingable", "hosts.*.runs", "hosts.*.networks", "hosts.*.manual_done", "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", "tiles[].checks[].template", "tiles[].checks[].params", "spotify.name", "site", "timing",
"fixes", "hosts", "tiles", "flap", "auto_fix", "network", "variables", "companion", "atem", "spotify", "fixes", "hosts", "tiles", "flap", "auto_fix", "network", "variables", "companion", "atem", "spotify",
"restart_page", "service_windows", "restart_page.keys", "tiles[].checks", "auto_fix.windows", "restart_page", "service_windows", "restart_page.keys", "tiles[].checks", "auto_fix.windows",