Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e83162a2ac | ||
|
|
5cd9a0c4c2 | ||
|
|
dd76fcf5d4 | ||
|
|
e1760e3316 |
No files matched your search
@@ -249,7 +249,11 @@ def sweep_targets(root, journal):
|
|||||||
"""
|
"""
|
||||||
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"}
|
||||||
gone = {r["path"] for r in journal.records if r.get("op") == "rmdir"}
|
# 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]
|
dsts = [r["dst"] for r in moves]
|
||||||
marked = set()
|
marked = set()
|
||||||
for r in moves:
|
for r in moves:
|
||||||
|
|||||||
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**; sweeps only what a run emptied | 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)
|
||||||
@@ -68,8 +69,8 @@ scripts/media-apply.py rollback --journal /mnt/entropy/media-relayout.jsonl --ro
|
|||||||
```
|
```
|
||||||
|
|
||||||
An apply also leaves the release folders it emptied standing where they were —
|
An apply also leaves the release folders it emptied standing where they were —
|
||||||
the live run left 1675 of them at the library root — which is what `sweep`
|
hundreds of them at the library root — which is what `sweep` removes, dry-run
|
||||||
removes, dry-run first, from the same journal.
|
first, from the same journal.
|
||||||
|
|
||||||
After it lands, both servers need a scan or the change is invisible.
|
After it lands, both servers need a scan or the change is invisible.
|
||||||
|
|
||||||
@@ -103,6 +104,42 @@ 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
|
-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
|
- **A moved file is a 404 in [[Plex]] until its section is rescanned.** The
|
||||||
@@ -159,6 +196,8 @@ curl -s -K /tmp/jf.hdr -o /dev/null -w "jellyfin scan: %{http_code}\n" \
|
|||||||
empty directory goes with it. A release's `Screens/` or `Sample/` is never a
|
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
|
move source itself, so 146 of them held their emptied parents on disk after
|
||||||
the first sweep.
|
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
|
- **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
|
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
|
`Monk`. Keyed on the whole title instead, every episode became a group of one
|
||||||
@@ -167,6 +206,10 @@ curl -s -K /tmp/jf.hdr -o /dev/null -w "jellyfin scan: %{http_code}\n" \
|
|||||||
keep: a lone film named `Part 2` stays a film. Episodes of a series are parked
|
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 —
|
in `_TV needs a TV library - <series>` for a human to move to a TV source —
|
||||||
the movie tool never files TV.
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
@@ -136,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")" \
|
||||||
@@ -243,6 +274,81 @@ for k in sorted(pile):
|
|||||||
= "$LIB/The Godfather Part 2 (1974)/The Godfather Part 2 (1974).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'
|
||||||
@@ -419,6 +525,22 @@ PY
|
|||||||
[ ! -e "$LIB/AMERICAN POP" ]
|
[ ! -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" {
|
@test "sweep without --apply reports the husks and changes nothing" {
|
||||||
plan
|
plan
|
||||||
apply_plan
|
apply_plan
|
||||||
|
|||||||
Reference in new issue
Block a user