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",