media-tvdupes.py censuses a TV library by episode key and never by folder. 10 of the 25 shows that looked duplicated in the 2026-11-02 census held complementary halves (Game of Thrones Complete carries no S01 or S08, Modern Family S05 is the only copy of season 5), so a folder-level verdict would have staged some 340 GB of unique episodes to recover 54 GiB of true copies. Show identity comes from the folder's [tvdbid-N] tag, from the tvshow.nfo the metadata view exposes, or from an operator --ids map. A derived id that contradicts a tag is dropped and reported, never unioned: Jellyfin's provider table carries a 2026 special's id on the folder tagged [tvdbid-73838], and unioning the two makes one show of a 2000 series and a 2026 special whose bigger files then dominate the original's pilot. Files named by an id key only when the server's own episode table (the --witness) names them, so a bare number is never mistaken for an episode. Seam: tests/media_tvdupes.bats — 12 assertions on which files land in the plan and which the tool refuses to touch, on a synthetic library.
216 lines
9.1 KiB
Bash
Executable File
216 lines
9.1 KiB
Bash
Executable File
#!/usr/bin/env bats
|
|
# Seam: scripts/media-tvdupes.py — the episode-level duplicate census for the TV
|
|
# library.
|
|
#
|
|
# The failure this seam exists to prevent is the one that almost cost real
|
|
# content: two folders of one show look like copies, and a folder-level "keep
|
|
# the bigger folder" deletes the half of the series only the smaller folder
|
|
# holds (the 2026-11-02 census found 10 such complementary pairs). So every
|
|
# assertion here is about WHICH files land in the plan and WHICH the tool
|
|
# refuses to touch — never about its internals. Everything runs on a synthetic
|
|
# library in the test's own tmpdir: no media server, no network, no array.
|
|
|
|
load helpers
|
|
|
|
TOOL="$REPO_ROOT/scripts/media-tvdupes.py"
|
|
|
|
# at PATH SIZE — a file of deterministic bytes.
|
|
at() { mkdir -p "$(dirname "$1")"; head -c "$2" /dev/zero > "$1"; }
|
|
|
|
# sources FILE — the sources the plan moves, one per line.
|
|
sources() { awk -F'\t' '$1=="move"{print $2}' "$1"; }
|
|
|
|
# destinations FILE — where the plan puts them.
|
|
destinations() { awk -F'\t' '$1=="move"{print $3}' "$1"; }
|
|
|
|
# plan_moves FILE — how many files the plan stages.
|
|
plan_moves() {
|
|
[ -f "$1" ] || { echo 0; return; }
|
|
awk -F'\t' '$1=="move"' "$1" | wc -l
|
|
}
|
|
|
|
setup() {
|
|
LIB="$BATS_TEST_TMPDIR/TVShows"
|
|
HOLD="$BATS_TEST_TMPDIR/.AGENTTRASH/20991231-tv-duplicates"
|
|
PLAN="$BATS_TEST_TMPDIR/plan.tsv"
|
|
mkdir -p "$LIB" "$HOLD"
|
|
}
|
|
|
|
@test "the smaller copy of an episode is staged and the larger stays put" {
|
|
at "$LIB/Doc Who (2005) [tvdbid-78804]/Season 01/Doc Who - S01E01 - Pilot.mkv" 4000
|
|
at "$LIB/Doc Who (2005) [tvdbid-78804]/Season 01/backup/Doc.Who.S01E01.720p.mkv" 1200
|
|
|
|
run python3 "$TOOL" --library "$LIB" --hold "$HOLD" --out "$PLAN"
|
|
[ "$status" -eq 0 ]
|
|
[ "$(plan_moves "$PLAN")" -eq 1 ]
|
|
run sources "$PLAN"
|
|
[[ "$output" == *"backup/Doc.Who.S01E01.720p.mkv"* ]]
|
|
[[ "$output" != *"Pilot.mkv"* ]]
|
|
run destinations "$PLAN"
|
|
[[ "$output" == "$HOLD/"* ]]
|
|
}
|
|
|
|
@test "an equal-size pair stages exactly one copy" {
|
|
at "$LIB/Equal (2001) [tvdbid-11]/Season 01/Equal - S01E01.mkv" 2500
|
|
at "$LIB/Equal (2001) [tvdbid-11]/Rip/Equal - S01E01.mkv" 2500
|
|
|
|
run python3 "$TOOL" --library "$LIB" --hold "$HOLD" --out "$PLAN"
|
|
[ "$status" -eq 0 ]
|
|
[ "$(plan_moves "$PLAN")" -eq 1 ]
|
|
}
|
|
|
|
@test "a file holding one episode nothing else has is never staged" {
|
|
# The shape that defeats a folder-level verdict: Season 02 exists only here,
|
|
# so the folder that looks like a leftover is the only copy of an episode.
|
|
at "$LIB/King (2010) [tvdbid-21]/Season 01/King - S01E01 - One.mkv" 3000
|
|
at "$LIB/King (2010) [tvdbid-21]/Season 01/backup/King - S01E01 - One.mkv" 900
|
|
at "$LIB/King (2010) [tvdbid-21]/Season 02/King - S02E01 - Two.mkv" 800
|
|
|
|
run python3 "$TOOL" --library "$LIB" --hold "$HOLD" --out "$PLAN"
|
|
[ "$status" -eq 0 ]
|
|
run sources "$PLAN"
|
|
[[ "$output" != *"S02E01"* ]]
|
|
[[ "$output" == *"backup/King - S01E01 - One.mkv"* ]]
|
|
}
|
|
|
|
@test "a two-in-one pack competes for every episode in its range" {
|
|
at "$LIB/Range (2012) [tvdbid-31]/Season 01/Range - S01E01.mkv" 1500
|
|
at "$LIB/Range (2012) [tvdbid-31]/Season 01/Range - S01E02.mkv" 1500
|
|
at "$LIB/Range (2012) [tvdbid-31]/Packs/Range - S01E01-02.mkv" 2400
|
|
|
|
# The pack is the biggest carrier of BOTH episodes, so the two rips go.
|
|
run python3 "$TOOL" --library "$LIB" --hold "$HOLD" --out "$PLAN"
|
|
[ "$(plan_moves "$PLAN")" -eq 2 ]
|
|
run sources "$PLAN"
|
|
[[ "$output" == *"Season 01/Range - S01E01.mkv"* ]]
|
|
[[ "$output" != *"Packs/Range - S01E01-02.mkv"* ]]
|
|
|
|
# Make the season file the bigger carrier of E02 and the pack becomes the
|
|
# only carrier of E01: it stays, however small it is.
|
|
rm "$LIB/Range (2012) [tvdbid-31]/Season 01/Range - S01E01.mkv"
|
|
rm "$LIB/Range (2012) [tvdbid-31]/Season 01/Range - S01E02.mkv"
|
|
at "$LIB/Range (2012) [tvdbid-31]/Season 01/Range - S01E02.mkv" 9000
|
|
run python3 "$TOOL" --library "$LIB" --hold "$HOLD" --out "$PLAN"
|
|
[ "$(plan_moves "$PLAN")" -eq 0 ]
|
|
}
|
|
|
|
@test "two tvdb ids on the same words are never merged" {
|
|
at "$LIB/Avatar The Last Airbender (2024) [tvdbid-385925]/Season 01/Avatar - S01E01.mkv" 9000
|
|
at "$LIB/Avatar The Last Airbender (1999) [tvdbid-74852]/Season 01/Avatar - S01E01.mkv" 500
|
|
|
|
run python3 "$TOOL" --library "$LIB" --hold "$HOLD" --out "$PLAN"
|
|
[ "$status" -eq 0 ]
|
|
[ "$(plan_moves "$PLAN")" -eq 0 ]
|
|
[[ "$output" == *CONFLICT* ]]
|
|
[[ "$output" == *"tvdb [74852] vs [385925]"* ]]
|
|
}
|
|
|
|
@test "a derived id that contradicts the folder's own tag never merges them" {
|
|
# Measured 2026-11-02: Jellyfin's provider table carried a 2026 special's tvdb
|
|
# id on the folder tagged [tvdbid-73838]. Unioning the two makes one show of a
|
|
# 2000 series and a 2026 special, and the special's bigger files dominate the
|
|
# original's pilot. The tag is the operator's word; the nfo and `--ids` layers
|
|
# fill an untagged folder and never overrule a tagged one.
|
|
at "$LIB/Middle (2000) [tvdbid-73838]/Season 01/Middle - S01E01 - Pilot.mkv" 800
|
|
at "$LIB/Middle (2026) [tvdbid-457779]/Season 01/Middle - S01E01 - Episode 1.mkv" 1100
|
|
IDS="$BATS_TEST_TMPDIR/ids.tsv"
|
|
printf '%s\t%s\n' "Middle (2000) [tvdbid-73838]" 457779 > "$IDS"
|
|
|
|
run python3 "$TOOL" --library "$LIB" --hold "$HOLD" --out "$PLAN" --ids "$IDS"
|
|
[ "$status" -eq 0 ]
|
|
[ "$(plan_moves "$PLAN")" -eq 0 ]
|
|
[[ "$output" == *"DISAGREE"* ]]
|
|
[[ "$output" == *"tag tvdb [73838]"* ]]
|
|
[[ "$output" == *"derived tvdb [457779]"* ]]
|
|
}
|
|
|
|
@test "an untagged drop is identified by the tvshow.nfo in the metadata view" {
|
|
at "$LIB/Clone Wars/Season 01/Clone Wars - S01E01.mkv" 1200
|
|
at "$LIB/Clone Wars (2008) [tvdbid-84859]/Season 01/Clone Wars - S01E01.mkv" 4000
|
|
VIEW="$BATS_TEST_TMPDIR/view"
|
|
mkdir -p "$VIEW/Clone Wars"
|
|
printf '%s\n' '<tvshow><title>Clone Wars</title>' \
|
|
'<uniqueid type="tvdb" default="true">84859</uniqueid></tvshow>' \
|
|
> "$VIEW/Clone Wars/tvshow.nfo"
|
|
|
|
run python3 "$TOOL" --library "$LIB" --hold "$HOLD" --out "$PLAN" --meta "$VIEW"
|
|
[ "$status" -eq 0 ]
|
|
[ "$(plan_moves "$PLAN")" -eq 1 ]
|
|
run sources "$PLAN"
|
|
[[ "$output" == *"Clone Wars/Season 01/Clone Wars - S01E01.mkv"* ]]
|
|
|
|
# Without the nfo layer nothing identifies the drop, so nothing moves.
|
|
run python3 "$TOOL" --library "$LIB" --hold "$HOLD" \
|
|
--out "$BATS_TEST_TMPDIR/no-meta.tsv"
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *UNRESOLVED* ]]
|
|
[ "$(plan_moves "$BATS_TEST_TMPDIR/no-meta.tsv")" -eq 0 ]
|
|
}
|
|
|
|
@test "a file named by an id is keyed only when the witness names it" {
|
|
BIG="$LIB/Edd (1999) [tvdbid-75897]/Season 01/Edd - S01E01 - Down the Line.mkv"
|
|
SMALL="$LIB/Edd (1999) [tvdbid-75897]/Season 01/104348.mkv"
|
|
at "$BIG" 2100
|
|
at "$SMALL" 700
|
|
WIT="$BATS_TEST_TMPDIR/witness.tsv"
|
|
printf '%s\t1\t1\n' "$SMALL" > "$WIT"
|
|
|
|
run python3 "$TOOL" --library "$LIB" --hold "$HOLD" --out "$PLAN"
|
|
[ "$(plan_moves "$PLAN")" -eq 0 ]
|
|
[[ "$output" == *"unkeyed, never staged: 1 files"* ]]
|
|
|
|
run python3 "$TOOL" --library "$LIB" --hold "$HOLD" --out "$PLAN" --witness "$WIT"
|
|
[ "$(plan_moves "$PLAN")" -eq 1 ]
|
|
run sources "$PLAN"
|
|
[[ "$output" == *104348.mkv* ]]
|
|
}
|
|
|
|
@test "the sidecars of a staged episode are staged with it" {
|
|
at "$LIB/Side (2003) [tvdbid-41]/Season 01/Side - S01E01 - First.mkv" 3000
|
|
at "$LIB/Side (2003) [tvdbid-41]/Rip/Side - S01E01 - First.mkv" 1000
|
|
at "$LIB/Side (2003) [tvdbid-41]/Rip/Side - S01E01 - First.srt" 400
|
|
at "$LIB/Side (2003) [tvdbid-41]/Season 01/Side - S01E01 - First.srt" 450
|
|
|
|
run python3 "$TOOL" --library "$LIB" --hold "$HOLD" --out "$PLAN"
|
|
[ "$(plan_moves "$PLAN")" -eq 2 ]
|
|
run sources "$PLAN"
|
|
[[ "$output" == *"Rip/Side - S01E01 - First.srt"* ]]
|
|
[[ "$output" != *"Season 01/Side - S01E01 - First.srt"* ]]
|
|
}
|
|
|
|
@test "a hold inside the library is refused" {
|
|
at "$LIB/Hostage (2007) [tvdbid-51]/Season 01/Hostage - S01E01.mkv" 100
|
|
run python3 "$TOOL" --library "$LIB" --hold "$LIB/.AGENTTRASH/hold" \
|
|
--out "$PLAN"
|
|
[ "$status" -ne 0 ]
|
|
[[ "$output" == *"--hold"* ]]
|
|
}
|
|
|
|
@test "a plan whose hold target is occupied is refused, plan and all" {
|
|
at "$LIB/Gone (2009) [tvdbid-61]/Season 01/Gone - S01E01.mkv" 1000
|
|
at "$LIB/Gone (2009) [tvdbid-61]/Rip/Gone - S01E01.mkv" 400
|
|
|
|
run python3 "$TOOL" --library "$LIB" --hold "$HOLD" --out "$PLAN"
|
|
[ "$(plan_moves "$PLAN")" -eq 1 ]
|
|
|
|
# The hold already holds one of the destinations: the executor would refuse
|
|
# it, so a plan that cannot run must not be handed over at all.
|
|
mkdir -p "$HOLD/Gone (2009) [tvdbid-61]/Rip"
|
|
at "$HOLD/Gone (2009) [tvdbid-61]/Rip/Gone - S01E01.mkv" 400
|
|
rm "$PLAN"
|
|
run python3 "$TOOL" --library "$LIB" --hold "$HOLD" --out "$PLAN"
|
|
[ "$status" -eq 1 ]
|
|
[[ "$output" == *"hold target occupied"* ]]
|
|
[ ! -e "$PLAN" ]
|
|
}
|
|
|
|
@test "a library with nothing duplicated writes an empty plan" {
|
|
at "$LIB/Unique (2015) [tvdbid-71]/Season 01/Unique - S01E01.mkv" 1000
|
|
at "$LIB/Other Show (2016) [tvdbid-72]/Season 01/Other Show - S01E01.mkv" 1000
|
|
|
|
run python3 "$TOOL" --library "$LIB" --hold "$HOLD" --out "$PLAN"
|
|
[ "$status" -eq 0 ]
|
|
[ "$(plan_moves "$PLAN")" -eq 0 ]
|
|
[[ "$output" == *"plan: 0 moves"* ]]
|
|
}
|