277 lines
15 KiB
Python
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")
|