Names check for a copied campus; the ATEM folder says when the page may not read Google Drive

Site setup > Companion lists every connection, Dante device and MIDI session the keys use that
this campus does not report, with a map-to choice that renames it across checks, restart keys
and fixes. The ATEM section explains the Full Disk Access step when the cloud folder looks empty
to the background page. Verified on NA Ingest; Install the staged folder tested live there too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 15:09:17 -04:00
co-authored by Claude Fable 5.1
parent a33e344cae
commit 3b3a1c7245
3 changed files with 97 additions and 4 deletions
+56 -2
View File
@@ -644,12 +644,21 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(200, spotify_info(url))
if path == "/api/atem-reference/folder":
folder = os.path.expanduser((site.cfg.get("atem") or {}).get("source_folder") or "")
files = sorted(glob.glob(os.path.join(folder, "*.xml")), key=os.path.getmtime, reverse=True) if folder and os.path.isdir(folder) else []
return self.send(200, {"folder": folder, "mounted": bool(folder and os.path.isdir(folder)),
mounted = bool(folder and os.path.isdir(folder))
files = sorted(glob.glob(os.path.join(folder, "*.xml")), key=os.path.getmtime, reverse=True) if mounted else []
blocked = False
if mounted and not files:
try:
blocked = not os.listdir(folder) and "/Library/CloudStorage/" in folder # a cloud folder that looks empty to a background process
except OSError:
blocked = True
return self.send(200, {"folder": folder, "mounted": mounted, "blocked": blocked,
"files": [{"name": os.path.basename(f), "when": os.path.getmtime(f)} for f in files[:10]]})
if path == "/api/live/midi":
snap = site.read_json("snapshot.json", {}) or {}
return self.send(200, {"local": snap.get("midi"), "remote": snap.get("remote_midi")})
if path == "/api/site/names":
return self.send(200, self.names_check((site.draft.read() or {}).get("config") or site.load()))
if path == "/api/setup":
return self.send(200, {"computers": scripts.setup_all(site.load())})
if path == "/api/program/staged":
@@ -878,6 +887,51 @@ class Handler(http.server.BaseHTTPRequestHandler):
log.exception("%s %s", self.command, path)
return self.send(500, {"error": str(e)})
def names_check(self, cfg):
"""Every device name the keys use, against what this Companion, the Dante network and the Macs report now:
the mapping step of a copied campus."""
site = self.site
try:
live = {c["label"]: c.get("moduleId") for c in site.comp.connections()}
companion_ok = True
except Exception:
live, companion_ok = {}, False
used = {}
for t in cfg.get("tiles", []):
for c in t.get("checks", []):
if c.get("conn") and c["type"] != "dante" or (c["type"] == "dante" and c.get("conn")):
used.setdefault(c["conn"], {"keys": set(), "module": schema.EXPECTED_MODULE.get(c["type"])})["keys"].add(t.get("label", t["id"]))
for k in (cfg.get("restart_page") or {}).get("keys", []):
for w in k.get("watch", []):
if w.startswith("conn:"):
used.setdefault(w[5:], {"keys": set(), "module": None})["keys"].add("restart key " + k.get("label", k["id"]))
conns = []
for name, u in sorted(used.items()):
cands = [l for l, m in live.items() if l not in used and (not u["module"] or not m or m == u["module"])]
conns.append({"name": name, "keys": sorted(u["keys"]), "live": name in live, "module": u["module"],
"live_module": live.get(name), "candidates": sorted(cands)})
snap = site.read_json("snapshot.json", {}) or {}
dmap = snap.get("dante_map") or {}
dante = []
for t in cfg.get("tiles", []):
for c in t.get("checks", []):
if c["type"] == "dante":
for n in c.get("devices", {}):
dante.append({"name": n, "seen": n in dmap, "candidates": sorted(x for x in dmap if x not in c["devices"])})
local, remote = snap.get("midi") or {}, snap.get("remote_midi") or {}
midi = []
for t in cfg.get("tiles", []):
for c in t.get("checks", []):
if c["type"] == "midi_peer":
src = (remote.get(c["host"]) or {}) if c.get("host") else local
sess = src.get(c.get("session")) if isinstance(src, dict) else None
peers = [p.get("name") for p in (sess or {}).get("peers", [])] if sess else []
midi.append({"session": c.get("session"), "peer": c.get("peer"), "host": c.get("host"), "key": t.get("label", t["id"]),
"session_seen": sess is not None, "peer_seen": c.get("peer") in peers,
"sessions_seen": sorted(src) if isinstance(src, dict) else [], "peers_seen": peers})
return {"companion_ok": companion_ok, "connections": conns, "dante": dante, "midi": midi,
"missing": sum(1 for c in conns if not c["live"]) + sum(1 for d in dante if not d["seen"]) + sum(1 for m in midi if not m["session_seen"] or not m["peer_seen"])}
def staged(self):
"""The staged folder (~/status-panel-install) against the running code."""
src = os.path.expanduser("~/status-panel-install")
+23 -2
View File
@@ -827,7 +827,7 @@
const plain = own.filter(([, f]) => !f.advanced), adv = own.filter(([, f]) => f.advanced);
let body = own.length ? `<div class="card"><h3>Settings</h3>${plain.map(fieldHtml).join("") || "<p class='muted small'>Only numbers here; they sit under Advanced.</p>"}${adv.length ? `<details><summary>Advanced</summary>${adv.map(fieldHtml).join("")}</details>` : ""}</div>` : "";
if (which === "site") body += await programHtml();
if (which === "companion") body = (await companionHtml()) + body;
if (which === "companion") body = (await namesHtml()) + (await companionHtml()) + body;
if (which === "schedules") body = schedulesHtml(c) + body;
if (which === "network-dante") body += await danteHtml(c);
if (which === "spotify") body = spotifyHtml(c) + body;
@@ -860,6 +860,25 @@
<div class="row"><a class="btn" href="/api/site/bundle" download>Export site bundle</a>${editing() ? `<label class="btn">New campus from a bundle <input type="file" id="bundlefile" accept=".zip,.json" hidden></label>` : ""}</div>
<p class="help">New campus from a bundle: this campus's name, addresses and editing code stay; every computer from the bundle is flagged "copied from … - check it" until you touch it; keys, checks and fixes come along. Then Review & Apply.</p></div>`;
}
async function namesHtml() {
const n = await api("/api/site/names").catch(() => null);
if (!n || !n.missing) return "";
const sel = (kind, name, cands) => editing() ? `<select data-map="${kind}|${esc(name)}"><option value="">– map it to –</option>${cands.map((c) => `<option>${esc(c)}</option>`).join("")}</select>` : "";
const conns = n.connections.filter((c) => !c.live).map((c) => `<tr><td>${lightDot("problem")}<span class="mono">${esc(c.name)}</span></td><td class="small">${esc(c.keys.join(", "))}</td><td class="small">${n.companion_ok ? "not in this Companion" : "Companion not answering"}${c.module ? ` <span class="muted">(needs module ${esc(c.module)})</span>` : ""}</td><td>${sel("conn", c.name, c.candidates)}</td></tr>`).join("");
const dante = n.dante.filter((d) => !d.seen).map((d) => `<tr><td>${lightDot("warn")}<span class="mono">${esc(d.name)}</span></td><td class="small">DANTE</td><td class="small">not seen on the Dante network (last survey)</td><td>${sel("dante", d.name, d.candidates)}</td></tr>`).join("");
const midi = n.midi.filter((m) => !m.session_seen || !m.peer_seen).map((m) => `<tr><td>${lightDot("warn")}<span class="mono">${esc(m.session)} › ${esc(m.peer)}</span></td><td class="small">${esc(m.key)}</td><td class="small">${!m.session_seen ? `session not seen${m.host ? " on " + esc((cfg().hosts[m.host] || {}).name || m.host) : ""}${m.sessions_seen.length ? " (seen: " + esc(m.sessions_seen.join(", ")) + ")" : ""}` : `peer not in the session now${m.peers_seen.length ? " (in it: " + esc(m.peers_seen.join(", ")) + ")" : ""}`}</td><td class="small muted">change it on the key</td></tr>`).join("");
return `<div class="card"><h3>Names the keys use that are not here (${n.missing})</h3><p class="small muted">A copied campus, or something renamed. Map each name to what this campus has; the keys, checks and restart keys that use it follow.</p>
<div class="tablewrap"><table><tr><th>name</th><th>used by</th><th></th><th></th></tr>${conns}${dante}${midi}</table></div></div>`;
}
function renameConn(from, to) {
const d = state.draft.config;
for (const t of d.tiles) for (const c of t.checks || []) if (c.conn === from) c.conn = to;
if (d.restart_page) for (const k of d.restart_page.keys) k.watch = (k.watch || []).map((w) => (w === "conn:" + from ? "conn:" + to : w));
for (const f of Object.values(d.fixes)) if (f.conn === from) f.conn = to;
}
function renameDante(from, to) {
for (const t of state.draft.config.tiles) for (const c of t.checks || []) if (c.type === "dante" && c.devices && from in c.devices) { c.devices[to] = c.devices[from]; delete c.devices[from]; if (c.fix && typeof c.fix === "object" && from in c.fix) { c.fix[to] = c.fix[from]; delete c.fix[from]; } }
}
async function companionHtml() {
const cc = await api("/api/companion");
const lights = Object.entries(cc.lights || {}).map(([k, v]) => `<li>${lightDot(v === true ? "ok" : v === false ? "problem" : "unknown")}${esc(k)}</li>`).join("");
@@ -893,10 +912,12 @@
const pending = state.atemPending ? `<div class="notice">A new saved switcher file is waiting for Apply. ${editing() ? `<button class="small" id="atemdiscard">Discard it</button>` : ""}</div>` : "";
return `<div class="card"><h3>The saved switcher file</h3><p class="small muted">The panel keeps the switcher's audio and camera settings in line with this file (atem-reference.xml). Save a new one from ATEM Software Control when the show changes.</p>${pending}
${editing() ? `<div class="row"><label class="btn">Upload a saved file <input type="file" id="atemfile" accept=".xml" hidden></label><button id="atemnewest" ${folder.mounted && (folder.files || []).length ? "" : "disabled"}>Pick the newest from the folder</button></div>` : ""}
<p class="small">Folder: <span class="mono">${esc(folder.folder || "(not set)")}</span> ${folder.mounted ? `· newest: ${(folder.files || []).length ? esc(folder.files[0].name) + " (" + esc(when(folder.files[0].when)) + ")" : "none"}` : "· <b>not mounted</b>"}</p><p id="ateminfo" class="help"></p></div>`;
<p class="small">Folder: <span class="mono">${esc(folder.folder || "(not set)")}</span> ${folder.mounted ? `· newest: ${(folder.files || []).length ? esc(folder.files[0].name) + " (" + esc(when(folder.files[0].when)) + ")" : "none"}` : "· <b>not mounted</b>"}</p>
${folder.blocked ? `<div class="notice grey">The folder is there but this page is not allowed to read Google Drive (macOS keeps background programs out of cloud folders). Once, on the Ingest Mac: System Settings › Privacy & Security › Full Disk Access › add <span class="mono">/usr/bin/python3</span>, then restart the settings page from the Overview. Until then, use Upload a saved file.</div>` : ""}<p id="ateminfo" class="help"></p></div>`;
}
function wireSite() {
const q = (s) => main.querySelector(s);
for (const m of main.querySelectorAll("[data-map]")) m.onchange = () => { if (!m.value || !ensureDraft()) return; const [kind, name] = m.dataset.map.split("|"); (kind === "conn" ? renameConn : renameDante)(name, m.value); saveDraft(); route(); };
for (const b of main.querySelectorAll("[data-addconn]")) b.onclick = () => { const conn = b.dataset.addconn; if (!ensureDraft()) return; const d = state.draft.config; let id = slug(conn) || "key", n = 2; while (d.tiles.some((t) => t.id === id)) id = slug(conn) + "_" + n++; const t = { id, label: conn.toUpperCase().replace(/[_-]+/g, " ").slice(0, 10).trim(), checks: [{ type: "conn", conn, fix: "restart-connection" }] }; const pos = firstEmpty(d, "status"); if (pos) t.pos = pos; d.tiles.push(t); saveDraft(); location.hash = "#keys/" + id; };
for (const b of main.querySelectorAll("[data-addwin]")) b.onclick = () => { if (!ensureDraft()) return; const path = b.dataset.addwin; const list = (getPath(path, state.draft.config) || []).slice(); list.push({ days: ["sun"], start: "09:00", end: "12:00" }); setPath(path, list); route(); };
for (const b of main.querySelectorAll("[data-delwin]")) b.onclick = () => { if (!ensureDraft()) return; const path = b.dataset.delwin.replace(/\.\d+$/, ""), i = +b.dataset.delwin.split(".").pop(); const list = (getPath(path, state.draft.config) || []).slice(); list.splice(i, 1); setPath(path, list); route(); };