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:
parent
58c2883492
commit
6a557bcdd9
37 changed files with 6486 additions and 85 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue