Files
felhom.eu/documentation/audits/media-all-drives-2026-10-14/tools/chain.py
T
admin 8395aee4c2
gates / gates (push) Successful in 7m46s
Media all drives, Parts A-C on Tester 1: S1-S3 proven (hardlink with one bind per drive; today's shape copies); R-1014..R-1019 filed
Two test drives enrolled through the dashboard; no test media in any backup tier (A6, three proofs); Tester 1 follows a
credential-free local mirror of the drill catalog (R-992 worked around a third way). The live-fix task may start.
2026-10-14 17:36:50 +02:00

219 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""chain.py <run> — S2/S3: one public-domain film through the WHOLE path, run INSIDE the guest (run_in_guest.py).
Radarr (movie added with a root folder + tag) -> release push API -> download client (picked by the tag; for S2 the
other same-tag client is switched off for the grab so the named one is used) -> download on a drive -> Radarr's
completed-download import -> library file. Then the evidence: download path, library path, link counts, inodes,
Radarr's history entry.
Content: „Night of the Living Dead" (1968), public domain (archive.org items carry the CC Public Domain Mark), < 1 GB.
runs:
s2-qbit root B, tag drive-b, only „qBittorrent drive-b" enabled among the drive-b clients
s2-trans root B, tag drive-b, only „Transmission drive-b" enabled among the drive-b clients
s3-tag root A, tag drive-a, both drive-a clients enabled (the tag alone picks)
s3-mis root B, tag drive-a (the mismatch)
cleanup delete the movie with its files in Radarr, remove every torrent with its data in both clients
Every key/password is read at runtime and redacted from the output.
"""
import base64, datetime, json, os, re, subprocess, sys, time, urllib.error, urllib.request
SECRETS = []
TMDB = 10331
ITEMS = {"s2-qbit": "night-of-the-living-dead-1968_202508", "s2-trans": "night-of-the-living-dead-1968_202508",
"s3-tag": "night-of-the-living-dead-1968_202508", "s3-mis": "night-of-the-living-dead-1968_202508"}
RUNS = {"s2-qbit": ("media_b", "drive-b", "qBittorrent drive-b"), "s2-trans": ("media_b", "drive-b", "Transmission drive-b"),
"s3-tag": ("media_a", "drive-a", None), "s3-mis": ("media_b", "drive-a", None)}
def red(s):
s = str(s)
for x in SECRETS:
if x:
s = s.replace(x, "<redacted>")
return s
def say(*a):
print(red(" ".join(str(x) for x in a)), flush=True)
def sh(c):
return subprocess.run(c, shell=True, capture_output=True, text=True).stdout.strip()
def ip(c):
return sh(f"docker inspect {c} -f '{{{{range .NetworkSettings.Networks}}}}{{{{.IPAddress}}}} {{{{end}}}}'").split()[0]
def call(method, url, headers=None, data=None, form=False):
h = dict(headers or {})
if data is not None:
if form:
data = "&".join(f"{k}={urllib.request.quote(str(v))}" for k, v in data.items())
h["Content-Type"] = "application/x-www-form-urlencoded"
else:
data = json.dumps(data)
h["Content-Type"] = "application/json"
data = data.encode()
req = urllib.request.Request(url, data=data, headers=h, method=method)
try:
with urllib.request.urlopen(req, timeout=120) as r:
code, txt, hd = 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, hd = 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 else None
except ValueError:
body = txt
return code, body, hd
key = re.search(r"<ApiKey>([^<]+)</ApiKey>", open("/var/lib/docker/volumes/radarr_radarr_config/_data/config.xml").read()).group(1)
SECRETS.append(key)
RA = f"http://{ip('radarr')}:7878/api/v3"
H = {"X-Api-Key": key}
def ra(method, path, data=None):
c, b, _ = call(method, RA + path, H, data)
if c >= 300:
say(f" !! radarr {method} {path} -> {c} {red(json.dumps(b))[:500]}")
return c, b
def movie():
c, ms = ra("GET", "/movie")
return next((m for m in ms if m.get("tmdbId") == TMDB), None)
def clients():
return ra("GET", "/downloadclient")[1]
def set_enable(cl, on):
if cl["enable"] != on:
cl = dict(cl, enable=on)
ra("PUT", f"/downloadclient/{cl['id']}", cl)
def tag_id(label):
return next(t["id"] for t in ra("GET", "/tag")[1] if t["label"] == label)
def st(p):
try:
s = os.stat(p)
return f"links={s.st_nlink} inode={s.st_ino} size={s.st_size}"
except OSError as e:
return f"stat failed: {e}"
# ---------------------------------------------------------------- client side (for cleanup and the download path)
def qb():
QB = f"http://{ip('qbittorrent')}:8080"
pw = sh("docker exec qbittorrent printenv QBT_ADMIN_PASSWORD"); SECRETS.append(pw)
c, _, hd = call("POST", QB + "/api/v2/auth/login", {"Referer": QB}, {"username": "admin", "password": pw}, form=True)
m = re.search(r"((?:QBT_)?SID[A-Za-z0-9_]*)=([^;]+)", hd.get("set-cookie", "")); SECRETS.append(m.group(2))
return QB, {"Referer": QB, "Cookie": f"{m.group(1)}={m.group(2)}"}
def tr(method, args=None):
TR = f"http://{ip('transmission')}:9091/transmission/rpc"
pw = sh("docker exec transmission printenv PASS"); SECRETS.append(pw)
h = {"Authorization": "Basic " + base64.b64encode(f"admin:{pw}".encode()).decode()}
SECRETS.append(h["Authorization"].split()[1])
c, b, hd = call("POST", TR, h, {"method": method, "arguments": args or {}})
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 {}})
return b
def cleanup():
m = movie()
if m:
ra("DELETE", f"/movie/{m['id']}?deleteFiles=true&addImportExclusion=false")
say(f"radarr: movie {m['id']} deleted with its files")
QB, QH = qb()
c, ts, _ = call("GET", QB + "/api/v2/torrents/info", QH)
for t in ts or []:
call("POST", QB + "/api/v2/torrents/delete", QH, {"hashes": t["hash"], "deleteFiles": "true"}, form=True)
say(f"qbittorrent: removed {t['name']} with its data ({t['content_path']})")
b = tr("torrent-get", {"fields": ["id", "name", "downloadDir"]})
for t in (b or {}).get("arguments", {}).get("torrents", []):
tr("torrent-remove", {"ids": [t["id"]], "delete-local-data": True})
say(f"transmission: removed {t['name']} with its data ({t['downloadDir']})")
for c in ra("GET", "/downloadclient")[1]:
set_enable(c, True)
say("all download clients enabled again")
def run(name):
drive, tag, only = RUNS[name]
item = ITEMS[name]
root = f"/mnt/felhom-drives/{drive}/userdata/media/movies"
say(f"=== {name}: root={root} tag={tag} client={'(by tag)' if not only else only} item=archive.org/{item}")
m = movie()
if m:
sys.exit(say("STOP: the film is already in Radarr — run cleanup first") or 1)
cls = clients()
if only:
for c in cls:
if tag_id(tag) in c["tags"]:
set_enable(c, c["name"] == only)
say(" clients:", [(c["name"], c["enable"], c["tags"]) for c in clients()])
c, lk = ra("GET", f"/movie/lookup/tmdb?tmdbId={TMDB}")
qp = ra("GET", "/qualityprofile")[1][0]
body = dict(lk, qualityProfileId=qp["id"], rootFolderPath=root, monitored=True, tags=[tag_id(tag)],
minimumAvailability="released", addOptions={"searchForMovie": False})
c, m = ra("POST", "/movie", body)
say(f" movie added: id={m['id']} '{m['title']}' ({m['year']}) path={m['path']} tags={m['tags']} profile={qp['name']}")
sz = {"night-of-the-living-dead-1968_202312": 550604180, "night-of-the-living-dead-1968_202508": 596600000,
"night-of-the-living-dead_202309": 598500000}[item]
rel = {"title": "Night.of.the.Living.Dead.1968.480p.WEB-DL.x264-ARCHIVEORG",
"downloadUrl": f"https://archive.org/download/{item}/{item}_archive.torrent",
"protocol": "torrent", "publishDate": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"size": sz, "indexer": "archive.org (release push)"}
t0 = time.time()
c, d = ra("POST", "/release/push", rel)
dd = d[0] if isinstance(d, list) else d
say(f" release push -> {c} approved={dd.get('approved')} rejections={dd.get('rejections')} downloadAllowed={dd.get('downloadAllowed')}")
grabbed = imported = None
for i in range(240):
time.sleep(10)
c, h = ra("GET", f"/history/movie?movieId={m['id']}")
for e in h or []:
if e["eventType"] == "grabbed" and not grabbed:
grabbed = e
say(f" +{int(time.time()-t0)}s grabbed: downloadClient={e['data'].get('downloadClient')} "
f"downloadClientName={e['data'].get('downloadClientName')} downloadId={e.get('downloadId')}")
if e["eventType"] == "downloadFolderImported":
imported = e
if imported:
break
if i % 6 == 5:
c, q = ra("GET", f"/queue?movieIds={m['id']}")
for r in (q or {}).get("records", []):
say(f" +{int(time.time()-t0)}s queue: status={r.get('status')} trackedState={r.get('trackedDownloadState')} "
f"left={r.get('sizeleft')} client={r.get('downloadClient')} output={r.get('outputPath')} "
f"msgs={[s.get('messages') for s in r.get('statusMessages', [])]}")
if not imported:
say(f" RESULT {name}: NOT imported within {int(time.time()-t0)}s")
return
e = imported["data"]
say(f" +{int(time.time()-t0)}s history downloadFolderImported: {json.dumps({k: e.get(k) for k in ('downloadClient', 'downloadClientName', 'droppedPath', 'importedPath', 'size')})}")
say(f" download file: {e['droppedPath']} {st(e['droppedPath'])}")
say(f" library file : {e['importedPath']} {st(e['importedPath'])}")
a, b = os.stat(e["droppedPath"]), os.stat(e["importedPath"])
say(f" RESULT {name}: {'HARDLINK (same inode)' if (a.st_ino, a.st_dev) == (b.st_ino, b.st_dev) else 'COPY (two inodes)'}"
f"; download on {e['droppedPath'].split('/')[3]}, library on {e['importedPath'].split('/')[3]}")
for c in clients():
set_enable(c, True)
if sys.argv[1] == "cleanup":
cleanup()
else:
run(sys.argv[1])