Implement MediaShelf v1

The application the design describes: Flask + SQLite, Plex for library data,
Tautulli for watch history, report-only.

Structure follows the design's seams. providers/ splits MediaProvider from
HistoryProvider, because on this network library data and watch data live on
different machines and Jellyfin later will have no Tautulli equivalent.
scoring.py implements the reclaim score twice - as a SQL expression for the
live grid (weights change on every slider drag, so storing it would mean
rewriting thousands of rows per drag) and in Python for CSV export and tests,
with a property test over 500 generated rows asserting the two agree.
rules.py compiles saved views to parameterized SQL through a field/operator
whitelist; nothing user-supplied is ever interpolated.

Three properties are enforced by test rather than asserted in prose:

- Ingest is idempotent. Three consecutive full scans leave every count and
  every byte total unchanged. A scanner that double-counts produces a report
  that looks plausible and is wrong.
- Keep marks survive Plex reassigning every rating key in the library. They
  are keyed on content GUID, scoped per library so the Movies and 4K Movies
  copies of the same film mark independently.
- Every config variable the app reads is declared in docker-compose.yml, so
  a variable set in Portainer can never silently do nothing.

Also found and fixed while verifying against a fake Plex+Tautulli pair:
executescript() commits the pending transaction, so migrations needed their
BEGIN/COMMIT inside the script; replaceChildren() renders null as the literal
text "null"; a hash-only URL change does not reload the document, so deep
links needed a hashchange listener; and SQLite ROUND rounds half away from
zero where Python rounds half to even.

73 tests, no live server required.

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 14:59:03 +00:00
parent 58c2883492
commit 6a557bcdd9
No known key found for this signature in database
37 changed files with 6486 additions and 85 deletions

View file

@ -1,119 +1,291 @@
#!/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."""
"""A fake Plex + Tautulli pair, good enough to exercise the whole ingest.
Used two ways:
* standalone, to try tools/probe.py without touching a live server
* imported by the test suite (tests/conftest.py) as a fixture
It deliberately reproduces the awkward parts of the real APIs: multi-version
movies, split parts, shows whose episodes carry no show GUID, Tautulli's
200-with-result-error failure mode, and the fact that a library's item count and
its reported totalSize have to agree for paging to terminate.
Not part of the application.
"""
from __future__ import annotations
import json
import random
import threading
import time
import urllib.parse
from http.server import BaseHTTPRequestHandler, HTTPServer
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
NOW = int(time.time())
random.seed(7)
DAY = 86400
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}],
})
MACHINE_ID = "abc123def456abc123def456"
COVERAGE_DAYS = 400 # Tautulli has been logging this long
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}]}],
def _build(seed=7):
rnd = random.Random(seed)
movies, episodes, shows, history = [], [], [], []
# ── movies, in two libraries that share GUIDs (the duplicate case) ──
for i in range(60):
guid = "plex://movie/%04d" % i
parts = [{
"id": 9000 + i, "file": "/mnt/titan4/Movies/Film %d/Film %d.mkv" % (i, i),
"size": rnd.randint(2, 40) * 10**9, "container": "mkv",
}]
if i % 12 == 0: # a split file
parts.append({
"id": 9500 + i,
"file": "/mnt/titan4/Movies/Film %d/Film %d.part2.mkv" % (i, i),
"size": rnd.randint(1, 8) * 10**9, "container": "mkv",
})
movies.append({
"sectionKey": "1", "ratingKey": str(1000 + i), "guid": guid, "type": "movie",
"title": "Film %d" % i, "titleSort": "Film %04d" % i, "year": 1990 + i % 35,
"addedAt": NOW - rnd.randint(10, 3000) * DAY,
"updatedAt": NOW - rnd.randint(1, 500) * DAY,
"duration": rnd.randint(80, 190) * 60000,
"viewCount": rnd.choice([0, 0, 0, 1, 2]),
"Media": [{"videoResolution": "1080", "videoCodec": "h264",
"audioCodec": "eac3", "bitrate": 8000, "Part": parts}],
})
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"])
# 4K copies of the first 15 films: same GUID, different library
for i in range(15):
movies.append({
"sectionKey": "2", "ratingKey": str(2000 + i), "guid": "plex://movie/%04d" % i,
"type": "movie", "title": "Film %d" % i, "year": 1990 + i % 35,
"addedAt": NOW - rnd.randint(10, 900) * DAY,
"duration": rnd.randint(80, 190) * 60000, "viewCount": rnd.choice([0, 1]),
"Media": [{"videoResolution": "4k", "videoCodec": "hevc", "bitrate": 40000,
"Part": [{"id": 7000 + i,
"file": "/mnt/titan4/4K Movies/Film %d/Film %d.4k.mkv" % (i, i),
"size": rnd.randint(40, 90) * 10**9, "container": "mkv"}]}],
})
# ── shows / seasons / episodes ─────────────────────────────────────
ep_rk = 50000
for s in range(6):
show_rk = str(900 + s)
shows.append({
"sectionKey": "3", "ratingKey": show_rk, "guid": "plex://show/%04d" % s,
"type": "show", "title": "Show %d" % s,
})
for se in range(1, rnd.randint(2, 4)):
season_rk = str(9000 + s * 10 + se)
for ep in range(1, rnd.randint(6, 13)):
episodes.append({
"sectionKey": "3", "ratingKey": str(ep_rk), "guid": "plex://episode/%d" % ep_rk,
"type": "episode", "title": "Episode %d" % ep, "index": ep,
"grandparentRatingKey": show_rk, "grandparentTitle": "Show %d" % s,
"parentRatingKey": season_rk, "parentIndex": se,
"addedAt": NOW - rnd.randint(10, 2500) * DAY,
"duration": rnd.randint(20, 55) * 60000,
"viewCount": rnd.choice([0, 0, 1]),
"Media": [{"videoResolution": "1080", "videoCodec": "h264",
"Part": [{"id": 60000 + ep_rk,
"file": "/mnt/titan4/TVShows/Show %d/S%02d/E%02d.mkv" % (s, se, ep),
"size": rnd.randint(1, 5) * 10**9}]}],
})
ep_rk += 1
# ── Tautulli history ───────────────────────────────────────────────
watchable = [m["ratingKey"] for m in movies] + [e["ratingKey"] for e in episodes]
row_id = 1
for _ in range(1200):
rk = rnd.choice(watchable)
pc = rnd.choice([2, 6, 11, 30, 55, 70, 88, 95, 99, 100, 100])
started = NOW - rnd.randint(1, COVERAGE_DAYS) * DAY - rnd.randint(0, 80000)
history.append({
"row_id": row_id, "reference_id": row_id // 2,
"date": started, "started": started,
"stopped": started + rnd.randint(300, 7200),
"rating_key": rk,
"user_id": rnd.choice([1, 2, 3, 4, 5]),
"user": "user%d" % rnd.choice([1, 2, 3]),
"friendly_name": rnd.choice(["Jess", "Sam", "Alex", "Guest"]),
"media_type": "movie" if rk.startswith(("1", "2")) else "episode",
"percent_complete": pc,
"watched_status": 1 if pc >= 85 else (0.5 if pc >= 15 else 0),
"play_duration": rnd.randint(120, 7000), "paused_counter": 0,
"platform": "Chrome",
})
row_id += 1
# guarantee the oldest event is exactly COVERAGE_DAYS old, so pre_history is testable
history[0]["date"] = history[0]["started"] = NOW - COVERAGE_DAYS * DAY
history.sort(key=lambda h: h["date"])
return movies, episodes, shows, history
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])
MOVIES, EPISODES, SHOWS, HISTORY = _build()
SECTIONS = [
{"key": "1", "type": "movie", "title": "Movies",
"Location": [{"path": "/mnt/titan4/Movies"}]},
{"key": "2", "type": "movie", "title": "4K Movies",
"Location": [{"path": "/mnt/titan4/4K Movies"}]},
{"key": "3", "type": "show", "title": "TV Shows",
"Location": [{"path": "/mnt/titan4/TVShows"}]},
{"key": "4", "type": "artist", "title": "Music",
"Location": [{"path": "/mnt/titan4/Music"}]},
]
def _page(items, headers, query):
start = int(headers.get("X-Plex-Container-Start")
or query.get("X-Plex-Container-Start", ["0"])[0])
size = int(headers.get("X-Plex-Container-Size")
or query.get("X-Plex-Container-Size", [str(len(items))])[0])
return items[start:start + size], len(items)
class H(BaseHTTPRequestHandler):
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a):
pass
def _send(self, obj):
def _send(self, obj, code=200):
body = json.dumps(obj).encode()
self.send_response(200)
self.send_response(code)
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):
def do_GET(self): # noqa: N802
u = urllib.parse.urlsplit(self.path)
q = urllib.parse.parse_qs(u.query)
# ── Plex ───────────────────────────────────────────────────────
if u.path == "/identity":
return self._send({"MediaContainer": {"machineIdentifier": "abc123def456",
"version": "1.41.0.1234"}})
return self._send({"MediaContainer": {
"machineIdentifier": MACHINE_ID, "version": "1.43.3.10861",
"friendlyName": "Loki"}})
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"}]},
]}})
return self._send({"MediaContainer": {"Directory": SECTIONS}})
if u.path == "/accounts":
return self._send({"MediaContainer": {"Account": [
{"id": 1, "name": "jess"}, {"id": 2, "name": "sam"}]}})
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)}})
libtype = q.get("type", ["1"])[0]
if libtype == "1":
src = [m for m in MOVIES if m["sectionKey"] == key]
elif libtype == "4":
src = [e for e in EPISODES if e["sectionKey"] == key]
elif libtype == "2":
src = [s for s in SHOWS if s["sectionKey"] == key]
else:
src = []
batch, total = _page(src, self.headers, q)
return self._send({"MediaContainer": {
"Metadata": batch, "totalSize": total, "size": len(batch)}})
if u.path == "/status/sessions/history/all":
rows = [{"historyKey": "/status/sessions/history/%d" % h["row_id"],
"ratingKey": h["rating_key"], "viewedAt": h["date"],
"accountID": h["user_id"], "type": h["media_type"]}
for h in sorted(HISTORY, key=lambda x: -x["date"])]
batch, total = _page(rows, self.headers, q)
return self._send({"MediaContainer": {
"Metadata": batch, "totalSize": total, "size": len(batch)}})
# ── Tautulli ───────────────────────────────────────────────────
if u.path == "/api/v2":
cmd = q.get("cmd", [""])[0]
if q.get("apikey", [""])[0] == "BADKEY":
return self._send({"response": {"result": "error",
"message": "Invalid apikey", "data": None}})
if cmd == "get_server_info":
ident = q.get("_force_id", [MACHINE_ID])[0]
return self._send({"response": {"result": "success", "data": {
"pms_identifier": "abc123def456", "pms_name": "Loki",
"pms_identifier": ident, "pms_name": "Loki",
"pms_ip": "192.168.1.10", "pms_port": 32400}}})
if cmd == "get_users":
return self._send({"response": {"result": "success", "data": [
{"user_id": i, "username": "user%d" % i,
"friendly_name": n} for i, n in
enumerate(["Jess", "Sam", "Alex", "Guest", "Kid"], start=1)]}})
if cmd == "get_history":
d = q.get("order_dir", ["desc"])[0]
rows = HISTORY if d == "asc" else list(reversed(HISTORY))
direction = q.get("order_dir", ["desc"])[0]
rows = sorted(HISTORY, key=lambda h: h["date"],
reverse=(direction != "asc"))
start = int(q.get("start", ["0"])[0])
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": []}})
"data": rows[start:start + length],
"recordsFiltered": len(rows), "recordsTotal": len(rows)}}})
if cmd == "get_libraries":
return self._send({"response": {"result": "success", "data": [
{"section_id": s["key"], "section_name": s["title"],
"section_type": s["type"]} for s in SECTIONS]}})
if cmd == "get_library_media_info":
# Show sections really do return zero sizes here (§4.11).
key = q.get("section_id", [""])[0]
sec = next((s for s in SECTIONS if s["key"] == key), None)
if sec and sec["type"] == "show":
rows = [{"rating_key": s["ratingKey"], "title": s["title"],
"media_type": "show", "file_size": 0, "play_count": 0}
for s in SHOWS]
else:
rows = [{"rating_key": m["ratingKey"], "title": m["title"],
"media_type": "movie",
"file_size": sum(p["size"] for md in m["Media"] for p in md["Part"]),
"added_at": m["addedAt"], "play_count": m.get("viewCount", 0)}
for m in MOVIES if m["sectionKey"] == key]
return self._send({"response": {"result": "success", "data": {
"data": rows, "recordsFiltered": len(rows), "recordsTotal": len(rows)}}})
return self._send({"response": {"result": "error",
"message": "unknown cmd", "data": None}})
self.send_response(404)
self.send_header("Content-Length", "0")
self.end_headers()
class FakeServer:
"""Context manager wrapping the handler on an ephemeral port."""
def __init__(self, port: int = 0):
self.httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
self.httpd.daemon_threads = True
self.port = self.httpd.server_address[1]
self.url = "http://127.0.0.1:%d" % self.port
self._thread: threading.Thread | None = None
def __enter__(self) -> "FakeServer":
self._thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self._thread.start()
return self
def __exit__(self, *exc):
self.httpd.shutdown()
self.httpd.server_close()
return False
def stats() -> dict:
return {
"movies": len(MOVIES), "episodes": len(EPISODES), "shows": len(SHOWS),
"history": len(HISTORY),
"total_bytes": sum(p["size"] for m in MOVIES for md in m["Media"] for p in md["Part"])
+ sum(p["size"] for e in EPISODES for md in e["Media"] for p in md["Part"]),
}
if __name__ == "__main__":
HTTPServer(("127.0.0.1", 8899), H).serve_forever()
srv = FakeServer(8899)
print("mock Plex+Tautulli on %s" % srv.url)
print(json.dumps(stats(), indent=2))
srv.httpd.serve_forever()