Files
felhom.eu/documentation/audits/media-drive-events-2026-10-14/tools/k7.py
T
admin 0e88b7eb99
gates / gates (push) Successful in 6m34s
Media drive events, night 2026-10-14: 15 §10 (event x app, measured), K4-K8 evidence, rows R-1044..R-1049, narrowed R-1022/1023/1024/1026/1028/1037/1038, night report
- 15-media-library.md §10: what the box does on each drive event, per app, with the measured call; the guards
  that ARE needed and the ones that are NOT (K1: nothing lost while a drive is gone).
- Evidence K4 (dead-drive check: mountinfo `shutdown`, stat unreliable), K5 (restart), K6 (unclaimed Plex),
  K7 (old Sonarr/Radarr repair on the bench), K8 (hub received the drive-return events and mailed none), K9 (files identical).
- nightly-rotation.md runs scripts/https_only_check.py first every night; scripts/CHANGELOG.md entry.
- STATUS, CONTEXT (decisions 244-246), REPORT-night-2026-10-14.md.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-14 23:40:26 +02:00

117 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""k7.py — K7 on the BENCH (LXC 9401): Sonarr and Radarr installed from the templates BEFORE app-catalog bc54c01
(library at /media/tv, /media/movies inside the container), then updated to bc54c01 (one same-path `media` bind), then
repaired through the apps' OWN API without moving a file. Needs /tmp/k7/{old,new}-{sonarr,radarr}.yml and
/tmp/k7/probe.mkv (a 10 s self-made clip). Everything it makes is removed at the end (containers, volumes, folders).
"""
import json, os, re, shutil, subprocess, time, urllib.error, urllib.request
W = "/srv/k7"; U = f"{W}/userdata"
def sh(c, t=600):
r = subprocess.run(c, shell=True, capture_output=True, text=True, timeout=t)
return (r.stdout + r.stderr).strip()
def ip(c):
return sh(f"docker inspect {c} -f '{{{{range .NetworkSettings.Networks}}}}{{{{.IPAddress}}}}{{{{end}}}}'")
def call(m, url, H, body=None):
h = dict(H); data = None
if body is not None:
data = json.dumps(body).encode(); h["Content-Type"] = "application/json"
try:
with urllib.request.urlopen(urllib.request.Request(url, data=data, headers=h, method=m), timeout=120) as r:
t = r.read(); return r.status, (json.loads(t) if t else None)
except urllib.error.HTTPError as e:
return e.code, e.read().decode()[:300]
def up(app, yml):
os.makedirs(f"{W}/{app}", exist_ok=True); shutil.copy(yml, f"{W}/{app}/docker-compose.yml")
open(f"{W}/{app}/.env", "w").write(f"DOMAIN=bench.invalid\nSUBDOMAIN={app}\nUSERDATA_PATH={U}\n")
print(sh(f"cd {W}/{app} && docker compose -p k7{app} up -d 2>&1 | tail -1"))
port = 8989 if app == "sonarr" else 7878
for _ in range(60):
time.sleep(3)
if sh(f"docker inspect {app} -f '{{{{.State.Health.Status}}}}'") == "healthy":
break
key = re.search(r"<ApiKey>([^<]+)", sh(f"docker exec {app} cat /config/config.xml")).group(1)
return f"http://{ip(app)}:{port}/api/v3", {"X-Api-Key": key}
def cmd(base, H, name, **kw):
c, d = call("POST", base + "/command", H, dict(name=name, **kw))
for _ in range(60):
time.sleep(2)
c2, s = call("GET", base + f"/command/{d['id']}", H)
if s.get("status") in ("completed", "failed"):
return s["status"]
def report(app, base, H, label):
kind = "series" if app == "sonarr" else "movie"
c, items = call("GET", base + f"/{kind}", H)
c, roots = call("GET", base + "/rootfolder", H)
c, health = call("GET", base + "/health", H)
print(f" [{label}] root folders: {[(r['path'], r['accessible']) for r in roots]}")
for x in items:
st = x.get("statistics") or {}
print(f" [{label}] {x['title']}: path={x['path']} files={st.get('episodeFileCount', x.get('hasFile'))} sizeOnDisk={st.get('sizeOnDisk')}")
print(f" [{label}] health: {[(h['source'], h['message'][:90]) for h in health]}")
for app, kind, lib, lookup, fname in (
("sonarr", "series", "tv", "/series/lookup?term=tvdb:73705", "{t} - S01E01 - Episode.mkv"),
("radarr", "movie", "movies", "/movie/lookup/tmdb?tmdbId=10331", "{t}.mkv")):
print(f"======== {app}")
os.makedirs(f"{U}/media/{lib}", exist_ok=True); os.makedirs(f"{U}/downloads", exist_ok=True)
sh(f"chown -R 1000:1000 {W}; chmod -R 2775 {U}")
base, H = up(app, f"/tmp/k7/old-{app}.yml")
print(" 1. installed from the OLD template (bind /media/" + lib + ")")
call("POST", base + "/rootfolder", H, {"path": f"/media/{lib}"})
c, lk = call("GET", base + lookup, H)
lk = lk[0] if isinstance(lk, list) else lk
qp = call("GET", base + "/qualityprofile", H)[1][0]["id"]
body = dict(lk, qualityProfileId=qp, rootFolderPath=f"/media/{lib}", monitored=True,
addOptions={"searchForMissingEpisodes": False} if app == "sonarr" else {"searchForMovie": False})
if app == "sonarr":
body["languageProfileId"] = 1; body["seasonFolder"] = True
c, item = call("POST", base + f"/{kind}", H, body)
title_path = item["path"] # /media/tv/<Title> (container path)
host_dir = f"{U}/media/{lib}/" + title_path.split(f"/media/{lib}/", 1)[1] + ("/Season 01" if app == "sonarr" else "")
os.makedirs(host_dir, exist_ok=True)
f = os.path.join(host_dir, fname.format(t=item["title"] + (f" ({item['year']})" if app == "radarr" else "")))
shutil.copy("/tmp/k7/probe.mkv", f); sh(f"chown -R 1000:1000 '{U}/media/{lib}'")
ino0 = os.stat(f).st_ino
print(" rescan ->", cmd(base, H, "RescanSeries" if app == "sonarr" else "RescanMovie"))
report(app, base, H, "OLD template")
print(" 2. updated to TODAY's template (bc54c01): one bind, the same path inside")
base, H = up(app, f"/tmp/k7/new-{app}.yml")
print(" rescan ->", cmd(base, H, "RescanSeries" if app == "sonarr" else "RescanMovie"))
report(app, base, H, "NEW template, before the repair")
print(" 3. the repair, through the app's own API, NO file moved")
new_root = f"{U}/media/{lib}"
c, r = call("POST", base + "/rootfolder", H, {"path": new_root}); print(f" add root folder {new_root} -> {c}")
c, items = call("GET", base + f"/{kind}", H)
ids = [x["id"] for x in items]
if app == "sonarr":
c, r = call("PUT", base + "/series/editor", H, {"seriesIds": ids, "rootFolderPath": new_root, "moveFiles": False})
else:
c, r = call("PUT", base + "/movie/editor", H, {"movieIds": ids, "rootFolderPath": new_root, "moveFiles": False})
print(f" editor: rootFolderPath -> {new_root}, moveFiles=false -> {c}")
print(" rescan ->", cmd(base, H, "RescanSeries" if app == "sonarr" else "RescanMovie"))
for r0 in call("GET", base + "/rootfolder", H)[1]:
if r0["path"].rstrip("/") == f"/media/{lib}":
print(f" remove the old root folder /media/{lib} -> {call('DELETE', base + f'/rootfolder/{r0['id']}', H)[0]}")
report(app, base, H, "NEW template, after the repair")
print(f" the file: same inode before and after = {os.stat(f).st_ino == ino0} (moved by nobody), size={os.path.getsize(f)}")
print("======== teardown")
for app in ("sonarr", "radarr"):
print(sh(f"cd {W}/{app} && docker compose -p k7{app} down -v 2>&1 | tail -1"))
shutil.rmtree(W, ignore_errors=True)
print("left:", sh("docker ps -aq -f name=sonarr -f name=radarr | wc -l"), "containers;", sh("docker volume ls -q | grep -c k7"), "volumes")