From e83162a2acf11dc6937bb302784f07616eff79f1 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 30 Oct 2026 20:30:24 -0700 Subject: [PATCH] feat(media): a TV mover that will not file a show the library already has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: / ()/Season /.. 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. --- scripts/media-apply.py | 6 +- scripts/media-tv.py | 281 ++++++++++++++++++ .../Dankserver/Media Relayout.md | 53 +++- tests/media_relayout.bats | 122 ++++++++ 4 files changed, 456 insertions(+), 6 deletions(-) create mode 100755 scripts/media-tv.py diff --git a/scripts/media-apply.py b/scripts/media-apply.py index 9351cfd..8b66f7b 100755 --- a/scripts/media-apply.py +++ b/scripts/media-apply.py @@ -249,7 +249,11 @@ def sweep_targets(root, journal): """ 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"} + # 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: diff --git a/scripts/media-tv.py b/scripts/media-tv.py new file mode 100755 index 0000000..76ea4b1 --- /dev/null +++ b/scripts/media-tv.py @@ -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 - ` 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: + + / ()/Season /. + +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()) diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index c40ca0a..502696b 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -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**; 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) @@ -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 — -the live run left 1675 of them at the library root — which is what `sweep` -removes, dry-run first, from the same journal. +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. @@ -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 ``` +## 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 - `. `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 - **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 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 @@ -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 in `_TV needs a TV library - ` 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 diff --git a/tests/media_relayout.bats b/tests/media_relayout.bats index bd0a614..20a1a1b 100755 --- a/tests/media_relayout.bats +++ b/tests/media_relayout.bats @@ -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" @@ -136,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")" \ @@ -243,6 +274,81 @@ for k in sorted(pile): = "$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' @@ -419,6 +525,22 @@ PY [ ! -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 -- 2.54.0