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

328
mediashelf/keeps.py Normal file
View file

@ -0,0 +1,328 @@
"""Keep marks — human judgements the score must not override (§6.6).
The critical property of this module is that marks survive Plex reassigning
rating keys. They are stored against content identity:
movie / show : (library_id, guid)
season : (library_id, show_guid, season_number)
provider_item_id is stored for convenience and linking, and is allowed to go
stale. It is never what a mark is matched on. If that invariant is ever broken,
the failure mode in v2 is deleting content someone explicitly protected which
is why test_keeps.py reassigns every rating key in the library and asserts the
marks still resolve.
"""
from __future__ import annotations
import json
import time
SCOPES = ("show", "season", "movie")
MODES = ("keep", "exclude")
class KeepError(ValueError):
pass
# ── resolution ───────────────────────────────────────────────────────────
RESOLVE_SQL = """
UPDATE media_item SET
kept = 0,
kept_via = NULL,
kept_mark_id = NULL
"""
def resolve_all(db) -> dict:
"""Recompute kept/kept_via for every item. Most specific mark wins.
Precedence (§6.6):
movie/season explicit 'exclude' -> NOT kept
movie/season explicit 'keep' -> kept
show 'keep' -> kept
library keep_all -> kept
otherwise -> not kept
"""
conn = db.conn
conn.execute("UPDATE media_item SET kept = 0, kept_via = NULL, kept_mark_id = NULL")
# 1. library rules (weakest)
conn.execute("""
UPDATE media_item SET kept = 1, kept_via = 'library'
WHERE library_id IN (SELECT id FROM library WHERE keep_all = 1)
""")
# 2. show marks -> the show row, its seasons
conn.execute("""
UPDATE media_item SET kept = 1, kept_via = 'show',
kept_mark_id = (
SELECT k.id FROM keep_mark k
WHERE k.scope = 'show' AND k.mode = 'keep'
AND k.library_id = media_item.library_id
AND k.guid = media_item.guid)
WHERE kind = 'show' AND guid IS NOT NULL AND EXISTS (
SELECT 1 FROM keep_mark k
WHERE k.scope = 'show' AND k.mode = 'keep'
AND k.library_id = media_item.library_id
AND k.guid = media_item.guid)
""")
conn.execute("""
UPDATE media_item SET kept = 1, kept_via = 'show',
kept_mark_id = (
SELECT k.id FROM keep_mark k
WHERE k.scope = 'show' AND k.mode = 'keep'
AND k.library_id = media_item.library_id
AND k.guid = media_item.show_guid)
WHERE kind = 'season' AND show_guid IS NOT NULL AND EXISTS (
SELECT 1 FROM keep_mark k
WHERE k.scope = 'show' AND k.mode = 'keep'
AND k.library_id = media_item.library_id
AND k.guid = media_item.show_guid)
""")
# 3. explicit season/movie keeps
conn.execute("""
UPDATE media_item SET kept = 1, kept_via = 'season',
kept_mark_id = (
SELECT k.id FROM keep_mark k
WHERE k.scope = 'season' AND k.mode = 'keep'
AND k.library_id = media_item.library_id
AND k.guid = media_item.show_guid
AND k.season_number = media_item.season_number)
WHERE kind = 'season' AND EXISTS (
SELECT 1 FROM keep_mark k
WHERE k.scope = 'season' AND k.mode = 'keep'
AND k.library_id = media_item.library_id
AND k.guid = media_item.show_guid
AND k.season_number = media_item.season_number)
""")
conn.execute("""
UPDATE media_item SET kept = 1, kept_via = 'movie',
kept_mark_id = (
SELECT k.id FROM keep_mark k
WHERE k.scope = 'movie' AND k.mode = 'keep'
AND k.library_id = media_item.library_id
AND k.guid = media_item.guid)
WHERE kind = 'movie' AND guid IS NOT NULL AND EXISTS (
SELECT 1 FROM keep_mark k
WHERE k.scope = 'movie' AND k.mode = 'keep'
AND k.library_id = media_item.library_id
AND k.guid = media_item.guid)
""")
# 4. explicit excludes win over everything above
conn.execute("""
UPDATE media_item SET kept = 0, kept_via = NULL, kept_mark_id = NULL
WHERE kind = 'season' AND EXISTS (
SELECT 1 FROM keep_mark k
WHERE k.scope = 'season' AND k.mode = 'exclude'
AND k.library_id = media_item.library_id
AND k.guid = media_item.show_guid
AND k.season_number = media_item.season_number)
""")
conn.execute("""
UPDATE media_item SET kept = 0, kept_via = NULL, kept_mark_id = NULL
WHERE kind = 'movie' AND guid IS NOT NULL AND EXISTS (
SELECT 1 FROM keep_mark k
WHERE k.scope = 'movie' AND k.mode = 'exclude'
AND k.library_id = media_item.library_id
AND k.guid = media_item.guid)
""")
kept_items = db.scalar("SELECT COUNT(*) FROM media_item WHERE kept = 1 AND kind != 'show'") or 0
kept_bytes = db.scalar(
"SELECT COALESCE(SUM(size_bytes),0) FROM media_item WHERE kept = 1 AND kind != 'show'"
) or 0
return {"kept_items": kept_items, "kept_bytes": kept_bytes}
def mark_matches(db) -> dict[int, int]:
"""How many items each mark currently resolves to. 0 => orphaned."""
counts: dict[int, int] = {}
for row in db.query("SELECT id FROM keep_mark"):
counts[row["id"]] = 0
for row in db.query(
"SELECT kept_mark_id AS mid, COUNT(*) AS n FROM media_item "
"WHERE kept_mark_id IS NOT NULL GROUP BY kept_mark_id"
):
counts[row["mid"]] = row["n"]
# Excludes never set kept_mark_id, so count them separately.
for row in db.query("SELECT * FROM keep_mark WHERE mode = 'exclude'"):
if row["scope"] == "movie":
n = db.scalar(
"SELECT COUNT(*) FROM media_item WHERE kind='movie' "
"AND library_id=? AND guid=?",
(row["library_id"], row["guid"]),
)
else:
n = db.scalar(
"SELECT COUNT(*) FROM media_item WHERE kind='season' "
"AND library_id=? AND show_guid=? AND season_number=?",
(row["library_id"], row["guid"], row["season_number"]),
)
counts[row["id"]] = n or 0
return counts
def stamp_matches(db, scan_id: int | None) -> int:
"""Record which marks matched something this scan. Returns orphan count."""
counts = mark_matches(db)
orphans = 0
for mark_id, n in counts.items():
if n > 0:
db.execute("UPDATE keep_mark SET last_matched_scan_id = ? WHERE id = ?",
(scan_id, mark_id))
else:
orphans += 1
return orphans
# ── mutation ─────────────────────────────────────────────────────────────
def create_from_item(db, item_id: int, mode: str = "keep", note: str | None = None) -> int:
"""Create a mark from a grid row, resolving it to the durable key."""
if mode not in MODES:
raise KeepError("mode must be 'keep' or 'exclude'")
row = db.one(
"SELECT i.*, lib.title AS library_title FROM media_item i "
"JOIN library lib ON lib.id = i.library_id WHERE i.id = ?",
(item_id,),
)
if row is None:
raise KeepError("no such item")
kind = row["kind"]
if kind == "movie":
scope, guid, season = "movie", row["guid"], None
label = row["title"] + (" (%s)" % row["year"] if row["year"] else "")
elif kind == "show":
scope, guid, season = "show", row["guid"], None
label = row["title"]
elif kind == "season":
scope, guid, season = "season", row["show_guid"], row["season_number"]
parent = db.one("SELECT title FROM media_item WHERE id = ?", (row["parent_id"],))
show_title = parent["title"] if parent else "(unknown show)"
label = "%s — Season %s" % (show_title, row["season_number"])
else:
raise KeepError("cannot keep a %s" % kind)
if not guid:
raise KeepError(
"this item has no Plex GUID, so a keep could not survive a library "
"rebuild; refusing to create one that would silently detach"
)
return upsert(db, scope, mode, row["library_id"], guid, season,
label=label, note=note, provider_item_id=row["provider_item_id"])
def upsert(db, scope: str, mode: str, library_id: int, guid: str,
season_number: int | None, *, label: str, note: str | None = None,
provider_item_id: str | None = None) -> int:
if scope not in SCOPES:
raise KeepError("scope must be one of %s" % (SCOPES,))
if mode not in MODES:
raise KeepError("mode must be 'keep' or 'exclude'")
if scope == "season" and season_number is None:
raise KeepError("a season mark needs a season_number")
if scope != "season":
season_number = None
now = int(time.time())
existing = db.one(
"SELECT id FROM keep_mark WHERE scope=? AND library_id=? AND guid=? "
"AND season_number IS ?",
(scope, library_id, guid, season_number),
)
if existing:
db.execute(
"UPDATE keep_mark SET mode=?, note=COALESCE(?, note), label=?, "
"provider_item_id=?, updated_at=? WHERE id=?",
(mode, note, label, provider_item_id, now, existing["id"]),
)
return existing["id"]
cur = db.execute(
"INSERT INTO keep_mark (scope, mode, library_id, guid, season_number, "
"provider_item_id, label, note, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
(scope, mode, library_id, guid, season_number, provider_item_id,
label, note, now, now),
)
return cur.lastrowid
def delete(db, mark_id: int) -> bool:
cur = db.execute("DELETE FROM keep_mark WHERE id = ?", (mark_id,))
return cur.rowcount > 0
def set_library_keep_all(db, library_id: int, keep_all: bool) -> None:
db.execute("UPDATE library SET keep_all = ? WHERE id = ?",
(1 if keep_all else 0, library_id))
# ── export / import (§11.5) ──────────────────────────────────────────────
def export(db) -> dict:
"""Human-readable, durable-key export. The only irreplaceable data here."""
libs = {r["id"]: r["title"] for r in db.query("SELECT id, title FROM library")}
marks = []
for r in db.query("SELECT * FROM keep_mark ORDER BY id"):
marks.append({
"scope": r["scope"],
"mode": r["mode"],
"library": libs.get(r["library_id"], str(r["library_id"])),
"guid": r["guid"],
"season_number": r["season_number"],
"label": r["label"],
"note": r["note"],
"created_at": r["created_at"],
})
library_rules = [r["title"] for r in
db.query("SELECT title FROM library WHERE keep_all = 1 ORDER BY title")]
return {
"version": 1,
"exported_at": int(time.time()),
"library_keep_all": library_rules,
"marks": marks,
}
def import_(db, payload: dict) -> dict:
"""Restore an export. Matches libraries by title, since ids are local."""
if not isinstance(payload, dict) or payload.get("version") != 1:
raise KeepError("unrecognised keep export")
by_title = {r["title"]: r["id"] for r in db.query("SELECT id, title FROM library")}
added = skipped = 0
for title in payload.get("library_keep_all") or []:
lid = by_title.get(title)
if lid:
set_library_keep_all(db, lid, True)
else:
skipped += 1
for m in payload.get("marks") or []:
lid = by_title.get(m.get("library"))
if not lid or not m.get("guid"):
skipped += 1
continue
try:
upsert(db, m["scope"], m.get("mode", "keep"), lid, m["guid"],
m.get("season_number"), label=m.get("label") or m["guid"],
note=m.get("note"))
added += 1
except KeepError:
skipped += 1
resolve_all(db)
return {"imported": added, "skipped": skipped}
def write_export_file(db, path: str) -> None:
with open(path, "w") as fh:
json.dump(export(db), fh, indent=2)