"""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)