Compare commits

..
1 Commits
Author SHA1 Message Date
Frank a405c5ed35 fix(media-apply): refuse a plan row whose verb the tool does not know
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
2026-11-02 09:51:53 -08:00
3 changed files with 49 additions and 5 deletions

No files matched your search

+10 -2
View File
@@ -121,14 +121,14 @@ class Journal:
def read_plan(path, root, force): def read_plan(path, root, force):
"""Plan rows: (mkdirs, keeps, moves), header dropped, all paths under root.""" """Plan rows: (mkdirs, keeps, moves), header dropped, all paths under root."""
mkdirs, keeps, moves = [], [], [] mkdirs, keeps, moves, bad = [], [], [], []
with open(path) as fh: with open(path) as fh:
header = fh.readline() header = fh.readline()
if not header.startswith("action\t"): if not header.startswith("action\t"):
raise Refuse(f"{path} is not a plan (first column is " raise Refuse(f"{path} is not a plan (first column is "
f"{header.split(chr(9))[0]!r}, expected 'action') —" f"{header.split(chr(9))[0]!r}, expected 'action') —"
" regenerate it with scripts/media-plan.py") " regenerate it with scripts/media-plan.py")
for line in fh: for n, line in enumerate(fh, 2):
f = line.rstrip("\n").split("\t") f = line.rstrip("\n").split("\t")
if f[0] == "mkdir": if f[0] == "mkdir":
mkdirs.append(f[2]) mkdirs.append(f[2])
@@ -136,6 +136,14 @@ def read_plan(path, root, force):
keeps.append(f[1]) keeps.append(f[1])
elif f[0] == "move": elif f[0] == "move":
moves.append((f[1], f[2], f[3] if len(f) > 3 else "")) 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: if not force:
for p in mkdirs + keeps + [p for m in moves for p in m[:2]]: 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)): if p and not (p == root or p.startswith(root + os.sep)):
@@ -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: - **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 `media-relayout.jsonl` next to the root): a log inside the library is a file
Jellyfin indexes. Jellyfin indexes.
- **Refusals are all-or-nothing.** An occupied target, a vanished source or a - **Refusals are all-or-nothing.** An occupied target, a vanished source, a
duplicate destination stops the run *before the first rename*, so the operator duplicate destination, or a plan row whose verb is not `mkdir`, `keep` or
fixes one thing and re-runs instead of untangling a half-applied plan. `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. `--force` relaxes only the opinion guards; it never permits overwriting.
- **A sweep deletes only what the journal proves the run emptied.** A folder is - **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, eligible when a journalled file moved out of it or out of something under it,
+33
View File
@@ -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" ] [ -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" { @test "a run interrupted between the journal write and the move finishes the job" {
plan plan
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl" JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"