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
119 lines
5.2 KiB
Python
119 lines
5.2 KiB
Python
#!/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()
|