#!/usr/bin/env python3 """media-apply.py — the only thing that moves movie-library files. Reads the plan written by scripts/media-plan.py (rows: `action src dst kind`, plus `mkdir` rows) and performs it with rename(2) — bytes are never copied, so an interrupted run has destroyed nothing. Every run is resumable and every run is undoable: the journal is a write-ahead log, one JSON line per intent, fsynced BEFORE the rename it authorises. That ordering is the whole design: a crash leaves either an intent whose move happened or an intent whose move never ran, and both are recognised on the next run. A file that moved with no journal line cannot have been moved by this tool, and saying so is better than guessing. Three subcommands: apply --plan PLAN [--root ROOT] [--journal J] [--apply] no --apply => report only and write NOTHING, not even folders rollback --journal J [--root ROOT] [--apply] back to the paths the census saw; folders this run created are removed only when empty, never anything else verify --plan PLAN --journal J [--root ROOT] every planned file is at its target with the journalled byte count Refusals (exit 2, before the first rename, with nothing touched): a target already occupied by a foreign file, a source that has gone, a duplicate target, a path outside --root, a journal that was already rolled back, a move that would cross a filesystem (rename cannot do it and a silent copy at 24 TB would not). --force relaxes only the guards that are opinion (journal already rolled back, paths outside --root); it never allows overwriting a file. The journal defaults to `media-relayout.jsonl` BESIDE the library root, never inside it: a log inside the library is a file Jellyfin will index. """ import argparse import datetime import json import os import sys DEFAULT_ROOT = os.environ.get("MEDIA_ROOT", "/mnt/entropy/Movies") class Refuse(Exception): """A gate the operator has to see; every refusal leaves the library alone.""" def journal_default(root): return os.path.join(os.path.dirname(os.path.abspath(root)), "media-relayout.jsonl") class Journal: """Append-only JSONL of intents. Writes are fsynced before the tool acts.""" def __init__(self, path, writing): self.path, self.writing = path, writing self.records = [] if os.path.exists(path): for line in open(path): line = line.strip() if line: self.records.append(json.loads(line)) self._fh = None def already(self, dst, src): """An intent for this exact (src, dst) was written by an earlier run.""" return any(r.get("op") == "move" and r.get("dst") == dst and r.get("src") == src for r in self.records) @property def rolled_back(self): return any(r.get("op") == "rollback" for r in self.records) def record(self, **fields): self.records.append(fields) if not self.writing: return if self._fh is None: self._fh = open(self.path, "a") self._fh.write(json.dumps(fields) + "\n") self._fh.flush() os.fsync(self._fh.fileno()) def close(self): if self._fh: self._fh.close() def read_plan(path, root, force): """Plan rows: (mkdirs, keeps, moves), header dropped, all paths under root.""" mkdirs, keeps, moves = [], [], [] with open(path) as fh: header = fh.readline() if not header.startswith("action\t"): raise Refuse(f"{path} is not a plan (first column is " f"{header.split(chr(9))[0]!r}, expected 'action') —" " regenerate it with scripts/media-plan.py") for line in fh: f = line.rstrip("\n").split("\t") if f[0] == "mkdir": mkdirs.append(f[2]) elif f[0] == "keep": keeps.append(f[1]) elif f[0] == "move": moves.append((f[1], f[2], f[3] if len(f) > 3 else "")) if not force: for p in mkdirs + keeps + [p for m in moves for p in m[:2]]: if p and not (p == root or p.startswith(root + os.sep)): raise Refuse(f"{p} is outside --root {root}" " (a plan built for another library?)") return mkdirs, keeps, moves def do_apply(args, root, journal, mkdirs, keeps, moves): """Preflight everything, then folders, then renames. Never a partial start.""" problems = [] for p in keeps: if not os.path.exists(p): problems.append(f"missing: keep row source is gone: {p}") seen = {} for src, dst, _kind in moves: if src == dst: problems.append(f"no-op: move row whose target is its source: {src}") if dst in seen: problems.append(f"double-claim: {dst} claimed by {seen[dst]} and {src}") seen[dst] = src if journal.already(dst, src) and os.path.exists(dst) and not os.path.exists(src): continue # this intent already ran if not os.path.exists(src): if os.path.exists(dst): problems.append(f"missing: {src} is gone and {dst} carries no journal" " line — it moved outside this tool, refusing to guess") else: problems.append(f"missing: {src} is gone and {dst} is not there either") continue if os.path.exists(dst): problems.append(f"occupied: target already holds another file: {dst}") if problems: print(f"refusing: {len(problems)} problem(s), nothing moved") for p in sorted(problems)[:20]: print(" " + p) if len(problems) > 20: print(f" ... and {len(problems) - 20} more") return 2 if not args.apply: print(f"dry run: {len(moves)} moves, {len(mkdirs)} folders, {len(keeps)} files stay" " — nothing written (pass --apply to run)") for src, dst, _k in moves[:5]: print(f" would move {src} -> {dst}") return 0 made = {r.get("dst") for r in journal.records if r.get("op") == "mkdir"} dirs_new = 0 for d in sorted(mkdirs, key=lambda q: q.count(os.sep)): if d in made or os.path.isdir(d): continue journal.record(op="mkdir", dst=d) os.makedirs(d, exist_ok=True) dirs_new += 1 # same-filesystem gate: rename cannot cross a device, and a silent copy of a # library this size is not an option anyone would choose. cross = [] for src, dst, _k in moves: if journal.already(dst, src) and os.path.exists(dst) and not os.path.exists(src): continue parent = os.path.dirname(dst) if os.path.exists(parent) and os.stat(parent).st_dev != os.stat(src).st_dev: cross.append(dst) if cross: print(f"refusing: {len(cross)} moves would cross a filesystem" f" (first: {cross[0]}) — this tool only renames") return 2 moved = done = 0 for src, dst, _kind in moves: if journal.already(dst, src): if os.path.exists(dst) and not os.path.exists(src): done += 1 continue if os.path.exists(src) and not os.path.exists(dst): os.rename(src, dst) # the intent ran, the move did not moved += 1 continue st = os.stat(src) journal.record(op="move", src=src, dst=dst, bytes=st.st_size, mode=st.st_mode, mtime=st.st_mtime, ino=st.st_ino, dev=st.st_dev) os.rename(src, dst) moved += 1 print(f"applied: {moved} moved, {done} already done in an earlier run," f" {dirs_new} folders created, {len(keeps)} files stayed put") print(f"journal: {journal.path} — undo with: media-apply.py rollback" f" --journal {journal.path} --root {root} --apply") return 0 def do_verify(root, journal, moves, keeps): """Every planned file sits at its target carrying the journalled bytes.""" journalled = {(r["src"], r["dst"]): r for r in journal.records if r.get("op") == "move"} todo = moves or [(r["src"], r["dst"], "") for r in journal.records if r.get("op") == "move"] bad, missing, bytes_total = [], [], 0 for src, dst, _kind in todo: rec = journalled.get((src, dst)) if not os.path.exists(dst): missing.append(dst) continue size = os.path.getsize(dst) bytes_total += size if rec and rec.get("bytes") not in (None, size): bad.append(f"{dst}: {size} bytes on disk, {rec['bytes']} at move time") if os.path.exists(src) and src != dst: bad.append(f"{src}: still in place as well as at {dst}") missing += [p for p in keeps if not os.path.exists(p)] if bad or missing: print(f"verify FAILED: {len(missing)} absent, {len(bad)} wrong") for line in (missing + bad)[:20]: print(" " + line) return 2 empty = [r["dst"] for r in journal.records if r.get("op") == "mkdir" and os.path.isdir(r["dst"]) and not os.listdir(r["dst"])] print(f"verified: {len(todo)} files at their targets" f" ({bytes_total / 2**40:.2f} TiB), {len(keeps)} kept") if empty: print(f"note: {len(empty)} folders are empty (left in place — see the trash law)") return 0 def do_rollback(args, root, journal): """Undo every journalled move, newest first, after a full preflight.""" moves = [r for r in journal.records if r.get("op") == "move"] made = [r["dst"] for r in journal.records if r.get("op") == "mkdir"] problems = [] for r in reversed(moves): src, dst = r["src"], r["dst"] if not args.force and not (src.startswith(root + os.sep) or dst.startswith(root + os.sep)): problems.append(f"outside --root: {dst}") continue if os.path.exists(dst) and os.path.exists(src): problems.append(f"occupied: both paths exist, refusing to choose:" f" {src} | {dst}") elif os.path.exists(dst): pass elif os.path.exists(src): continue # already undone else: problems.append(f"missing: {dst} is gone and {src} never came back") if problems: print(f"refusing: {len(problems)} problem(s), nothing moved back") for p in problems[:20]: print(" " + p) return 2 if not args.apply: print(f"dry run: would move {len(moves)} files back to their census paths" " (pass --apply)") return 0 undone = 0 for r in reversed(moves): if os.path.exists(r["dst"]) and not os.path.exists(r["src"]): os.makedirs(os.path.dirname(r["src"]), exist_ok=True) os.rename(r["dst"], r["src"]) undone += 1 dirs_gone = 0 for d in sorted(set(made), key=lambda q: -q.count(os.sep)): if d == root or not os.path.isdir(d) or os.listdir(d): continue try: os.rmdir(d) dirs_gone += 1 except OSError as e: print(f"left in place: {d} ({e})") journal.record(op="rollback", undone=undone, dirs_removed=dirs_gone, t=datetime.datetime.now(datetime.timezone.utc).isoformat()) print(f"rolled back: {undone} files back where the census found them," f" {dirs_gone} empty folders removed") return 0 def main(): ap = argparse.ArgumentParser(description="apply or undo a media relayout plan") ap.add_argument("cmd", choices=["apply", "rollback", "verify"]) ap.add_argument("--plan") ap.add_argument("--root", default=DEFAULT_ROOT) ap.add_argument("--journal") ap.add_argument("--apply", action="store_true", help="actually change the library") ap.add_argument("--force", action="store_true", help="relax the opinion guards") args = ap.parse_args() root = os.path.abspath(args.root) if not os.path.isdir(root): raise Refuse(f"no library at {root}") journal_path = args.journal or journal_default(root) journal = Journal(journal_path, args.apply) try: if args.cmd in ("rollback", "verify") and not os.path.exists(journal_path): raise Refuse(f"no journal at {journal_path} — nothing to {args.cmd}") if args.cmd == "rollback" and not args.force and journal.rolled_back: raise Refuse("this journal has already been rolled back" " (--force to replay it)") if args.cmd in ("apply", "verify") and not args.plan: raise Refuse(f"{args.cmd} needs --plan") mkdirs, keeps, moves = (read_plan(args.plan, root, args.force) if args.plan else ([], [], [])) if args.cmd == "apply": return do_apply(args, root, journal, mkdirs, keeps, moves) if args.cmd == "verify": return do_verify(root, journal, moves, keeps) return do_rollback(args, root, journal) finally: journal.close() if __name__ == "__main__": try: sys.exit(main()) except Refuse as r: print(f"refusing: {r}") sys.exit(2)