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:
1 parent
04802b3e48
commit
acab4cef22
3 files changed
+221
-18
No files matched your search
+108
-7
@@ -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}")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user