Files
serverAppConfigs/tests/media_shows.bats
T
Frank 2625ad1083 fix(media): a number too long to be an episode is not one
The Attack on Titan box set on the array names two files S03E2019 and
S04E47370355, and the provider rows the metadata server recorded for them carry
the same junk. EP_RANGE capped its first episode group at three digits without
guarding the run, so the pass-2 plan filed both under E201 and E473 — slots
that belong to real episodes — inside 進撃の巨人 (2013) [tvdbid-267440].

- guard the first episode group the way the second was already guarded, so a
  longer digit run states no marker at all; a glued resolution stays legal,
  because S01E01-1080p is still ONE episode
- cap a provider row's episode at EP_MAX: such a row is renumbered from tvdb's
  title when the series' id answers, and parks with a reason that names the
  junk when nothing answers
- split the operator-map exceptions into the two things they are: a folder that
  IS on the array whose files never became a series (bonus material with no
  owner: Fairy Tail, Kimetsu no Yaiba, Dorohedoro) is no longer reported as a
  folder the array no longer holds, and the missing-folder hint says the review
  pile counts as moved because the map reads top-level folders only
- the vault's evidence list carries the law; the four tests were read red
  against the shipping planner before any of it changed

Replanning the two real files now parks them under their own names with the
reasons "a provider row of season 3 episode 2019" and
"… season 4 episode 47370355".
2026-10-31 14:49:12 -07:00

752 lines
35 KiB
Bash
Executable File

#!/usr/bin/env bats
# Seam: the TV-library relayout — scripts/media-census.py jellyfin (the metadata
# authority export) and scripts/media-shows.py (the plan).
#
# The TV library is 52k files written in five different release layouts, and the
# one thing that makes a rename of that size survivable is that the plan is
# derived from what a metadata provider already matched, not from a guess at the
# file name. So every test here states which authority a file had — name only,
# provider only, both, neither — and asserts where the PLAN files it, never how
# the planner decided. A fixture Jellyfin database stands in for the live one and
# the name caches are input files, so no test touches the network.
#
# The target contract is tvdb-grade naming:
# <Series> (<year>) [tvdbid-NNNN]/Season NN/<Series> (<year>) - SxxEyy - <Title>.<ext>
# The id rides the FOLDER only: it is what both matchers read the series by, and a
# 180-character ceiling on a file name is not the place to spend it. Season 00
# holds specials; a provider row whose "season" is a YEAR is renumbered by the
# episode title tvdb states for the series' id, never by a guess.
# tvdb is the IDENTITY authority: a provider name a server derived from a folder
# is not a series name, so a polluted name is undone through the series' tvdb id.
# The review pile parks OUTSIDE the library — a parked folder inside it indexes
# as one junk show, and that show's rows are then mistaken for evidence.
#
# The two outcomes that cost a re-run of a 50k-file plan are a file filed as an
# episode that is not one (it steals an episode number from its real owner) and a
# move that lands on an existing file. Everything below is written to catch
# exactly those, plus the gate that matters most: the plan must be replayable by
# scripts/media-apply.py, whose preflight is the fleet's only overwrite guard.
load helpers
PLAN="$REPO_ROOT/scripts/media-shows.py"
CENSUS="$REPO_ROOT/scripts/media-census.py"
APPLY="$REPO_ROOT/scripts/media-apply.py"
# at PATH SIZE — a file with deterministic content.
at() { mkdir -p "$(dirname "$1")"; head -c "$2" /dev/zero > "$1"; }
# title_row FILE SERIES YEAR SEASON EPISODE [EXTRA] [TMDB] [TITLE] [TVDB]
# — one row of the authority export scripts/media-census.py jellyfin writes.
title_row() {
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "${4:-}" \
"${5:-}" "${6:-}" "${7:-}" "${8:-}" "${9:-}" >> "$TITLES"
}
# file PATH SIZE — `at` plus the row the enum census would have carried had the
# file existed when setup walked the tree. A test that needs a shape the fixture
# does not ship adds it here, or the planner never sees it.
file() {
at "$1" "$2"
local b="${1##*/}"
printf '%s\t.%s\t%s\n' "$2" "${b##*.}" "$1" >> "$ENUM"
}
# dst SRC — the destination the plan gives SRC, or nothing when it does not move.
dst() {
awk -F '\t' -v s="$1" '$1 == "move" && $2 == s {print $3}' "$OUT"
}
setup() {
LIB="$BATS_TEST_TMPDIR/TVShows"
PARK="$LIB/_unsorted"
OUTSIDE="$BATS_TEST_TMPDIR/park"
MOVIES="$BATS_TEST_TMPDIR/Movies"
ENUM="$BATS_TEST_TMPDIR/enum.tsv"
PROBED="$BATS_TEST_TMPDIR/probed.tsv"
TITLES="$BATS_TEST_TMPDIR/titles.tsv"
CACHE="$BATS_TEST_TMPDIR/tmdb.json"
OUT="$BATS_TEST_TMPDIR/plan.tsv"
EXC="$BATS_TEST_TMPDIR/exceptions.txt"
: > "$PROBED"
SEASON="$LIB/The Examples (2010) Season 1 S01 (1080p BluRay x265 Silence)"
# An episode its own name states, with a subtitle, a season image and a
# trailer: four things that have to end up in four different places.
at "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.mkv" 9000
at "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.en.srt" 200
at "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.mkv.forced.srt" 250
# A scene pack buries its subtitles two levels down, in Subs/<release-name>/.
at "$SEASON/Subs/The.Examples.S01E01.Pilot.1080p.x265/The.Examples.S01E01.Pilot.1080p.x265.jpn.srt" 220
at "$SEASON/folder.jpg" 300
at "$SEASON/Trailers/teaser.mkv" 1000
at "$SEASON/tvshow.nfo" 400
# An anime pack: no episode marker anywhere in its name, numbered by the
# provider only. Its year is the one thing no authority states.
ANIME="$LIB/Anime Pack [grp] (1080p HEVC)"
at "$ANIME/[grp] Anime Pack - 05.mkv" 5000
# Two releases claiming one episode, and a file holding two episodes.
at "$LIB/Dupes/Show Two S01E01.mkv" 4000
at "$LIB/Dupes/Show.Two.S01E01.720p.other.mkv" 7000
at "$LIB/Dupes/Show.Two.S01E02-E03.720p.mkv" 7000
# A file no authority numbers, and a long file the movie provider knows.
at "$LIB/Orphan Tapes/clip001.mp4" 3000
at "$LIB/Longform/Some Documentary Special (2015).mkv" 8000
# A bonus folder inside a release pack whose own name is not the show's. The
# pack's rows name the show; the bonus folder may never invent a series.
at "$ANIME/01. Extras/NCOP 01.mkv" 1200
# A season folder whose rows are stale: the database numbers every file of this
# pack season 16 episode 20, four seasons' worth of them.
at "$LIB/SNL Pack/Season 19/01 Charles Barkley, Nirvana.avi" 6000
at "$LIB/Loose.Show.S02E03.720p.WEB-DL.mkv" 4000
# A box-set folder whose Series item the server named AFTER THE FOLDER: the
# row's own name is junk, and only a provider id can undo it.
BOX="$LIB/Show Box (2016) Season 1 S01 (1080p WEB-DL)"
at "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv" 6000
# The park inside the library: the server indexed the whole pile as ONE show
# named after the folder, so its rows state a series that is not a show.
at "$PARK/Park Show S01E01.mkv" 5000
at "$PARK/Twisted.Metal.S01E01.1080p.WEB-DL.mkv" 5200
# The enum census lists every file; the planner reads that instead of walking
# the tree, so the fixture writes the same three columns.
python3 - "$LIB" "$ENUM" <<'PY'
import os, sys
root, out = sys.argv[1], sys.argv[2]
with open(out, "w") as fh:
for d, _, fs in os.walk(root):
for f in fs:
p = os.path.join(d, f)
fh.write(f"{os.stat(p).st_size}\t{os.path.splitext(f)[1]}\t{p}\n")
PY
printf 'file\tseries\tyear\tseason\tepisode\textra\ttmdb\ttitle\ttvdb\n' > "$TITLES"
printf '{}\n' > "$CACHE"
title_row "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.mkv" \
"The Examples" 2010 1 1 "" "" "Pilot"
title_row "$ANIME/[grp] Anime Pack - 05.mkv" "Anime Pack" "" 1 5
title_row "$LIB/Dupes/Show Two S01E01.mkv" "Show Two" 2016 1 1 "" "" "Two Pilots"
title_row "$LIB/Dupes/Show.Two.S01E01.720p.other.mkv" "Show Two" 2016 1 1 "" "" "Two Pilots"
title_row "$LIB/Dupes/Show.Two.S01E02-E03.720p.mkv" "Show Two" 2016 1 2 "" "" "Double Trouble"
title_row "$LIB/Loose.Show.S02E03.720p.WEB-DL.mkv" "Loose Show" 2019 2 3 "" "" "Loose Ends"
title_row "$LIB/SNL Pack/Season 19/01 Charles Barkley, Nirvana.avi" \
"SNL Pack" "" 16 20
title_row "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv" \
"Show Box (2016) Season 1" 2016 1 1 "" "2334" "Pilot" "78850"
# The row the server wrote for a parked file: the series is the PARK FOLDER.
title_row "$PARK/Park Show S01E01.mkv" "_unsorted" "" 1 1
}
plan() {
run python3 "$PLAN" --root "$LIB" --enum "$ENUM" --titles "$TITLES" \
--probed "$PROBED" --tmdb-cache "$CACHE" --movies "$MOVIES" \
--out "$OUT" --exceptions "$EXC"
}
plan_park() { # the review pile outside the library
run python3 "$PLAN" --root "$LIB" --enum "$ENUM" --titles "$TITLES" \
--probed "$PROBED" --tmdb-cache "$CACHE" --movies "$MOVIES" \
--park "$OUTSIDE" --out "$OUT" --exceptions "$EXC"
}
@test "--tmdb with no key is reported as an unfinished plan, not a finished one" {
# A dead provider is invisible where it happens: every 401 is one line on
# stderr, and a terminal that scrolled past them shows a plan that reads as
# complete while every folder still carries the metadata provider's junk. The
# plan is still written — the answers that DID arrive are worth their rename —
# so the state has to ride in the summary line and in the review file.
# env -u keeps this offline no matter what the developer's shell exports.
run env -u TMDB_API_KEY python3 "$PLAN" --root "$LIB" --enum "$ENUM" \
--titles "$TITLES" --probed "$PROBED" --tmdb-cache "$CACHE" \
--movies "$MOVIES" --tmdb --out "$OUT" --exceptions "$EXC"
[ "$status" -eq 0 ]
grep -q 'tmdb NOT ASKED (TMDB_API_KEY unset): NOT provider-complete' <<<"$output"
grep -q 'WARNING: tmdb NOT ASKED (TMDB_API_KEY unset) — NOT provider-complete' "$EXC"
grep -qE '^plan: [1-9][0-9]* moves' <<<"$output"
}
@test "the census exports the provider's episode numbering as a TSV" {
DB="$BATS_TEST_TMPDIR/jellyfin.db"
python3 - "$DB" "$SEASON" <<'PY'
import os, sqlite3, sys, uuid
db, season = sys.argv[1], sys.argv[2]
c = sqlite3.connect(db)
pre = "/data/TVShows/" + os.path.basename(season)
c.execute("""create table BaseItems (Id, ParentId, Type, Name, Path, IndexNumber,
ParentIndexNumber, ProductionYear, ExtraType,
IsVirtualItem, Data)""")
sid = str(uuid.uuid4())
c.execute("insert into BaseItems values (?,?,?,?,?,?,?,?,?,?,?)",
(sid, None, "MediaBrowser.Controller.Entities.TV.Series", "The Examples",
pre, None, None, 2010, None, 0,
'{"ProviderIds":{"Tmdb":"1234","Tvdb":"78850"}}'))
c.execute("insert into BaseItems values (?,?,?,?,?,?,?,?,?,?,?)",
(str(uuid.uuid4()), sid, "MediaBrowser.Controller.Entities.TV.Episode",
"Pilot", pre + "/The.Examples.S01E01.Pilot.1080p.x265.mkv", 1, 1,
None, None, 0, '{}'))
c.commit()
PY
run python3 "$CENSUS" jellyfin --root "$LIB" --db "$DB" \
--out "$BATS_TEST_TMPDIR/exp.tsv"
[ "$status" -eq 0 ]
run head -1 "$BATS_TEST_TMPDIR/exp.tsv"
[ "${lines[0]}" = "file series year season episode extra tmdb title tvdb" ]
tail -1 "$BATS_TEST_TMPDIR/exp.tsv" \
| grep -q "^$SEASON/The\.Examples\.S01E01\.Pilot\.1080p\.x265\.mkv The Examples 2010 1 1 1234 Pilot 78850$"
}
@test "the census reads provider ids from the table newer Jellyfin keeps" {
DB="$BATS_TEST_TMPDIR/jellyfin12.db"
python3 - "$DB" "$SEASON" <<'PY'
import os, sqlite3, sys, uuid
db, season = sys.argv[1], sys.argv[2]
c = sqlite3.connect(db)
pre = "/data/TVShows/" + os.path.basename(season)
c.execute("""create table BaseItems (Id, ParentId, Type, Name, Path, IndexNumber,
ParentIndexNumber, ProductionYear, ExtraType,
IsVirtualItem, Data)""")
# Jellyfin 12 moved the ids out of the Data blob and into their own table.
c.execute("create table BaseItemProviders (ItemId, ProviderId, ProviderValue)")
sid = str(uuid.uuid4())
eid = str(uuid.uuid4())
c.execute("insert into BaseItems values (?,?,?,?,?,?,?,?,?,?,?)",
(sid, None, "MediaBrowser.Controller.Entities.TV.Series", "The Examples",
pre, None, None, 2010, None, 0, '{}'))
c.execute("insert into BaseItems values (?,?,?,?,?,?,?,?,?,?,?)",
(eid, sid, "MediaBrowser.Controller.Entities.TV.Episode",
"Pilot", pre + "/The.Examples.S01E01.Pilot.1080p.x265.mkv", 1, 1,
None, None, 0, '{}'))
c.execute("insert into BaseItemProviders values (?,?,?)", (sid, "Tvdb", "9999"))
c.commit()
PY
run python3 "$CENSUS" jellyfin --root "$LIB" --db "$DB" \
--out "$BATS_TEST_TMPDIR/exp12.tsv"
[ "$status" -eq 0 ]
tail -1 "$BATS_TEST_TMPDIR/exp12.tsv" \
| grep -q " Pilot 9999$"
}
@test "an episode its own name states files as the titled standard name" {
plan
[ "$status" -eq 0 ]
[ "$(dst "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.mkv")" \
= "$LIB/The Examples (2010)/Season 01/The Examples (2010) - S01E01 - Pilot.mkv" ]
}
@test "the tvdb identity names the series folder, id and all, not the folder's echo" {
printf '{"tvdb:series:78850": {"name": "Show Box", "year": "2016"}}\n' > "$CACHE"
plan
want="$LIB/Show Box (2016) [tvdbid-78850]/Season 01"
want+="/Show Box (2016) - S01E01 - Pilot.mkv"
[ "$(dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv")" = "$want" ]
}
@test "a provider name that already carries the year is not given it twice" {
# TVDb answers the reboot as `Acapulco (2021)`. The folder contract is ONE year,
# and `Acapulco (2021) (2021)` is a folder neither matcher nor human reuses.
printf '{"tvdb:series:78850": {"name": "Show Box (2016)", "year": "2016"}}\n' > "$CACHE"
plan
want="$LIB/Show Box (2016) [tvdbid-78850]/Season 01"
want+="/Show Box (2016) - S01E01 - Pilot.mkv"
[ "$(dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv")" = "$want" ]
run grep -F '(2016) (2016)' "$OUT"
[ -z "$output" ]
}
@test "a series no authority resolves keeps the provider's name and is reported" {
plan
[ "$(dst "$LIB/SNL Pack/Season 19/01 Charles Barkley, Nirvana.avi")" \
= "$LIB/SNL Pack/Season 19/SNL Pack - S19E01.avi" ]
grep -A9 -i "no tvdb" "$EXC" | grep -q "SNL Pack"
}
@test "a row whose series is the park folder is not evidence of a show" {
plan_park
run awk -F '\t' -v p="$PARK/" '$1 == "move" && index($3, p) == 1 {print $3}' "$OUT"
[ -z "$output" ]
run awk -F '\t' '$1 == "move" && $2 ~ /Park Show S01E01/ {print $3}' "$OUT"
[[ "$output" == "$OUTSIDE/"* ]]
}
@test "the pile's sanitized display name is still the pile, not a series" {
# Jellyfin drops the leading underscore when it shows the hidden folder, so the
# row says the series is "unsorted" while the file sits in `_unsorted`. Match
# only the underscored spelling and the plan renames real files into a visible
# folder named after the review pile — which is how 617 of them got there.
title_row "$PARK/Twisted.Metal.S01E01.1080p.WEB-DL.mkv" "unsorted" "" 1 1 \
"" "" "Twisted Metal"
plan_park
run awk -F '\t' -v l="$LIB" \
'$1 == "move" && index($3, l "/unsorted/") == 1 {print $3}' "$OUT"
[ -z "$output" ]
run awk -F '\t' '$1 == "move" && $2 ~ /Twisted\.Metal\.S01E01/ {print $3}' "$OUT"
[[ "$output" == "$OUTSIDE/"* ]]
}
@test "a subtitle follows its episode and keeps its language flag" {
plan
[ "$(dst "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.en.srt")" \
= "$LIB/The Examples (2010)/Season 01/The Examples (2010) - S01E01 - Pilot.en.srt" ]
}
@test "a subtitle two folders down inside Subs/<release-name>/ still follows its episode" {
plan
[ "$(dst "$SEASON/Subs/The.Examples.S01E01.Pilot.1080p.x265/The.Examples.S01E01.Pilot.1080p.x265.jpn.srt")" \
= "$LIB/The Examples (2010)/Season 01/The Examples (2010) - S01E01 - Pilot.jpn.srt" ]
}
@test "a subtitle that carries the container's word in its own name still follows the episode" {
plan
[ "$(dst "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.mkv.forced.srt")" \
= "$LIB/The Examples (2010)/Season 01/The Examples (2010) - S01E01 - Pilot.forced.srt" ]
}
@test "a season image keeps its own name; a series nfo keeps the series folder" {
plan
[ "$(dst "$SEASON/folder.jpg")" = "$LIB/The Examples (2010)/Season 01/folder.jpg" ]
[ "$(dst "$SEASON/tvshow.nfo")" = "$LIB/The Examples (2010)/tvshow.nfo" ]
}
@test "bonus material files as an extra, never as an episode" {
plan
[ "$(dst "$SEASON/Trailers/teaser.mkv")" = "$LIB/The Examples (2010)/trailers/teaser.mkv" ]
[ -z "$(awk -F '\t' '$3 ~ /Season .*teaser/ {print $3}' "$OUT")" ]
}
@test "a file only the provider numbers files at the episode it is matched to, title or no title" {
plan
[ "$(dst "$ANIME/[grp] Anime Pack - 05.mkv")" \
= "$LIB/Anime Pack/Season 01/Anime Pack - S01E05.mkv" ]
}
@test "a bonus folder inside a release pack belongs to the pack's show, not to its own name" {
plan
[ "$(dst "$ANIME/01. Extras/NCOP 01.mkv")" = "$LIB/Anime Pack/extras/NCOP 01.mkv" ]
[ -z "$(awk -F '\t' '$3 ~ /\/01\. Extras\// {print $3}' "$OUT")" ]
}
@test "a Season folder outvotes the stale row its files carry" {
plan
[ "$(dst "$LIB/SNL Pack/Season 19/01 Charles Barkley, Nirvana.avi")" \
= "$LIB/SNL Pack/Season 19/SNL Pack - S19E01.avi" ]
}
@test "one file holding two episodes keeps both numbers in its name" {
plan
[ "$(dst "$LIB/Dupes/Show.Two.S01E02-E03.720p.mkv")" \
= "$LIB/Show Two (2016)/Season 01/Show Two (2016) - S01E02-E03 - Double Trouble.mkv" ]
}
@test "two files claiming one episode: one keeps the name, the other parks" {
plan
n=$(grep -c " $LIB/Show Two (2016)/Season 01/Show Two (2016) - S01E01 - Two Pilots.mkv " "$OUT")
[ "$n" -eq 1 ]
run dst "$LIB/Dupes/Show Two S01E01.mkv"
[[ "$output" == "$PARK/"* ]]
run dst "$LIB/Dupes/Show.Two.S01E01.720p.other.mkv"
[[ "$output" == "$LIB/Show Two (2016)/Season 01/"* ]]
}
@test "a file no authority numbers is parked, never renamed into the library" {
plan
run dst "$LIB/Orphan Tapes/clip001.mp4"
[[ "$output" == "$PARK/Orphan Tapes/clip001.mp4" ]]
grep -q "clip001.mp4" "$EXC"
}
@test "a long file the movie provider knows leaves the TV library for the movie one" {
printf '8000\t%s\t7200\thevc\t1920x1080\t23.976\t4000000\tyuv420p10le\taac:eng\t0\t0\tmatroska,webm\n' \
"$LIB/Longform/Some Documentary Special (2015).mkv" > "$PROBED"
python3 -c 'import json, sys
json.dump({"movie:some documentary":
{"name": "Some Documentary Special", "year": 2015, "id": 55}},
open(sys.argv[1], "w"))' "$CACHE"
plan
[ "$(dst "$LIB/Longform/Some Documentary Special (2015).mkv")" \
= "$MOVIES/Some Documentary Special (2015)/Some Documentary Special (2015).mkv" ]
}
@test "a long file no movie provider knows is parked, not moved into Movies" {
printf '8000\t%s\t7200\thevc\t1920x1080\t23.976\t4000000\tyuv420p10le\taac:eng\t0\t0\tmatroska,webm\n' \
"$LIB/Longform/Some Documentary Special (2015).mkv" > "$PROBED"
plan
run dst "$LIB/Longform/Some Documentary Special (2015).mkv"
[[ "$output" == "$PARK/"* ]]
}
@test "a movie answer the file does not itself name stays in the review pile" {
# TMDb's top hit for a truncated query is a NEAR miss, not an answer: "mobile
# suit gundam 00" comes back as Special Edition I while the file is Special
# Edition III, and a one-word query "russia" comes back as a 1963 Bond film. A
# file leaves the TV library only when it states the title the provider names.
printf '8000\t%s\t7200\thevc\t1920x1080\t23.976\t4000000\tyuv420p10le\taac:eng\t0\t0\tmatroska,webm\n' \
"$LIB/Longform/Some Documentary Special (2015).mkv" > "$PROBED"
python3 -c 'import json, sys
json.dump({"movie:some documentary":
{"name": "Some Documentary Special Part Two", "year": "2015", "id": 55}},
open(sys.argv[1], "w"))' "$CACHE"
plan
run dst "$LIB/Longform/Some Documentary Special (2015).mkv"
[[ "$output" == "$PARK/"* ]]
run awk -F '\t' -v m="$MOVIES/" '$1 == "move" && index($3, m) == 1 {print $3}' "$OUT"
[ -z "$output" ]
}
@test "a movie answer from another year is not that film" {
# The year the file states outranks a provider's eagerness: the 2015 special is
# not the 1999 one, however alike their names are.
printf '8000\t%s\t7200\thevc\t1920x1080\t23.976\t4000000\tyuv420p10le\taac:eng\t0\t0\tmatroska,webm\n' \
"$LIB/Longform/Some Documentary Special (2015).mkv" > "$PROBED"
python3 -c 'import json, sys
json.dump({"movie:some documentary":
{"name": "Some Documentary Special", "year": "1999", "id": 55}},
open(sys.argv[1], "w"))' "$CACHE"
plan
run dst "$LIB/Longform/Some Documentary Special (2015).mkv"
[[ "$output" == "$PARK/"* ]]
}
@test "the plan creates every folder before the move that needs it" {
plan
python3 - "$OUT" "$LIB" "$MOVIES" "$PARK" <<'PY'
import os, sys
plan, known = sys.argv[1], set(sys.argv[2:])
made, bad = set(), []
for line in open(plan).read().splitlines()[1:]:
action, _src, dest, _kind = line.split("\t")
if action == "mkdir":
made.add(dest)
elif action == "move":
d = os.path.dirname(dest)
while d not in known and d != os.path.dirname(d):
if d not in made:
bad.append(f"{dest}: {d} was never made")
d = os.path.dirname(d)
print("\n".join(bad[:5]))
sys.exit(1 if bad else 0)
PY
}
@test "no move lands outside the library or the movie library" {
plan
run awk -F '\t' -v l="$LIB" -v m="$MOVIES" \
'$1 == "move" && $3 !~ "^" l "/" && $3 !~ "^" m "/" {print $3}' "$OUT"
[ -z "$output" ]
}
@test "the executor preflights the plan and no target is claimed twice" {
plan
run python3 "$APPLY" apply --plan "$OUT" --root "$BATS_TEST_TMPDIR" \
--journal "$BATS_TEST_TMPDIR/journal.jsonl"
[ "$status" -eq 0 ]
dup=$(awk -F '\t' '$1 == "move" {print $3}' "$OUT" | sort | uniq -d)
[ -z "$dup" ]
}
@test "re-planning a library the plan already satisfies moves nothing" {
plan
# Applying the plan and re-censusing is exactly what the operator does next:
# every authority file is keyed by path, so all three are replayed through the
# plan the way a fresh census would see them. (The enum has no header; the
# titles export does, and its header row is not a path so it survives.)
python3 - "$OUT" "$ENUM" "$TITLES" "$PROBED" <<'PY'
import os, sys
plan = sys.argv[1]
moves = {}
for line in open(plan).read().splitlines()[1:]:
action, src, dest, _kind = line.split("\t")
if action == "move":
moves[src] = dest
os.makedirs(os.path.dirname(dest), exist_ok=True)
os.rename(src, dest)
for path, col in ((sys.argv[2], 2), (sys.argv[3], 0), (sys.argv[4], 1)):
if not os.path.getsize(path):
continue
rows = [line.rstrip("\n").split("\t") for line in open(path)]
with open(path, "w") as fh:
for f in rows:
f[col] = moves.get(f[col], f[col])
fh.write("\t".join(f) + "\n")
PY
plan
[ -z "$(awk -F '\t' '$1 == "move" {print $2}' "$OUT")" ]
}
# Specials and the year-shaped season. Jellyfin's contract puts specials in
# Season 00, and its date matcher hands back rows whose "season" is the AIRDATE
# YEAR. Both used to park — 1,412 and 1,033 files of one 52k plan — although one
# is a legal season and the other is renumberable from the title tvdb states.
@test "a provider's season 0 is a special, so it files under Season 00" {
file "$LIB/Show Bumps/Best Of The Examples.mkv" 5000
title_row "$LIB/Show Bumps/Best Of The Examples.mkv" "The Examples" 2010 0 2
plan
[ "$(dst "$LIB/Show Bumps/Best Of The Examples.mkv")" \
= "$LIB/The Examples (2010)/Season 00/The Examples (2010) - S00E02.mkv" ]
}
# These three tests carry a tvdb id of their own. The fixture's box set is 78850 and
# ONE id means ONE show: reusing it here would merge these files into that series and
# the duplicate rule would park them before any numbering question is asked.
@test "a row whose season is a year is renumbered by the title tvdb states" {
printf '{"tvdb:series:4242": %s, "tvdb:episodes:4242": %s}\n' \
'{"name": "The Examples", "year": "2010"}' \
'{"1/7": "Best Of"}' > "$CACHE"
file "$LIB/Show Bumps/Best Of The Examples.mkv" 5000
title_row "$LIB/Show Bumps/Best Of The Examples.mkv" \
"The Examples" 2010 2010 11 "" "" "Best Of" "4242"
plan
want="$LIB/The Examples (2010) [tvdbid-4242]/Season 01"
want+="/The Examples (2010) - S01E07 - Best Of.mkv"
[ "$(dst "$LIB/Show Bumps/Best Of The Examples.mkv")" = "$want" ]
}
@test "a title tvdb gives twice never renumbers an episode" {
printf '{"tvdb:series:4242": %s, "tvdb:episodes:4242": %s}\n' \
'{"name": "The Examples", "year": "2010"}' \
'{"1/7": "Best Of", "2/3": "Best Of"}' > "$CACHE"
file "$LIB/Show Bumps/Best Of The Examples.mkv" 5000
title_row "$LIB/Show Bumps/Best Of The Examples.mkv" \
"The Examples" 2010 2010 11 "" "" "Best Of" "4242"
plan_park
run awk -F '\t' -v p="$OUTSIDE/" '$1 == "move" && index($3, p) == 1 {print $3}' "$OUT"
[[ "$output" == *"Best Of The Examples.mkv"* ]]
}
# A release pack can carry a number that is not an episode at all: two files of
# the Attack on Titan box set on the array are named S03E2019 and S04E47370355,
# and the provider rows the server recorded for them say the same junk. Reading
# either as "the first three digits" files the box set under E201 and E473 and
# steals those episode slots from their real owners, so a run too long to be an
# episode is no marker and no number: the file is renumbered from tvdb's title
# when the series' id answers it, and parks when nothing answers.
@test "an episode number longer than three digits never files as an episode" {
file "$LIB/Junk Numbers/Attack Pack S03E2019.mkv" 5000
title_row "$LIB/Junk Numbers/Attack Pack S03E2019.mkv" "Attack Pack" 2013 3 2019
plan_park
run grep -c -- "- S03E201 -" "$OUT"
[ "$output" = "0" ]
[[ "$(dst "$LIB/Junk Numbers/Attack Pack S03E2019.mkv")" == "$OUTSIDE/"* ]]
grep -q "a provider row of season 3 episode 2019" "$EXC"
}
@test "a provider episode too long to be one is renumbered by the title tvdb states" {
printf '{"tvdb:series:4242": %s, "tvdb:episodes:4242": %s}\n' \
'{"name": "Attack Pack", "year": "2013"}' \
'{"3/7": "A Title tvdb Holds"}' > "$CACHE"
file "$LIB/Junk Numbers/Attack Pack S03E2019.mkv" 5000
title_row "$LIB/Junk Numbers/Attack Pack S03E2019.mkv" \
"Attack Pack" 2013 3 2019 "" "" "A Title tvdb Holds" "4242"
plan
want="$LIB/Attack Pack (2013) [tvdbid-4242]/Season 03"
want+="/Attack Pack (2013) - S03E07 - A Title tvdb Holds.mkv"
[ "$(dst "$LIB/Junk Numbers/Attack Pack S03E2019.mkv")" = "$want" ]
}
@test "a resolution glued to the marker still reads as one episode" {
file "$LIB/Junk Numbers/Glued Mark S01E02-1080p.mkv" 5000
title_row "$LIB/Junk Numbers/Glued Mark S01E02-1080p.mkv" \
"The Examples" 2010 1 2 "" "" "Second Look"
plan
[ "$(dst "$LIB/Junk Numbers/Glued Mark S01E02-1080p.mkv")" \
= "$LIB/The Examples (2010)/Season 01/The Examples (2010) - S01E02 - Second Look.mkv" ]
}
# Anime numbering is the one place a tvdb title can be confidently wrong: a pack
# numbered by its release group still names S05E01, and tvdb answers that slot with
# a different episode. A season the authority does not hold, or holds fewer of than
# the pack books, is CONTRADICTED: the rename still happens, the title does not.
@test "a season tvdb contradicts keeps the rename but never its titles" {
printf '{"tvdb:series:4242": %s, "tvdb:episodes:4242": %s}\n' \
'{"name": "Anime Gate", "year": "2010"}' \
'{"1/1": "Pilot"}' > "$CACHE"
file "$LIB/Gate Pack/Season 01/one S01E01.mkv" 5000
file "$LIB/Gate Pack/Season 05/two S05E01.mkv" 5000
file "$LIB/Gate Pack/Season 05/three S05E02.mkv" 5000
title_row "$LIB/Gate Pack/Season 01/one S01E01.mkv" \
"Anime Gate" 2010 1 1 "" "" "" "4242"
title_row "$LIB/Gate Pack/Season 05/two S05E01.mkv" \
"Anime Gate" 2010 5 1 "" "" "" "4242"
title_row "$LIB/Gate Pack/Season 05/three S05E02.mkv" \
"Anime Gate" 2010 5 2 "" "" "" "4242"
plan
want="$LIB/Anime Gate (2010) [tvdbid-4242]/Season 01"
want+="/Anime Gate (2010) - S01E01 - Pilot.mkv"
[ "$(dst "$LIB/Gate Pack/Season 01/one S01E01.mkv")" = "$want" ]
want="$LIB/Anime Gate (2010) [tvdbid-4242]/Season 05"
want+="/Anime Gate (2010) - S05E01.mkv"
[ "$(dst "$LIB/Gate Pack/Season 05/two S05E01.mkv")" = "$want" ]
grep -A6 -i "numbering tvdb contradicts" "$EXC" | grep -q "Anime Gate"
}
# Tvdb's default episode query answers at most 500 rows and states no total, so a
# list that long is a PAGE, not a list: One Piece (1999), Crayon Shin-chan (1992)
# and Detective Conan (1996) each answer exactly 500 while holding over a thousand.
# Read as the whole truth, that page contradicts every long-running show's pack
# and strips the tvdb title from all of it.
@test "an episode list the authority paginates never contradicts the pack" {
python3 - "$CACHE" <<'PY'
import json, sys
json.dump({"tvdb:series:4242": {"name": "Long Run", "year": "1999"},
"tvdb:episodes:4242": {f"1/{n}": f"Episode {n}"
for n in range(1, 501)}},
open(sys.argv[1], "w"))
PY
for n in $(seq 1 501); do
ep=$(printf '%02d' "$n")
file "$LIB/Long Pack/Season 01/long $ep.mkv" 1000
title_row "$LIB/Long Pack/Season 01/long $ep.mkv" \
"Long Run" 1999 1 "$n" "" "" "" "4242"
done
plan
want="$LIB/Long Run (1999) [tvdbid-4242]/Season 01"
want+="/Long Run (1999) - S01E01 - Episode 1.mkv"
[ "$(dst "$LIB/Long Pack/Season 01/long 01.mkv")" = "$want" ]
run grep -i "numbering tvdb contradicts" "$EXC"
[ "$status" -ne 0 ]
}
# ------------------------------------------------------------- the operator map
# Most of this library carries no tvdb id anywhere: the authority titles anime by
# its own record — in the show's language — and the English name rides in aliases
# the planner's name search never reads, so a search that demands tvdb's own
# spelling cannot reach it. The operator's map is the other door: one line per
# folder, `folder <TAB> tvdb id <TAB> note`, each id checked by hand against the
# record it names. It outranks a provider id, it merges the release folders that
# are one show, and every folder it touched is named in the exceptions: one wrong
# row there is 1,268 renames to the wrong show, so nothing about it is silent.
plan_map() { # the plan, with the operator's map in hand
run python3 "$PLAN" --root "$LIB" --enum "$ENUM" --titles "$TITLES" \
--probed "$PROBED" --tmdb-cache "$CACHE" --movies "$MOVIES" \
--tvdb-ids "$1" --out "$OUT" --exceptions "$EXC"
}
@test "a folder the operator mapped to a tvdb id renames to that record" {
printf '{"tvdb:series:331753": %s}\n' \
'{"name": "Black Clover", "year": "2017"}' > "$CACHE"
{
printf '# the note column carries why the name search cannot reach it\n'
printf 'Black Clover 001\t331753\tthe record holds no alias\n'
} > "$BATS_TEST_TMPDIR/ids.tsv"
file "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" 5000
title_row "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" \
"Black Clover 001" "" 1 1
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
want="$LIB/Black Clover (2017) [tvdbid-331753]/Season 01"
want+="/Black Clover (2017) - S01E01.mkv"
[ "$(dst "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv")" = "$want" ]
}
@test "release folders one id maps are ONE series folder" {
printf '{"tvdb:series:81797": %s}\n' \
'{"name": "One Piece", "year": "1999"}' > "$CACHE"
{
printf '[Anime Time] One Piece (0001-1071)\t81797\tbare "One Piece" is the 2023 live action\n'
printf 'One Piece Ship Collection\t81797\tthe same record, split by release\n'
} > "$BATS_TEST_TMPDIR/ids.tsv"
file "$LIB/[Anime Time] One Piece (0001-1071)/One.Piece.0001.mkv" 5000
file "$LIB/One Piece Ship Collection/One.Piece.0002.mkv" 6000
title_row "$LIB/[Anime Time] One Piece (0001-1071)/One.Piece.0001.mkv" \
"One Piece" 1999 1 1
title_row "$LIB/One Piece Ship Collection/One.Piece.0002.mkv" \
"One Piece Ship Collection" "" 1 2
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
want="$LIB/One Piece (1999) [tvdbid-81797]/Season 01"
want+="/One Piece (1999) - S01E01.mkv"
[ "$(dst "$LIB/[Anime Time] One Piece (0001-1071)/One.Piece.0001.mkv")" \
= "$want" ]
want="$LIB/One Piece (1999) [tvdbid-81797]/Season 01"
want+="/One Piece (1999) - S01E02.mkv"
[ "$(dst "$LIB/One Piece Ship Collection/One.Piece.0002.mkv")" = "$want" ]
}
@test "the map outranks a provider id and says which two disagreed" {
printf '{"tvdb:series:4242": %s, "tvdb:series:78850": %s}\n' \
'{"name": "Real Ship", "year": "1999"}' \
'{"name": "Box Junk", "year": "2016"}' > "$CACHE"
printf '%s\t4242\thand-verified against the record\n' \
"Show Box (2016) Season 1 S01 (1080p WEB-DL)" > "$BATS_TEST_TMPDIR/ids.tsv"
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv" \
| grep -q "^$LIB/Real Ship (1999) \[tvdbid-4242\]/Season 01/"
run grep -E '78850.*4242|4242.*78850' "$EXC"
[ "$status" -eq 0 ]
}
@test "a map id the authority never answered keeps the provider's name and is named" {
printf 'Black Clover 001\t331753\ttvdb never answered\n' \
> "$BATS_TEST_TMPDIR/ids.tsv"
file "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" 5000
title_row "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" \
"Black Clover 001" "" 1 1
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
dst "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" \
| grep -q "^$LIB/Black Clover 001 \[tvdbid-331753\]/Season 01/"
run grep -q "331753" "$EXC"
[ "$status" -eq 0 ]
}
@test "a map row for a folder the array no longer holds is reported" {
printf 'Gone Pack\t4242\tmoved away\n' > "$BATS_TEST_TMPDIR/ids.tsv"
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
run grep "Gone Pack" "$EXC"
[ "$status" -eq 0 ]
}
@test "a map row whose folder is on the array but never became a series says so" {
# The array holds three folders exactly like this one: a credits/extras pack
# the operator mapped to a record, whose every file is bonus material with no
# series to own it. The folder IS on the array, and telling the operator the
# pack moved sends them hunting for a folder that never left.
printf 'Credit Pack\t114801\tthe record tvdb names\n' > "$BATS_TEST_TMPDIR/ids.tsv"
file "$LIB/Credit Pack/01. Extras/NCOP 01.mkv" 1200
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
grep -q "bonus material with no series to own it" "$EXC"
grep -q "never became a series" "$EXC"
run grep "no folder on the array" "$EXC"
[ "$status" -ne 0 ]
}
@test "a map line whose id is not a number stops the plan" {
printf 'Black Clover 001\tnotanid\ttypo\n' > "$BATS_TEST_TMPDIR/ids.tsv"
file "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" 5000
title_row "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" \
"Black Clover 001" "" 1 1
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
[ "$status" -ne 0 ]
[[ "$output" == *"notanid"* ]]
}
# The map the fleet ships is scripts/media-shows-tvdb-ids.tsv, and the planner
# reads it by default: an unusable line there stops every plan the operator runs
# from the checkout, so the seam validates the real file, not a fixture copy.
@test "the shipped operator map parses and never stops a plan" {
MAPS="$REPO_ROOT/scripts/media-shows-tvdb-ids.tsv"
run awk -F '\t' '!/^#/ && NF {
if (NF < 2 || $2 !~ /^[0-9]+$/) { print "unusable row: " $0; bad = 1 }
} END { exit bad }' "$MAPS"
[ "$status" -eq 0 ]
dup=$(awk -F '\t' '!/^#/ && NF {print $1}' "$MAPS" | sort | uniq -d)
[ -z "$dup" ]
plan_map "$MAPS"
[ "$status" -eq 0 ]
grep -qi "operator's map" "$EXC"
}
@test "a map that names one folder twice stops the plan" {
printf '{"tvdb:series:78850": %s}\n' \
'{"name": "Naruto", "year": "2002"}' > "$CACHE"
{
printf 'Naruto Complete Series\t78850\tthe dub pack\n'
printf 'Naruto Complete Series\t331753\ta paste of another row\n'
} > "$BATS_TEST_TMPDIR/ids.tsv"
file "$LIB/Naruto Complete Series/Season 01/naruto 01.mkv" 5000
title_row "$LIB/Naruto Complete Series/Season 01/naruto 01.mkv" \
"Naruto Complete Series" "" 1 1
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
[ "$status" -eq 2 ]
[[ "$output" == *"named twice"* ]]
}