Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e83162a2ac | ||
|
|
5cd9a0c4c2 | ||
|
|
a803aa046a | ||
|
|
73f77a13c3 | ||
|
|
dd76fcf5d4 | ||
|
|
1cb28095a7 | ||
|
|
e1760e3316 |
No files matched your search
+110
-6
@@ -10,7 +10,7 @@ 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
|
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.
|
cannot have been moved by this tool, and saying so is better than guessing.
|
||||||
|
|
||||||
Three subcommands:
|
Four subcommands:
|
||||||
|
|
||||||
apply --plan PLAN [--root ROOT] [--journal J] [--apply]
|
apply --plan PLAN [--root ROOT] [--journal J] [--apply]
|
||||||
no --apply => report only and write NOTHING, not even folders
|
no --apply => report only and write NOTHING, not even folders
|
||||||
@@ -19,6 +19,10 @@ Three subcommands:
|
|||||||
removed only when empty, never anything else
|
removed only when empty, never anything else
|
||||||
verify --plan PLAN --journal J [--root ROOT]
|
verify --plan PLAN --journal J [--root ROOT]
|
||||||
every planned file is at its target with the journalled byte count
|
every planned file is at its target with the journalled byte count
|
||||||
|
sweep --journal J [--root ROOT] [--apply]
|
||||||
|
removes the folders THIS RUN emptied — the journal has to show a
|
||||||
|
file that moved out of one, so a folder someone made by hand can
|
||||||
|
never be removed by this tool, and rollback puts them back
|
||||||
|
|
||||||
Refusals (exit 2, before the first rename, with nothing touched): a target
|
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,
|
already occupied by a foreign file, a source that has gone, a duplicate target,
|
||||||
@@ -228,10 +232,103 @@ def do_verify(root, journal, moves, keeps):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def sweep_targets(root, journal):
|
||||||
|
"""The folders this run emptied, deepest first — never one it did not touch.
|
||||||
|
|
||||||
|
Eligibility is evidence, not appearance. A folder is a target only when the
|
||||||
|
journal shows a file moved out of it or out of something beneath it, it holds
|
||||||
|
nothing but empty directories now, it is not a folder this run created, and no
|
||||||
|
journalled destination lives under it. A symlink anywhere inside disqualifies
|
||||||
|
the whole subtree: this tool never follows a link, and deleting the folder
|
||||||
|
that holds one would delete someone's mount point.
|
||||||
|
|
||||||
|
Proof carries downward: once a folder qualifies, every empty directory inside
|
||||||
|
it qualifies too. A release's junk (``Screens/``, ``Sample/``) is never itself
|
||||||
|
a move source, so without that rule it holds its emptied parent on disk
|
||||||
|
forever — the parent can never be removed while the child stands.
|
||||||
|
"""
|
||||||
|
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"}
|
||||||
|
# An rmdir INTENT that failed left its folder standing, and the next sweep
|
||||||
|
# must be able to finish it: only a folder that is really gone is out of
|
||||||
|
# scope. Counting every intent as done made 146 live survivors permanent.
|
||||||
|
gone = {r["path"] for r in journal.records if r.get("op") == "rmdir"
|
||||||
|
and not os.path.exists(r["path"])}
|
||||||
|
dsts = [r["dst"] for r in moves]
|
||||||
|
marked = set()
|
||||||
|
for r in moves:
|
||||||
|
d = os.path.dirname(r["src"])
|
||||||
|
while d.startswith(root + os.sep):
|
||||||
|
marked.add(d)
|
||||||
|
d = os.path.dirname(d)
|
||||||
|
|
||||||
|
def removable(d):
|
||||||
|
if d in made or d in gone or os.path.islink(d):
|
||||||
|
return False
|
||||||
|
if any(p == d or p.startswith(d + os.sep) for p in dsts):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
entries = os.listdir(d)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
for e in entries:
|
||||||
|
c = os.path.join(d, e)
|
||||||
|
if os.path.islink(c) or not os.path.isdir(c) or not removable(c):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
roots = [d for d in marked if removable(d)]
|
||||||
|
targets = set(roots)
|
||||||
|
for d in roots:
|
||||||
|
for dirpath, dirnames, _ in os.walk(d):
|
||||||
|
for c in dirnames:
|
||||||
|
p = os.path.join(dirpath, c)
|
||||||
|
if removable(p):
|
||||||
|
targets.add(p)
|
||||||
|
return sorted(targets, key=lambda q: (-q.count(os.sep), q))
|
||||||
|
|
||||||
|
|
||||||
|
def do_sweep(args, root, journal):
|
||||||
|
"""Remove the empty husks an apply left standing at the library root."""
|
||||||
|
moves = [r for r in journal.records if r.get("op") == "move"]
|
||||||
|
if not moves:
|
||||||
|
raise Refuse("the journal holds no moves — a sweep follows an apply, it"
|
||||||
|
" does not guess at emptiness")
|
||||||
|
outside = sorted({p for r in moves for p in (r["src"], r["dst"])
|
||||||
|
if not (p == root or p.startswith(root + os.sep))})
|
||||||
|
if outside and not args.force:
|
||||||
|
raise Refuse(f"{len(outside)} journalled paths are outside --root {root}"
|
||||||
|
f" (first: {outside[0]}) — this journal belongs to another"
|
||||||
|
" library")
|
||||||
|
targets = sweep_targets(root, journal)
|
||||||
|
if not targets:
|
||||||
|
print("swept: 0 folders — nothing this run emptied is still standing empty")
|
||||||
|
return 0
|
||||||
|
if not args.apply:
|
||||||
|
print(f"dry run: {len(targets)} folders this run emptied would be removed"
|
||||||
|
" (pass --apply to run)")
|
||||||
|
for d in targets[:5]:
|
||||||
|
print(f" would remove {d}")
|
||||||
|
return 0
|
||||||
|
removed = 0
|
||||||
|
for d in targets:
|
||||||
|
journal.record(op="rmdir", path=d)
|
||||||
|
try:
|
||||||
|
os.rmdir(d)
|
||||||
|
except OSError as e:
|
||||||
|
print(f"left in place: {d} ({e})")
|
||||||
|
continue
|
||||||
|
removed += 1
|
||||||
|
print(f"swept: {removed} folders removed, {len(targets) - removed} left in place")
|
||||||
|
print(f"journal: {journal.path} — rollback recreates them with their files")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def do_rollback(args, root, journal):
|
def do_rollback(args, root, journal):
|
||||||
"""Undo every journalled move, newest first, after a full preflight."""
|
"""Undo every journalled move, newest first, after a full preflight."""
|
||||||
moves = [r for r in journal.records if r.get("op") == "move"]
|
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"]
|
made = [r["dst"] for r in journal.records if r.get("op") == "mkdir"]
|
||||||
|
swept = [r["path"] for r in journal.records if r.get("op") == "rmdir"]
|
||||||
problems = []
|
problems = []
|
||||||
for r in reversed(moves):
|
for r in reversed(moves):
|
||||||
src, dst = r["src"], r["dst"]
|
src, dst = r["src"], r["dst"]
|
||||||
@@ -255,9 +352,14 @@ def do_rollback(args, root, journal):
|
|||||||
return 2
|
return 2
|
||||||
if not args.apply:
|
if not args.apply:
|
||||||
print(f"dry run: would move {len(moves)} files back to their census paths"
|
print(f"dry run: would move {len(moves)} files back to their census paths"
|
||||||
" (pass --apply)")
|
f" and recreate {len(set(swept))} swept folders (pass --apply)")
|
||||||
return 0
|
return 0
|
||||||
undone = 0
|
undone = 0
|
||||||
|
recreated = 0
|
||||||
|
for d in sorted(set(swept), key=lambda q: q.count(os.sep)):
|
||||||
|
if not os.path.isdir(d):
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
recreated += 1
|
||||||
for r in reversed(moves):
|
for r in reversed(moves):
|
||||||
if os.path.exists(r["dst"]) and not os.path.exists(r["src"]):
|
if os.path.exists(r["dst"]) and not os.path.exists(r["src"]):
|
||||||
os.makedirs(os.path.dirname(r["src"]), exist_ok=True)
|
os.makedirs(os.path.dirname(r["src"]), exist_ok=True)
|
||||||
@@ -275,13 +377,13 @@ def do_rollback(args, root, journal):
|
|||||||
journal.record(op="rollback", undone=undone, dirs_removed=dirs_gone,
|
journal.record(op="rollback", undone=undone, dirs_removed=dirs_gone,
|
||||||
t=datetime.datetime.now(datetime.timezone.utc).isoformat())
|
t=datetime.datetime.now(datetime.timezone.utc).isoformat())
|
||||||
print(f"rolled back: {undone} files back where the census found them,"
|
print(f"rolled back: {undone} files back where the census found them,"
|
||||||
f" {dirs_gone} empty folders removed")
|
f" {dirs_gone} empty folders removed, {recreated} swept folders recreated")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description="apply or undo a media relayout plan")
|
ap = argparse.ArgumentParser(description="apply or undo a media relayout plan")
|
||||||
ap.add_argument("cmd", choices=["apply", "rollback", "verify"])
|
ap.add_argument("cmd", choices=["apply", "rollback", "verify", "sweep"])
|
||||||
ap.add_argument("--plan")
|
ap.add_argument("--plan")
|
||||||
ap.add_argument("--root", default=DEFAULT_ROOT)
|
ap.add_argument("--root", default=DEFAULT_ROOT)
|
||||||
ap.add_argument("--journal")
|
ap.add_argument("--journal")
|
||||||
@@ -295,9 +397,9 @@ def main():
|
|||||||
journal_path = args.journal or journal_default(root)
|
journal_path = args.journal or journal_default(root)
|
||||||
journal = Journal(journal_path, args.apply)
|
journal = Journal(journal_path, args.apply)
|
||||||
try:
|
try:
|
||||||
if args.cmd in ("rollback", "verify") and not os.path.exists(journal_path):
|
if args.cmd in ("rollback", "verify", "sweep") and not os.path.exists(journal_path):
|
||||||
raise Refuse(f"no journal at {journal_path} — nothing to {args.cmd}")
|
raise Refuse(f"no journal at {journal_path} — nothing to {args.cmd}")
|
||||||
if args.cmd == "rollback" and not args.force and journal.rolled_back:
|
if args.cmd in ("rollback", "sweep") and not args.force and journal.rolled_back:
|
||||||
raise Refuse("this journal has already been rolled back"
|
raise Refuse("this journal has already been rolled back"
|
||||||
" (--force to replay it)")
|
" (--force to replay it)")
|
||||||
if args.cmd in ("apply", "verify") and not args.plan:
|
if args.cmd in ("apply", "verify") and not args.plan:
|
||||||
@@ -308,6 +410,8 @@ def main():
|
|||||||
return do_apply(args, root, journal, mkdirs, keeps, moves)
|
return do_apply(args, root, journal, mkdirs, keeps, moves)
|
||||||
if args.cmd == "verify":
|
if args.cmd == "verify":
|
||||||
return do_verify(root, journal, moves, keeps)
|
return do_verify(root, journal, moves, keeps)
|
||||||
|
if args.cmd == "sweep":
|
||||||
|
return do_sweep(args, root, journal)
|
||||||
return do_rollback(args, root, journal)
|
return do_rollback(args, root, journal)
|
||||||
finally:
|
finally:
|
||||||
journal.close()
|
journal.close()
|
||||||
|
|||||||
@@ -222,8 +222,9 @@ eps = collections.defaultdict(list)
|
|||||||
for r in erows:
|
for r in erows:
|
||||||
if r["path"] in marks | extras:
|
if r["path"] in marks | extras:
|
||||||
continue
|
continue
|
||||||
if eng.ep_of(r["path"]):
|
ep = eng.ep_of(r["path"])
|
||||||
eps[eng.key_of(r["title"])].append(r)
|
if ep:
|
||||||
|
eps[ep[0]].append(r) # keyed by the SERIES, never the episode title
|
||||||
tv_keys = {k for k, v in eps.items() if len(v) >= 5}
|
tv_keys = {k for k, v in eps.items() if len(v) >= 5}
|
||||||
tv_set = {r["path"] for k, v in eps.items() if k in tv_keys for r in v}
|
tv_set = {r["path"] for k, v in eps.items() if k in tv_keys for r in v}
|
||||||
# A folder INSIDE a movie's folder is bonus territory even when its own name is
|
# A folder INSIDE a movie's folder is bonus territory even when its own name is
|
||||||
@@ -776,19 +777,17 @@ readmes.append(f"README in {ROOT}: this library was relaid out into Jellyfin's "
|
|||||||
"one-folder-per-movie layout; `_release-group watermarks/`, "
|
"one-folder-per-movie layout; `_release-group watermarks/`, "
|
||||||
"`_unsorted files/` and `_unsorted bonus/` hold files no film claims.")
|
"`_unsorted files/` and `_unsorted bonus/` hold files no film claims.")
|
||||||
for key in tv_keys:
|
for key in tv_keys:
|
||||||
nm = "_TV needs a TV library - " + collections.Counter(
|
readmes.append(f"README in {ROOT}/{sanitize('_TV needs a TV library - ' + key)}: "
|
||||||
r["title"] for r in eps[key]).most_common(1)[0][0]
|
"these are TV episodes found in the MOVIES library; move the folder "
|
||||||
readmes.append(f"README in {ROOT}/{sanitize(nm)}: these are TV episodes found in "
|
"to a TV library source, else Jellyfin groups them wrongly as one "
|
||||||
"the MOVIES library; move the folder to a TV library source, else "
|
"movie.")
|
||||||
"Jellyfin groups them wrongly as one movie.")
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- TV
|
# --------------------------------------------------------------------- TV
|
||||||
for key in tv_keys:
|
for key in tv_keys:
|
||||||
items = eps[key]
|
items = eps[key]
|
||||||
title = collections.Counter(r["title"] for r in items).most_common(1)[0][0]
|
|
||||||
year = collections.Counter(r["year"] for r in items if r["year"] != "?")
|
year = collections.Counter(r["year"] for r in items if r["year"] != "?")
|
||||||
yr = year.most_common(1)[0][0] if year else None
|
yr = year.most_common(1)[0][0] if year else None
|
||||||
base = f"_TV needs a TV library - {sanitize(title)}" + (f" ({yr})" if yr else "")
|
base = f"_TV needs a TV library - {sanitize(key)}" + (f" ({yr})" if yr else "")
|
||||||
tops = {parts_of(r["path"])[0] for r in items}
|
tops = {parts_of(r["path"])[0] for r in items}
|
||||||
members = sorted([r["path"] for r in items]
|
members = sorted([r["path"] for r in items]
|
||||||
+ [q for q in extras | marks if len(parts_of(q)) > 1
|
+ [q for q in extras | marks if len(parts_of(q)) > 1
|
||||||
|
|||||||
+12
-3
@@ -322,13 +322,22 @@ def key_of(title):
|
|||||||
|
|
||||||
|
|
||||||
def ep_of(path):
|
def ep_of(path):
|
||||||
"""(show, episode-token) when the basename carries SxxEyy / 1x04 / Part N."""
|
"""(series, episode-token) when the basename carries SxxEyy / 1x04 / Part N.
|
||||||
|
|
||||||
|
The series is the name BEFORE the marker. The box-set convention writes
|
||||||
|
"Monk - S05E03 - Mr. Monk and the Big Game", and the episode title that
|
||||||
|
follows the marker is not the show's name: keying on the whole title gave
|
||||||
|
every episode of one series its own group, so a 124-episode box set never
|
||||||
|
reached the size that calls something a series and each episode was filed
|
||||||
|
as its own film. A file named only by its marker has nothing before it, so
|
||||||
|
its whole title stands in."""
|
||||||
name = os.path.splitext(os.path.basename(path))[0]
|
name = os.path.splitext(os.path.basename(path))[0]
|
||||||
m = EP_RE.search(name)
|
m = EP_RE.search(name)
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
show, _ = title_of(path)
|
head = name[:m.start()].strip(" ._\u2013-")
|
||||||
return show, norm(m.group(1)).replace(" ", "")
|
series = title_of(head)[0] if head else None
|
||||||
|
return series or title_of(path)[0], norm(m.group(1)).replace(" ", "")
|
||||||
|
|
||||||
|
|
||||||
def base_words(title, key):
|
def base_words(title, key):
|
||||||
|
|||||||
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())
|
||||||
@@ -11,15 +11,16 @@ timestamp: 2026-10-30T00:00:00Z
|
|||||||
The movie library on [[Entropy Volume]] arrived as release folders —
|
The movie library on [[Entropy Volume]] arrived as release folders —
|
||||||
`Some.Movie.2019.1080p.WiKiTaLx.x265-FOO/` with the video inside named after
|
`Some.Movie.2019.1080p.WiKiTaLx.x265-FOO/` with the video inside named after
|
||||||
the release. [[Jellyfin]] matches a movie by folder and file name, so the
|
the release. [[Jellyfin]] matches a movie by folder and file name, so the
|
||||||
release noise cost posters, extras and versions. These three scripts turn that
|
release noise cost posters, extras and versions. These scripts turn that
|
||||||
library into Jellyfin's own shape, and nothing else in the repo moves a media
|
library into Jellyfin's own shape — and move TV back out of it — and nothing
|
||||||
file.
|
else in the repo touches a media file.
|
||||||
|
|
||||||
| Script | Touches media? | Output |
|
| Script | Touches media? | Output |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `scripts/media-census.py` | reads headers only | `enum`, `probed`, `plex` TSVs |
|
| `scripts/media-census.py` | reads headers only | `enum`, `probed`, `plex` TSVs |
|
||||||
| `scripts/media-plan.py` | no — TSVs in, TSVs out | the plan + the exception report |
|
| `scripts/media-plan.py` | no — TSVs in, TSVs out | the plan + the exception report |
|
||||||
| `scripts/media-apply.py` | **renames, never copies** | the moves, plus a journal |
|
| `scripts/media-apply.py` | **renames, never copies**; sweeps only what a run emptied | the moves, plus a journal |
|
||||||
|
| `scripts/media-tv.py` | no — TSV out | a plan that moves TV into the TV library |
|
||||||
|
|
||||||
The seam is `tests/media_relayout.bats`; the target contract is quoted from
|
The seam is `tests/media_relayout.bats`; the target contract is quoted from
|
||||||
[jellyfin.org/docs/general/server/media/movies](https://jellyfin.org/docs/general/server/media/movies)
|
[jellyfin.org/docs/general/server/media/movies](https://jellyfin.org/docs/general/server/media/movies)
|
||||||
@@ -61,17 +62,90 @@ that would be filed wrong, and the plan is not run until it is understood.
|
|||||||
```bash
|
```bash
|
||||||
scripts/media-apply.py apply --plan /tmp/jf-plan.tsv --root /mnt/entropy/Movies # dry, writes nothing
|
scripts/media-apply.py apply --plan /tmp/jf-plan.tsv --root /mnt/entropy/Movies # dry, writes nothing
|
||||||
scripts/media-apply.py apply --plan /tmp/jf-plan.tsv --root /mnt/entropy/Movies --apply
|
scripts/media-apply.py apply --plan /tmp/jf-plan.tsv --root /mnt/entropy/Movies --apply
|
||||||
scripts/media-apply.py verify --plan /tmp/jf-plan.tsv --journal …/media-relayout.jsonl
|
scripts/media-apply.py verify --plan /tmp/jf-plan.tsv --journal /mnt/entropy/media-relayout.jsonl
|
||||||
scripts/media-apply.py rollback --journal …/media-relayout.jsonl --apply
|
scripts/media-apply.py sweep --journal /mnt/entropy/media-relayout.jsonl --root /mnt/entropy/Movies # dry
|
||||||
|
scripts/media-apply.py sweep --journal /mnt/entropy/media-relayout.jsonl --root /mnt/entropy/Movies --apply
|
||||||
|
scripts/media-apply.py rollback --journal /mnt/entropy/media-relayout.jsonl --root /mnt/entropy/Movies --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
After it lands, both servers need a scan or the change is invisible:
|
An apply also leaves the release folders it emptied standing where they were —
|
||||||
[[Jellyfin]] *Library → Scan All Libraries*, and Plex's library scan
|
hundreds of them at the library root — which is what `sweep` removes, dry-run
|
||||||
(`podman exec plex …` or the web UI). `verify` reporting empty folders is normal
|
first, from the same journal.
|
||||||
for the folders the piles own; they are left in place per the deletion policy.
|
|
||||||
|
After it lands, both servers need a scan or the change is invisible.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run this from the host shell through `podman exec`, never from a neighbour
|
||||||
|
# container: Plex's HTTPD closes without a single byte on any request that
|
||||||
|
# arrives from another container's netns (10.89.x.x reads as "empty reply", not
|
||||||
|
# as a 401). The token rides stdin, never argv, where any process on the host
|
||||||
|
# could read it out of /proc/<pid>/cmdline.
|
||||||
|
umask 077
|
||||||
|
podman exec plex sh -c "sed -n 's/.*PlexOnlineToken=\"\\([^\"]*\\)\".*/\\1/p' \
|
||||||
|
'/config/Library/Application Support/Plex Media Server/Preferences.xml'" >/tmp/px.tok
|
||||||
|
podman exec -i plex sh -c 'read -r T
|
||||||
|
for k in $(curl -s -H "X-Plex-Token: $T" -H "Accept: application/json" \
|
||||||
|
http://127.0.0.1:32400/library/sections | grep -o "\"key\":\"[0-9]*\"" | cut -d\" -f4); do
|
||||||
|
curl -s -o /dev/null -w "plex section $k: %{http_code}\n" -X POST \
|
||||||
|
-H "X-Plex-Token: $T" "http://127.0.0.1:32400/library/sections/$k/refresh"
|
||||||
|
done' </tmp/px.tok
|
||||||
|
```
|
||||||
|
|
||||||
|
[[Jellyfin]] has no credential on this box that can trigger a scan: the two keys
|
||||||
|
in its database are Seerr's, unused since 2026-09-01, and answer 401 to every
|
||||||
|
call. Mint one (*Dashboard → Advanced → API Keys*, Administrator) and store it in
|
||||||
|
`~/.config/secrets/jellyfin`, or press *Library → Scan All Libraries* in the web
|
||||||
|
UI. A scripted scan passes the key in a mode-600 curl config, never in a header
|
||||||
|
argument:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
umask 077; printf 'header = "X-Emby-Token: %s"\n' "$(cat ~/.config/secrets/jellyfin)" >/tmp/jf.hdr
|
||||||
|
curl -s -K /tmp/jf.hdr -o /dev/null -w "jellyfin scan: %{http_code}\n" \
|
||||||
|
-X POST http://127.0.0.1:8096/Library/Refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
## TV in the movie library
|
||||||
|
|
||||||
|
A box set that arrives in the movie share stays there until someone moves it:
|
||||||
|
`media-plan.py` refuses to file TV and parks each series in
|
||||||
|
`_TV needs a TV library - <series>`. `scripts/media-tv.py` is the mover. It
|
||||||
|
walks the movie library itself (no census needed), groups episode-shaped names
|
||||||
|
by series, and writes a plan in the executor's own format:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/media-tv.py --root /mnt/entropy/Movies --library /mnt/entropy/TVShows \
|
||||||
|
--out /mnt/entropy/media-tv-plan.tsv
|
||||||
|
scripts/media-apply.py apply --plan /mnt/entropy/media-tv-plan.tsv --root /mnt/entropy
|
||||||
|
scripts/media-apply.py apply --plan /mnt/entropy/media-tv-plan.tsv --root /mnt/entropy \
|
||||||
|
--journal /mnt/entropy/media-relayout-tv.jsonl --apply
|
||||||
|
scripts/media-apply.py sweep --journal /mnt/entropy/media-relayout-tv.jsonl \
|
||||||
|
--root /mnt/entropy --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
The shape it writes is the one both matchers read — `Monk (2002)/Season 05/
|
||||||
|
Monk - S05E03 - Mr. Monk and the Big Game.mkv` — keeping the file's own name,
|
||||||
|
because the publisher's spelling beats a re-derivation, with the year taken
|
||||||
|
from the box-set folder. What it refuses it prints and leaves alone:
|
||||||
|
|
||||||
|
- fewer than `--min` (5) files under one series name: one file named `Part 2`
|
||||||
|
is a film, not a series;
|
||||||
|
- an episode whose name states no season number — no invented `Season 01`;
|
||||||
|
- a destination two files both claim: the executor refuses the whole plan
|
||||||
|
before its first rename, and only Frank decides which copy is the keeper.
|
||||||
|
- an episode the destination library already holds at the same byte count. The
|
||||||
|
movie share carried a second copy of all 124 Monk episodes — the TV library
|
||||||
|
has had the same box set for years, every episode the same size, ~31 GiB of
|
||||||
|
it. The mover leaves such a series in place and says so; a duplicate is
|
||||||
|
deleted or kept by Frank, never copied into one library by a tool.
|
||||||
|
|
||||||
|
A sidecar that shares an episode's own name (`.srt`, `.nfo`) moves with it.
|
||||||
|
|
||||||
## Laws
|
## Laws
|
||||||
|
|
||||||
|
- **A moved file is a 404 in [[Plex]] until its section is rescanned.** The
|
||||||
|
database keeps the release-folder path, so playback fails `Couldn't find the
|
||||||
|
file to stream` while the scanner works through the section. Never read such a
|
||||||
|
404 as a lost file: the file is where the journal says it is.
|
||||||
- **Plex's matched title outranks the filename**, and a Plex year of `0` means
|
- **Plex's matched title outranks the filename**, and a Plex year of `0` means
|
||||||
*indexed but never matched* — trusting it named folders `Title (0)`, which is
|
*indexed but never matched* — trusting it named folders `Title (0)`, which is
|
||||||
a Jellyfin item with no year, no poster and no identity. A year that is not a
|
a Jellyfin item with no year, no poster and no identity. A year that is not a
|
||||||
@@ -110,14 +184,40 @@ for the folders the piles own; they are left in place per the deletion policy.
|
|||||||
duplicate destination stops the run *before the first rename*, so the operator
|
duplicate destination stops the run *before the first rename*, so the operator
|
||||||
fixes one thing and re-runs instead of untangling a half-applied plan.
|
fixes one thing and re-runs instead of untangling a half-applied plan.
|
||||||
`--force` relaxes only the opinion guards; it never permits overwriting.
|
`--force` relaxes only the opinion guards; it never permits overwriting.
|
||||||
|
- **A sweep deletes only what the journal proves the run emptied.** A folder is
|
||||||
|
eligible when a journalled file moved out of it or out of something under it,
|
||||||
|
it now holds nothing but empty directories, no journalled destination lives
|
||||||
|
beneath it, and it is not a folder the run created. A folder nobody moved —
|
||||||
|
someone's placeholder — cannot be selected at all, and a symlink anywhere
|
||||||
|
inside disqualifies the subtree, because the thing behind a link is not this
|
||||||
|
tool's to delete. Every removal is journalled as `rmdir` and `rollback`
|
||||||
|
recreates the folder, so the sweep is undone by the same command as the moves.
|
||||||
|
Proof carries downward: inside a folder the journal proves emptied, every
|
||||||
|
empty directory goes with it. A release's `Screens/` or `Sample/` is never a
|
||||||
|
move source itself, so 146 of them held their emptied parents on disk after
|
||||||
|
the first sweep.
|
||||||
|
A failed `rmdir` is not a removal: only a folder that is really gone leaves
|
||||||
|
scope, so the next sweep finishes what a permission problem stopped.
|
||||||
|
- **An episode-shaped name marks a SERIES, not a film — and the series is the
|
||||||
|
name BEFORE the marker.** `Monk - S05E03 - Mr. Monk and the Big Game` is series
|
||||||
|
`Monk`. Keyed on the whole title instead, every episode became a group of one
|
||||||
|
and never reached the five-episode size that calls something a series, so a
|
||||||
|
box set was filed as 124 movies in 124 folders. The threshold still earns its
|
||||||
|
keep: a lone film named `Part 2` stays a film. Episodes of a series are parked
|
||||||
|
in `_TV needs a TV library - <series>` for a human to move to a TV source —
|
||||||
|
the movie tool never files TV.
|
||||||
|
- **One journal is one undo.** The TV move runs on its own journal, and with
|
||||||
|
`--root` set to the share that holds BOTH libraries: the executor refuses any
|
||||||
|
path outside `--root`, and a journal shared with the movie relayout would
|
||||||
|
undo both runs when only one of them is wanted.
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
One command, from the journal of the run:
|
One command, from the journal of the run:
|
||||||
`media-apply.py rollback --journal …/media-relayout.jsonl --apply`. It restores
|
`media-apply.py rollback --journal …/media-relayout.jsonl --apply`. It recreates
|
||||||
every source path, removes only the folders that run created *and that are now
|
any folder a sweep removed, restores every source path, removes only the folders
|
||||||
empty*, and refuses everything if any moved file cannot be accounted for. A
|
that run created *and that are now empty*, and refuses everything if any moved
|
||||||
rolled-back journal refuses to roll back twice.
|
file cannot be accounted for. A rolled-back journal refuses to roll back twice.
|
||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ load helpers
|
|||||||
PLAN="$REPO_ROOT/scripts/media-plan.py"
|
PLAN="$REPO_ROOT/scripts/media-plan.py"
|
||||||
CENSUS="$REPO_ROOT/scripts/media-census.py"
|
CENSUS="$REPO_ROOT/scripts/media-census.py"
|
||||||
APPLY="$REPO_ROOT/scripts/media-apply.py"
|
APPLY="$REPO_ROOT/scripts/media-apply.py"
|
||||||
|
TV="$REPO_ROOT/scripts/media-tv.py"
|
||||||
|
|
||||||
# probe_row FILE DUR [RES] [AUDIO] [SUBS] — one probed-TSV row: the twelve
|
# probe_row FILE DUR [RES] [AUDIO] [SUBS] — one probed-TSV row: the twelve
|
||||||
# columns media-census.py probe writes (bytes, path, dur, vcodec, res, fps,
|
# columns media-census.py probe writes (bytes, path, dur, vcodec, res, fps,
|
||||||
@@ -47,6 +48,9 @@ setup() {
|
|||||||
PLEX="$BATS_TEST_TMPDIR/plex.tsv"
|
PLEX="$BATS_TEST_TMPDIR/plex.tsv"
|
||||||
OUT="$BATS_TEST_TMPDIR/plan.tsv"
|
OUT="$BATS_TEST_TMPDIR/plan.tsv"
|
||||||
EXC="$BATS_TEST_TMPDIR/exceptions.txt"
|
EXC="$BATS_TEST_TMPDIR/exceptions.txt"
|
||||||
|
TVLIB="$BATS_TEST_TMPDIR/TVShows"
|
||||||
|
TVPLAN="$BATS_TEST_TMPDIR/tv.tsv"
|
||||||
|
TVJ="$BATS_TEST_TMPDIR/tv.jsonl"
|
||||||
: > "$PROBED"
|
: > "$PROBED"
|
||||||
printf 'file\tleaf_title\tleaf_year\n' > "$PLEX"
|
printf 'file\tleaf_title\tleaf_year\n' > "$PLEX"
|
||||||
|
|
||||||
@@ -71,6 +75,20 @@ setup() {
|
|||||||
at "$LIB/Alien (1979)/._Alien.1979.1080p.BluRay.x265.10bit.mkv" 220
|
at "$LIB/Alien (1979)/._Alien.1979.1080p.BluRay.x265.10bit.mkv" 220
|
||||||
# A bonus file whose name is a number: never a movie folder named "1".
|
# A bonus file whose name is a number: never a movie folder named "1".
|
||||||
at "$LIB/Bruce Lee Collection 1971-1981/.Extra 1.mkv" 2700000
|
at "$LIB/Bruce Lee Collection 1971-1981/.Extra 1.mkv" 2700000
|
||||||
|
# A TV box set sitting in the movie library. This one names each file
|
||||||
|
# "Show - SxxEyy - Episode Title", so the episode title comes AFTER the
|
||||||
|
# marker; that convention is what made 124 episodes of one series look like
|
||||||
|
# 124 different films, each under its own threshold.
|
||||||
|
for e in 01 02 03 04 05; do
|
||||||
|
at "$LIB/Monk 2002 Seasons 1 to 8/Season 1/Monk - S01E$e - Mr Monk and the Rival.mkv" 3000000
|
||||||
|
done
|
||||||
|
# The same trouble named the other way round, with no episode title at all.
|
||||||
|
for e in 01 02 03 04 05; do
|
||||||
|
at "$LIB/Big Little Lies Season 1/Big Little Lies S01E$e.mp4" 3000000
|
||||||
|
done
|
||||||
|
# A film whose own name carries a bare "Part 2": one episode-shaped token is
|
||||||
|
# not a series, and it must not be dragged into the TV pile.
|
||||||
|
at "$LIB/The Godfather Part 2 (1974)/The.Godfather.Part.2.1974.1080p.BluRay.mkv" 9000000
|
||||||
|
|
||||||
probe_row "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv" 4700
|
probe_row "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv" 4700
|
||||||
probe_row "$LIB/Alien (1979)/Featurettes/A Bug And A Bite - Making Alien.mkv" 2700
|
probe_row "$LIB/Alien (1979)/Featurettes/A Bug And A Bite - Making Alien.mkv" 2700
|
||||||
@@ -82,11 +100,18 @@ setup() {
|
|||||||
probe_row "$LIB/VHS 2 (2013)/VHS.2.2013.1080p.BluRay.x265.mkv" 5700
|
probe_row "$LIB/VHS 2 (2013)/VHS.2.2013.1080p.BluRay.x265.mkv" 5700
|
||||||
probe_row "$LIB/AMERICAN POP/AMERICAN.POP.2023.1080p.WEB-DL.mkv" 6300
|
probe_row "$LIB/AMERICAN POP/AMERICAN.POP.2023.1080p.WEB-DL.mkv" 6300
|
||||||
probe_row "$LIB/Bruce Lee Collection 1971-1981/.Extra 1.mkv" 5220
|
probe_row "$LIB/Bruce Lee Collection 1971-1981/.Extra 1.mkv" 5220
|
||||||
|
for e in 01 02 03 04 05; do
|
||||||
|
probe_row "$LIB/Monk 2002 Seasons 1 to 8/Season 1/Monk - S01E$e - Mr Monk and the Rival.mkv" 2600
|
||||||
|
probe_row "$LIB/Big Little Lies Season 1/Big Little Lies S01E$e.mp4" 3300
|
||||||
|
done
|
||||||
|
probe_row "$LIB/The Godfather Part 2 (1974)/The.Godfather.Part.2.1974.1080p.BluRay.mkv" 6200
|
||||||
plex_row "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv" "Alien" "1979"
|
plex_row "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv" "Alien" "1979"
|
||||||
plex_row "$LIB/Marnie (1964)/Marnie.1964.1080p.BluRay.x265.mkv" "Marnie" "1964"
|
plex_row "$LIB/Marnie (1964)/Marnie.1964.1080p.BluRay.x265.mkv" "Marnie" "1964"
|
||||||
plex_row "$LIB/VHS 2 (2013)/VHS.2.2013.1080p.BluRay.x265.mkv" "V/H/S/2" "2013"
|
plex_row "$LIB/VHS 2 (2013)/VHS.2.2013.1080p.BluRay.x265.mkv" "V/H/S/2" "2013"
|
||||||
# An unmatched Plex row: the leaf title echoes the file and the year is 0.
|
# An unmatched Plex row: the leaf title echoes the file and the year is 0.
|
||||||
plex_row "$LIB/AMERICAN POP/AMERICAN.POP.2023.1080p.WEB-DL.mkv" "AMERICAN POP" "0"
|
plex_row "$LIB/AMERICAN POP/AMERICAN.POP.2023.1080p.WEB-DL.mkv" "AMERICAN POP" "0"
|
||||||
|
plex_row "$LIB/The Godfather Part 2 (1974)/The.Godfather.Part.2.1974.1080p.BluRay.mkv" \
|
||||||
|
"The Godfather Part 2" "1974"
|
||||||
|
|
||||||
export MEDIA_ROOT="$LIB"
|
export MEDIA_ROOT="$LIB"
|
||||||
run python3 "$CENSUS" enumerate --root "$LIB" --out "$ENUM"
|
run python3 "$CENSUS" enumerate --root "$LIB" --out "$ENUM"
|
||||||
@@ -115,6 +140,33 @@ for line in open(sys.argv[1]):
|
|||||||
' "$OUT" "$1"
|
' "$OUT" "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# tv_plan — the TV mover over the fixture library; its stdout is the operator's
|
||||||
|
# census of what it called a series and what it refused to touch.
|
||||||
|
tv_plan() {
|
||||||
|
run python3 "$TV" --root "$LIB" --library "$TVLIB" --out "$TVPLAN"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
printf '%s\n' "$output" > "$BATS_TEST_TMPDIR/tv.out"
|
||||||
|
}
|
||||||
|
|
||||||
|
# tv_apply — that plan through the same executor. The destination is a sibling
|
||||||
|
# share, so the plan is applied at the root that holds both libraries.
|
||||||
|
tv_apply() {
|
||||||
|
run python3 "$APPLY" apply --plan "$TVPLAN" --root "$BATS_TEST_TMPDIR" \
|
||||||
|
--journal "$TVJ" --apply
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# tv_dst_of SRC — the destination the TV plan gives one file, empty if none.
|
||||||
|
tv_dst_of() {
|
||||||
|
python3 -c '
|
||||||
|
import sys
|
||||||
|
for line in open(sys.argv[1]):
|
||||||
|
f = line.rstrip("\n").split("\t")
|
||||||
|
if len(f) > 2 and f[1] == sys.argv[2]:
|
||||||
|
print(f[2]); break
|
||||||
|
' "$TVPLAN" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
@test "a film lands in Title (year)/Title (year).mkv, named by Plex" {
|
@test "a film lands in Title (year)/Title (year).mkv, named by Plex" {
|
||||||
plan
|
plan
|
||||||
[ "$(dst_of "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv")" \
|
[ "$(dst_of "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv")" \
|
||||||
@@ -191,6 +243,112 @@ for line in open(sys.argv[1]):
|
|||||||
grep -q "UNNAMED" "$EXC"
|
grep -q "UNNAMED" "$EXC"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "a box set in the movie library is quarantined, not filed as movies" {
|
||||||
|
plan
|
||||||
|
local d
|
||||||
|
d="$(dst_of "$LIB/Monk 2002 Seasons 1 to 8/Season 1/Monk - S01E01 - Mr Monk and the Rival.mkv")"
|
||||||
|
[[ "$d" == "$LIB"/_TV\ needs\ a\ TV\ library\ -\ monk* ]]
|
||||||
|
d="$(dst_of "$LIB/Big Little Lies Season 1/Big Little Lies S01E01.mp4")"
|
||||||
|
[[ "$d" == "$LIB"/_TV\ needs\ a\ TV\ library\ -\ big\ little\ lies* ]]
|
||||||
|
# one series is one pile, whatever its episodes are called
|
||||||
|
run python3 -c '
|
||||||
|
import re, sys
|
||||||
|
pile = {}
|
||||||
|
for line in open(sys.argv[1]):
|
||||||
|
f = line.rstrip("\n").split("\t")
|
||||||
|
if len(f) > 2 and f[0] == "move":
|
||||||
|
m = re.search(r"/_TV needs a TV library - ([^/]*)/", f[2] + "/")
|
||||||
|
if m:
|
||||||
|
pile[m.group(1)] = pile.get(m.group(1), 0) + 1
|
||||||
|
for k in sorted(pile):
|
||||||
|
print(f"{k}={pile[k]}")
|
||||||
|
' "$OUT"
|
||||||
|
[ "${#lines[@]}" -eq 2 ]
|
||||||
|
[[ "${lines[0]}" == big\ little\ lies*=5 ]]
|
||||||
|
[[ "${lines[1]}" == monk*=5 ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a film named Part 2 is not called a series" {
|
||||||
|
plan
|
||||||
|
[ "$(dst_of "$LIB/The Godfather Part 2 (1974)/The.Godfather.Part.2.1974.1080p.BluRay.mkv")" \
|
||||||
|
= "$LIB/The Godfather Part 2 (1974)/The Godfather Part 2 (1974).mkv" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the TV mover files a box set into the TV library in Jellyfin shape" {
|
||||||
|
tv_plan
|
||||||
|
tv_apply
|
||||||
|
[ -e "$TVLIB/Monk (2002)/Season 01/Monk - S01E01 - Mr Monk and the Rival.mkv" ]
|
||||||
|
[ -e "$TVLIB/Big Little Lies/Season 01/Big Little Lies S01E01.mp4" ]
|
||||||
|
[ ! -e "$LIB/Monk 2002 Seasons 1 to 8/Season 1/Monk - S01E01 - Mr Monk and the Rival.mkv" ]
|
||||||
|
# films are not the TV mover's business
|
||||||
|
[ -e "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the TV mover leaves a film whose name only looks like an episode" {
|
||||||
|
tv_plan
|
||||||
|
tv_apply
|
||||||
|
local gf="$LIB/The Godfather Part 2 (1974)/The.Godfather.Part.2.1974.1080p.BluRay.mkv"
|
||||||
|
[ "$(tv_dst_of "$gf")" = "" ]
|
||||||
|
[ -e "$gf" ]
|
||||||
|
grep -q "Godfather.*not a series" "$BATS_TEST_TMPDIR/tv.out"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the TV mover keeps an episode's own subtitle with the episode" {
|
||||||
|
at "$LIB/Monk 2002 Seasons 1 to 8/Season 1/Monk - S01E01 - Mr Monk and the Rival.srt" 40000
|
||||||
|
tv_plan
|
||||||
|
tv_apply
|
||||||
|
[ -e "$TVLIB/Monk (2002)/Season 01/Monk - S01E01 - Mr Monk and the Rival.srt" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a second TV move finds nothing left to move" {
|
||||||
|
tv_plan
|
||||||
|
tv_apply
|
||||||
|
tv_plan
|
||||||
|
grep -q "0 moves" "$BATS_TEST_TMPDIR/tv.out"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a show whose own name carries the year is not given it twice" {
|
||||||
|
local d="$LIB/Leah Remini Scientology and the Aftermath (2016)/Season 1"
|
||||||
|
for e in 01 02 03 04 05; do
|
||||||
|
at "$d/Leah Remini Scientology and the Aftermath (2016) - S01E$e - Episode.mkv" 3000000
|
||||||
|
done
|
||||||
|
tv_plan
|
||||||
|
tv_apply
|
||||||
|
[ -e "$TVLIB/Leah Remini Scientology and the Aftermath (2016)/Season 01/Leah Remini Scientology and the Aftermath (2016) - S01E01 - Episode.mkv" ]
|
||||||
|
[ ! -e "$TVLIB/Leah Remini Scientology and the Aftermath (2016) (2016)" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "an all-lowercase series name is written the way the library writes names" {
|
||||||
|
for e in 01 02 03 04 05; do
|
||||||
|
at "$LIB/downton abbey 2010/Season 1/downton abbey - s01e$e - episode title.mkv" 3000000
|
||||||
|
done
|
||||||
|
tv_plan
|
||||||
|
tv_apply
|
||||||
|
# the FOLDER is titled; the file keeps the name the library already holds
|
||||||
|
[ -e "$TVLIB/Downton Abbey (2010)/Season 01/downton abbey - s01e01 - episode title.mkv" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the mover leaves an episode the TV library already holds" {
|
||||||
|
local src="$LIB/Monk 2002 Seasons 1 to 8/Season 1/Monk - S01E01 - Mr Monk and the Rival.mkv"
|
||||||
|
for e in 01 02 03 04 05; do
|
||||||
|
at "$TVLIB/Monk (2002)/Season 01/Monk - S01E$e - Mr Monk and the Rival.mkv" 3000000
|
||||||
|
done
|
||||||
|
tv_plan
|
||||||
|
[ -z "$(tv_dst_of "$src")" ]
|
||||||
|
[ -e "$src" ]
|
||||||
|
grep -q "Monk.*already in" "$BATS_TEST_TMPDIR/tv.out"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "an episode the TV library holds in another encode is still moved" {
|
||||||
|
for e in 01 02 03 04 05; do
|
||||||
|
at "$TVLIB/Big Little Lies/Season 01/Big Little Lies S01E$e 1080p.mp4" 4000000
|
||||||
|
done
|
||||||
|
tv_plan
|
||||||
|
tv_apply
|
||||||
|
[ -e "$TVLIB/Big Little Lies/Season 01/Big Little Lies S01E01.mp4" ]
|
||||||
|
[ -e "$TVLIB/Big Little Lies/Season 01/Big Little Lies S01E01 1080p.mp4" ]
|
||||||
|
}
|
||||||
|
|
||||||
@test "every file in the census is placed or kept — nothing is dropped" {
|
@test "every file in the census is placed or kept — nothing is dropped" {
|
||||||
plan
|
plan
|
||||||
python3 - "$ENUM" "$OUT" <<'PY'
|
python3 - "$ENUM" "$OUT" <<'PY'
|
||||||
@@ -331,3 +489,132 @@ PY
|
|||||||
# the refusal is all-or-nothing: another film did not move back
|
# the refusal is all-or-nothing: another film did not move back
|
||||||
[ -e "$LIB/Marnie (1964)/Marnie (1964).mkv" ]
|
[ -e "$LIB/Marnie (1964)/Marnie (1964).mkv" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- sweep
|
||||||
|
#
|
||||||
|
# A relayout leaves the release folders it emptied standing at the library root:
|
||||||
|
# 1675 of them after the live run, each holding nothing but empty subfolders. The
|
||||||
|
# sweep pass removes only folders THIS RUN emptied — proven from the journal,
|
||||||
|
# never from "it looks empty" — so a folder someone made by hand is untouchable,
|
||||||
|
# and every removal rides the same rollback as the moves.
|
||||||
|
|
||||||
|
@test "sweep removes only the folders this run emptied" {
|
||||||
|
plan
|
||||||
|
apply_plan
|
||||||
|
mkdir -p "$LIB/A Empty Placeholder" # someone's folder, never a move source
|
||||||
|
run python3 "$APPLY" sweep --root "$LIB" --journal "$JOURNAL" --apply
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"swept"* ]]
|
||||||
|
[ ! -e "$LIB/AMERICAN POP" ]
|
||||||
|
[ ! -e "$LIB/VHS 2 (2013)" ]
|
||||||
|
[ ! -e "$LIB/Alien (1979)/Featurettes" ]
|
||||||
|
[ ! -e "$LIB/Alien (1979)/subs" ]
|
||||||
|
[ ! -e "$LIB/Marnie (1964)/Exrtas" ]
|
||||||
|
# a folder the run never moved a file out of survives, empty or not
|
||||||
|
[ -d "$LIB/A Empty Placeholder" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a swept folder carries the empty junk folders inside it" {
|
||||||
|
mkdir -p "$LIB/AMERICAN POP/Screens" # screenshots, never a move source
|
||||||
|
plan
|
||||||
|
apply_plan
|
||||||
|
run python3 "$APPLY" sweep --root "$LIB" --journal "$JOURNAL" --apply
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"0 left in place"* ]]
|
||||||
|
[ ! -e "$LIB/AMERICAN POP/Screens" ]
|
||||||
|
[ ! -e "$LIB/AMERICAN POP" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a sweep whose rmdir failed tries those folders again" {
|
||||||
|
plan
|
||||||
|
apply_plan
|
||||||
|
mkdir -p "$LIB/AMERICAN POP/Screens"
|
||||||
|
chmod 500 "$LIB/AMERICAN POP" # readable, but nothing can be removed
|
||||||
|
run python3 "$APPLY" sweep --root "$LIB" --journal "$JOURNAL" --apply
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"left in place"* ]]
|
||||||
|
[ -e "$LIB/AMERICAN POP/Screens" ]
|
||||||
|
chmod 700 "$LIB/AMERICAN POP" # the operator cleared the blocker
|
||||||
|
run python3 "$APPLY" sweep --root "$LIB" --journal "$JOURNAL" --apply
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" != *"left in place: "* ]]
|
||||||
|
[ ! -e "$LIB/AMERICAN POP" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "sweep without --apply reports the husks and changes nothing" {
|
||||||
|
plan
|
||||||
|
apply_plan
|
||||||
|
local before jlines
|
||||||
|
before=$(find "$LIB" -type d | wc -l)
|
||||||
|
jlines=$(wc -l < "$JOURNAL")
|
||||||
|
run python3 "$APPLY" sweep --root "$LIB" --journal "$JOURNAL"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"--apply"* ]]
|
||||||
|
[ "$(find "$LIB" -type d | wc -l)" -eq "$before" ]
|
||||||
|
[ "$(wc -l < "$JOURNAL")" -eq "$jlines" ]
|
||||||
|
[ -e "$LIB/AMERICAN POP" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a folder that still holds content is never swept" {
|
||||||
|
plan
|
||||||
|
apply_plan
|
||||||
|
run python3 "$APPLY" sweep --root "$LIB" --journal "$JOURNAL" --apply
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
# the film folder, the folder the run created, and the piles all stay
|
||||||
|
[ -d "$LIB/Marnie (1964)" ]
|
||||||
|
[ -e "$LIB/Marnie (1964)/Marnie (1964).mkv" ]
|
||||||
|
[ -d "$LIB/V-H-S-2 (2013)" ]
|
||||||
|
[ -d "$LIB/_unsorted bonus/extras" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "rollback after a sweep brings the folders and the files back" {
|
||||||
|
plan
|
||||||
|
apply_plan
|
||||||
|
run python3 "$APPLY" sweep --root "$LIB" --journal "$JOURNAL" --apply
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ ! -e "$LIB/AMERICAN POP" ]
|
||||||
|
run python3 "$APPLY" rollback --root "$LIB" --journal "$JOURNAL" --apply
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -e "$LIB/AMERICAN POP/AMERICAN.POP.2023.1080p.WEB-DL.mkv" ]
|
||||||
|
[ -e "$LIB/VHS 2 (2013)/VHS.2.2013.1080p.BluRay.x265.mkv" ]
|
||||||
|
[ -e "$LIB/Alien (1979)/Featurettes/A Bug And A Bite - Making Alien.mkv" ]
|
||||||
|
[ -e "$LIB/Alien (1979)/subs/3_Eng.srt" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a second sweep has nothing left to do and says so" {
|
||||||
|
plan
|
||||||
|
apply_plan
|
||||||
|
run python3 "$APPLY" sweep --root "$LIB" --journal "$JOURNAL" --apply
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
local before
|
||||||
|
before=$(find "$LIB" -type d | wc -l)
|
||||||
|
run python3 "$APPLY" sweep --root "$LIB" --journal "$JOURNAL" --apply
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"swept: 0"* ]]
|
||||||
|
[ "$(find "$LIB" -type d | wc -l)" -eq "$before" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "sweep never follows a symlink and never removes the folder it guards" {
|
||||||
|
plan
|
||||||
|
apply_plan
|
||||||
|
mkdir -p "$LIB/Elsewhere"
|
||||||
|
at "$LIB/Elsewhere/keep.txt" 10
|
||||||
|
ln -s "$LIB/Elsewhere" "$LIB/AMERICAN POP/pointer"
|
||||||
|
run python3 "$APPLY" sweep --root "$LIB" --journal "$JOURNAL" --apply
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
# a link is content, not nothing: the husk it sits in survives untouched
|
||||||
|
[ -d "$LIB/AMERICAN POP" ]
|
||||||
|
[ -L "$LIB/AMERICAN POP/pointer" ]
|
||||||
|
[ -e "$LIB/Elsewhere/keep.txt" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "sweep refuses a journal that belongs to another library" {
|
||||||
|
plan
|
||||||
|
apply_plan
|
||||||
|
mkdir -p "$BATS_TEST_TMPDIR/Other"
|
||||||
|
run python3 "$APPLY" sweep --root "$BATS_TEST_TMPDIR/Other" \
|
||||||
|
--journal "$JOURNAL" --apply
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"outside"* ]]
|
||||||
|
[ -d "$LIB/AMERICAN POP" ]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user