#!/usr/bin/env python3 """k1.py — 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"([^<]+)", 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])