Files
serverAppConfigs/tests/media_tvdupes.bats
Frank 9f9043c037 feat(media): measure a series per episode, and let the tag outrank a borrowed id
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.
2026-11-02 00:44:46 -08:00

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"* ]]
}