From a405c5ed35f0f530d74dfba5114c80ddb3de63ab Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 2 Nov 2026 09:51:53 -0800 Subject: [PATCH] fix(media-apply): refuse a plan row whose verb the tool does not know MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_plan's if/elif chain had no else, so any row that was not mkdir, keep or move was dropped in silence. A plan of nothing but typos therefore applied as a successful no-op: on 2026-11-02 a pass meant to rename two TVShows series folders reported "applied: 0 moved" and exit 0, and the journal was never written โ€” nothing told the operator the plan had never been read. An unread row is a plan bug, and every other plan bug in this tool refuses before the first rename, so now this one does too, naming the line and the verb. cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f ยท session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb --- scripts/media-apply.py | 12 +++++-- .../Dankserver/Media Relayout.md | 9 +++-- tests/media_relayout.bats | 33 +++++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/scripts/media-apply.py b/scripts/media-apply.py index f497cf0..75c3c94 100755 --- a/scripts/media-apply.py +++ b/scripts/media-apply.py @@ -121,14 +121,14 @@ class Journal: def read_plan(path, root, force): """Plan rows: (mkdirs, keeps, moves), header dropped, all paths under root.""" - mkdirs, keeps, moves = [], [], [] + mkdirs, keeps, moves, bad = [], [], [], [] with open(path) as fh: header = fh.readline() if not header.startswith("action\t"): raise Refuse(f"{path} is not a plan (first column is " f"{header.split(chr(9))[0]!r}, expected 'action') โ€”" " regenerate it with scripts/media-plan.py") - for line in fh: + for n, line in enumerate(fh, 2): f = line.rstrip("\n").split("\t") if f[0] == "mkdir": mkdirs.append(f[2]) @@ -136,6 +136,14 @@ def read_plan(path, root, force): keeps.append(f[1]) elif f[0] == "move": moves.append((f[1], f[2], f[3] if len(f) > 3 else "")) + else: + bad.append(f"line {n}: unknown action {f[0]!r} โ€” this tool" + " knows mkdir, keep and move (a rename is a move)") + if bad: + # A row this tool could not read is a plan bug, and a plan bug refuses: + # silently dropping it turns a typo into a no-op run that reports success. + head = "; ".join(bad[:3]) + raise Refuse(head + (f" (+{len(bad) - 3} more)" if len(bad) > 3 else "")) if not force: for p in mkdirs + keeps + [p for m in moves for p in m[:2]]: if p and not (p == root or p.startswith(root + os.sep)): diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index 3ed15db..b5cb277 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -403,9 +403,12 @@ executor, the one-journal-per-arm law, the sweep and the rollback. - **The journal lives BESIDE the library root, never inside it** (default: `media-relayout.jsonl` next to the root): a log inside the library is a file Jellyfin indexes. -- **Refusals are all-or-nothing.** An occupied target, a vanished source or a - 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. +- **Refusals are all-or-nothing.** An occupied target, a vanished source, a + duplicate destination, or a plan row whose verb is not `mkdir`, `keep` or + `move`, stops the run *before the first rename*, so the operator fixes one + thing and re-runs instead of untangling a half-applied plan. An unread verb is + refused by line number: silently dropping one turned a pass meant to rename + two series folders into `applied: 0 moved` with exit 0 (2026-11-02). `--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, diff --git a/tests/media_relayout.bats b/tests/media_relayout.bats index e3b2af4..c5f300f 100755 --- a/tests/media_relayout.bats +++ b/tests/media_relayout.bats @@ -460,6 +460,39 @@ print(sum(1 for l in open(sys.argv[1]) if json.loads(l).get("op") == "move"))' " [ -e "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv" ] } +# A hand-written plan row whose verb is not in the executor's vocabulary used +# to be dropped in silence: read_plan's if/elif chain had no else, so a plan of +# nothing but typos applied as a successful no-op (hit for real on 2026-11-02, +# when a pass meant to rename two series folders reported "applied: 0 moved" +# and exit 0). An unread row is a plan bug, and a plan bug refuses. +@test "a plan row with a verb this tool does not know stops the whole run" { + plan + JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl" + at "$LIB/Stray/Stray.S01E01.mkv" 4000 + printf 'rename\t%s\t%s\tfile\n' \ + "$LIB/Stray/Stray.S01E01.mkv" "$LIB/Stray/Stray - S01E01.mkv" >> "$OUT" + run python3 "$APPLY" apply --plan "$OUT" --root "$LIB" --journal "$JOURNAL" --apply + [ "$status" -ne 0 ] + [[ "$output" == *"rename"* ]] + # all-or-nothing: the rows this tool DOES understand did not run either + [ -e "$LIB/Stray/Stray.S01E01.mkv" ] + [ ! -e "$JOURNAL" ] +} + +@test "a plan whose every row is an unknown verb refuses instead of reporting success" { + JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl" + at "$LIB/Stray/Stray.S01E01.mkv" 4000 + { printf 'action\tsrc\tdst\tkind\n' + printf 'rename\t%s\t%s\tfile\n' \ + "$LIB/Stray/Stray.S01E01.mkv" "$LIB/Stray/Stray - S01E01.mkv" + } > "$BATS_TEST_TMPDIR/typo.tsv" + run python3 "$APPLY" apply --plan "$BATS_TEST_TMPDIR/typo.tsv" --root "$LIB" --journal "$JOURNAL" --apply + [ "$status" -ne 0 ] + [[ "$output" == *"unknown action"* ]] + [[ "$output" != *"applied"* ]] + [ -e "$LIB/Stray/Stray.S01E01.mkv" ] +} + @test "a run interrupted between the journal write and the move finishes the job" { plan JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"