Files
serverAppConfigs/tests/media_relayout.bats
T
dank 272e06daf6 feat(media): the movie-library relayout toolchain — census, plan, executor
The movie library on the entropy volume is release-folded: the video inside
`Some.Movie.2019.1080p…-FOO/` is named after the release, and Jellyfin matches
a movie by folder and file name, so the noise cost posters, extras and versions.
Three scripts own the fix, and nothing else in the repo moves a media file.

  * media-census.py  the read-only inputs: a stat pass, an ffprobe header pass
                     (nice'd, so it is safe beside a live endpoint), and the
                     Plex metadata cache read that outranks any filename guess
  * media-plan.py    the Jellyfin-format planner: one folder per movie, the
                     video named exactly like the folder, extras in the
                     canonical subfolders, copies of one film as versions. It
                     opens no media file, so a whole-library plan costs seconds,
                     and it ends in gates — bad-names, occupied, double-claim,
                     left-unmoved, unplanned — that must all read zero before a
                     human points the executor at 24 TB
  * media-apply.py   the executor: rename(2) only, the intent fsynced to a
                     write-ahead journal BEFORE the move it authorises, so an
                     interrupted run is finishable and every run is undoable.
                     Refusals are all-or-nothing and it never overwrites a file

Laws the fixture pins (tests/media_relayout.bats, 16 tests, each proven red
against a reverted copy of the rule it guards):

  * a Plex year of 0 means "indexed, never matched" — trusting it produced
    folders "Title (0)": items with no year, no poster, no identity
  * a Plex title carrying "/" is sanitised, or "V/H/S/2" became a five-level
    tree neither matcher reads as a movie
  * a folder inside a movie's folder is bonus territory even when its name is
    misspelled ("Exrtas"), unless the file's own name carries another year
  * matching words split on everything but a letter or digit — the earlier
    tokenizer left "(1960)/extras" glued, so a film could not be found by the
    folder holding it and one box set's featurettes drifted into a neighbour
  * a name with no words in it is bonus material, never a movie folder named 1

Docs: the runbook page owns the procedure, the gates and the rollback; AGENTS.md
links it from the runbook index and the summary table.
2026-10-30 19:33:44 -07:00

334 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bats
# Seam: the movie-library relayout — scripts/media-census.py (the read-only
# census), scripts/media-plan.py (the plan) and scripts/media-apply.py (the only
# thing that moves files).
#
# The library is 8k files on a 100 %-full array, so the two things this seam
# exists to catch are the ones that cost a re-run of a 20-minute census: a file
# that ends up in the WRONG movie folder, and a move that destroys something.
# Everything below runs on a synthetic library in the test's own tmpdir: no
# ffprobe, no network, no server. The probe step is the one part a fixture
# cannot run (it opens real containers), so the tests feed the planner the
# probed TSV in the column order scripts/media-census.py exports — which pins
# that contract as a side effect.
#
# The plan's own gate lines (unplanned, left-unmoved, double-claim, occupied)
# are asserted as output, because they are how the operator learns the plan is
# trustworthy before pointing the executor at 24 TB.
load helpers
PLAN="$REPO_ROOT/scripts/media-plan.py"
CENSUS="$REPO_ROOT/scripts/media-census.py"
APPLY="$REPO_ROOT/scripts/media-apply.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,
# vbit, pix, audio, subs, spare, fmt).
probe_row() {
local p="$1" dur="$2" res="${3:-1920x1080}" aud="${4:-aac+aac}" subs="${5:-0}"
printf '%s\t%s\t%s\thevc\t%s\t23.976\t5000000\t8bit\t%s\t%s\t0\tmatroska,webm\n' \
"$(stat -c %s "$p")" "$p" "$dur" "$res" "$aud" "$subs" >> "$PROBED"
}
# plex_row FILE TITLE YEAR — one Plex metadata row; the planner reads these
# three columns and treats a row without a plausible year as no match at all.
plex_row() {
printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "$PLEX"
}
# at PATH SIZE — a file with deterministic content, big enough to matter.
at() { mkdir -p "$(dirname "$1")"; head -c "$2" /dev/zero > "$1"; }
setup() {
LIB="$BATS_TEST_TMPDIR/Movies"
ENUM="$BATS_TEST_TMPDIR/enum.tsv"
PROBED="$BATS_TEST_TMPDIR/probed.tsv"
PLEX="$BATS_TEST_TMPDIR/plex.tsv"
OUT="$BATS_TEST_TMPDIR/plan.tsv"
EXC="$BATS_TEST_TMPDIR/exceptions.txt"
: > "$PROBED"
printf 'file\tleaf_title\tleaf_year\n' > "$PLEX"
# Two films that share a box set and a word, plus their bonus material:
# the shape that made one film's featurettes land in another's folder.
at "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv" 4000
at "$LIB/Alien (1979)/Featurettes/A Bug And A Bite - Making Alien.mkv" 3300000
at "$LIB/Alien (1979)/Extras/Extra - Logo.mkv" 900000
at "$LIB/Alien (1979)/subs/3_Eng.srt" 40000
at "$LIB/Marnie (1964)/Marnie.1964.1080p.BluRay.x265.mkv" 5000000
at "$LIB/Marnie (1964)/Featurettes/The Trouble With Marnie.mkv" 2400000
# A long extra: over 45 minutes, so only the folder may call it bonus.
at "$LIB/Marnie (1964)/Featurettes/Commentary And Archives.mkv" 2600000
# A misspelled bonus folder: no bonus vocabulary, so only the folder that
# holds the feature can decide that this is not a film.
at "$LIB/Marnie (1964)/Exrtas/Marnie - A Closer Look.mkv" 2500000
# A Plex title that carries "/" and a Plex row that was never matched.
at "$LIB/VHS 2 (2013)/VHS.2.2013.1080p.BluRay.x265.mkv" 4200000
at "$LIB/AMERICAN POP/AMERICAN.POP.2023.1080p.WEB-DL.mkv" 3100000
# Junk the layout has to park somewhere other than a movie folder.
at "$LIB/.DS_Store" 6148
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".
at "$LIB/Bruce Lee Collection 1971-1981/.Extra 1.mkv" 2700000
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)/Extras/Extra - Logo.mkv" 30
probe_row "$LIB/Marnie (1964)/Marnie.1964.1080p.BluRay.x265.mkv" 4800
probe_row "$LIB/Marnie (1964)/Featurettes/The Trouble With Marnie.mkv" 1200
probe_row "$LIB/Marnie (1964)/Featurettes/Commentary And Archives.mkv" 3000
probe_row "$LIB/Marnie (1964)/Exrtas/Marnie - A Closer Look.mkv" 2600
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/Bruce Lee Collection 1971-1981/.Extra 1.mkv" 5220
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/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.
plex_row "$LIB/AMERICAN POP/AMERICAN.POP.2023.1080p.WEB-DL.mkv" "AMERICAN POP" "0"
export MEDIA_ROOT="$LIB"
run python3 "$CENSUS" enumerate --root "$LIB" --out "$ENUM"
[ "$status" -eq 0 ]
}
# plan — run the planner over the fixture, keeping its plan TSV, its exception
# report and the gate lines it prints (that stdout is the operator's evidence
# that the plan accounts for every file, so the tests read it too).
plan() {
PLANOUT="$BATS_TEST_TMPDIR/plan.out"
run python3 "$PLAN" --root "$LIB" --enum "$ENUM" --probed "$PROBED" \
--plex "$PLEX" --out "$OUT" --exceptions "$EXC"
[ "$status" -eq 0 ]
printf '%s\n' "$output" > "$PLANOUT"
}
# dst_of SRC — the destination the plan gives one file.
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
' "$OUT" "$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")" \
= "$LIB/Alien (1979)/Alien (1979).mkv" ]
}
@test "a Plex title that carries a slash becomes one folder, not a nested tree" {
plan
local d; d="$(dst_of "$LIB/VHS 2 (2013)/VHS.2.2013.1080p.BluRay.x265.mkv")"
[ "$(dirname "$d")" = "$LIB/V-H-S-2 (2013)" ]
[ ! -e "$LIB/V" ]
# the folder must be ONE mkdir: an un-sanitized name would make the
# executor build Movies/V/H/S/2 (2013) instead
run grep -F "mkdir" "$OUT"
[[ "$output" == *"$(printf '%s' "$LIB/V-H-S-2 (2013)")"* ]]
run awk -F'\t' '$1=="mkdir"{print $3}' "$OUT"
[[ "$output" != *"$LIB/V/"* ]]
}
@test "an unmatched Plex row never names a folder with a zero year" {
plan
run awk -F'\t' '{print $3}' "$OUT"
[[ "$output" != *"(0)"* ]]
# the filename's own year carries the folder, the folder and the file share
# the name (Jellyfin's rule), and the film sits at the library root rather
# than in an unsorted pile
local d; d="$(dst_of "$LIB/AMERICAN POP/AMERICAN.POP.2023.1080p.WEB-DL.mkv")"
[ "$(dirname "$(dirname "$d")")" = "$LIB" ]
[[ "$(basename "$(dirname "$d")")" == *" (2023)" ]]
[ "$(basename "$d")" = "$(basename "$(dirname "$d")").mkv" ]
}
@test "a bonus folder keeps its featurettes inside its own film, not a neighbour's" {
plan
local d
d="$(dst_of "$LIB/Alien (1979)/Featurettes/A Bug And A Bite - Making Alien.mkv")"
[ "$(dirname "$(dirname "$d")")" = "$LIB/Alien (1979)" ]
d="$(dst_of "$LIB/Marnie (1964)/Featurettes/The Trouble With Marnie.mkv")"
[ "$(dirname "$(dirname "$d")")" = "$LIB/Marnie (1964)" ]
d="$(dst_of "$LIB/Alien (1979)/Extras/Extra - Logo.mkv")"
[ "$(dirname "$(dirname "$d")")" = "$LIB/Alien (1979)" ]
# the feature-length extra is bonus too: it sits in a bonus folder and no
# metadata matched it, so it may not become a movie of its own
d="$(dst_of "$LIB/Marnie (1964)/Featurettes/Commentary And Archives.mkv")"
[ "$(dirname "$(dirname "$d")")" = "$LIB/Marnie (1964)" ]
}
@test "a folder inside a movie folder is bonus even when its name is misspelled" {
plan
local d; d="$(dst_of "$LIB/Marnie (1964)/Exrtas/Marnie - A Closer Look.mkv")"
[ "$(dirname "$(dirname "$d")")" = "$LIB/Marnie (1964)" ]
}
@test "a subtitle follows the film of the folder it sits in" {
plan
local d; d="$(dst_of "$LIB/Alien (1979)/subs/3_Eng.srt")"
[ "$(dirname "$(dirname "$d")")" = "$LIB/Alien (1979)" ]
}
@test "macOS sidecars are parked in one pile, never inside a movie folder" {
plan
local d; d="$(dst_of "$LIB/Alien (1979)/._Alien.1979.1080p.BluRay.x265.10bit.mkv")"
[[ "$(dirname "$d")" == "$LIB"/_* ]]
d="$(dst_of "$LIB/.DS_Store")"
[[ "$(dirname "$d")" == "$LIB"/_* ]]
grep -q "AppleDouble" "$EXC"
}
@test "a file whose name is a number becomes a review line, not a folder named 1" {
plan
run awk -F'\t' '$1=="mkdir"{print $3}' "$OUT"
[[ "$output" != "$LIB/1"$'\n'* ]] && [[ "$output" != "$LIB/1" ]]
[[ "$output" != *"$LIB/1/"* ]]
grep -q "UNNAMED" "$EXC"
}
@test "every file in the census is placed or kept — nothing is dropped" {
plan
python3 - "$ENUM" "$OUT" <<'PY'
import sys
enum = {l.rstrip("\n").split("\t")[2] for l in open(sys.argv[1])}
placed = set()
for line in open(sys.argv[2]):
f = line.rstrip("\n").split("\t")
if f[0] in ("move", "keep"):
placed.add(f[1])
missing = enum - placed
assert not missing, f"{len(missing)} census files with no plan row: {sorted(missing)[:3]}"
PY
grep -q "left-unmoved=0" "$PLANOUT"
grep -q "double-claim=0" "$PLANOUT"
grep -q "occupied=0" "$PLANOUT"
grep -q "bad-names=0" "$PLANOUT"
grep -q "unplanned=0" "$EXC"
}
# ---------------------------------------------------------------- executor
# apply_plan — plan, then apply for real; the journal goes in the tmpdir.
apply_plan() {
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"
run python3 "$APPLY" apply --plan "$OUT" --root "$LIB" --journal "$JOURNAL" --apply
}
@test "a run without --apply says what it would do and touches nothing" {
plan
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"
run python3 "$APPLY" apply --plan "$OUT" --root "$LIB" --journal "$JOURNAL"
[ "$status" -eq 0 ]
[[ "$output" == *"--apply"* ]]
[ -e "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv" ]
[ ! -e "$LIB/Alien (1979)/Alien (1979).mkv" ]
# a dry run may not even build the folders: an operator must be able to
# run it against the live library with no --apply and change nothing
[ ! -d "$LIB/V-H-S-2 (2013)" ]
[ ! -e "$JOURNAL" ]
}
@test "apply moves every planned file, keeps its bytes, and journals each move" {
plan
apply_plan
[ "$status" -eq 0 ]
[ -e "$LIB/Alien (1979)/Alien (1979).mkv" ]
[ ! -e "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv" ]
local planned moved
planned=$(awk -F'\t' '$1=="move"' "$OUT" | wc -l)
moved=$(python3 -c 'import json,sys
print(sum(1 for l in open(sys.argv[1]) if json.loads(l).get("op") == "move"))' "$JOURNAL")
[ "$moved" -eq "$planned" ]
run python3 "$APPLY" verify --plan "$OUT" --root "$LIB" --journal "$JOURNAL"
[ "$status" -eq 0 ]
}
@test "a target already holding a different file stops the whole run" {
plan
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"
at "$LIB/Alien (1979)/Alien (1979).mkv" 7
run python3 "$APPLY" apply --plan "$OUT" --root "$LIB" --journal "$JOURNAL" --apply
[ "$status" -ne 0 ]
[[ "$output" == *"occupied"* ]]
# nothing moved at all: the other films are still where the census saw them
[ -e "$LIB/Marnie (1964)/Marnie.1964.1080p.BluRay.x265.mkv" ]
[ ! -e "$JOURNAL" ]
}
@test "a plan row whose source has gone stops the run instead of inventing a move" {
plan
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"
rm "$LIB/Marnie (1964)/Marnie.1964.1080p.BluRay.x265.mkv"
run python3 "$APPLY" apply --plan "$OUT" --root "$LIB" --journal "$JOURNAL" --apply
[ "$status" -ne 0 ]
[[ "$output" == *"missing"* ]]
[ -e "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv" ]
}
@test "a run interrupted between the journal write and the move finishes the job" {
plan
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"
# The journal is written before each move, so an interruption leaves two
# states and only two: an intent whose move happened, and an intent whose
# move never ran. Both are simulated here by hand.
python3 - "$OUT" "$JOURNAL" <<'PY'
import json, os, sys
moves = [r for r in (l.rstrip("\n").split("\t") for l in open(sys.argv[1]))
if r[0] == "move"][:3]
with open(sys.argv[2], "w") as fh:
for i, r in enumerate(moves):
fh.write(json.dumps({"op": "move", "src": r[1], "dst": r[2],
"bytes": os.path.getsize(r[1])}) + "\n")
if i < 2: # the first two moves actually ran
os.makedirs(os.path.dirname(r[2]), exist_ok=True)
os.rename(r[1], r[2])
PY
run python3 "$APPLY" apply --plan "$OUT" --root "$LIB" --journal "$JOURNAL" --apply
[ "$status" -eq 0 ]
run python3 "$APPLY" verify --plan "$OUT" --root "$LIB" --journal "$JOURNAL"
[ "$status" -eq 0 ]
python3 - "$OUT" "$JOURNAL" <<'PY'
import json, os, sys
moves = [r for r in (l.rstrip("\n").split("\t") for l in open(sys.argv[1]))
if r[0] == "move"]
journal = [json.loads(l) for l in open(sys.argv[2])]
seen = [j["dst"] for j in journal]
assert len(seen) == len(set(seen)), "a destination was journalled twice"
for r in moves:
assert os.path.exists(r[2]) and not os.path.exists(r[1]), r[2]
PY
}
@test "rollback puts every file back where the census found it" {
plan
apply_plan
[ "$status" -eq 0 ]
run python3 "$APPLY" rollback --root "$LIB" --journal "$JOURNAL" --apply
[ "$status" -eq 0 ]
[ -e "$LIB/Alien (1979)/Alien.1979.1080p.BluRay.x265.10bit.mkv" ]
[ -e "$LIB/Marnie (1964)/Marnie.1964.1080p.BluRay.x265.mkv" ]
[ -e "$LIB/Alien (1979)/subs/3_Eng.srt" ]
[ ! -e "$LIB/Alien (1979)/Alien (1979).mkv" ]
# the folders the run created are empty afterwards and must not litter the
# library: this one never existed before the plan ran
[ -d "$LIB/VHS 2 (2013)" ]
[ ! -e "$LIB/V-H-S-2 (2013)" ]
}
@test "rollback refuses when a moved file has gone missing" {
plan
apply_plan
[ "$status" -eq 0 ]
rm "$LIB/Alien (1979)/Alien (1979).mkv"
run python3 "$APPLY" rollback --root "$LIB" --journal "$JOURNAL" --apply
[ "$status" -ne 0 ]
[[ "$output" == *"missing"* ]]
# the refusal is all-or-nothing: another film did not move back
[ -e "$LIB/Marnie (1964)/Marnie (1964).mkv" ]
}