Validate the design against the live servers, and correct it

Ran the probe and a new reclaim preview against Loki and Tautulli from a LAN
host. The measured library is 65.7 TB across 24 libraries with 60 users and
87,640 logged plays. Five things in the design were wrong or missing.

- Tautulli's get_library_media_info returns file_size 0 for every show
  section regardless of section_type, so it cannot cross-check TV sizes.
  Plex is the only size authority for TV, which is 49 of the 66 TB.
- solitude: divisor of 3 confirmed correct (61.7% of watched items have
  exactly one viewer), but weight raised 0.04 -> 0.10 since on a 60-user
  server "only one person watched this" is real signal.
- rejection: abandonment is 4.9% of plays, not the 30% hoped for. Weight
  cut 0.12 -> 0.06. Kept because it is decisive when it fires.
- Cross-library duplicates promoted from "later candidate" to v1: with 16
  movie sections, Movies and 4K Movies routinely hold the same film.
- Protected libraries added. Family Videos is 38 GB of irreplaceable home
  video that is 86% "never played" and scores as a perfect delete target.

Also splits the reclaim pool into confident (6.5 TB, added while Tautulli
was watching and never played) and uncertain (20.9 TB, predates coverage).
80% of the library predates Tautulli, so pre_history is the majority state,
not an edge case.

Adds tools/reclaim_preview.py, which produced these numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVbG48GAXfCZatcmX123Ra
This commit is contained in:
Jess Hallsworth 2026-09-07 05:23:34 +00:00
parent 2c1033e4a7
commit 5e57fd1614
No known key found for this signature in database
4 changed files with 542 additions and 43 deletions

327
tools/reclaim_preview.py Normal file
View file

@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""
MediaShelf reclaim preview read-only, LAN-side.
Answers the question the whole project exists to answer: how much of the library
has never been played, and how confident can we be about that?
It works the way MediaShelf itself will: **sizes and paths from Plex, plays from
Tautulli**, joined on Plex's rating_key. (Tautulli's own get_library_media_info
table reports file_size 0 for every TV section regardless of the section_type
parameter, so it cannot be the size source see docs/design.md §4.11.)
Output is the calibration data the reclaim score needs:
· never-played bytes per library, split by whether the item predates
Tautulli's coverage window
· TV broken out at season granularity, MediaShelf's actual unit
· watcher-count spread, for calibrating the 'solitude' component
READ-ONLY. GET requests only. Credentials never appear in output.
export PLEX_BASE_URL=http://192.168.1.10:32400
export PLEX_TOKEN=...
export TAUTULLI_BASE_URL=http://192.168.1.100:8181
export TAUTULLI_API_KEY=...
python3 reclaim_preview.py
"""
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter, defaultdict
TIMEOUT = 90
PLEX_PAGE = 500
HIST_PAGE = 10000
class Fail(Exception):
pass
def redact(url):
p = urllib.parse.urlsplit(url)
q = [(k, "***" if k.lower() in ("apikey", "x-plex-token") else v)
for k, v in urllib.parse.parse_qsl(p.query)]
return urllib.parse.urlunsplit((p.scheme, p.netloc, p.path,
urllib.parse.urlencode(q), ""))
def fetch(url, headers=None):
try:
req = urllib.request.Request(url, headers=headers or {})
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
return json.loads(r.read().decode("utf-8", "replace"))
except urllib.error.HTTPError as e:
raise Fail("HTTP %s from %s" % (e.code, redact(url)))
except urllib.error.URLError as e:
raise Fail("cannot reach %s (%s)" % (redact(url), e.reason))
def human(n):
n = float(n or 0)
for u in ("B", "KB", "MB", "GB", "TB", "PB"):
if abs(n) < 1024:
return "%.1f %s" % (n, u)
n /= 1024.0
return "%.1f EB" % n
def ymd(ts):
try:
ts = int(ts)
except (TypeError, ValueError):
return ""
return time.strftime("%Y-%m-%d", time.localtime(ts)) if ts else ""
def bar(frac, width=26):
f = int(round(max(0.0, min(1.0, frac)) * width))
return "" * f + "·" * (width - f)
# ──────────────────────────────── sources ────────────────────────────────
class Plex:
def __init__(self, base, token):
self.base = base.rstrip("/")
self.h = {"X-Plex-Token": token, "Accept": "application/json",
"X-Plex-Product": "MediaShelf-preview"}
def mc(self, path, **p):
url = self.base + path + ("?" + urllib.parse.urlencode(p) if p else "")
return fetch(url, self.h).get("MediaContainer", {})
def sections(self):
return self.mc("/library/sections").get("Directory", [])
def items(self, key, libtype):
start = 0
while True:
mc = self.mc("/library/sections/%s/all" % key, type=libtype,
**{"X-Plex-Container-Start": start,
"X-Plex-Container-Size": PLEX_PAGE})
batch = mc.get("Metadata") or []
if not batch:
return
for r in batch:
yield r
start += len(batch)
if start >= int(mc.get("totalSize") or mc.get("size") or 0):
return
class Tautulli:
def __init__(self, base, key):
self.base = base.rstrip("/") + "/api/v2"
self.key = key
def cmd(self, c, **p):
p["apikey"] = self.key
p["cmd"] = c
body = fetch(self.base + "?" + urllib.parse.urlencode(p))
r = body.get("response") or {}
if r.get("result") != "success":
raise Fail("cmd=%s: %s" % (c, r.get("message")))
return r.get("data")
def all_history(self):
"""Page the whole history. Returns rows with the fields we need."""
out = []
start = 0
while True:
d = self.cmd("get_history", order_column="date", order_dir="desc",
grouping=0, start=start, length=HIST_PAGE) or {}
rows = d.get("data") or []
if not rows:
break
out.extend(rows)
total = int(d.get("recordsFiltered") or d.get("recordsTotal") or 0)
start += len(rows)
sys.stderr.write("\r history: %s / %s" % (f"{start:,}", f"{total:,}"))
sys.stderr.flush()
if start >= total:
break
sys.stderr.write("\n")
return out
def size_of(row):
t = 0
for m in row.get("Media") or []:
for p in m.get("Part") or []:
t += int(p.get("size") or 0)
return t
# ───────────────────────────────── main ──────────────────────────────────
def main():
pb, pt = os.environ.get("PLEX_BASE_URL"), os.environ.get("PLEX_TOKEN")
tb, tk = os.environ.get("TAUTULLI_BASE_URL"), os.environ.get("TAUTULLI_API_KEY")
if not (pb and pt and tb and tk):
sys.exit("PLEX_BASE_URL, PLEX_TOKEN, TAUTULLI_BASE_URL and "
"TAUTULLI_API_KEY must all be set.")
plex, taut = Plex(pb, pt), Tautulli(tb, tk)
print("MediaShelf reclaim preview — read-only.")
print("Run at %s\n" % time.strftime("%Y-%m-%d %H:%M:%S"))
# ── Tautulli: every play, reduced to per-leaf facts ────────────────
sys.stderr.write("Pulling Tautulli history...\n")
hist = taut.all_history()
coverage = min((int(h.get("date") or 0) for h in hist if h.get("date")), default=0)
plays = Counter()
watchers = defaultdict(set)
last_play = {}
finished = partial = abandoned = 0
for h in hist:
rk = str(h.get("rating_key") or "")
if not rk:
continue
plays[rk] += 1
watchers[rk].add(h.get("user_id"))
d = int(h.get("date") or 0)
if d > last_play.get(rk, 0):
last_play[rk] = d
pc = int(h.get("percent_complete") or 0)
if pc >= 85:
finished += 1
elif pc < 15:
abandoned += 1
else:
partial += 1
print("Tautulli coverage begins : %s (%s plays, %s distinct items)"
% (ymd(coverage), f"{len(hist):,}", f"{len(plays):,}"))
tot_p = finished + partial + abandoned or 1
print("Completion of ALL plays : finished %.1f%% · partial %.1f%% · abandoned %.1f%%\n"
% (100.0 * finished / tot_p, 100.0 * partial / tot_p, 100.0 * abandoned / tot_p))
# ── Plex: walk every library, join on rating_key ───────────────────
print("%-24s %8s %10s %11s %11s" %
("LIBRARY", "UNITS", "TOTAL", "NEVER PL.", "CONFIDENT"))
print("" * 70)
g = Counter()
per_lib = []
season_rows = []
for s in plex.sections():
stype = s.get("type")
if stype not in ("movie", "show"):
continue
name = (s.get("title") or "").strip()
libtype = 1 if stype == "movie" else 4
total = never = never_conf = 0
units = 0
seasons = defaultdict(lambda: {"size": 0, "plays": 0, "added": 0,
"eps": 0, "watchers": set()})
try:
for row in plex.items(s.get("key"), libtype):
sz = size_of(row)
rk = str(row.get("ratingKey") or "")
added = int(row.get("addedAt") or 0)
total += sz
p = plays.get(rk, 0)
if stype == "movie":
units += 1
if not p:
never += sz
if coverage and added >= coverage:
never_conf += sz
else:
sk = str(row.get("parentRatingKey") or "")
d = seasons[sk]
d["size"] += sz
d["plays"] += p
d["eps"] += 1
d["watchers"] |= watchers.get(rk, set())
if added and (not d["added"] or added < d["added"]):
d["added"] = added
d["show"] = row.get("grandparentTitle")
d["idx"] = row.get("parentIndex")
except Fail as e:
print("%-24s ERROR %s" % (name[:24], e))
continue
if stype == "show":
units = len(seasons)
for sk, d in seasons.items():
if not d["plays"]:
never += d["size"]
if coverage and d["added"] >= coverage:
never_conf += d["size"]
season_rows.append({"lib": name, "show": d.get("show"),
"idx": d.get("idx"), "size": d["size"],
"eps": d["eps"], "plays": d["plays"],
"added": d["added"]})
per_lib.append({"name": name, "type": stype, "units": units,
"total": total, "never": never, "conf": never_conf})
g["total"] += total
g["never"] += never
g["conf"] += never_conf
g["units"] += units
print("%-24s %8s %10s %11s %11s" %
(name[:24], f"{units:,}", human(total), human(never), human(never_conf)))
print("" * 70)
print("%-24s %8s %10s %11s %11s" %
("TOTAL", f"{g['units']:,}", human(g["total"]),
human(g["never"]), human(g["conf"])))
tot = g["total"] or 1
print("\nNever played (any era) : %s (%.1f%% of library)"
% (human(g["never"]), 100.0 * g["never"] / tot))
print(" of which CONFIDENT : %s (%.1f%%) — added after %s and never played"
% (human(g["conf"]), 100.0 * g["conf"] / tot, ymd(coverage)))
print(" of which UNCERTAIN : %s (%.1f%%) — predates Tautulli, may have been watched"
% (human(g["never"] - g["conf"]), 100.0 * (g["never"] - g["conf"]) / tot))
print("\nBiggest never-played pools:")
for L in sorted(per_lib, key=lambda x: -x["never"])[:12]:
frac = L["never"] / (L["total"] or 1)
print(" %-24s %s %5.1f%% %s"
% (L["name"][:24], bar(frac), 100 * frac, human(L["never"])))
if season_rows:
cold = [r for r in season_rows if not r["plays"]]
cold.sort(key=lambda r: -r["size"])
print("\nLargest never-played SEASONS (MediaShelf's unit for TV):")
for r in cold[:15]:
label = "%s S%s" % (r["show"], r["idx"])
print(" %9s %2d eps added %s %s"
% (human(r["size"]), r["eps"], ymd(r["added"]), str(label)[:44]))
print(" ... %s never-played seasons totalling %s"
% (f"{len(cold):,}", human(sum(r['size'] for r in cold))))
print("\nWatcher spread (for calibrating 'solitude'):")
dist = Counter(len(v) for v in watchers.values())
tot_i = sum(dist.values()) or 1
for k in sorted(dist)[:8]:
print(" watched by %2d user(s): %7s (%.1f%%)"
% (k, f"{dist[k]:,}", 100.0 * dist[k] / tot_i))
print(" distinct users overall: %d"
% len({u for v in watchers.values() for u in v}))
print("\nDone. Nothing was modified.")
if __name__ == "__main__":
try:
main()
except Fail as e:
sys.exit("Failed: %s" % e)
except KeyboardInterrupt:
sys.exit("\nInterrupted.")