The first tvdb-grade plan handed 12 files to the movie library, and two of them
were wrong in a way the exceptions file could not show: an episode of the
documentary series `Russia 1985-1999: TraumaZone` was renamed to the 1963 Bond
film `From Russia with Love`, and `Gundam 00 Special Edition III - Return The
World` became `Special Edition I - Celestial Being`.
Two guards, each alone insufficient:
- `title_of` salvages a short query from a long release name ("russia", "mobile
suit gundam 00"), and tmdb() took the FIRST search result. The call site then
compared the provider's words against that salvaged query, so any title merely
CONTAINING the query passed. The provider's title must now be a run of words the
FILE itself states: `Special Edition I: Celestial Being` is not in a filename
that says `Special Edition III - Return The World`.
- tmdb() read the year from `year`/`first_air_date`, which the MOVIE search does
not return — it returns `release_date`. Every film answer therefore arrived
year-less and the year guard at the call site could never fire. That is how a
1963 answer survived a filename stating 1991.
Tests: a provider answer the file does not name parks, and nothing lands in the
movie library; an answer from another year is refused. The year-from-the-wire half
is proven by the live re-plan, which now dates every movie answer.
460 lines
21 KiB
Bash
Executable File
460 lines
21 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>)/Season NN/<Series> (<year>) - SxxEyy - <Episode Title>.<ext>
|
|
# 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/<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, 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 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
|
|
[ "$(dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv")" \
|
|
= "$LIB/Show Box (2016)/Season 01/Show Box (2016) - S01E01 - Pilot.mkv" ]
|
|
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")" ]
|
|
}
|