Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a803aa046a | ||
|
|
73f77a13c3 | ||
|
|
1cb28095a7 |
No files matched your search
+106
-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,99 @@ 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"}
|
||||||
|
gone = {r["path"] for r in journal.records if r.get("op") == "rmdir"}
|
||||||
|
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 +348,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 +373,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 +393,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 +406,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):
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ file.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `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 |
|
||||||
|
|
||||||
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 +61,54 @@ 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
|
the live run left 1675 of them at the library root — which is what `sweep`
|
||||||
(`podman exec plex …` or the web UI). `verify` reporting empty folders is normal
|
removes, dry-run 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
|
||||||
|
```
|
||||||
|
|
||||||
## 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 +147,34 @@ 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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,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 +96,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"
|
||||||
@@ -191,6 +212,37 @@ 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 "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 +383,116 @@ 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 "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