feat(media): the folder carries its tvdb id; specials and year-seasons resolve

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.
This commit is contained in:
Frank committed 2026-10-31 12:27:15 -07:00
1 parent 04802b3e48
commit acab4cef22
3 files changed
+221 -18

No files matched your search

+108 -7
View File
@@ -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}")