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
328
mediashelf/keeps.py
Normal file
328
mediashelf/keeps.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue