Files
felhom.eu/documentation/audits/media-build-2026-10-15/tools/k1.py
T

174 lines
8.1 KiB
Python

#!/usr/bin/env python3
"""k1.py <step> — K1 (what the media apps do while a drive is gone), run INSIDE the Tester 1 guest.
steps:
mark mark one Media B episode watched in Plex and Jellyfin (the watch-state probe)
state what every app shows now (per item: present / unavailable / gone; watch state of the probe)
plexscan force Plex to scan both libraries (and wait)
plexset <0|1> set Plex's „empty trash automatically after every scan" (autoEmptyTrash)
jfscan force Jellyfin to scan (and wait)
arrscan run Sonarr's RefreshSeries + RescanSeries and Radarr's RefreshMovie + RescanMovie (and wait)
arrset <0|1> set Sonarr/Radarr „unmonitor deleted episodes/movies"
Plex's token: its own Preferences.xml. Jellyfin's admin password: substituted into this copy by the runner. Nothing printed.
"""
import json, re, subprocess, sys, time, urllib.error, urllib.parse, urllib.request
JFPW = "__JF_PW__"
PROBE_SHOW, PROBE_EP = "American Horror Story", (13, 1) # on Media B
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}}}}'")
def call(m, url, h=None, body=None, raw=False, t=60):
hh = dict(h or {})
data = None
if body is not None:
data = json.dumps(body).encode(); hh["Content-Type"] = "application/json"
try:
with urllib.request.urlopen(urllib.request.Request(url, data=data, headers=hh, method=m), timeout=t) as r:
b = r.read(); return r.status, (b.decode() if raw else (json.loads(b) if b else None))
except urllib.error.HTTPError as e:
return e.code, None
P = "/var/lib/docker/volumes/plex_plex_config/_data/Library/Application Support/Plex Media Server"
PT = re.search(r'PlexOnlineToken="([^"]+)', open(f"{P}/Preferences.xml").read()).group(1)
PB = f"http://{ip('plex')}:32400"
PH = {"Accept": "application/json", "X-Plex-Token": PT}
def plex(m, path, **q):
return call(m, PB + path + ("?" + urllib.parse.urlencode(q) if q else ""), PH)
def arr(app):
port = 8989 if app == "sonarr" else 7878
k = re.search(r"<ApiKey>([^<]+)", open(f"/var/lib/docker/volumes/{app}_{app}_config/_data/config.xml").read()).group(1)
return f"http://{ip(app)}:{port}/api/v3", {"X-Api-Key": k, "Host": app}
def jf():
b = f"http://{ip('jellyfin')}:8096"
A = 'MediaBrowser Client="felhom-test", Device="k1", DeviceId="felhom-k1", Version="1"'
c, r = call("POST", b + "/Users/AuthenticateByName", {"Authorization": A}, {"Username": "admin", "Pw": JFPW})
return b, {"X-Emby-Token": r["AccessToken"]}, r["User"]["Id"]
def plex_items():
out = {}
for sec in (1, 2):
c, d = plex("GET", f"/library/sections/{sec}/all")
for m in (d or {}).get("MediaContainer", {}).get("Metadata", []):
out[m["title"]] = m["ratingKey"]
return out
def plex_probe():
rk = plex_items().get(PROBE_SHOW)
if not rk:
return None, "show gone from Plex"
c, d = plex("GET", f"/library/metadata/{rk}/allLeaves")
for e in (d or {}).get("MediaContainer", {}).get("Metadata", []):
if (e.get("parentIndex"), e.get("index")) == PROBE_EP:
return e["ratingKey"], f"viewCount={e.get('viewCount', 0)}"
return None, "episode gone"
def jf_probe(b, H, uid):
# the plain recursive episode list (the per-show and search routes answered empty on this 10.11)
c, d = call("GET", b + f"/Users/{uid}/Items?Recursive=true&IncludeItemTypes=Episode", H)
for e in (d or {}).get("Items", []):
if "Horror" in (e.get("SeriesName") or "") and (e.get("ParentIndexNumber"), e.get("IndexNumber")) == PROBE_EP:
return e["Id"], f"Played={e.get('UserData', {}).get('Played')}"
return None, "episode gone"
def state():
print(f"--- {time.strftime('%H:%M:%S')} state")
for sec in (1, 2):
c, d = plex("GET", f"/library/sections/{sec}/all", includeMissing=1)
for m in (d or {}).get("MediaContainer", {}).get("Metadata", []):
c2, d2 = plex("GET", f"/library/metadata/{m['ratingKey']}/allLeaves" if m["type"] == "show" else f"/library/metadata/{m['ratingKey']}")
leaves = (d2 or {}).get("MediaContainer", {}).get("Metadata", [])
parts = [p for l in leaves for md in l.get("Media", []) for p in md.get("Part", [])]
gone = sum(1 for p in parts if p.get("accessible") is False or p.get("exists") is False)
print(f" plex {m['type']:5} {m['title'][:24]:24} items={len(leaves)} parts={len(parts)} unavailable={gone}")
print(" plex probe:", plex_probe()[1])
b, H, uid = jf()
c, d = call("GET", b + f"/Users/{uid}/Items?Recursive=true&IncludeItemTypes=Series,Movie,Episode", H)
items = (d or {}).get("Items", [])
print(" jellyfin:", {t: len([i for i in items if i["Type"] == t]) for t in ("Series", "Movie", "Episode")},
sorted(i["Name"] for i in items if i["Type"] in ("Series", "Movie")))
print(" jellyfin probe:", jf_probe(b, H, uid)[1])
for app, kind in (("sonarr", "series"), ("radarr", "movie")):
base, H2 = arr(app)
c, s = call("GET", base + f"/{kind}", H2)
for x in s or []:
st = x.get("statistics") or {}
print(f" {app} {x['title'][:24]:24} monitored={x['monitored']} files={st.get('episodeFileCount', x.get('hasFile'))} path={x['path'].split('/')[3]}")
if app == "sonarr":
for x in s or []:
c, eps = call("GET", base + f"/episode?seriesId={x['id']}", H2)
eps = eps or []
print(f" {x['title'][:24]:24} episodes monitored={sum(1 for e in eps if e['monitored'])}/{len(eps)} hasFile={sum(1 for e in eps if e['hasFile'])}")
def wait_cmd(base, H, cid):
for _ in range(60):
time.sleep(3)
c, d = call("GET", base + f"/command/{cid}", H)
if (d or {}).get("status") in ("completed", "failed", "aborted"):
return d["status"]
return "timeout"
step = sys.argv[1]
if step == "mark":
rk, s = plex_probe(); print("plex probe before:", s)
c, _ = plex("GET", "/:/scrobble", key=rk, identifier="com.plexapp.plugins.library"); print("plex scrobble ->", c)
b, H, uid = jf(); iid, s = jf_probe(b, H, uid); print("jellyfin probe before:", s)
c, _ = call("POST", b + f"/Users/{uid}/PlayedItems/{iid}", H); print("jellyfin played ->", c)
print("after: plex", plex_probe()[1], "| jellyfin", jf_probe(b, H, uid)[1])
elif step == "state":
state()
elif step == "plexscan":
for sec in (1, 2):
print(f"plex refresh section {sec} ->", plex("GET", f"/library/sections/{sec}/refresh", force=1)[0])
for _ in range(60):
time.sleep(5)
c, d = plex("GET", "/library/sections")
if not any(x.get("refreshing") for x in (d or {}).get("MediaContainer", {}).get("Directory", [])):
break
print("plex scan done", time.strftime("%H:%M:%S"))
elif step == "plexset":
print("plex autoEmptyTrash ->", plex("PUT", "/:/prefs", autoEmptyTrash=sys.argv[2])[0])
elif step == "jfscan":
b, H, uid = jf()
print("jellyfin /Library/Refresh ->", call("POST", b + "/Library/Refresh", H)[0])
for _ in range(60):
time.sleep(5)
c, tasks = call("GET", b + "/ScheduledTasks", H)
t = [x for x in tasks or [] if x.get("Key") == "RefreshLibrary"]
if t and t[0].get("State") == "Idle":
break
print("jellyfin scan done", time.strftime("%H:%M:%S"))
elif step == "arrscan":
for app, cmds in (("sonarr", ("RefreshSeries", "RescanSeries")), ("radarr", ("RefreshMovie", "RescanMovie"))):
base, H = arr(app)
for cmd in cmds:
c, d = call("POST", base + "/command", H, {"name": cmd})
print(f"{app} {cmd} -> {c} {wait_cmd(base, H, (d or {}).get('id'))}")
elif step == "arrset":
v = sys.argv[2] == "1"
for app, key in (("sonarr", "autoUnmonitorPreviouslyDownloadedEpisodes"), ("radarr", "autoUnmonitorPreviouslyDownloadedMovies")):
base, H = arr(app)
c, mm = call("GET", base + "/config/mediamanagement", H)
mm[key] = v
print(f"{app} {key}={v} ->", call("PUT", base + f"/config/mediamanagement/{mm['id']}", H, mm)[0])