Add a read-only LAN probe for validating the design
Tautulli stays local-network-only, so nothing off the LAN can check this design against real data. tools/probe.py closes that gap from the inside: GET requests only, standard library only, credentials redacted from all output including error messages. It answers open questions 1-4 in one run — Tautulli's coverage horizon, the finished/partial/abandoned split across real plays, whether successive plays are being grouped, library shapes and sizes, multi-version items, and path roots by size. It also runs the pms_identifier cross-check from section 4.11. tools/mockserver.py mocks both APIs so the probe is testable without a live server. Verified against it: the happy path, Tautulli absent, Tautulli unreachable, Tautulli erroring, Plex unreachable, and an identifier mismatch. Credential redaction confirmed in every error path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GVbG48GAXfCZatcmX123Ra
This commit is contained in:
parent
74dfc4dab8
commit
2c1033e4a7
4 changed files with 626 additions and 5 deletions
449
tools/probe.py
Executable file
449
tools/probe.py
Executable file
|
|
@ -0,0 +1,449 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
MediaShelf probe — read-only reconnaissance against Plex and Tautulli.
|
||||
|
||||
Run this on the LAN (anywhere that can reach both servers). It answers the open
|
||||
questions in docs/design.md against real data, so the design can be validated
|
||||
without exposing either service outside the network.
|
||||
|
||||
It is READ-ONLY. It issues GET requests only. It never deletes, modifies, or
|
||||
writes anything to either server, and it never prints your credentials.
|
||||
|
||||
Standard library only — no pip install needed. Python 3.8+.
|
||||
|
||||
Usage:
|
||||
|
||||
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 probe.py
|
||||
|
||||
# or, to also write the full item inventory out for offline analysis:
|
||||
python3 probe.py --dump inventory.json
|
||||
|
||||
Everything it prints is safe to share.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
TIMEOUT = 30
|
||||
PAGE = 500
|
||||
HIST_PAGE = 1000
|
||||
|
||||
|
||||
# ─────────────────────────────── plumbing ────────────────────────────────
|
||||
|
||||
|
||||
class Fail(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _get(url, headers=None):
|
||||
req = urllib.request.Request(url, headers=headers or {}, method="GET")
|
||||
try:
|
||||
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, _safe(url)))
|
||||
except urllib.error.URLError as e:
|
||||
raise Fail("cannot reach %s (%s)" % (_safe(url), e.reason))
|
||||
except json.JSONDecodeError:
|
||||
raise Fail("non-JSON response from %s" % _safe(url))
|
||||
|
||||
|
||||
def _safe(url):
|
||||
"""Strip credentials out of a URL before it appears in any output."""
|
||||
parts = urllib.parse.urlsplit(url)
|
||||
q = urllib.parse.parse_qsl(parts.query)
|
||||
q = [(k, "***" if k.lower() in ("apikey", "x-plex-token") else v) for k, v in q]
|
||||
return urllib.parse.urlunsplit(
|
||||
(parts.scheme, parts.netloc, parts.path, urllib.parse.urlencode(q), "")
|
||||
)
|
||||
|
||||
|
||||
def human(n):
|
||||
if n is None:
|
||||
return "?"
|
||||
for unit in ("B", "KB", "MB", "GB", "TB", "PB"):
|
||||
if abs(n) < 1024:
|
||||
return "%.1f %s" % (n, unit)
|
||||
n /= 1024.0
|
||||
return "%.1f EB" % n
|
||||
|
||||
|
||||
def ymd(ts):
|
||||
if not ts:
|
||||
return "—"
|
||||
return time.strftime("%Y-%m-%d", time.localtime(int(ts)))
|
||||
|
||||
|
||||
def days_since(ts):
|
||||
if not ts:
|
||||
return None
|
||||
return int((time.time() - int(ts)) / 86400)
|
||||
|
||||
|
||||
# ──────────────────────────────── plex ───────────────────────────────────
|
||||
|
||||
|
||||
class Plex:
|
||||
def __init__(self, base, token):
|
||||
self.base = base.rstrip("/")
|
||||
self.headers = {
|
||||
"X-Plex-Token": token,
|
||||
"Accept": "application/json",
|
||||
"X-Plex-Product": "MediaShelf-probe",
|
||||
"X-Plex-Client-Identifier": "mediashelf-probe",
|
||||
}
|
||||
|
||||
def get(self, path, **params):
|
||||
url = self.base + path
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
return _get(url, self.headers).get("MediaContainer", {})
|
||||
|
||||
def identity(self):
|
||||
return self.get("/identity")
|
||||
|
||||
def sections(self):
|
||||
return self.get("/library/sections").get("Directory", [])
|
||||
|
||||
def section_detail(self, key):
|
||||
return self.get("/library/sections/" + str(key))
|
||||
|
||||
def items(self, key, libtype):
|
||||
"""Page through a section, yielding raw Video/Directory dicts."""
|
||||
start = 0
|
||||
while True:
|
||||
mc = self.get(
|
||||
"/library/sections/%s/all" % key,
|
||||
type=libtype,
|
||||
**{
|
||||
"X-Plex-Container-Start": start,
|
||||
"X-Plex-Container-Size": PAGE,
|
||||
}
|
||||
)
|
||||
batch = mc.get("Metadata", []) or []
|
||||
if not batch:
|
||||
return
|
||||
for row in batch:
|
||||
yield row
|
||||
start += len(batch)
|
||||
total = mc.get("totalSize") or mc.get("size") or 0
|
||||
if start >= int(total):
|
||||
return
|
||||
|
||||
|
||||
def item_size(row):
|
||||
total = 0
|
||||
parts = 0
|
||||
for media in row.get("Media", []) or []:
|
||||
for part in media.get("Part", []) or []:
|
||||
total += int(part.get("size") or 0)
|
||||
parts += 1
|
||||
return total, parts
|
||||
|
||||
|
||||
def item_paths(row):
|
||||
out = []
|
||||
for media in row.get("Media", []) or []:
|
||||
for part in media.get("Part", []) or []:
|
||||
if part.get("file"):
|
||||
out.append(part["file"])
|
||||
return out
|
||||
|
||||
|
||||
# ────────────────────────────── tautulli ─────────────────────────────────
|
||||
|
||||
|
||||
class Tautulli:
|
||||
def __init__(self, base, key):
|
||||
self.base = base.rstrip("/") + "/api/v2"
|
||||
self.key = key
|
||||
|
||||
def cmd(self, command, **params):
|
||||
params["apikey"] = self.key
|
||||
params["cmd"] = command
|
||||
url = self.base + "?" + urllib.parse.urlencode(params)
|
||||
body = _get(url)
|
||||
resp = body.get("response") or {}
|
||||
if resp.get("result") != "success":
|
||||
raise Fail(
|
||||
"Tautulli cmd=%s returned %s: %s"
|
||||
% (command, resp.get("result"), resp.get("message"))
|
||||
)
|
||||
return resp.get("data")
|
||||
|
||||
def server_info(self):
|
||||
return self.cmd("get_server_info") or {}
|
||||
|
||||
def users(self):
|
||||
return self.cmd("get_users") or []
|
||||
|
||||
def history_edge(self, direction):
|
||||
"""Oldest (asc) or newest (desc) single history row."""
|
||||
d = self.cmd(
|
||||
"get_history", order_column="date", order_dir=direction,
|
||||
grouping=0, start=0, length=1,
|
||||
) or {}
|
||||
rows = d.get("data") or []
|
||||
return (rows[0] if rows else None), d.get("recordsFiltered") or d.get("recordsTotal") or 0
|
||||
|
||||
def history_sample(self, length=2000):
|
||||
d = self.cmd(
|
||||
"get_history", order_column="date", order_dir="desc",
|
||||
grouping=0, start=0, length=length,
|
||||
) or {}
|
||||
return d.get("data") or []
|
||||
|
||||
|
||||
# ───────────────────────────────── report ────────────────────────────────
|
||||
|
||||
|
||||
def section(title):
|
||||
print("\n" + title)
|
||||
print("─" * len(title))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Read-only Plex + Tautulli probe for MediaShelf.")
|
||||
ap.add_argument("--dump", metavar="FILE", help="write the full item inventory to FILE as JSON")
|
||||
ap.add_argument("--history-sample", type=int, default=2000,
|
||||
help="how many recent history rows to sample (default 2000)")
|
||||
args = ap.parse_args()
|
||||
|
||||
plex_url = os.environ.get("PLEX_BASE_URL")
|
||||
plex_token = os.environ.get("PLEX_TOKEN")
|
||||
taut_url = os.environ.get("TAUTULLI_BASE_URL")
|
||||
taut_key = os.environ.get("TAUTULLI_API_KEY")
|
||||
|
||||
if not plex_url or not plex_token:
|
||||
sys.exit("PLEX_BASE_URL and PLEX_TOKEN must be set.")
|
||||
|
||||
print("MediaShelf probe — read-only. No data is modified.")
|
||||
print("Run at %s" % time.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
plex = Plex(plex_url, plex_token)
|
||||
taut = Tautulli(taut_url, taut_key) if (taut_url and taut_key) else None
|
||||
|
||||
# ── identity and the cross-check ───────────────────────────────────
|
||||
section("Servers")
|
||||
try:
|
||||
ident = plex.identity()
|
||||
pms_id = ident.get("machineIdentifier")
|
||||
print("Plex : version %s, machineIdentifier %s…"
|
||||
% (ident.get("version"), (pms_id or "")[:8]))
|
||||
except Fail as e:
|
||||
sys.exit("Plex unreachable: %s" % e)
|
||||
|
||||
taut_ok = False
|
||||
if taut:
|
||||
try:
|
||||
si = taut.server_info()
|
||||
taut_ok = True
|
||||
match = si.get("pms_identifier") == pms_id
|
||||
print("Tautulli : watching %s (%s:%s)"
|
||||
% (si.get("pms_name"), si.get("pms_ip"), si.get("pms_port")))
|
||||
print(" pms_identifier match: %s"
|
||||
% ("YES" if match else "NO — Tautulli is watching a DIFFERENT Plex server"))
|
||||
if not match:
|
||||
print(" ** MediaShelf would refuse to join this history data. **")
|
||||
except Fail as e:
|
||||
print("Tautulli : UNREACHABLE — %s" % e)
|
||||
else:
|
||||
print("Tautulli : not configured (set TAUTULLI_BASE_URL and TAUTULLI_API_KEY)")
|
||||
|
||||
# ── question 1: coverage horizon ───────────────────────────────────
|
||||
coverage_since = None
|
||||
if taut_ok:
|
||||
section("Q1 · Tautulli coverage horizon")
|
||||
try:
|
||||
oldest, total = taut.history_edge("asc")
|
||||
newest, _ = taut.history_edge("desc")
|
||||
if oldest:
|
||||
coverage_since = int(oldest.get("date") or 0)
|
||||
print("Oldest logged play : %s (%s days ago)"
|
||||
% (ymd(coverage_since), days_since(coverage_since)))
|
||||
print("Newest logged play : %s" % ymd(newest.get("date") if newest else None))
|
||||
print("Total history rows : %s" % f"{int(total):,}")
|
||||
print("\n-> Anything added before %s that was watched before that date"
|
||||
% ymd(coverage_since))
|
||||
print(" will look never-watched. MediaShelf flags these 'pre_history'.")
|
||||
else:
|
||||
print("No history rows found — Tautulli has logged nothing yet.")
|
||||
except Fail as e:
|
||||
print("Could not read history: %s" % e)
|
||||
|
||||
# ── question 2: grouping sanity + user spread ──────────────────────
|
||||
if taut_ok:
|
||||
section("Q2 · History shape (sample)")
|
||||
try:
|
||||
rows = taut.history_sample(args.history_sample)
|
||||
print("Sampled %s most recent rows." % f"{len(rows):,}")
|
||||
if rows:
|
||||
users = Counter(r.get("friendly_name") or r.get("user") for r in rows)
|
||||
print("\nDistinct users in sample: %d" % len(users))
|
||||
for name, n in users.most_common(10):
|
||||
print(" %-24s %6d plays" % (str(name)[:24], n))
|
||||
|
||||
pcs = [int(r.get("percent_complete") or 0) for r in rows]
|
||||
finished = sum(1 for p in pcs if p >= 85)
|
||||
abandoned = sum(1 for p in pcs if p < 15)
|
||||
partial = len(pcs) - finished - abandoned
|
||||
print("\nCompletion breakdown of sampled plays:")
|
||||
print(" finished (>=85%%) : %6d (%.1f%%)" % (finished, 100.0 * finished / len(pcs)))
|
||||
print(" partial (15-85%%): %6d (%.1f%%)" % (partial, 100.0 * partial / len(pcs)))
|
||||
print(" abandoned (<15%%) : %6d (%.1f%%)" % (abandoned, 100.0 * abandoned / len(pcs)))
|
||||
print("\n-> The 'abandoned' share is the signal Plex cannot provide.")
|
||||
|
||||
refs = Counter(r.get("reference_id") for r in rows if r.get("reference_id"))
|
||||
dupes = sum(1 for c in refs.values() if c > 1)
|
||||
print("\nRows sharing a reference_id: %d groups" % dupes)
|
||||
print("-> Non-zero is expected and fine: it means successive plays exist")
|
||||
print(" and MediaShelf's own session merging has real work to do.")
|
||||
except Fail as e:
|
||||
print("Could not sample history: %s" % e)
|
||||
|
||||
# ── questions 3 & 4: library shape and paths ───────────────────────
|
||||
section("Q3/Q4 · Libraries, sizes and path roots")
|
||||
try:
|
||||
secs = plex.sections()
|
||||
except Fail as e:
|
||||
sys.exit("Could not list sections: %s" % e)
|
||||
|
||||
inventory = []
|
||||
grand_total = 0
|
||||
grand_count = 0
|
||||
root_bytes = defaultdict(int)
|
||||
|
||||
for s in secs:
|
||||
stype = s.get("type")
|
||||
if stype not in ("movie", "show"):
|
||||
print("\n%-24s %-8s (skipped — not movie/show)" % (s.get("title"), stype))
|
||||
continue
|
||||
|
||||
key = s.get("key")
|
||||
locs = [d.get("path") for d in (s.get("Location") or [])]
|
||||
libtype = 1 if stype == "movie" else 4 # movie | episode
|
||||
|
||||
n = 0
|
||||
size = 0
|
||||
parts = 0
|
||||
oldest_add = None
|
||||
multi_version = 0
|
||||
shows = set()
|
||||
seasons = set()
|
||||
|
||||
try:
|
||||
for row in plex.items(key, libtype):
|
||||
sz, pc = item_size(row)
|
||||
n += 1
|
||||
size += sz
|
||||
parts += pc
|
||||
if pc > 1:
|
||||
multi_version += 1
|
||||
added = int(row.get("addedAt") or 0)
|
||||
if added and (oldest_add is None or added < oldest_add):
|
||||
oldest_add = added
|
||||
if stype == "show":
|
||||
shows.add(row.get("grandparentRatingKey"))
|
||||
seasons.add(row.get("parentRatingKey"))
|
||||
|
||||
for p in item_paths(row):
|
||||
# bucket by the first two path components, e.g. /mnt/vault2
|
||||
bits = p.split("/")
|
||||
root = "/".join(bits[:3]) if len(bits) > 3 else p
|
||||
root_bytes[root] += sz if pc == 1 else sz // max(pc, 1)
|
||||
|
||||
if args.dump is not None:
|
||||
inventory.append({
|
||||
"section": s.get("title"),
|
||||
"type": stype,
|
||||
"ratingKey": row.get("ratingKey"),
|
||||
"title": row.get("title"),
|
||||
"grandparentTitle": row.get("grandparentTitle"),
|
||||
"parentIndex": row.get("parentIndex"),
|
||||
"year": row.get("year"),
|
||||
"addedAt": added,
|
||||
"viewCount": int(row.get("viewCount") or 0),
|
||||
"lastViewedAt": int(row.get("lastViewedAt") or 0) or None,
|
||||
"size": sz,
|
||||
"parts": pc,
|
||||
"paths": item_paths(row),
|
||||
})
|
||||
except Fail as e:
|
||||
print("\n%-24s ERROR: %s" % (s.get("title"), e))
|
||||
continue
|
||||
|
||||
grand_total += size
|
||||
grand_count += n
|
||||
|
||||
print("\n%s [%s]" % (s.get("title"), stype))
|
||||
if stype == "movie":
|
||||
print(" movies : %s" % f"{n:,}")
|
||||
else:
|
||||
print(" episodes : %s" % f"{n:,}")
|
||||
print(" seasons : %s (MediaShelf's unit of analysis)"
|
||||
% f"{len(seasons):,}")
|
||||
print(" shows : %s" % f"{len(shows):,}")
|
||||
print(" total size : %s" % human(size))
|
||||
print(" files (parts) : %s" % f"{parts:,}")
|
||||
if multi_version:
|
||||
print(" multi-file items : %s <- these are the ones a naive scan undercounts"
|
||||
% f"{multi_version:,}")
|
||||
print(" oldest addedAt : %s" % ymd(oldest_add))
|
||||
for loc in locs:
|
||||
print(" root : %s" % loc)
|
||||
|
||||
section("Totals")
|
||||
print("Items scanned : %s" % f"{grand_count:,}")
|
||||
print("Total size : %s" % human(grand_total))
|
||||
if root_bytes:
|
||||
print("\nApproximate size by path root:")
|
||||
for root, b in sorted(root_bytes.items(), key=lambda kv: -kv[1]):
|
||||
print(" %-40s %s" % (root[:40], human(b)))
|
||||
print("\n-> If these map to separate arrays, MediaShelf can group by vault,")
|
||||
print(" which matters when the goal is freeing one specific array.")
|
||||
|
||||
# ── a first taste of the actual output ─────────────────────────────
|
||||
if args.dump is not None and inventory:
|
||||
with open(args.dump, "w") as fh:
|
||||
json.dump(inventory, fh)
|
||||
print("\nWrote %s items to %s" % (f"{len(inventory):,}", args.dump))
|
||||
|
||||
section("Preview · 20 largest items Plex thinks were never watched")
|
||||
print("(Plex's viewCount is token-scoped, so treat this as indicative only —")
|
||||
print(" the real report uses Tautulli. This is a sanity check on sizes.)\n")
|
||||
never = [i for i in inventory if not i["viewCount"]]
|
||||
never.sort(key=lambda i: -i["size"])
|
||||
for i in never[:20]:
|
||||
label = i["title"]
|
||||
if i.get("grandparentTitle"):
|
||||
label = "%s — S%s %s" % (i["grandparentTitle"], i.get("parentIndex"), i["title"])
|
||||
print(" %10s added %s %s" % (human(i["size"]), ymd(i["addedAt"]), label[:60]))
|
||||
reclaimable = sum(i["size"] for i in never)
|
||||
print("\n %s across %s items Plex records no views for."
|
||||
% (human(reclaimable), f"{len(never):,}"))
|
||||
|
||||
section("Done")
|
||||
print("Nothing was modified. Paste this output back to review the design against it.")
|
||||
if coverage_since:
|
||||
print("Key number: Tautulli coverage begins %s." % ymd(coverage_since))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
sys.exit("\nInterrupted.")
|
||||
except Fail as e:
|
||||
sys.exit("Failed: %s" % e)
|
||||
Loading…
Add table
Add a link
Reference in a new issue