#!/usr/bin/env python3 """walk_9201_link.py — the read-back on demo-hp 9201 from the LIVE catalog after the publish (R-1048, brief Part D3). Runs ON DOOPLEX with TARGET=9201, SC=<0600 dir holding .ctlpw>, EV=. Presses only what a person presses: POST /api/sync, the deploy wizard's POST /api/stacks//deploy, the dashboard's „Bekötés most" (POST /api/storage/media/wire), stop + remove KEEPING data. Reads Prowlarr's own API inside its container with the key from its own config.xml (never printed), exactly as the box's wiring does. The two orders the controller's code implies (mediawire runs when a media_library app comes up; Prowlarr has no marker): B Sonarr first, Prowlarr after -> expected NO link by itself; „Bekötés most" makes it A Radarr after Prowlarr -> expected the link by itself, ~60 s after Radarr comes up Then: Prowlarr's own connection test for each link, and its sync command to completion. """ import json import os import re import shlex import sys import time sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts") import box_walk as w # noqa: E402 EV = os.environ["EV"] os.makedirs(EV, exist_ok=True) LOG = open(os.path.join(EV, "link.txt"), "a", buffering=1) TO = "lscr.io/linuxserver/prowlarr:version-2.6.5.5623" HDD = "/mnt/felhom-drives/hdd_1" RES = {} _lib_say = w.say def say(*a): _lib_say(*a) LOG.write(" ".join(str(x) for x in a) + "\n") w.say = say def papi(path, body=None, method=None): # FIXED 2026-10-15 after the first run: json.dumps() quoted this with DOUBLE quotes, so the guest shell expanded # $(sed …) and $K before docker exec — empty key, every call 401 (link.txt [B3]/[C2]). shlex.quote keeps it literal. """Prowlarr's own API from inside its container (the key read from its config.xml at use, never printed).""" m = method or ("POST" if body is not None else "GET") data = json.dumps(body) if body is not None else "" script = ("K=$(sed -n 's:.*\\(.*\\).*:\\1:p' /config/config.xml); " f"curl -s -o /tmp/pb.json -w '%{{http_code}}' -X {m} -H \"X-Api-Key: $K\" -H 'Content-Type: application/json' " f"--data-binary @- http://127.0.0.1:9696/api/v1{path}; echo; cat /tmp/pb.json") out = w.guest(f"docker exec -i prowlarr sh -c {shlex.quote(script)} <<'EOF'\n{data}\nEOF") code, _, body_ = out.partition("\n") try: return code.strip(), json.loads(body_) if body_.strip() else None except Exception: return code.strip(), body_[:300] def apps(): c, d = papi("/applications") return [(a.get("name"), a.get("syncLevel"), next((f.get("value") for f in a.get("fields", []) if f.get("name") == "baseUrl"), None)) for a in (d or [])] if c == "200" else c def wiring_lines(since): return w.guest(f"docker logs --since {since} felhom-controller 2>&1 | grep -E '\\[media\\] wiring|\\[mediawire\\]' | cut -c1-400").strip() def install(name, values): if w.stack(name).get("deployed"): sys.exit(say(f"{name} is already installed on 9201 — STOP, a household's app is never reused") or 1) code, d = w.ctl("POST", f"/api/stacks/{name}/deploy", {"values": values}) say(f" deploy {name} {json.dumps(values)} -> {code} {json.dumps(d, ensure_ascii=False)[:120]}") w.DEPLOYED_THIS_RUN.add(name) for _ in range(90): time.sleep(5) st = w.stack(name) if st.get("deployed") and (st.get("app_config") or {}).get("pinned_images") and st.get("state") == "running": say(f" {name}: deployed, running, pinned={(st.get('app_config') or {}).get('pinned_images')}") return True sys.exit(say(f" {name} never became running — STOP") or 1) def wait_healthy(c, tries=60): for _ in range(tries): if w.guest(f"docker inspect {c} -f '{{{{.State.Health.Status}}}}'").strip() == "healthy": return True time.sleep(5) return False def main(): w.login() if os.environ.get("CONTINUE") == "1": return part2() t0 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) for a in ("prowlarr", "sonarr", "radarr"): if w.stack(a).get("deployed"): sys.exit(say(f"{a} is installed on 9201 — STOP") or 1) say("[0] controller: " + w.guest("cat /etc/felhom-controller-image").strip()) w.sync_rescan(expect_app="prowlarr", expect_ref=TO) say("[0] live catalog on the box: " + w.guest("docker exec felhom-controller sh -c 'git -C /opt/docker/felhom-controller/data/catalog-cache log -1 --format=\"%h %s\"; git -C /opt/docker/felhom-controller/data/catalog-cache remote get-url origin'").strip().replace("\n", " | ")) say("[0] prowlarr template on the box: " + w.guest("wc -c /opt/docker/stacks/prowlarr/.felhom.yml; grep -E 'image:' /opt/docker/stacks/prowlarr/docker-compose.yml; grep -E '^setup_gate' /opt/docker/stacks/prowlarr/.felhom.yml").strip().replace("\n", " | ")) # ── order B: Sonarr first ── install("sonarr", {"DOMAIN": w.DOMAIN, "SUBDOMAIN": "sonarr", "HDD_PATH": HDD}) wait_healthy("sonarr") time.sleep(75) # the wiring runs 60 s after the last media app came up say("[B1] wiring after Sonarr came up:\n" + wiring_lines(t0)) tP = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) install("prowlarr", {"DOMAIN": w.DOMAIN, "SUBDOMAIN": "prowlarr"}) wait_healthy("prowlarr") say("[B2] prowlarr first-start line: " + w.guest("docker logs prowlarr 2>&1 | grep -m1 felhom").strip()) time.sleep(150) b_apps = apps() b_wire = wiring_lines(tP) say(f"[B3] 150 s after Prowlarr came up (Sonarr was there first): Prowlarr's Apps = {b_apps}") say(f"[B3] wiring runs since Prowlarr's install: {b_wire or '(none)'}") RES["order_B_apps_without_press"] = b_apps RES["order_B_wiring_runs"] = b_wire # ── the household's „Bekötés most" ── code, d = w.ctl("POST", "/api/storage/media/wire", {}) pr = [r for r in ((d or {}).get("data") or []) if isinstance(r, dict) and r.get("app") == "prowlarr"] say(f"[C1] „Bekötés most” (POST /api/storage/media/wire) -> {code}; prowlarr result: {json.dumps(pr, ensure_ascii=False)}") c_apps = apps() say(f"[C2] Prowlarr's Apps after the press: {c_apps}") RES["after_press_apps"] = c_apps # ── order A: Radarr after Prowlarr ── tR = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) install("radarr", {"DOMAIN": w.DOMAIN, "SUBDOMAIN": "radarr", "HDD_PATH": HDD}) wait_healthy("radarr") part2(tR) def part2(tR=None): """From order A's check on (CONTINUE=1 re-enters here after the first run's key bug; the installs stand).""" if tR is None: tR = w.guest("docker inspect radarr -f '{{.State.StartedAt}}'").strip()[:19] + "Z" say(f"[CONTINUE] re-entered after the key fix; Radarr started {tR}; Sonarr, Prowlarr, Radarr installed by run 1 of this script") say(f"[CONTINUE] Prowlarr's Apps read now with the right key: {apps()}") a_apps = None for i in range(30): time.sleep(10) a_apps = apps() if isinstance(a_apps, list) and any(n == "Radarr" for n, _, _ in a_apps): say(f"[A1] Radarr appeared in Prowlarr's Apps by itself, ~{(i + 1) * 10} s after Radarr ran") break say(f"[A2] Prowlarr's Apps: {a_apps}") say("[A2] wiring runs since Radarr's install:\n" + wiring_lines(tR)) RES["order_A_apps"] = a_apps # ── the links work: Prowlarr's own test per app, then its sync command to completion ── c, full = papi("/applications") for a in (full or []): tc, td = papi("/applications/test", a) say(f"[D1] Prowlarr's connection test for {a.get('name')} ({a.get('implementation')}, syncLevel {a.get('syncLevel')}) -> {tc} {str(td)[:160] if td else ''}") RES[f"test_{a.get('name')}"] = tc c, cmd = papi("/command", {"name": "ApplicationIndexerSync"}) cid = (cmd or {}).get("id") if isinstance(cmd, dict) else None say(f"[D2] sync command ApplicationIndexerSync -> {c} id={cid}") st = None for _ in range(30): time.sleep(2) c, cd = papi(f"/command/{cid}") st = (cd or {}).get("status") if isinstance(cd, dict) else cd if st in ("completed", "failed", "aborted"): break say(f"[D2] sync command status: {st}") RES["sync_status"] = st say("[D3] Prowlarr's own log of the sync: " + w.guest( "docker exec prowlarr sh -c \"grep -h -iE 'ApplicationIndexerSync|Syncing|sonarr:8989|radarr:7878' /config/logs/prowlarr.txt | tail -6\"").strip().replace("\n", " | ")[:900]) say("[D4] Sonarr's and Radarr's indexer lists (Prowlarr has no indexer yet, so both empty is the expected answer): " + w.guest("for a in sonarr:8989:v3 radarr:7878:v3; do n=${a%%:*}; r=${a#*:}; p=${r%%:*}; v=${r#*:}; " "docker exec $n sh -c \"K=\\$(sed -n 's:.*\\(.*\\).*:\\1:p' /config/config.xml); " "curl -s -H \\\"X-Api-Key: \\$K\\\" http://127.0.0.1:$p/api/$v/indexer\" | head -c 200; echo \" <- $n\"; done").strip().replace("\n", " | ")) # ── remove all three, KEEPING data (the brief: without „delete data") ── for a in ("radarr", "prowlarr", "sonarr"): c1, _ = w.ctl("POST", f"/api/stacks/{a}/stop") for _ in range(24): time.sleep(5) if w.stack(a).get("state") != "running": break code, d = w.ctl("POST", f"/api/stacks/{a}/remove", {"remove_hdd_data": False, "remove_backups": False}) say(f"[X] {a}: stop {c1}, remove keeping data -> {code} {json.dumps(d, ensure_ascii=False)[:260]}") time.sleep(5) say("[X] left: " + w.guest("echo containers=$(docker ps -aq -f name='^(prowlarr|sonarr|radarr)$' | wc -l) " "volumes=$(docker volume ls -q | grep -cE '^(prowlarr|sonarr|radarr)_')").strip()) json.dump(RES, open(os.path.join(EV, "link-results.json"), "w"), indent=2, ensure_ascii=False) main()