Three defects in the TVShows planner, all three visible in the applied plan: - A series folder named a show by words alone. Jellyfin's Tvdb agent and Plex's scanner both read `[tvdbid-NNNN]`, so the resolved id now rides the series folder — and only the folder: a file name gets 180 characters and the episode title needs the room. - 1,412 files parked because their provider row said `season 0`. Season 0 is a legal season; Jellyfin's contract is `Season 00` for specials. - 1,033 files parked because Jellyfin's date matcher writes the AIRDATE YEAR into the season column. Such a row is now renumbered by the title tvdb states for the series' id, read backwards off its own episode list. A title tvdb gives to two episodes names no episode, and that file parks with the reason it always carried. A season tvdb's own episode list CONTRADICTS — a release pack books more episodes there than the authority holds, the anime numbering hazard — keeps its rename and loses the tvdb title, because the slot its number points at is a different episode. Those series are listed in the exceptions file, which is where the operator reads them. Tests: six through the fixture (five red before the change; the ambiguous title is a guard that the recovery must never guess). Full bats suite green.
542 lines
25 KiB
Bash
Executable File
542 lines
25 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"* ]]
|
|
}
|
|
|
|
# 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"
|
|
}
|