From acab4cef22ed9daccd93209e5876f98540c68d83 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 31 Oct 2026 12:27:15 -0700 Subject: [PATCH 1/2] feat(media): the folder carries its tvdb id; specials and year-seasons resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/media-shows.py | 115 ++++++++++++++++-- .../Dankserver/Media Relayout.md | 30 ++++- tests/media_shows.bats | 94 +++++++++++++- 3 files changed, 221 insertions(+), 18 deletions(-) diff --git a/scripts/media-shows.py b/scripts/media-shows.py index 6137661..a07222b 100755 --- a/scripts/media-shows.py +++ b/scripts/media-shows.py @@ -417,6 +417,49 @@ def tvdb_episodes(sid): for e in data.get("episodes") or []} return CACHE[ck] + + +# tvdb's titles read backwards, for the rows that carry a title and no numbering. +_TITLE_IDX = {} + + +def tvdb_title_index(sid): + """tvdb's episode list as `title -> (season, episode)`. A title two episodes + share names no episode: both halves are dropped, because a file that parks is + honest and a file filed at the wrong number is a lie the server believes.""" + if sid in _TITLE_IDX: + return _TITLE_IDX[sid] + index, tie = {}, set() + for key, name in (tvdb_episodes(sid) or {}).items(): + season, _, ep = key.partition("/") + t = eng.norm(name) + if not t or t in tie: + continue + if t in index: + del index[t] + tie.add(t) + continue + try: + n_season, n_ep = int(season), int(ep) + except ValueError: + continue # half-numbers ("12.5") state no slot + index[t] = (n_season, n_ep) + _TITLE_IDX[sid] = index + return index + + +def tvdb_number(t): + """The (season, episode) tvdb states for a provider row whose own numbering is + not a season at all. Jellyfin's date matcher files specials by AIRDATE, so the + row says season 2002 — a YEAR — and only the title the provider recorded says + which episode it is. No id, no title, or a title tvdb gives twice: no number, + and the file parks with the reason it always carried.""" + sid = (t.get("tvdb") or "").strip() + title = (t.get("title") or "").strip() + if not sid or not title: + return None + return tvdb_title_index(sid).get(eng.norm(title)) + # ------------------------------------------------------------------- the inputs rows = [] for line in open(ARGS.enum): @@ -613,9 +656,16 @@ for r in video: else: season = fs if fs is not None else num(t["season"]) ep = leading_index(r["base"]) or num(t["episode"]) - if not 0 < season <= 200 or ep <= 0: - unknown.append((r, f"a provider row of season {season} episode {ep}")) - continue + if not 0 <= season <= 200 or ep <= 0: + # Season 0 IS a season: specials live in Season 00 by Jellyfin's own + # contract. A season that is a YEAR is the date matcher's doing, and + # the only authority on it is tvdb's episode list for the series. + recovered = tvdb_number(t) + if not recovered: + unknown.append( + (r, f"a provider row of season {season} episode {ep}")) + continue + season, ep = recovered eps = [ep] claimants.append((r, owner, season, eps)) note(owner, t["year"] if t else "", t["tmdb"] if t else "", @@ -705,11 +755,38 @@ def label_of(s): return f"{nm} ({yr})" +def contradicted_seasons(s): + """Seasons whose numbering tvdb's own episode list contradicts — the one place + a tvdb title can be a confident lie. An anime pack numbered by its release group + books S05E01 in a season tvdb does not hold, or books more episodes there than + tvdb lists, and the slot its number points at is a DIFFERENT episode. Books <= + tvdb's count stays plausible: a partial pack is the normal shape.""" + if "contradicted" in s: + return s["contradicted"] + bad = set() + eps = tvdb_episodes(s["tvdb"]) if s["tvdb"] else None + if eps: + have = collections.Counter() + for k in eps: + season, _, _ep = k.partition("/") + if season.isdigit(): + have[int(season)] += 1 + booked = collections.defaultdict(set) + for _r, name, season, claimed in claimants: + if canon.get(series_key(name)) is s: + booked[season].update(claimed) + bad = {n for n, seen in booked.items() if len(seen) > have.get(n, 0)} + s["contradicted"] = bad + return bad + + def episode_title(r, s, season, ep): """The title this episode carries: tvdb's for the id the series resolved to, - else the title the matched row states, else nothing at all — a dangling ` - ` - is not a title, and an invented one is worse than none.""" - if s["tvdb"]: + else the title the matched row states, else nothing at all — a dangling ` - ` is + not a title, and an invented one is worse than none. A season tvdb's own list + contradicts gets no tvdb title: its number points somewhere else, and a wrong + title is worse than a bare one.""" + if s["tvdb"] and season not in contradicted_seasons(s): t = ((tvdb_episodes(s["tvdb"]) or {}).get(f"{season}/{ep}") or "").strip() if t: return clean(t) @@ -718,8 +795,17 @@ def episode_title(r, s, season, ep): def series_dir(name): + """One series, one folder — and the folder CARRIES the tvdb id. Both matchers + read `[tvdbid-NNNN]` (Jellyfin's Tvdb agent, Plex's scanner), so a show whose + title is ambiguous resolves by number instead of by a human guessing which + folder is which. The id rides the FOLDER only: Jellyfin gives a file name 180 + characters and the episode title needs the room. A series nothing resolved + keeps the provider's words with no tag at all.""" s = canon.get(series_key(name)) - return f"{ROOT}/{label_of(s)}" if s else f"{ROOT}/{clean(series_name(name))}" + if not s: + return f"{ROOT}/{clean(series_name(name))}" + tag = f" [tvdbid-{s['tvdb']}]" if s["tvdb"] else "" + return f"{ROOT}/{label_of(s)}{tag}" # ------------------------------------------------------------------- the plan @@ -973,6 +1059,8 @@ def write_plan(): def write_exceptions(): no_year = sorted({s["name"] for s in canon.values() if not s["year"]}) no_tvdb = sorted({identity(s)[0] for s in canon.values() if not s.get("canon")}) + clash = sorted({(identity(s)[0], tuple(sorted(contradicted_seasons(s)))) + for s in canon.values() if contradicted_seasons(s)}) with open(ARGS.exceptions, "w") as fh: fh.write(f"TV relayout exceptions for {ROOT}\n\n") fh.write(f"Everything below is parked under {PARK}, keeping the relative path it\n") @@ -1002,6 +1090,19 @@ def write_exceptions(): fh.write(" title was taken. Nothing was invented: give these a resolvable\n") fh.write(" id (--tvdb) and re-plan; a rename is idempotent.\n") fh.write(" " + ", ".join(no_tvdb[:300]) + "\n") + if clash: + fh.write(f"\n{len(clash):>6} series carry seasons whose numbering tvdb" + " contradicts:\n") + fh.write(" a pack books more episodes in a season than tvdb holds" + " there — an anime\n pack numbered by its release group is" + " the usual shape. Those files still\n rename to the" + " standard shape, but they do NOT ride with tvdb's titles:" + "\n the slot tvdb answers for that number is a different" + " episode. Correct the\n numbering and re-plan; a rename" + " is idempotent.\n") + fh.write(" " + "; ".join( + f"{n} S{'/'.join(f'{x:02d}' for x in ss)}" + for n, ss in clash[:300]) + "\n") print(f"exceptions -> {ARGS.exceptions}") diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index d3ecab2..c7c3fb6 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -181,7 +181,8 @@ show. A name whose marker is `Part 2` is a film and stays put. already matched and numbered most of it. `scripts/media-shows.py` re-files that library into the shape Jellyfin reads, with **tvdb as the name authority**: - ()/Season NN/ () - SxxEyy - .ext + () [tvdbid-NNNN]/Season NN/ + () - SxxEyy - .ext It never guesses: every series name, episode number and title it writes traces to an authority, and what has none is parked for a human to read. @@ -234,7 +235,11 @@ Evidence, strongest first — the order the planner believes things: whose own name says nothing; - the **row's tvdb id, else its TMDb id carried through TMDb's external ids, else an exact tvdb name search**: that id names the SERIES FOLDER and supplies the - episode title, because a name the server read off a folder is not a show name; + episode title, because a name the server read off a folder is not a show name. + The folder CARRIES the id: `[tvdbid-NNNN]` is what Jellyfin's Tvdb agent and + Plex's scanner both read, so a show whose title is ambiguous resolves by number + instead of by a human guessing which folder is which. The id rides the folder + only — a file name gets 180 characters and the episode title needs the room. - a **bonus folder** anywhere above the file, or Jellyfin's `ExtraType` → the series' canonical extra folder; - a runtime over `SHORT_MAX` makes it film-shaped, and it leaves for `--movies` only @@ -244,7 +249,7 @@ Evidence, strongest first — the order the planner believes things: for the word "russia", *Special Edition I* for a file saying *Edition III*. Else it parks. -Two refusals carry the design, and a dry run on the real library found both: +Four refusals carry the design: what the planner will NOT do is the point of it. - **Folders resolve a series; they never invent one.** A folder's words may resolve a file to a series a provider has already named — `(CBB) Tengen Toppa @@ -254,8 +259,23 @@ Two refusals carry the design, and a dry run on the real library found both: - **A scraped row does not outrank the filesystem.** Jellyfin numbers multi-season packs badly: every file under `SNL 16-20/Season 16..19` carries season 16 episode 20, and one Gundam row says season 2004. The folder, then - the file's own leading number, win; a row that still insists on season 1989 - parks its file instead of creating `Season 1989`. + the file's own leading number, win. +- **A season that is a YEAR is the date matcher, not a season.** Jellyfin files + specials by airdate, so a row can arrive saying `season 2002`; that is not a + season and it never becomes a folder. The one authority on such a row is tvdb's + episode list read backwards: the title the row carries names the (season, + episode) tvdb gives it. A title tvdb gives to two episodes names nothing, and + the file parks with the reason it always carried. `Season 00` IS a legal + season — specials live there — so a row that really says 0 files there instead + of parking. +- **A season tvdb's own list contradicts gets no tvdb title.** An anime pack + numbered by its release group books `S05E01` in a season tvdb does not hold, or + books more episodes there than tvdb lists: the slot its number points at is a + different episode, and dressing it in that episode's title is a confident lie. + The file still renames to the standard shape; the title does not ride, and the + series is listed under *seasons whose numbering tvdb contradicts* in the + exceptions. A season holding FEWER files than tvdb lists stays plausible — a + partial pack is the normal shape. **A provider name is evidence only when an id backs it.** Jellyfin names a Series after the folder it scanned, so a library filed by release folders ends up with diff --git a/tests/media_shows.bats b/tests/media_shows.bats index 043dc8a..1ef2f42 100755 --- a/tests/media_shows.bats +++ b/tests/media_shows.bats @@ -11,7 +11,11 @@ # the name caches are input files, so no test touches the network. # # The target contract is tvdb-grade naming: -# ()/Season NN/ () - SxxEyy - . +# () [tvdbid-NNNN]/Season NN/ () - SxxEyy - .<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 @@ -39,6 +43,15 @@ title_row() { "${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" @@ -220,11 +233,12 @@ PY = "$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" { +@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 - [ "$(dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv")" \ - = "$LIB/Show Box (2016)/Season 01/Show Box (2016) - S01E01 - Pilot.mkv" ] + 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" { @@ -232,8 +246,9 @@ PY # 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" ] + 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" ] } @@ -457,3 +472,70 @@ 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" +} -- 2.54.0 From e4656cfd7a64f0955a55825c24b78a47a61ea17e Mon Sep 17 00:00:00 2001 From: Frank <frank@dankware> Date: Sat, 31 Oct 2026 12:40:49 -0700 Subject: [PATCH 2/2] fix(media): a tvdb episode page is not the episode list Tvdb's default episode query answers at most 500 rows and states no total. One Piece (1999), Crayon Shin-chan (1992) and Detective Conan (1996) each answer exactly 500 while holding over a thousand, so the contradiction rule added in the previous commit read every long-running show as contradicted: the pack books more episodes than "the authority holds", and the rule that exists to stop a wrong anime title would have stripped the right one from every file of the season. A capped list now records that it is capped (`tvdb:capped:<id>`, so a warm cache answers the same question a cold one did) and contradicts nothing; titles for the episodes the page does hold still ride. Test: a 500-row page with a 501-episode pack keeps ` - Episode 1` and lists no contradiction. Red before the change, proved in a copy of the tree with the cap raised past the fixture. --- scripts/media-shows.py | 48 +++++++++++++------ .../Dankserver/Media Relayout.md | 6 ++- tests/media_shows.bats | 27 +++++++++++ 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/scripts/media-shows.py b/scripts/media-shows.py index a07222b..4dff323 100755 --- a/scripts/media-shows.py +++ b/scripts/media-shows.py @@ -404,20 +404,37 @@ def tvdb_search(name): return None +# Tvdb's default episode query answers AT MOST this many rows and states no +# total: One Piece (1999), Crayon Shin-chan (1992) and Detective Conan (1996) +# each answer exactly 500 while holding over a thousand. A list that long bounds +# nothing above itself, so it must never be read as "the series holds 500". +EPISODE_PAGE_CAP = 500 + + def tvdb_episodes(sid): - """`season/episode` -> title for one series, in the one call tvdb answers.""" + """`season/episode` -> title for one series, in the one call tvdb answers. + Whether that call came back a cut-off page is recorded in the cache beside + it, so a warm cache answers the same question a cold one did.""" ck = f"tvdb:episodes:{sid}" - if ck in CACHE: - return CACHE[ck] - data = tvdb(f"/series/{sid}/episodes/default") - if not data: - return None - CACHE[ck] = {f"{e.get('seasonNumber')}/{e.get('number')}": - (e.get("name") or "").strip() - for e in data.get("episodes") or []} + if ck not in CACHE: + data = tvdb(f"/series/{sid}/episodes/default") + if not data: + return None + rows = data.get("episodes") or [] + CACHE[ck] = {f"{e.get('seasonNumber')}/{e.get('number')}": + (e.get("name") or "").strip() for e in rows} + CACHE[f"tvdb:capped:{sid}"] = len(rows) >= EPISODE_PAGE_CAP return CACHE[ck] +def tvdb_list_capped(sid): + """True when tvdb's episode answer for this series is a page, not a list.""" + rows = tvdb_episodes(sid) + if not rows: + return False + return CACHE.get(f"tvdb:capped:{sid}", len(rows) >= EPISODE_PAGE_CAP) + + # tvdb's titles read backwards, for the rows that carry a title and no numbering. _TITLE_IDX = {} @@ -757,14 +774,17 @@ def label_of(s): def contradicted_seasons(s): """Seasons whose numbering tvdb's own episode list contradicts — the one place - a tvdb title can be a confident lie. An anime pack numbered by its release group - books S05E01 in a season tvdb does not hold, or books more episodes there than - tvdb lists, and the slot its number points at is a DIFFERENT episode. Books <= - tvdb's count stays plausible: a partial pack is the normal shape.""" + a tvdb title can be a confident lie. An anime pack numbered by its release group + books S05E01 in a season tvdb does not hold, or books more episodes there than + tvdb lists, and the slot its number points at is a DIFFERENT episode. Books <= + tvdb's count stays plausible: a partial pack is the normal shape. A list the + authority cut off at its page limit (EPISODE_PAGE_CAP) contradicts nothing — + it says nothing about the episodes it never sent.""" if "contradicted" in s: return s["contradicted"] bad = set() - eps = tvdb_episodes(s["tvdb"]) if s["tvdb"] else None + eps = None if not s["tvdb"] or tvdb_list_capped(s["tvdb"]) \ + else tvdb_episodes(s["tvdb"]) if eps: have = collections.Counter() for k in eps: diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index c7c3fb6..a1ad5f1 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -275,7 +275,11 @@ Four refusals carry the design: what the planner will NOT do is the point of it. The file still renames to the standard shape; the title does not ride, and the series is listed under *seasons whose numbering tvdb contradicts* in the exceptions. A season holding FEWER files than tvdb lists stays plausible — a - partial pack is the normal shape. + partial pack is the normal shape. Neither reading holds for a list tvdb + cut off at its own page limit: the default episode query answers at most + 500 rows and states no total, and One Piece, Crayon Shin-chan and + Detective Conan each answer exactly 500 while holding over a thousand — + a page bounds nothing above itself, so it contradicts nothing. **A provider name is evidence only when an id backs it.** Jellyfin names a Series after the folder it scanned, so a library filed by release folders ends up with diff --git a/tests/media_shows.bats b/tests/media_shows.bats index 1ef2f42..e78bf1d 100755 --- a/tests/media_shows.bats +++ b/tests/media_shows.bats @@ -539,3 +539,30 @@ PY [ "$(dst "$LIB/Gate Pack/Season 05/two S05E01.mkv")" = "$want" ] grep -A6 -i "numbering tvdb contradicts" "$EXC" | grep -q "Anime Gate" } + +# Tvdb's default episode query answers at most 500 rows and states no total, so a +# list that long is a PAGE, not a list: One Piece (1999), Crayon Shin-chan (1992) +# and Detective Conan (1996) each answer exactly 500 while holding over a thousand. +# Read as the whole truth, that page contradicts every long-running show's pack +# and strips the tvdb title from all of it. +@test "an episode list the authority paginates never contradicts the pack" { + python3 - "$CACHE" <<'PY' +import json, sys +json.dump({"tvdb:series:4242": {"name": "Long Run", "year": "1999"}, + "tvdb:episodes:4242": {f"1/{n}": f"Episode {n}" + for n in range(1, 501)}}, + open(sys.argv[1], "w")) +PY + for n in $(seq 1 501); do + ep=$(printf '%02d' "$n") + file "$LIB/Long Pack/Season 01/long $ep.mkv" 1000 + title_row "$LIB/Long Pack/Season 01/long $ep.mkv" \ + "Long Run" 1999 1 "$n" "" "" "" "4242" + done + plan + want="$LIB/Long Run (1999) [tvdbid-4242]/Season 01" + want+="/Long Run (1999) - S01E01 - Episode 1.mkv" + [ "$(dst "$LIB/Long Pack/Season 01/long 01.mkv")" = "$want" ] + run grep -i "numbering tvdb contradicts" "$EXC" + [ "$status" -ne 0 ] +} -- 2.54.0