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:
Jess Hallsworth 2026-09-07 04:57:38 +00:00
parent 74dfc4dab8
commit 2c1033e4a7
No known key found for this signature in database
4 changed files with 626 additions and 5 deletions

119
tools/mockserver.py Normal file
View file

@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Throwaway mock of the Plex + Tautulli endpoints probe.py uses, for testing it
without a live server. Not part of the application."""
import json
import random
import time
import urllib.parse
from http.server import BaseHTTPRequestHandler, HTTPServer
NOW = int(time.time())
random.seed(7)
MOVIES = []
for i in range(120):
parts = [{"file": "/mnt/vault2/movies/Film %d/Film %d.mkv" % (i, i),
"size": random.randint(2, 60) * 10**9, "container": "mkv"}]
if i % 17 == 0: # some multi-version items
parts.append({"file": "/mnt/vault1/movies4k/Film %d/Film %d.4k.mkv" % (i, i),
"size": random.randint(40, 90) * 10**9, "container": "mkv"})
MOVIES.append({
"ratingKey": str(1000 + i), "type": "movie", "title": "Film %d" % i,
"year": 1990 + i % 35, "addedAt": NOW - random.randint(30, 3000) * 86400,
"viewCount": random.choice([0, 0, 0, 1, 2]),
"Media": [{"videoResolution": "1080", "Part": parts}],
})
EPISODES = []
for s in range(8):
for se in range(1, 4):
for ep in range(1, 11):
EPISODES.append({
"ratingKey": str(50000 + len(EPISODES)), "type": "episode",
"title": "Ep %d" % ep, "index": ep,
"grandparentRatingKey": str(900 + s), "grandparentTitle": "Show %d" % s,
"parentRatingKey": str(9000 + s * 10 + se), "parentIndex": se,
"addedAt": NOW - random.randint(30, 2000) * 86400,
"viewCount": random.choice([0, 0, 1]),
"Media": [{"Part": [{"file": "/mnt/vault2/tv/Show %d/S%02d/E%02d.mkv" % (s, se, ep),
"size": random.randint(1, 4) * 10**9}]}],
})
HISTORY = []
for i in range(500):
HISTORY.append({
"row_id": i, "reference_id": i // 2, "date": NOW - random.randint(1, 900) * 86400,
"rating_key": str(random.choice([m["ratingKey"] for m in MOVIES])),
"user_id": random.choice([1, 2, 3]), "user": "u%d" % random.choice([1, 2, 3]),
"friendly_name": random.choice(["Jess", "Sam", "Guest"]),
"media_type": "movie",
"percent_complete": random.choice([3, 8, 12, 45, 60, 92, 97, 100]),
"watched_status": random.choice([0, 0.5, 1]),
"play_duration": random.randint(60, 9000), "paused_counter": 0,
})
HISTORY.sort(key=lambda h: h["date"])
def page(items, q):
start = int(q.get("X-Plex-Container-Start", ["0"])[0])
size = int(q.get("X-Plex-Container-Size", [str(len(items))])[0])
return items[start:start + size], len(items)
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, obj):
body = json.dumps(obj).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
u = urllib.parse.urlsplit(self.path)
q = urllib.parse.parse_qs(u.query)
if u.path == "/identity":
return self._send({"MediaContainer": {"machineIdentifier": "abc123def456",
"version": "1.41.0.1234"}})
if u.path == "/library/sections":
return self._send({"MediaContainer": {"Directory": [
{"key": "1", "type": "movie", "title": "Movies",
"Location": [{"path": "/mnt/vault2/movies"}]},
{"key": "2", "type": "show", "title": "TV Shows",
"Location": [{"path": "/mnt/vault2/tv"}]},
{"key": "3", "type": "artist", "title": "Music",
"Location": [{"path": "/mnt/vault2/music"}]},
]}})
if u.path.startswith("/library/sections/") and u.path.endswith("/all"):
key = u.path.split("/")[3]
src = MOVIES if key == "1" else EPISODES
batch, total = page(src, q)
return self._send({"MediaContainer": {"Metadata": batch, "totalSize": total,
"size": len(batch)}})
if u.path == "/api/v2":
cmd = q.get("cmd", [""])[0]
if cmd == "get_server_info":
return self._send({"response": {"result": "success", "data": {
"pms_identifier": "abc123def456", "pms_name": "Loki",
"pms_ip": "192.168.1.10", "pms_port": 32400}}})
if cmd == "get_history":
d = q.get("order_dir", ["desc"])[0]
rows = HISTORY if d == "asc" else list(reversed(HISTORY))
length = int(q.get("length", ["25"])[0])
return self._send({"response": {"result": "success", "data": {
"data": rows[:length], "recordsFiltered": len(HISTORY),
"recordsTotal": len(HISTORY)}}})
if cmd == "get_users":
return self._send({"response": {"result": "success", "data": []}})
return self._send({"response": {"result": "error",
"message": "unknown cmd", "data": None}})
self.send_response(404)
self.end_headers()
if __name__ == "__main__":
HTTPServer(("127.0.0.1", 8899), H).serve_forever()