feat(media): a TV mover that will not file a show the library already has
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.
This commit is contained in:
1 parent
5cd9a0c4c2
commit
e83162a2ac
4 files changed
+456
-6
No files matched your search
Executable
+281
@@ -0,0 +1,281 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user