Step 2: the settings page can change things - shared draft, Review & Apply, rollback, history, undo
- settings/apply.py: the Apply pipeline (check, keep history, write, restart the watchdog, re-import pages by the marker-only rule, recolour page keys), rollback when the watchdog does not come back, the recording guard with the Emergency (LIVE) override, history entries and Go back / Undo. - settings/server.py: the write routes behind the editing code (draft PUT/DELETE with 409 on a conflict, validate, apply, restore, undo, watchdog restart/stop/start, commands to the watchdog, the switcher file upload / pick newest / discard, gear notes). - settings/ui/app.js: every screen grows inputs with Editing on; keys (checks, fixes, hold-always, members), computers, fixes, site setup (schedules, Dante devices, Spotify, ATEM), Review & Apply with live progress, History with Go back and Undo, Run this fix now. - schema.unmask, push_page.push, nav_keys.colour; tests for all of it (163 pass). - Verified live on NA Ingest: a page-only Apply, a restart Apply, Undo, history. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+269
-7
@@ -11,6 +11,7 @@ import argparse
|
||||
import gzip
|
||||
import glob
|
||||
import hashlib
|
||||
import tempfile
|
||||
import http.server
|
||||
import ipaddress
|
||||
import json
|
||||
@@ -29,6 +30,7 @@ import urllib.request
|
||||
from settings import PARENT, HERE # noqa: F401
|
||||
import schema
|
||||
import status_panel as sp
|
||||
from settings import apply as applying
|
||||
from settings import keyguide, probes
|
||||
|
||||
log = logging.getLogger("status-settings")
|
||||
@@ -45,6 +47,7 @@ COMPANION_FACTS = [
|
||||
("Does OBS say when it is streaming?", "Yes: the OBS-Youtube connection's variable 'streaming' reads 'Off-Air' when it is not; "
|
||||
"the on-air value is recorded the first time it streams."),
|
||||
("Can the page create the custom variables?", "Not yet known; they are made by hand in Companion for now."),
|
||||
("Which Companion runs here", "The one listening on port 8000 (5.0.7); an abandoned 5.1 upgrade left a dead settings folder, which the panel now skips."),
|
||||
("Can an old page's keys be blanked over the API?", "Not yet tried; after a page-number change the old keys are deleted by hand."),
|
||||
]
|
||||
|
||||
@@ -102,6 +105,8 @@ class Site:
|
||||
self.probing = None
|
||||
self.attempts = {} # client ip -> [count, first attempt time]
|
||||
self.page_cache = {} # name -> (time, value)
|
||||
self.draft = applying.Draft(data_dir)
|
||||
self.applier = applying.Applier(self)
|
||||
|
||||
def load(self):
|
||||
st = os.stat(self.config_path)
|
||||
@@ -536,6 +541,24 @@ class Site:
|
||||
return {"ok": False, "tries_left": max(0, 5 - count - 1), "no_code": not wanted}
|
||||
|
||||
|
||||
def spotify_info(url):
|
||||
"""Name, song count and URI of a public playlist, from open.spotify.com only."""
|
||||
m = re.search(r"(?:playlist[/:])([A-Za-z0-9]{22})", url or "")
|
||||
if not m:
|
||||
return {"ok": False, "error": "paste a Spotify playlist link (open.spotify.com/playlist/...)"}
|
||||
pid = m.group(1)
|
||||
try:
|
||||
req = urllib.request.Request("https://open.spotify.com/embed/playlist/" + pid, headers={"User-Agent": "Mozilla/5.0"})
|
||||
html = urllib.request.urlopen(req, timeout=10, context=sp._UNVERIFIED).read().decode("utf-8", "replace")
|
||||
mm = re.search(r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', html)
|
||||
entity = json.loads(mm.group(1))["props"]["pageProps"]["state"]["data"]["entity"]
|
||||
return {"ok": True, "uri": "spotify:playlist:" + pid, "name": entity.get("name") or entity.get("title"),
|
||||
"songs": len(entity.get("trackList", [])), "public": True}
|
||||
except Exception as e:
|
||||
return {"ok": True, "uri": "spotify:playlist:" + pid, "name": None, "songs": None, "public": False,
|
||||
"warning": "the page cannot read this playlist (private?): the key will show WRONG PLAYLIST less reliably (%s)" % e}
|
||||
|
||||
|
||||
def cfg_stale(cfg):
|
||||
return cfg.get("page_stale_seconds", 15)
|
||||
|
||||
@@ -591,7 +614,36 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
if path == "/api/keys":
|
||||
return self.send(200, site.keys_view())
|
||||
if path == "/api/history":
|
||||
return self.send(200, site.history_view())
|
||||
return self.send(200, {"entries": applying.history_entries(site.data_dir)})
|
||||
if path.startswith("/api/history/"):
|
||||
entry = path[len("/api/history/"):]
|
||||
cfg = applying.history_config(site.data_dir, entry)
|
||||
if cfg is None:
|
||||
return self.send(404, {"error": "no such version"})
|
||||
live_cfg = site.load()
|
||||
changes = [applying.words_for(p, a, b, cfg, live_cfg) for p, a, b in applying.changed_paths(cfg, live_cfg)]
|
||||
return self.send(200, {"config": schema.mask_secrets(cfg), "changes_to_now": changes})
|
||||
if path == "/api/draft":
|
||||
d = site.draft.read()
|
||||
return self.send(200, {"draft": self.masked_draft(d) if d else None, "live_sha": site.cfg_sha,
|
||||
"stale": bool(d and d.get("base_sha") != site.cfg_sha),
|
||||
"atem_pending": os.path.exists(os.path.join(site.data_dir, "draft-atem.xml"))})
|
||||
if path == "/api/apply/status":
|
||||
st = dict(site.applier.status)
|
||||
st["live"] = dict(zip(("live", "reason"), site.applier.live()))
|
||||
st["failed_last"] = site.read_json("last-apply-failed.json", None)
|
||||
return self.send(200, st)
|
||||
if path == "/api/live/spotify":
|
||||
url = q.get("url", [""])[0]
|
||||
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)),
|
||||
"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/facts":
|
||||
return self.send(200, {"facts": COMPANION_FACTS})
|
||||
if path == "/api/snapshot":
|
||||
@@ -614,15 +666,38 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
log.exception("GET %s", path)
|
||||
return self.send(500, {"error": str(e)})
|
||||
|
||||
def gate(self):
|
||||
"""Every change needs the editing code and a name. (ok, who) or (False, response sent)."""
|
||||
code, who = self.headers.get("X-Edit-Key", ""), (self.headers.get("X-Editor") or "").strip()[:40]
|
||||
r = self.site.check_code(self.client_address[0], code)
|
||||
if not r.get("ok"):
|
||||
self.send(401, dict(r, error="the editing code is needed" if not r.get("locked_for") else "locked out after too many wrong codes"))
|
||||
return False, None
|
||||
if not who:
|
||||
self.send(400, {"error": "your first name is needed"})
|
||||
return False, None
|
||||
return True, who
|
||||
|
||||
def read_body(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
return self.rfile.read(length) if length else b""
|
||||
|
||||
def do_PUT(self):
|
||||
self.do_POST()
|
||||
|
||||
def do_DELETE(self):
|
||||
self.do_POST()
|
||||
|
||||
def do_POST(self):
|
||||
if not allowed_client(self.client_address[0]):
|
||||
return self.send(403, {"error": "only from the church network"})
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
raw = self.read_body()
|
||||
ctype = self.headers.get("Content-Type") or ""
|
||||
body = {}
|
||||
if length:
|
||||
if raw and ctype.startswith("application/json"):
|
||||
try:
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
body = json.loads(raw or b"{}")
|
||||
except ValueError:
|
||||
return self.send(400, {"error": "bad JSON"})
|
||||
host = (self.headers.get("Host") or "").split(":")[0]
|
||||
@@ -631,6 +706,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return self.send(403, {"error": "wrong origin"})
|
||||
site = self.site
|
||||
try:
|
||||
if path == "/api/editing/check":
|
||||
return self.send(200, site.check_code(self.client_address[0], str(body.get("code", ""))))
|
||||
if path == "/api/machines/probe":
|
||||
if site.read_json("fixes-running.json", {}):
|
||||
return self.send(409, {"error": "a fix is running; try again in a minute"})
|
||||
@@ -641,13 +718,198 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
site.probing = threading.Thread(target=probes.probe_all, args=(site.load(), site.data_dir, only), daemon=True)
|
||||
site.probing.start()
|
||||
return self.send(200, {"started": True})
|
||||
if path == "/api/editing/check":
|
||||
return self.send(200, site.check_code(self.client_address[0], str(body.get("code", ""))))
|
||||
# ---- everything below changes something: the editing code and a name are needed ----
|
||||
ok, who = self.gate()
|
||||
if not ok:
|
||||
return
|
||||
if path == "/api/draft" and self.command == "PUT":
|
||||
cfg = schema.unmask(body.get("config") or {}, site.load())
|
||||
draft, conflict = site.draft.update(cfg, who, site.cfg_sha, body.get("expect"))
|
||||
if conflict:
|
||||
changed = applying.changed_paths(cfg, conflict["config"])
|
||||
return self.send(409, {"error": "%s changed this a moment ago" % conflict.get("by", "someone"), "draft": self.masked_draft(conflict),
|
||||
"changed": [applying.words_for(p, a, b, cfg, conflict["config"])["where"] for p, a, b in changed[:5]]})
|
||||
return self.send(200, {"draft": self.masked_draft(draft)})
|
||||
if path == "/api/draft" and self.command == "DELETE":
|
||||
site.draft.clear()
|
||||
self.discard_atem()
|
||||
return self.send(200, {"ok": True})
|
||||
if path == "/api/validate":
|
||||
cfg = schema.unmask(body.get("config") or (site.draft.read() or {}).get("config") or site.load(), site.load())
|
||||
return self.send(200, self.check(cfg))
|
||||
if path == "/api/apply":
|
||||
draft = site.draft.read()
|
||||
if not draft:
|
||||
return self.send(400, {"error": "there is no draft to apply"})
|
||||
if draft.get("base_sha") != site.cfg_sha:
|
||||
return self.send(409, {"error": "the live settings changed since this draft was started - review it again",
|
||||
"changed_since": True})
|
||||
atem = os.path.join(site.data_dir, "draft-atem.xml")
|
||||
r = site.applier.start(draft["config"], str(body.get("note", ""))[:200], who, self.client_address[0], body.get("emergency"),
|
||||
atem if os.path.exists(atem) else None)
|
||||
if r.get("ok"):
|
||||
site.draft.clear()
|
||||
return self.send(200 if r.get("ok") else 409, r)
|
||||
if path.startswith("/api/restore/"):
|
||||
entry = path[len("/api/restore/"):]
|
||||
cfg = applying.history_config(site.data_dir, entry)
|
||||
if cfg is None:
|
||||
return self.send(404, {"error": "no such version"})
|
||||
draft, _ = site.draft.update(cfg, who, site.cfg_sha)
|
||||
return self.send(200, {"draft": self.masked_draft(draft), "from": entry})
|
||||
if path == "/api/undo":
|
||||
entries = [e for e in applying.history_entries(site.data_dir) if e["kind"] == "apply"]
|
||||
if not entries:
|
||||
return self.send(404, {"error": "nothing to undo"})
|
||||
cfg = applying.history_config(site.data_dir, entries[0]["id"])
|
||||
draft, _ = site.draft.update(cfg, who, site.cfg_sha)
|
||||
return self.send(200, {"draft": self.masked_draft(draft), "from": entries[0]["id"], "note": entries[0]["note"]})
|
||||
if path in ("/api/watchdog/restart", "/api/watchdog/stop", "/api/watchdog/start"):
|
||||
live, reason = site.applier.live()
|
||||
if live and path != "/api/watchdog/start":
|
||||
return self.send(409, {"error": "live now (%s)" % reason})
|
||||
if path.endswith("/stop") and body.get("word") != "STOP":
|
||||
return self.send(400, {"error": "type STOP to confirm"})
|
||||
verb = path.rsplit("/", 1)[1]
|
||||
return self.send(200, self.watchdog_control(verb, who))
|
||||
if path == "/api/command":
|
||||
text = str(body.get("text", "")).strip()
|
||||
verb = text.split(" ")[0].lower() if text else ""
|
||||
if verb not in ("test", "resurvey", "service"):
|
||||
return self.send(400, {"error": "not a command the page may send"})
|
||||
if verb == "test":
|
||||
live, reason = site.applier.live()
|
||||
if live:
|
||||
return self.send(409, {"error": "live now (%s): no test runs" % reason})
|
||||
text = "%s %d" % (text, int(time.time()))
|
||||
names = site.cfg["variables"]
|
||||
cur = site.comp.get_custom(names["command"]) or ""
|
||||
ok, status = site.comp.set_custom(names["command"], (cur + ";" + text) if cur.strip() else text)
|
||||
log.info("%s sent command %r -> %s", who, text, ok)
|
||||
return self.send(200 if ok else 502, {"ok": ok, "sent": text})
|
||||
if path == "/api/atem-reference":
|
||||
return self.send(200, self.take_atem(raw))
|
||||
if path == "/api/atem-reference/pick-newest":
|
||||
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) if folder and os.path.isdir(folder) else []
|
||||
if not files:
|
||||
return self.send(404, {"error": "no .xml files in the folder (is Google Drive signed in?)"})
|
||||
with open(files[-1], "rb") as f:
|
||||
return self.send(200, dict(self.take_atem(f.read()), file=os.path.basename(files[-1])))
|
||||
if path == "/api/atem-reference/discard":
|
||||
self.discard_atem()
|
||||
return self.send(200, {"ok": True})
|
||||
if path == "/api/gear":
|
||||
cfg = site.load()
|
||||
gear = dict(cfg.get("gear") or {})
|
||||
name = str(body.get("name", ""))[:80]
|
||||
gear[name] = {"location": str(body.get("location", ""))[:120], "notes": str(body.get("notes", ""))[:300]}
|
||||
draft = site.draft.read() or {"config": cfg}
|
||||
draft_cfg = dict(draft["config"], gear=gear)
|
||||
d, _ = site.draft.update(draft_cfg, who, site.cfg_sha)
|
||||
return self.send(200, {"draft": self.masked_draft(d)})
|
||||
return self.send(404, {"error": "not found"})
|
||||
except Exception as e:
|
||||
log.exception("POST %s", path)
|
||||
log.exception("%s %s", self.command, path)
|
||||
return self.send(500, {"error": str(e)})
|
||||
|
||||
def masked_draft(self, draft):
|
||||
return dict(draft, config=schema.mask_secrets(draft["config"]))
|
||||
|
||||
def check(self, cfg):
|
||||
"""Everything Review shows: errors, warnings, the plain-words changes, what Apply will do."""
|
||||
site = self.site
|
||||
live_cfg = site.load()
|
||||
try:
|
||||
conns = {c["label"]: c.get("moduleId") for c in site.comp.connections()}
|
||||
except Exception:
|
||||
conns = None
|
||||
errors, warnings = schema.validate(cfg, {"connections": conns} if conns else None)
|
||||
tmp = tempfile.NamedTemporaryFile("wb", suffix=".json", delete=False)
|
||||
tmp.write(applying.config_text(cfg)); tmp.close()
|
||||
try:
|
||||
sp.load_config(tmp.name)
|
||||
except (ValueError, KeyError, TypeError) as e:
|
||||
errors.append("the watchdog would not accept it: %s" % e)
|
||||
finally:
|
||||
os.remove(tmp.name)
|
||||
plan = applying.what_apply_does(live_cfg, cfg, site.applier.hashes()) if not errors else {"restart": False, "pages": [], "nav": False, "changes": applying.changed_paths(live_cfg, cfg)}
|
||||
changes = [applying.words_for(p, a, b, live_cfg, cfg) for p, a, b in plan["changes"]]
|
||||
live, reason = site.applier.live()
|
||||
atem_pending = os.path.exists(os.path.join(site.data_dir, "draft-atem.xml"))
|
||||
return {"errors": errors, "warnings": warnings, "changes": changes, "plan": {k: plan[k] for k in ("restart", "pages", "nav")},
|
||||
"live": {"live": live, "reason": reason}, "atem_pending": atem_pending, "feel": self.feel(plan, atem_pending)}
|
||||
|
||||
def feel(self, plan, atem_pending):
|
||||
if plan.get("error"):
|
||||
return "The pages could not be built: " + plan["error"]
|
||||
parts = []
|
||||
if plan["restart"] or atem_pending:
|
||||
parts.append("the deck goes purple for about 5 seconds and comes back with your changes")
|
||||
if plan["pages"]:
|
||||
parts.append("the %s re-imported" % " and ".join("%s page is" % p.capitalize() for p in plan["pages"] if p in ("status", "restart")))
|
||||
if plan["nav"]:
|
||||
parts.append("the page-number keys are recoloured")
|
||||
return (", ".join(parts) + ".").capitalize() if parts else "Nothing on the deck changes."
|
||||
|
||||
def take_atem(self, raw):
|
||||
"""An uploaded saved switcher file: capped, no DOCTYPE or entities, parsed with the watchdog's own
|
||||
parser; kept as draft-atem.xml until Apply. Returns what differs from the current file."""
|
||||
site = self.site
|
||||
if len(raw) > 20 * 1024 * 1024:
|
||||
return {"ok": False, "error": "the file is bigger than 20 MB"}
|
||||
head = raw[:4096].decode("utf-8", "replace")
|
||||
if "<!DOCTYPE" in head or "<!ENTITY" in head:
|
||||
return {"ok": False, "error": "the file holds a DOCTYPE or entities; a saved switcher file never does"}
|
||||
pending = os.path.join(site.data_dir, "draft-atem.xml")
|
||||
with open(pending + ".tmp", "wb") as f:
|
||||
f.write(raw)
|
||||
try:
|
||||
new = sp.atem_reference(pending + ".tmp", (site.cfg.get("atem") or {}).get("compare"))
|
||||
except Exception as e:
|
||||
os.remove(pending + ".tmp")
|
||||
return {"ok": False, "error": "not a saved switcher file: %s" % e}
|
||||
os.replace(pending + ".tmp", pending)
|
||||
os.chmod(pending, 0o600)
|
||||
current = os.path.join(site.data_dir, "atem-reference.xml")
|
||||
try:
|
||||
old = sp.atem_reference(current, (site.cfg.get("atem") or {}).get("compare"))
|
||||
except Exception:
|
||||
old = {"audio": {}, "camera": {}}
|
||||
product = re.search(r'product="([^"]+)"', head)
|
||||
diffs = []
|
||||
for part in ("audio", "camera"):
|
||||
for k in sorted(set(old[part]) | set(new[part])):
|
||||
if old[part].get(k) != new[part].get(k):
|
||||
diffs.append({"part": part, "key": k, "before": old[part].get(k), "after": new[part].get(k)})
|
||||
return {"ok": True, "product": product.group(1) if product else None, "audio": len(new["audio"]), "camera": len(new["camera"]),
|
||||
"diffs": diffs[:50], "diff_count": len(diffs)}
|
||||
|
||||
def discard_atem(self):
|
||||
try:
|
||||
os.remove(os.path.join(self.site.data_dir, "draft-atem.xml"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def watchdog_control(self, verb, who):
|
||||
domain = "gui/%d" % os.getuid()
|
||||
label = applying.WATCHDOG
|
||||
plist = os.path.expanduser("~/Library/LaunchAgents/%s.plist" % label)
|
||||
try:
|
||||
if verb == "stop":
|
||||
r = sp.subprocess.run(["/bin/launchctl", "bootout", "%s/%s" % (domain, label)], capture_output=True, text=True, timeout=30)
|
||||
log.info("%s stopped the watchdog -> %s", who, r.returncode)
|
||||
return {"ok": r.returncode == 0, "detail": (r.stderr or "stopped").strip()[-200:]}
|
||||
if verb == "start":
|
||||
r = sp.subprocess.run(["/bin/launchctl", "bootstrap", domain, plist], capture_output=True, text=True, timeout=30)
|
||||
log.info("%s started the watchdog -> %s", who, r.returncode)
|
||||
return {"ok": r.returncode == 0, "detail": (r.stderr or "started").strip()[-200:]}
|
||||
ok, detail = self.site.applier.restart_watchdog(None)
|
||||
log.info("%s restarted the watchdog -> %s %s", who, ok, detail)
|
||||
return {"ok": ok, "detail": detail}
|
||||
except (OSError, sp.subprocess.TimeoutExpired) as e:
|
||||
return {"ok": False, "detail": str(e)}
|
||||
|
||||
def static(self, name, ctype):
|
||||
try:
|
||||
with open(os.path.join(UI, name), "rb") as f:
|
||||
|
||||
Reference in New Issue
Block a user