Two fixes found by one live sweep, shipped together because the second is what makes the first finishable. 1. scripts/media-tv.py — the other half of media-plan.py's refusal to file TV. Groups episode-shaped files by the series name before the marker (media-titles.ep_of, imported not restated), writes a plan in the executor's own format: <library>/<Series> (<year>)/Season <NN>/<own name>.<ext>. Refuses a group below --min, an episode naming no season, and an episode the destination library already holds at the same byte count — the movie share carries a second copy of all 124 Monk episodes the TV library has had for years; a duplicate is Frank's call, never a tool's copy. Applied by media-apply.py with its own journal and --root /mnt/entropy. 2. scripts/media-apply.py — gone meant "rmdir intended", so a folder whose rmdir failed was blacklisted forever and no later sweep could finish it (live movie sweep: 4517 targeted, 146 permanently stranded). gone now means "actually gone": a failed intent is retried by the next sweep. tests: 9 new tests in media_relayout.bats, each red against the previous revision — 8 TV + 1 sweep retry behind a chmod 500 parent. ./tests/run.sh 633 green. No live move run yet.
282 lines
12 KiB
Python
Executable File
282 lines
12 KiB
Python
Executable File
#!/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 - <series>` 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:
|
|
|
|
<library>/<Series> (<year>)/Season <NN>/<the file's own name>.<ext>
|
|
|
|
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())
|