#!/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: # ()/Season NN/ () - SxxEyy - . # 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" } # 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//. 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 # 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, not the name a folder left behind" { printf '{"tvdb:series:78850": {"name": "Show Box", "year": "2016"}}\n' > "$CACHE" plan [ "$(dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv")" \ = "$LIB/Show Box (2016)/Season 01/Show Box (2016) - S01E01 - Pilot.mkv" ] } @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 "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// 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 "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")" ] }