#!/usr/bin/env python3 """walk_link.py (from prowlarr-2026-10-15/tools/walk_9201_link.py) — R-1058 live proof on controller 0.314.0-rc.1: Sonarr first, then Prowlarr: the link must appear with NO button; a second run changes nothing. (original docstring:) 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 = os.environ.get("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() for a in ("prowlarr", "sonarr"): if w.stack(a).get("deployed"): sys.exit(say(f"{a} is installed — STOP") or 1) say("[0] controller: " + w.guest("cat /etc/felhom-controller-image").strip()) t0 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) install("sonarr", {"DOMAIN": w.DOMAIN, "SUBDOMAIN": "sonarr", "HDD_PATH": HDD}) wait_healthy("sonarr") time.sleep(75) say("[1] 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") linked = None for i in range(18): time.sleep(10) a = apps() if isinstance(a, list) and any(n == "Sonarr" for n, _, _ in a): linked = (i + 1) * 10 say(f"[2] R-1058 Sonarr appeared in Prowlarr's Apps with NO button, ~{linked} s after Prowlarr ran: {a}") break if linked is None: say(f"[2] R-1058 FAILED: no link 180 s after Prowlarr ran; Apps = {apps()}") say("[2] wiring runs since Prowlarr's install:\n" + wiring_lines(tP)) RES["linked_after_s"] = linked 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"[3] a second run („Bekötés most”) -> {code}; prowlarr result: {json.dumps(pr, ensure_ascii=False)} (expected: unchanged, nothing written)") RES["second_run"] = pr c, full = papi("/applications") for a in (full or []): tc, td = papi("/applications/test", a) say(f"[4] Prowlarr's connection test for {a.get('name')} -> {tc}") for a in ("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": True}) say(f"[X] {a}: remove (keep drive data, drop backups) -> {code} {json.dumps(d, ensure_ascii=False)[:200]}") time.sleep(5) say("[X] left: " + w.guest("echo containers=$(docker ps -aq -f name='^(prowlarr|sonarr)$' | wc -l) volumes=$(docker volume ls -q | grep -cE '^(prowlarr|sonarr)_')").strip()) json.dump(RES, open(os.path.join(EV, "link-results.json"), "w"), indent=2, ensure_ascii=False) main()