#!/usr/bin/env python3 """media-tv.py — move TV episodes out of the movie library into a TV library. `scripts/media-plan.py` refuses to file TV: it parks episode files in a `_TV needs a TV library - ` pile for a human. This is the other half of that refusal. It walks the movie library, groups the files whose own name carries an episode marker by SERIES, and writes a plan that places each series in the shape both Jellyfin and Plex read without help: / ()/Season /. It writes a plan and nothing else. Every rename belongs to `scripts/media-apply.py` — the write-ahead journal, the refusal to overwrite, the all-or-nothing preflight, the rollback. Apply it with `--root` set to the share that holds BOTH libraries (`/mnt/entropy`, not `/mnt/entropy/Movies`) so the executor's own path guard still has teeth, and give it its own journal — one journal is one undo, and a TV move must not be welded to a movie relayout: python3 scripts/media-tv.py --root /mnt/entropy/Movies \ --library /mnt/entropy/TVShows --out /mnt/entropy/media-tv-plan.tsv python3 scripts/media-apply.py apply --plan /mnt/entropy/media-tv-plan.tsv \ --root /mnt/entropy --journal /mnt/entropy/media-relayout-tv.jsonl python3 scripts/media-apply.py verify --plan /mnt/entropy/media-tv-plan.tsv \ --root /mnt/entropy --journal /mnt/entropy/media-relayout-tv.jsonl python3 scripts/media-apply.py sweep --root /mnt/entropy \ --journal /mnt/entropy/media-relayout-tv.jsonl --apply Guards, and why each one is here: * The same five-file threshold as the planner. An episode-shaped name on one file is not a series — `The Godfather Part 2` is a film — so a smaller group is reported and left exactly where it is. * An episode whose name carries no season number is reported and left, never filed into an invented `Season 01`. * A sidecar that shares an episode's own name (`.srt`, `.nfo`, …) follows the episode; an episode without its subtitles is a half-move. * A group that would claim one destination twice is not resolved here. The executor refuses the whole plan before its first rename, which is the right outcome: nobody but Frank decides which copy of an episode is the keeper. * An episode the destination library already holds at the same byte count is left where it is. The TV library has held a complete Monk box set for years while the movie library carried a second copy of all 124 episodes; a mover that did not look where it was filing would have put both inside one library. * A symlink is never followed and never moved. """ import argparse import collections import importlib.util import os import re def parse_args(): ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--root", default=os.environ.get("MEDIA_ROOT", "/mnt/entropy/Movies"), help="the movie library to empty of TV") ap.add_argument("--library", default="/mnt/entropy/TVShows", help="the TV library to fill (a different share or folder)") ap.add_argument("--out", default=None, help="plan TSV for scripts/media-apply.py" " (default: media-tv-plan.tsv beside --root)") ap.add_argument("--min", type=int, default=5, help="episode files that make something a series") a = ap.parse_args() if not a.out: a.out = os.path.join(os.path.dirname(os.path.abspath(a.root)), "media-tv-plan.tsv") return a ARGS = parse_args() ENGINE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "media-titles.py") spec = importlib.util.spec_from_file_location("eng", ENGINE) eng = importlib.util.module_from_spec(spec) spec.loader.exec_module(eng) BAD_CHARS = re.compile(r'[<>:"/|?*]') # The season and episode, from the token media-titles.ep_of() hands back: s05e03, 1x04. SEASON_RE = re.compile(r"^(?:s(\d{1,2})e(\d{1,4})|(\d{1,2})x(\d{1,4}))$") # Sidecars that follow an episode; macOS forks and resume data do not. SKIP_SIDECAR = re.compile(r"^(\._|\.DS_|dl_state)") # A box set that stamps the show's year into every file name; the folder's year # must not be added on top of it. YEAR_TAIL = re.compile(r"[(\[]\s*(18|19|20)\d\d\s*[)\]]\s*$") SMALL = {"a", "an", "the", "and", "of", "for", "in", "on", "to", "with", "at", "by", "de", "la", "vs"} def clean(name): """A name the filesystem and both matchers can read (same law as the plan).""" name = BAD_CHARS.sub("-", name).replace("\\", "-") name = re.sub(r"\s+", " ", name).strip().strip(". ").strip() return name[:180] or "untitled" def episode_name(path): """The file's own name, kept: the publisher's spelling beats a re-derivation. A dot-separated release name (`monk.s05e03.mkv`) becomes words; a name that already carries spaces is left exactly as it was written.""" stem, ext = os.path.splitext(os.path.basename(path)) if " " not in stem and stem.count(".") >= 2: stem = stem.replace(".", " ") return clean(stem) + ext.lower() def head_of(path): """The publisher's series name: the text BEFORE the episode marker. `Monk - S05E03 - Mr. Monk and the Big Game` is `Monk`; the episode title after the marker is not the show's name, and case is kept because it is the library's own spelling, not a normalised key.""" name = os.path.splitext(os.path.basename(path))[0] m = eng.EP_RE.search(name) head = name[:m.start()] if m else name return re.sub(r"\s+", " ", re.sub(r"[._]+", " ", head)).strip(" -\u2013").strip() def title_series(head): """The series folder in the case a TV library is written in. Only a name with no capital at all is retitled: a box set that was lower-cased on the way in would otherwise file `monk (2002)` among `Dexter's Laboratory (1996)`. A name the publisher wrote with case is left exactly as it was written.""" if any(c.isupper() for c in head): return head # the publisher wrote it with case; keep it words = head.split() return " ".join(w if i and w.lower() in SMALL else w.capitalize() for i, w in enumerate(words)) def season_episode(token): """(season, episode) an episode token states, or None when it states none.""" m = SEASON_RE.match(token) if not m: return None return int(m.group(1) or m.group(3)), int(m.group(2) or m.group(4)) def sidecars(path): """The files beside PATH that carry its exact stem — its subtitles, its nfo.""" d, stem = os.path.split(path) stem = os.path.splitext(stem)[0] out = [] try: entries = sorted(os.listdir(d)) except OSError: return out for e in entries: p = os.path.join(d, e) if p == path or os.path.islink(p) or not os.path.isfile(p): continue s, _ext = os.path.splitext(e) if s == stem and not SKIP_SIDECAR.match(e): out.append(p) return out def episodes_under(root): """(path, series, token) for every episode-shaped video file under root.""" for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = sorted(d for d in dirnames if not d.startswith(".") and d != "@eaDir") for f in filenames: p = os.path.join(dirpath, f) if os.path.islink(p) or not f.lower().endswith(eng.VIDEO_SUFFIX): continue e = eng.ep_of(p) if e: yield p, e[0], e[1] def walk(root, library): """Episode files under ROOT keyed by series, ignoring the destination.""" groups = collections.defaultdict(list) lib = os.path.abspath(library) for p, series, token in episodes_under(root): if os.path.abspath(p).startswith(lib + os.sep): continue groups[series].append((p, token)) return groups def holdings(library): """What the destination already carries: (series, season, episode) -> sizes. The one expensive read here, and the one that matters. The movie library holds a complete Monk box set that the TV library has held for years: 124 episodes, the same episode list, identical byte counts. Moving it would have filed a second copy of a whole show into the library that already had it. Byte count is the test on purpose — two encodes of one episode are two files and both are wanted; the same bytes are the same file.""" out = collections.defaultdict(set) for p, series, token in episodes_under(library): se = season_episode(token) if se is None: continue try: out[(series, *se)].add(os.path.getsize(p)) except OSError: continue return out def main(): root, library = os.path.abspath(ARGS.root), os.path.abspath(ARGS.library) groups = walk(root, library) held_here = holdings(library) mkdirs = {library} rows, lines, skipped = set(), [], [] for series, items in sorted(groups.items()): if len(items) < ARGS.min: skipped.append(f"skipped: {head_of(items[0][0]) or series} — {len(items)} " f"episode-shaped file{'s' if len(items) != 1 else ''}," f" below {ARGS.min}: not a series") continue years = collections.Counter(y for y in (eng.folder_year(p) for p, _t in items) if y) year = years.most_common(1)[0][0] if years else None heads = collections.Counter(head_of(p) for p, _t in items) head = heads.most_common(1)[0][0] if year and YEAR_TAIL.search(head): year = None name = clean(f"{title_series(head)}{f' ({year})' if year else ''}") series_dir = os.path.join(library, name) moved = held = 0 for path, token in items: se = season_episode(token) if se is None: skipped.append(f"skipped: {path} — no season number in its name," " nothing here invents one") continue try: size = os.path.getsize(path) except OSError: continue if size in held_here.get((series, *se), ()): held += 1 continue dst_dir = os.path.join(series_dir, f"Season {se[0]:02d}") mkdirs.update([series_dir, dst_dir]) base = episode_name(path) rows.add((path, os.path.join(dst_dir, base))) moved += 1 for side in sidecars(path): rows.add((side, os.path.join( dst_dir, clean(os.path.splitext(base)[0] + os.path.splitext(side)[1].lower())))) if moved: lines.append(f"{name}: {moved} episodes" f"{'' if year else ' (no year in any folder name)'}" f" -> {series_dir}") if held: lines.append(f"{name}: {held}" f" {'episode' if held == 1 else 'episodes'} already in" f" {library} at the same byte count — left there") # An empty plan creates nothing, not even the library folder: a run that # found no series must not be able to leave a stray directory behind. dirs = sorted(mkdirs, key=lambda p: (p.count(os.sep), p)) if rows else [] with open(ARGS.out, "w") as out: out.write("action\tsrc\tdst\tkind\n") for d in dirs: out.write(f"mkdir\t\t{d}\tdir\n") for src, dst in sorted(rows): out.write(f"move\t{src}\t{dst}\ttv\n") for line in lines + skipped: print(line) print(f"plan: {len(rows)} moves, {len(dirs)} folders -> {ARGS.out}") if rows: print(f"next: python3 scripts/media-apply.py apply --plan {ARGS.out}" f" --root {os.path.dirname(root)}" f" --journal {os.path.join(os.path.dirname(root),'media-relayout-tv.jsonl')}" " (add --apply to run it)") return 0 if __name__ == "__main__": raise SystemExit(main())