Compare commits

..
Author SHA1 Message Date
Frank 86f5903e74 docs(media): how to file a below-threshold episode by hand
Six single-episode files stayed in the movie library when the threshold refused
them; the hand path is the same executor on a hand-written plan and the same
journal. The trap recorded here is the series year -- never inherit it from the
title whose bonus folder an episode happens to be parked in.
2026-10-30 20:45:24 -07:00
Frank bd4ee78811 docs(media): clear the two OKF violations the last push shipped
The seam ran through `grep -c`, which returned success on a non-zero count and
let a red suite through: description over the 120-char cap, and a timestamp
that check-okf reads against the local date, so a UTC stamp a day ahead of
Seattle is 'in the future'. index.md regenerated for the new description.
docs_completeness 6/6, exit status checked this time.
2026-10-30 20:40:27 -07:00
Frank b7b39cdc92 docs(media): Jellyfin 12.4 needs the MediaBrowser header; the scan key is on the box
X-Emby-Token is refused for every key on 12.4 -- which is why this page
carried the false claim that no credential on the box could trigger a scan and
that the two Seerr keys were dead. The documented snippet now authenticates
with Authorization: MediaBrowser Token=..., reads 204 from /Library/Refresh,
and points at ~/.config/secrets/jellyfin (mirrored as JELLYFIN_API_KEY in the
ignored dankserver/.env). Law: prove a 401 against the ApiKeys table before
blaming the key.
2026-10-30 20:39:51 -07:00
Frank 09532e5216 docs(media): a deletion is staged on the item's own filesystem
Trashing 31 GiB of duplicate Monk to ~/.AGENTTRASH would have been a
cross-device copy of the thing being deleted, into 187 GiB of free space, on a
volume at 100% -- and rename(2) refuses it regardless. The stage belongs beside
the item.
2026-10-30 20:37:27 -07:00
Frank e1786e7cf2 docs(media): the Monk duplicate is verified; compare libraries with the series engine
The destination-hold refusal's evidence, stated exactly: 124 episodes both
sides, same episode list, every byte count identical, 31.5 GiB each way,
head+tail blake2b identical on a 6-episode sample.

Plus the law this came out of: a path-substring filter for a series name also
returns other shows (monk matches Monkey Dust), and whichever file a walk
reaches first standing in for an episode turns two libraries into a fake
duplicate — the same sloppiness aimed the other way deletes a unique file.
ep_of on both sides, and a hash before anything is trashed.
2026-10-30 20:33:41 -07:00
dank 409a8d8b0c Merge pull request 'feat(media): a TV mover that will not file a show the library already has' (#96) from feat/media-tv-mover into main 2026-10-30 20:31:38 -07:00
Frank e83162a2ac 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.
2026-10-30 20:30:24 -07:00
dank 5cd9a0c4c2 Merge pull request 'fix(media): the series is the name before the marker; a sweep carries its own empty subfolders' (#95) from fix/media-series-key into main 2026-10-30 20:20:24 -07:00
Frank a803aa046a fix(media): a sweep carries the emptied folder's own empty subfolders
The live sweep removed 2271 husks and left 146 standing, every one a release
folder blocked by a junk subfolder (Screens/, Sample/, a BDMV leaf) that no
file ever moved out of. Eligibility was per-folder, so the blocker was never a
target and its parent could never be removed.

Proof now carries downward: inside a folder the journal shows emptied, every
empty directory qualifies. Nothing under such a folder can hold data, and every
removal is still journalled as rmdir and undone by rollback.

The new test is red against the previous revision, green now. The live sweep
touched no file: 8193 before, 8193 after.
2026-10-30 20:18:54 -07:00
Frank 73f77a13c3 fix(media): a series is the name before the episode marker
Keyed on the whole episode title, every episode of a box set became a group of
one and never reached the five-episode size that calls something a series, so
the live run filed 124 Monk episodes as 124 movie folders, each named after its
own episode. ep_of now returns the series — the name before SxxEyy / 1x04 /
Part N — and the planner groups and names the quarantine pile by that series,
so one show is one pile: 'Monk - S05E03 - Mr. Monk and the Big Game' is series
monk. The threshold keeps its job: a lone film named Part 2 stays a film.

Re-planning the live census with this moves nothing differently except the 124
Monk files, which now join the TV pile: 168 episode files across 3 series.
2026-10-30 20:12:05 -07:00
dank dd76fcf5d4 Merge pull request 'feat(media): sweep removes the folders a completed run emptied' (#94) from feat/media-sweep into main 2026-10-30 20:04:24 -07:00
Frank 1cb28095a7 feat(media): sweep removes the folders a completed run emptied
An apply leaves every release folder it emptied standing where it was: the
live library run left 1675 husks at the root. sweep takes the same journal
and deletes only folders it can prove this run emptied - a journalled move
out of the folder or something under it, nothing but empty directories left,
no journalled destination beneath it, not a folder the run created. A folder
nobody moved cannot be selected at all, so a hand-made placeholder survives,
and a symlink anywhere inside disqualifies the subtree. Every rmdir is
journalled, so rollback recreates what a sweep removed.
2026-10-30 20:03:16 -07:00
dank e1760e3316 Merge pull request 'feat(media): the movie-library relayout toolchain — census, plan, executor' (#93) from feat/media-relayout into main 2026-10-30 19:38:03 -07:00
8 changed files with 862 additions and 34 deletions

No files matched your search

+110 -6
View File
@@ -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
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]
no --apply => report only and write NOTHING, not even folders
@@ -19,6 +19,10 @@ Three subcommands:
removed only when empty, never anything else
verify --plan PLAN --journal J [--root ROOT]
every planned file is at its target with the journalled byte count
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
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
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):
"""Undo every journalled move, newest first, after a full preflight."""
moves = [r for r in journal.records if r.get("op") == "move"]
made = [r["dst"] for r in journal.records if r.get("op") == "mkdir"]
swept = [r["path"] for r in journal.records if r.get("op") == "rmdir"]
problems = []
for r in reversed(moves):
src, dst = r["src"], r["dst"]
@@ -255,9 +352,14 @@ def do_rollback(args, root, journal):
return 2
if not args.apply:
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
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):
if os.path.exists(r["dst"]) and not os.path.exists(r["src"]):
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,
t=datetime.datetime.now(datetime.timezone.utc).isoformat())
print(f"rolled back: {undone} files back where the census found them,"
f" {dirs_gone} empty folders removed")
f" {dirs_gone} empty folders removed, {recreated} swept folders recreated")
return 0
def main():
ap = argparse.ArgumentParser(description="apply or undo a media relayout plan")
ap.add_argument("cmd", choices=["apply", "rollback", "verify"])
ap.add_argument("cmd", choices=["apply", "rollback", "verify", "sweep"])
ap.add_argument("--plan")
ap.add_argument("--root", default=DEFAULT_ROOT)
ap.add_argument("--journal")
@@ -295,9 +397,9 @@ def main():
journal_path = args.journal or journal_default(root)
journal = Journal(journal_path, args.apply)
try:
if args.cmd in ("rollback", "verify") and not os.path.exists(journal_path):
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}")
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"
" (--force to replay it)")
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)
if args.cmd == "verify":
return do_verify(root, journal, moves, keeps)
if args.cmd == "sweep":
return do_sweep(args, root, journal)
return do_rollback(args, root, journal)
finally:
journal.close()
+8 -9
View File
@@ -222,8 +222,9 @@ eps = collections.defaultdict(list)
for r in erows:
if r["path"] in marks | extras:
continue
if eng.ep_of(r["path"]):
eps[eng.key_of(r["title"])].append(r)
ep = eng.ep_of(r["path"])
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_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
@@ -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/`, "
"`_unsorted files/` and `_unsorted bonus/` hold files no film claims.")
for key in tv_keys:
nm = "_TV needs a TV library - " + collections.Counter(
r["title"] for r in eps[key]).most_common(1)[0][0]
readmes.append(f"README in {ROOT}/{sanitize(nm)}: these are TV episodes found in "
"the MOVIES library; move the folder to a TV library source, else "
"Jellyfin groups them wrongly as one movie.")
readmes.append(f"README in {ROOT}/{sanitize('_TV needs a TV library - ' + key)}: "
"these are TV episodes found in the MOVIES library; move the folder "
"to a TV library source, else Jellyfin groups them wrongly as one "
"movie.")
# --------------------------------------------------------------------- TV
for key in tv_keys:
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"] != "?")
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}
members = sorted([r["path"] for r in items]
+ [q for q in extras | marks if len(parts_of(q)) > 1
+12 -3
View File
@@ -322,13 +322,22 @@ def key_of(title):
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]
m = EP_RE.search(name)
if not m:
return None
show, _ = title_of(path)
return show, norm(m.group(1)).replace(" ", "")
head = name[:m.start()].strip(" ._\u2013-")
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):
+281
View File
@@ -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())
@@ -42,6 +42,15 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain →
Library folders point at `/data` paths; because the mount uses the same
absolute path as on the host, library paths survive container recreation.
`Local metadata` — save metadata to media folders — cannot work against a `ro`
mount, and it is enabled: every item update logs
`System.IO.IOException: Read-only file system : '/data/Movies/<film>/movie.nfo'`.
The exception lands in the sidecar saver, after the database row is written, so
indexing proceeds anyway — a 2026-10-30 scan dropped stale records and added
episodes while these lines filled the log. Expected noise, not a fault: if it
ever needs silencing, turn the saver off under *Library → Metadata* rather than
making `/data` writable.
## Ports & access
| Port | Proto | Purpose |
@@ -1,7 +1,7 @@
---
type: reference
title: Media Relayout
description: "Census, plan and apply the movie library into Jellyfin format: the gates to read, and the rollback."
description: "Census, plan and apply the movie library into Jellyfin format, and move TV out of it: the gates, refusals, rollback."
tags: [dankserver, media-server, jellyfin, plex, procedure]
timestamp: 2026-10-30T00:00:00Z
---
@@ -11,15 +11,16 @@ timestamp: 2026-10-30T00:00:00Z
The movie library on [[Entropy Volume]] arrived as release folders —
`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
release noise cost posters, extras and versions. These three scripts turn that
library into Jellyfin's own shape, and nothing else in the repo moves a media
file.
release noise cost posters, extras and versions. These scripts turn that
library into Jellyfin's own shape — and move TV back out of it — and nothing
else in the repo touches a media file.
| Script | Touches media? | Output |
|---|---|---|
| `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-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
[jellyfin.org/docs/general/server/media/movies](https://jellyfin.org/docs/general/server/media/movies)
@@ -61,17 +62,103 @@ that would be filed wrong, and the plan is not run until it is understood.
```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 --apply
scripts/media-apply.py verify --plan /tmp/jf-plan.tsv --journal …/media-relayout.jsonl
scripts/media-apply.py rollback --journal …/media-relayout.jsonl --apply
scripts/media-apply.py verify --plan /tmp/jf-plan.tsv --journal /mnt/entropy/media-relayout.jsonl
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:
[[Jellyfin]] *Library → Scan All Libraries*, and Plex's library scan
(`podman exec plex …` or the web UI). `verify` reporting empty folders is normal
for the folders the piles own; they are left in place per the deletion policy.
An apply also leaves the release folders it emptied standing where they were —
hundreds of them at the library root — which is what `sweep` removes, dry-run
first, from the same journal.
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]]'s scan key lives in `~/.config/secrets/jellyfin` (mode 600; the
same value is `JELLYFIN_API_KEY` in the ignored `dankserver/.env`). It rides a
mode-600 curl config, never a header argument, and it must use the
`MediaBrowser` form — Jellyfin 12.4 refuses a bare `X-Emby-Token` for any key:
```bash
umask 077
{ printf 'header = "Authorization: MediaBrowser Token=\\"%s\\""\n' \
"$(cat ~/.config/secrets/jellyfin)"
echo 'url = "http://127.0.0.1:8096/Library/Refresh"'
echo 'request = "POST"'; } >/tmp/jf.hdr
curl -s -K /tmp/jf.hdr -o /dev/null -w "jellyfin scan: %{http_code}\n"
shred -u /tmp/jf.hdr
```
`204` is acceptance; the scan then runs on the server's own schedule.
## 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 the whole Monk box set — 124 episodes
both sides, the same episode list, every byte count identical, 31.5 GiB each
way, and head+tail blake2b identical on a 6-episode sample. 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.
Below the threshold the mover prints and leaves, so a lone downloaded episode of
a real show stays in the movie library on purpose — six did on 2026-10-30.
Filing one by hand is the same executor on a hand-written plan and the same
journal; the trap is the series year. Read it from the file's own name or the
folder it owns, never from the title whose bonus folder it happens to be parked
in — `The Cleveland Show S03E07` inside `Die Hard (1988)/extras/` is not a 1988
show. A name whose marker is `Part 2` is a film and stays put.
## 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
*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
@@ -92,6 +179,32 @@ for the folders the piles own; they are left in place per the deletion policy.
folder holding it, and extras drifted into a neighbour. A bare number counts
only when it could be a release year, so `1080` and `2160` never pose as
agreement between two names.
- **Compare two media libraries with the series engine, never a path
substring.** Filtering paths for `monk` also returns *Monkey Dust*, and
letting whichever file a walk happens to reach first stand for an episode
paired two libraries into a fake duplicate — the same sloppiness pointed the
other way deletes a file that is unique. The comparison is `media-titles.ep_of`
on both sides: series, season, episode, bytes, and a hash before anything is
trashed.
- **A deletion is staged on the item's own filesystem.** The trash rule names
`~/.AGENTTRASH`, but `/home` and `/mnt/entropy` are different devices: a 31 GiB
stage there is a copy rather than a rename, it has to fit in the 187 GiB of
free space, and `rename(2)` refuses it anyway. Trash goes beside the item —
`/mnt/entropy/.AGENTTRASH/<date>-<topic>/` — so it costs one inode change and
rolls back exactly like the move that made it.
- **A Jellyfin 401 is the header, not the key.** 12.4 authenticates only
`Authorization: MediaBrowser Token="…"` and refuses a bare `X-Emby-Token` for
every key, valid or not. This page carried "no credential on this box can
trigger a scan, the two Seerr keys answer 401" for weeks because of that
header — the keys were innocent. Before believing a 401, prove the key against
the server's own table: `select count(*) from ApiKeys where AccessToken = ?`
on `jellyfin.db` opened `mode=ro&immutable=1`.
- **A scan that must see a new folder has to be recursive.** `POST
/Library/Refresh` is the incremental pass, and inotify does not cross a
rootless podman bind, so folders created on the host stay invisible to it. The
move is `POST /Items/{id}/Refresh?Recursive=true` (204) per library, the id
from `GET /Library/VirtualFolders` — its field is `ItemId`, not `Id` — and
that same record's `RefreshStatus` is the progress read.
- **A file whose name carries no words is bonus material, not a folder.**
`.Extra 1 ( 1H 27min multisub ).mkv` parses to `1`; a movie folder named `1`
is not a movie. It becomes a review line telling a human to name the file.
@@ -110,14 +223,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
fixes one thing and re-runs instead of untangling a half-applied plan.
`--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
One command, from the journal of the run:
`media-apply.py rollback --journal …/media-relayout.jsonl --apply`. It restores
every source path, removes only the folders that run created *and that are now
empty*, and refuses everything if any moved file cannot be accounted for. A
rolled-back journal refuses to roll back twice.
`media-apply.py rollback --journal …/media-relayout.jsonl --apply`. It recreates
any folder a sweep removed, restores every source path, removes only the folders
that run created *and that are now empty*, and refuses everything if any moved
file cannot be accounted for. A rolled-back journal refuses to roll back twice.
## See Also
+1 -1
View File
@@ -51,7 +51,7 @@ is a locator: one row, one clause, no numbers.
| [[Entropy Volume]] | The Dankserver entropy volume is a RAID-backed LUKS filesystem mounted at |
| [[Jellyfin]] | Jellyfin on Dankserver: quadlet unit, entropy-volume data layout, ports, client access, hardware-transcode status. |
| [[mariadb]] | The MariaDB backend for RomM: quadlet unit, network alias, env-file secret handling, and the backup path. |
| [[Media Relayout]] | Census, plan and apply the movie library into Jellyfin format: the gates to read, and the rollback. |
| [[Media Relayout]] | Census, plan and apply the movie library into Jellyfin format, and move TV out of it: the gates, refusals, rollback. |
| [[Plex]] | Plex Media Server is a **containerized media server on Dankserver**: a podman |
| [[redis]] | The Redis cache and task queue for RomM: quadlet unit, network alias topology, and the backup path. |
| [[RomM]] | RomM (ROM Manager) is a self-hosted ROM library manager and player. |
+287
View File
@@ -21,6 +21,7 @@ load helpers
PLAN="$REPO_ROOT/scripts/media-plan.py"
CENSUS="$REPO_ROOT/scripts/media-census.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
# columns media-census.py probe writes (bytes, path, dur, vcodec, res, fps,
@@ -47,6 +48,9 @@ setup() {
PLEX="$BATS_TEST_TMPDIR/plex.tsv"
OUT="$BATS_TEST_TMPDIR/plan.tsv"
EXC="$BATS_TEST_TMPDIR/exceptions.txt"
TVLIB="$BATS_TEST_TMPDIR/TVShows"
TVPLAN="$BATS_TEST_TMPDIR/tv.tsv"
TVJ="$BATS_TEST_TMPDIR/tv.jsonl"
: > "$PROBED"
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
# 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
# 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)/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/AMERICAN POP/AMERICAN.POP.2023.1080p.WEB-DL.mkv" 6300
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/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"
# 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/The Godfather Part 2 (1974)/The.Godfather.Part.2.1974.1080p.BluRay.mkv" \
"The Godfather Part 2" "1974"
export MEDIA_ROOT="$LIB"
run python3 "$CENSUS" enumerate --root "$LIB" --out "$ENUM"
@@ -115,6 +140,33 @@ for line in open(sys.argv[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" {
plan
[ "$(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"
}
@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" {
plan
python3 - "$ENUM" "$OUT" <<'PY'
@@ -331,3 +489,132 @@ PY
# the refusal is all-or-nothing: another film did not move back
[ -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" ]
}