Files
felhom.eu/documentation/audits/media-all-drives-2026-10-14/tools/wire.py
T

277 lines
15 KiB
Python

#!/usr/bin/env python3
"""wire.py [-v] — Part C: connect Sonarr and Radarr to qBittorrent and Transmission, ONE ENTRY PER DRIVE, on the box.
Runs INSIDE the guest (it talks to the containers on the docker network and reads their keys from their own config
volumes / environment). Stdlib only. Every key and password is read at runtime and NEVER written to a file or a log:
every printed line passes through redact(). `-v` prints every call and the SHAPE of every answer (status, top-level
keys / list length) — this is the reference the later link code copies.
For each drive d in DRIVES, M = /mnt/felhom-drives/<d>/userdata/media (the same path in every container):
qBittorrent : categories tv-<d>, movies-<d> -> save path M/torrents/{tv,movies}, incomplete path M/torrents/.temp
Sonarr : root folder M/tv; tag <d>; „qBittorrent <d>" (category tv-<d>, tag <d>) and
„Transmission <d>" (directory M/transmission/tv, tag <d>)
Radarr : the same with movies
Sonarr/Radarr: minimum free space when importing = MIN_FREE_MB; „use hardlinks instead of copy" quoted
Prowlarr : (when installed) one Application entry each for Sonarr and Radarr, sync level „Full Sync", reached by
container name; then an ApplicationIndexerSync command, and the indexers each app holds read back
Box-wide, recorded with the reason in the output:
qBittorrent : auto_tmm_enabled = true (a category's save path applies ONLY in automatic torrent management;
qBittorrent's default is manual, which saves every torrent to the default
path = drive A, whatever the category says)
Transmission: incomplete-dir-enabled = false (Transmission has ONE incomplete folder for all torrents; on drive A it
would make every drive-B download a cross-drive copy at completion)
Idempotent: an existing tag / root folder / client / category of the same name is left as it is and reported.
"""
import base64, json, re, subprocess, sys, urllib.error, urllib.parse, urllib.request
V = "-v" in sys.argv
DRIVES = [("drive-a", "media_a"), ("drive-b", "media_b")]
MIN_FREE_MB = 10240
SECRETS = []
def redact(s):
s = str(s)
for x in SECRETS:
if x:
s = s.replace(x, "<redacted>")
return s
def say(*a):
print(redact(" ".join(str(x) for x in a)), flush=True)
def sh(cmd):
return subprocess.run(cmd, shell=True, capture_output=True, text=True).stdout.strip()
def shape(body):
if isinstance(body, dict):
return "{" + ", ".join(sorted(body.keys()))[:300] + "}"
if isinstance(body, list):
return f"[list of {len(body)}]"
return repr(body)[:120]
def call(method, url, headers=None, data=None, form=False, raw=False, expect=(200, 201, 202, 204)):
h = dict(headers or {})
if data is not None and not isinstance(data, (bytes, str)):
data = urllib.parse.urlencode(data) if form else json.dumps(data)
h.setdefault("Content-Type", "application/x-www-form-urlencoded" if form else "application/json")
if isinstance(data, str):
data = data.encode()
req = urllib.request.Request(url, data=data, headers=h, method=method)
try:
with urllib.request.urlopen(req, timeout=60) as r:
code, txt, hdrs = r.status, r.read().decode("utf-8", "replace"), {k.lower(): v for k, v in r.headers.items()}
except urllib.error.HTTPError as e:
code, txt, hdrs = e.code, e.read().decode("utf-8", "replace"), {k.lower(): v for k, v in e.headers.items()}
try:
body = json.loads(txt) if txt and not raw else txt
except ValueError:
body = txt
if V:
say(f" {method} {url} -> {code} {shape(body)}")
if code not in expect:
say(f" !! {method} {url} -> {code}: {redact(txt)[:600]}")
return code, body, hdrs
def ip(c):
return sh(f"docker inspect {c} -f '{{{{range .NetworkSettings.Networks}}}}{{{{.IPAddress}}}} {{{{end}}}}'").split()[0]
def apikey(app):
x = open(f"/var/lib/docker/volumes/{app}_{app}_config/_data/config.xml").read() # sonarr, radarr, prowlarr
k = re.search(r"<ApiKey>([^<]+)</ApiKey>", x).group(1)
SECRETS.append(k)
return k
def env(c, var):
v = sh(f"docker exec {c} printenv {var}")
SECRETS.append(v)
return v
# ---------------------------------------------------------------- qBittorrent
QB = f"http://{ip('qbittorrent')}:8080"
qpw = env("qbittorrent", "QBT_ADMIN_PASSWORD")
say("== qBittorrent", QB, "(password: the deploy secret, read from the container's environment)")
c, _, hd = call("POST", QB + "/api/v2/auth/login", {"Referer": QB}, {"username": "admin", "password": qpw}, form=True, raw=True)
# qBittorrent 5.x names the session cookie QBT_SID_<port> (older versions: SID) — take whichever it set
m = re.search(r"((?:QBT_)?SID[A-Za-z0-9_]*)=([^;]+)", hd.get("set-cookie", ""))
if not m:
sys.exit(say(f" !! no session cookie; answer headers: {sorted(hd)}") or 1)
say(f" session cookie name: {m.group(1)}")
SECRETS.append(m.group(2))
QH = {"Referer": QB, "Cookie": f"{m.group(1)}={m.group(2)}"}
c, prefs, _ = call("GET", QB + "/api/v2/app/preferences", QH)
say(f" before: auto_tmm_enabled={prefs.get('auto_tmm_enabled')} save_path={prefs.get('save_path')} "
f"temp_path_enabled={prefs.get('temp_path_enabled')} temp_path={prefs.get('temp_path')}")
call("POST", QB + "/api/v2/app/setPreferences", QH, {"json": json.dumps({"auto_tmm_enabled": True})}, form=True, raw=True)
c, prefs, _ = call("GET", QB + "/api/v2/app/preferences", QH)
say(f" after : auto_tmm_enabled={prefs.get('auto_tmm_enabled')} (a category's save path applies only in automatic mode)")
c, cats, _ = call("GET", QB + "/api/v2/torrents/categories", QH)
for tag, d in DRIVES:
M = f"/mnt/felhom-drives/{d}/userdata/media"
for kind in ("tv", "movies"):
name = f"{kind}-{tag}"
form = {"category": name, "savePath": f"{M}/torrents/{kind}", "downloadPathEnabled": "true",
"downloadPath": f"{M}/torrents/.temp"}
ep = "editCategory" if name in (cats or {}) else "createCategory"
call("POST", QB + f"/api/v2/torrents/{ep}", QH, form, form=True, raw=True)
c, cats, _ = call("GET", QB + "/api/v2/torrents/categories", QH)
for n, v in sorted((cats or {}).items()):
say(f" category {n}: savePath={v.get('savePath')} downloadPath={v.get('download_path', v.get('downloadPath'))}")
# ---------------------------------------------------------------- Transmission
TR = f"http://{ip('transmission')}:9091/transmission/rpc"
tpw = env("transmission", "PASS")
TA = "Basic " + base64.b64encode(f"admin:{tpw}".encode()).decode()
SECRETS.append(TA.split()[1])
say("== Transmission", TR, "(password: the deploy secret, read from the container's environment)")
def trpc(method, args=None, host=None):
h = {"Authorization": TA}
if host:
h["Host"] = host
c, b, hd = call("POST", TR, h, {"method": method, "arguments": args or {}}, expect=(200, 409))
if c == 409:
h["X-Transmission-Session-Id"] = hd.get("x-transmission-session-id", "")
c, b, hd = call("POST", TR, h, {"method": method, "arguments": args or {}}, expect=(200, 421))
return c, b
c, b = trpc("session-get", {"fields": ["download-dir", "incomplete-dir", "incomplete-dir-enabled", "rename-partial-files"]})
say(f" before: {json.dumps((b or {}).get('arguments'))}")
trpc("session-set", {"incomplete-dir-enabled": False})
c, b = trpc("session-get", {"fields": ["download-dir", "incomplete-dir-enabled", "rename-partial-files"]})
say(f" after : {json.dumps((b or {}).get('arguments'))} (one incomplete folder for all torrents would be on drive A)")
# The probe the link code needs: can another container reach Transmission BY NAME? (Host-header allow-list)
c, b = trpc("session-get", {"fields": ["version"]}, host="transmission:9091")
say(f" probe Host: transmission:9091 -> HTTP {c} {redact(str(b))[:160]}")
TR_BY_NAME = c == 200
# ---------------------------------------------------------------- Sonarr / Radarr
def arr(app, port, kind, root_kind):
base = f"http://{ip(app)}:{port}/api/v3"
# Host = the container name: Sonarr/Radarr refuse a bare IP once the household sets „Allowed hosts"
# (measured 2026-10-14 18:30: 400 „Invalid Hostname" for Host=<ip>, 200 for Host=sonarr)
H = {"X-Api-Key": apikey(app), "Host": app}
say(f"== {app} {base} (key: <ApiKey> in /config/config.xml, read from the config volume)")
c, tags, _ = call("GET", base + "/tag", H)
have = {t["label"]: t["id"] for t in tags or []}
for tag, _d in DRIVES:
if tag not in have:
c, t, _ = call("POST", base + "/tag", H, {"label": tag})
have[tag] = t["id"]
say(f" tags: {have}")
c, roots, _ = call("GET", base + "/rootfolder", H)
rp = {r["path"].rstrip("/") for r in roots or []}
for tag, d in DRIVES:
p = f"/mnt/felhom-drives/{d}/userdata/media/{root_kind}"
if p not in rp:
call("POST", base + "/rootfolder", H, {"path": p})
c, roots, _ = call("GET", base + "/rootfolder", H)
for r in roots or []:
say(f" root folder: {r['path']} accessible={r.get('accessible')} freeSpace={r.get('freeSpace')}")
c, schema, _ = call("GET", base + "/downloadclient/schema", H)
sch = {s["implementation"]: s for s in schema}
c, clients, _ = call("GET", base + "/downloadclient", H)
names = {x["name"] for x in clients or []}
for tag, d in DRIVES:
M = f"/mnt/felhom-drives/{d}/userdata/media"
for impl, nm, vals in (
("QBittorrent", f"qBittorrent {tag}", {"host": "qbittorrent", "port": 8080, "username": "admin",
"password": qpw, f"{kind}Category": f"{'tv' if kind == 'tv' else 'movies'}-{tag}"}),
("Transmission", f"Transmission {tag}", {"host": "transmission", "port": 9091, "username": "admin",
"password": tpw, "urlBase": "/transmission/",
# the schema's default category must be CLEARED: Sonarr/Radarr
# refuse „Cannot use Category and Directory" (measured, run 1)
f"{kind}Category": "",
f"{kind}Directory": f"{M}/transmission/{root_kind}"})):
if nm in names:
say(f" client {nm}: exists — left as it is")
continue
body = json.loads(json.dumps(sch[impl]))
body.update({"name": nm, "enable": True, "tags": [have[tag]], "priority": 1})
for f in body["fields"]:
if f["name"] in vals:
f["value"] = vals[f["name"]]
c, r, _ = call("POST", base + "/downloadclient", H, body, expect=(200, 201, 400))
if c == 400:
say(f" client {nm}: REFUSED by {app}'s own connection test: {redact(json.dumps(r))[:400]}")
c, clients, _ = call("GET", base + "/downloadclient", H)
for x in clients or []:
f = {k["name"]: k.get("value") for k in x["fields"]}
say(f" client {x['name']}: impl={x['implementation']} enable={x['enable']} tags={x['tags']} host={f.get('host')}"
f" category={f.get(kind + 'Category')} directory={f.get(kind + 'Directory')}")
c, mm, _ = call("GET", base + "/config/mediamanagement", H)
say(f" before: minimumFreeSpaceWhenImporting={mm.get('minimumFreeSpaceWhenImporting')} MB, "
f"copyUsingHardlinks={mm.get('copyUsingHardlinks')}")
mm["minimumFreeSpaceWhenImporting"] = MIN_FREE_MB
call("PUT", base + f"/config/mediamanagement/{mm['id']}", H, mm)
c, mm, _ = call("GET", base + "/config/mediamanagement", H)
say(f" after : minimumFreeSpaceWhenImporting={mm.get('minimumFreeSpaceWhenImporting')} MB, "
f"copyUsingHardlinks={mm.get('copyUsingHardlinks')} (default kept)")
arr("sonarr", 8989, "tv", "tv")
arr("radarr", 7878, "movie", "movies")
say("== done; Transmission reachable by its container name:", TR_BY_NAME)
# ---------------------------------------------------------------- Prowlarr -> Sonarr / Radarr (Settings -> Apps)
def prowlarr():
if not sh("docker ps -q -f name=^prowlarr$"):
say("== prowlarr: not installed — skipped")
return
base = f"http://{ip('prowlarr')}:9696/api/v1"
H = {"X-Api-Key": apikey("prowlarr"), "Host": "prowlarr"}
say(f"== prowlarr {base} (key: <ApiKey> in /config/config.xml, read from the config volume)")
c, schema, _ = call("GET", base + "/applications/schema", H)
sch = {x["implementation"]: x for x in schema}
c, apps, _ = call("GET", base + "/applications", H)
have = {a["name"] for a in apps or []}
for impl, port in (("Sonarr", 8989), ("Radarr", 7878)):
if impl in have:
say(f" application {impl}: exists — left as it is")
continue
body = json.loads(json.dumps(sch[impl]))
vals = {"prowlarrUrl": "http://prowlarr:9696", "baseUrl": f"http://{impl.lower()}:{port}",
"apiKey": apikey(impl.lower())}
for f in body["fields"]:
if f["name"] in vals:
f["value"] = vals[f["name"]]
body.update({"name": impl, "syncLevel": "fullSync", "tags": []})
c, r, _ = call("POST", base + "/applications", H, body, expect=(200, 201, 400))
if c == 400:
say(f" application {impl}: REFUSED by Prowlarr's own test: {redact(json.dumps(r))[:400]}")
c, apps, _ = call("GET", base + "/applications", H)
for a in apps or []:
f = {k["name"]: k.get("value") for k in a["fields"]}
say(f" application {a['name']}: syncLevel={a.get('syncLevel')} prowlarrUrl={f.get('prowlarrUrl')} baseUrl={f.get('baseUrl')}"
f" syncCategories={f.get('syncCategories')}")
c, ix, _ = call("GET", base + "/indexer", H)
say(f" indexers in Prowlarr: {[i['name'] for i in ix or []]}")
c, cmd, _ = call("POST", base + "/command", H, {"name": "ApplicationIndexerSync"})
cid = (cmd or {}).get("id")
import time
for _ in range(20):
time.sleep(3)
c, st, _ = call("GET", base + f"/command/{cid}", H)
if (st or {}).get("status") in ("completed", "failed", "aborted"):
break
say(f" ApplicationIndexerSync -> status={(st or {}).get('status')} result={(st or {}).get('result')} message={(st or {}).get('message')}")
for app, port in (("sonarr", 8989), ("radarr", 7878)):
c, ai, _ = call("GET", f"http://{ip(app)}:{port}/api/v3/indexer", {"X-Api-Key": apikey(app), "Host": app})
say(f" {app} now holds {len(ai or [])} indexer(s): {[(i['name'], i.get('implementation')) for i in ai or []]}")
prowlarr()
say("== prowlarr part done")