#!/usr/bin/env python3 """Prove a relayout landed, using the media server's own tables. A scan answers `204` whether or not it found anything, and a rename that the scanner has not followed looks exactly like a rename that never happened from the outside. The only honest proof is the run's journal replayed against the database the server actually serves from: every `move` row must have a row at its destination, and NO row may still name its source — a source row is a scan that has not caught up, and it is the one signature of a library pointing at files that no longer exist. Two doors, both read-only, both without a lock while the server runs (`mode=ro&immutable=1`; there is no `sqlite3` binary on these hosts): jellyfin `BaseItems.Path`. The container binds the share at `/data`, so the database holds `/data//…` where the journal holds `/mnt/entropy//…`. Translate, or every row reads as missing. plex `media_parts.file`, joined up to `metadata_items`. Plex is bound at the host path, so its rows match the journal verbatim. Filter `deleted_at is null` on BOTH tables — a deleted scan leaves its parts behind, and one of those rows is not proof anything landed. Neither server holds a row for everything the journal renames: a `.log`, `.cue` or cover image has no row to land in, so its absence is not a fault. The report therefore breaks the missing rows down by extension, and `--ignore-ext` names the extensions this server does not index, which are then counted separately and kept out of the verdict. Exit codes: 0 every move landed; 1 something is stale or missing (the details are printed); 2 the journal or the database is not usable at all. """ import argparse import json import os import sqlite3 import sys from collections import Counter JF_DB = "/home/dank/Containers/jellyfin/config/data/jellyfin.db" PLEX_DB = ("/var/lib/plexmediaserver/Library/Application Support/Plex Media Server" "/Plug-in Support/Databases/com.plexapp.plugins.library.db") # Per kind: the table whose columns are required, those columns, and the query # that returns every path the server believes it has. KINDS = { "jellyfin": { "table": "BaseItems", "columns": {"Path"}, "sql": "select Path from BaseItems", "default_db": JF_DB, }, "plex": { "table": "media_parts", "columns": {"file", "media_item_id", "deleted_at"}, "sql": ("select p.file from media_parts p" " join media_items m on m.id = p.media_item_id" " join metadata_items mi on mi.id = m.metadata_item_id" " where p.deleted_at is null and mi.deleted_at is null"), "default_db": PLEX_DB, }, } def die(msg): print(f"media-verify: {msg}", file=sys.stderr) sys.exit(2) def read_moves(path): """The (src, dst) pairs of a journal, in the order the run performed them.""" moves = [] try: fh = open(path, encoding="utf-8") except OSError as e: die(f"cannot read the journal {path}: {e.strerror}") with fh: for n, line in enumerate(fh, 1): line = line.strip() if not line: continue try: rec = json.loads(line) except json.JSONDecodeError: die(f"{path} line {n} is not a journal record") if rec.get("op") == "move": if not rec.get("src") or not rec.get("dst"): die(f"{path} line {n} is a move without both src and dst") moves.append((rec["src"], rec["dst"])) return moves def server_paths(db, kind): """Every path the server holds, or a refusal that names what is missing.""" spec = KINDS[kind] try: con = sqlite3.connect(f"file:{db}?mode=ro&immutable=1", uri=True) except sqlite3.Error as e: die(f"cannot open {db}: {e}") with con: have = {r[1] for r in con.execute(f"pragma table_info({spec['table']})")} if not spec["columns"] <= have: die(f"{db} is not a {kind} library database ({spec['table']} has no " f"{','.join(sorted(spec['columns'] - have))})") try: return {r[0] for r in con.execute(spec["sql"]) if r[0]} except sqlite3.Error as e: die(f"{db} refused the {kind} query: {e}") def to_host(path, container, share): """The database's path in the journal's terms; identity when it is not a container-side path.""" if not container: return path prefix = container.rstrip("/") + "/" if path.startswith(prefix): return share.rstrip("/") + "/" + path[len(prefix):] return path def main(): ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--journal", required=True, help="the run's journal (scripts/media-apply.py writes it)") ap.add_argument("--db-kind", choices=sorted(KINDS), required=True) ap.add_argument("--db", default=None, help="the server's database on this host") ap.add_argument("--share", default="/mnt/entropy", help="the share every journal path lives under") ap.add_argument("--container", default=None, help="what the share is called inside the container; empty" " compares verbatim (default /data for jellyfin, nothing" " for plex)") ap.add_argument("--show", type=int, default=20, help="how many offenders to name before saying 'and N more'") ap.add_argument("--ignore-ext", default="", help="comma-separated extensions this server does not index," " kept out of the verdict (e.g. log,cue,nfo,srt)") args = ap.parse_args() db = args.db or KINDS[args.db_kind]["default_db"] container = ("/data" if args.db_kind == "jellyfin" else "") \ if args.container is None else args.container ignore = set() for ext in args.ignore_ext.split(","): ext = ext.strip().lower() if ext: ignore.add(ext if ext.startswith(".") else "." + ext) moves = read_moves(args.journal) stored = {to_host(p, container, args.share) for p in server_paths(db, args.db_kind)} landed, stale, missing, ignored = 0, [], [], 0 for src, dst in moves: if os.path.splitext(dst)[1].lower() in ignore: ignored += 1 continue at_dst, at_src = dst in stored, src in stored if at_dst and not at_src: landed += 1 continue if at_src: stale.append(src) if not at_dst: missing.append(dst) print(f"{args.db_kind}: {len(moves)} moves checked") print(f"landed: {landed}") print(f"still at src: {len(stale)}") print(f"missing from the DB: {len(missing)}") if ignored: print(f"not indexed (extension ignored): {ignored}") if missing: exts = Counter(os.path.splitext(d)[1].lower() or "(no extension)" for d in missing) print("missing by extension: " + ", ".join(f"{e} {n}" for e, n in sorted(exts.items()))) for label, items in (("stale", stale), ("missing", missing)): for path in items[:args.show]: print(f" {label}: {path}") if len(items) > args.show: print(f" {label}: … and {len(items) - args.show} more") return 1 if (stale or missing) else 0 if __name__ == "__main__": sys.exit(main())