gates / gates (push) Successful in 6m34s
- 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
117 lines
6.2 KiB
Python
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")
|