feat(media): tvdb names the show — episode titles, id-backed folders, park outside

The TV relayout that landed copied Jellyfin's Series names verbatim, and the
naming census showed what that launders in: one Series item per release folder
(1,107 items for 1,107 top-level dirs), the same show spelled six ways, and
box sets living in 346 separate folders for 821 identities. Jellyfin names a
Series after the folder it scanned, so `24 (2001) Season 1` is a folder echo,
not a show name.

The name authority is now tvdb, reached through the id the server matched:

  * media-census.py jellyfin emits two more columns — `title` (the episode row's
    own Name) and `tvdb` (the series id, read from BaseItemProviders where
    Jellyfin 12 keeps provider ids and from the row's Data blob where older
    builds do, so the census does not care which shape the live DB has);
  * media-shows.py resolves each series to tvdb — the row's tvdb id, else its
    TMDb id through TMDb's external ids, else an exact tvdb name search — and
    writes tvdb's name and year. That also collapses the provider's spellings
    into ONE identity, which is what turns a box set split over five release
    folders into one folder and what makes two files claiming one episode MEET
    in the duplicate rule;
  * the episode file becomes `<Series> (<year>) - SxxEyy - <Title>`: the title
    comes from tvdb's episode list, else the row's own title, else nowhere — no
    title means no separator, never an invented one.

Two refusals come with it. A row whose series is the hidden top-level folder the
file itself sits in is NOT evidence: the review pile parked inside the library
was indexed as one show named `_unsorted`, and its rows would have renamed real
shows to it. And inside such a folder a file's own words may MATCH a registered
show but never INVENT one — invention is what filled the pile. `--park`
therefore belongs outside the library, which is also what makes the next plan
re-file the pile instead of skipping it.

Both providers answer offline-reproducibly: every answer is cached in one JSON
file, so a plan can be re-planned without a key. Lookups stay behind --tmdb and
--tvdb, and the tvdb token is read from a mode-600 file, never argv.

tests/media_shows.bats is 23 tests, none of which touch the network — the
fixture Jellyfin DB carries the providers table and the name cache is an input
file. ./tests/run.sh: 753 green.
This commit is contained in:
Frank committed 2026-10-31 07:01:50 -07:00
1 parent 5dbbfcb8a0
commit c3ab565d9b
4 files changed
+463 -96

No files matched your search

+280 -34
View File
@@ -6,29 +6,40 @@ r"""Plan a Jellyfin-format relayout of a TV library. DRY RUN ONLY: nothing moves
[--out TSV] [--exceptions TXT]
Inputs are the TSVs scripts/media-census.py exports: `enumerate` lists what
exists, `jellyfin` says which episode each file IS, and `probe` is optional
duration evidence for the movie rule. The TMDb cache is a plain JSON file so a
plan stays reproducible: answers are read from it, and a miss is looked up only
with --tmdb and TMDB_API_KEY in the environment (the key never enters argv, and
exists, `jellyfin` says which episode each file IS (its series' TMDb and Tvdb
ids, and the episode title the row carries), and `probe` is optional duration
evidence for the movie rule. Every name and title answer lives in the cache JSON
so a plan stays reproducible: answers are read from it, and a miss is looked up
only with --tmdb/--tvdb and their credentials (a key never enters argv, and
never the cache).
Target contract (jellyfin.org/docs/general/server/media/shows):
<root>/<Series> (<year>)/Season <NN>/<Series> SxxEyy.ext
<root>/<Series> (<year>)/Season <NN>/<Series> (<year>) - SxxEyy - <Title>.ext
one file holding two episodes SxxE01-E02 specials Season 00
a documented part of an episode SxxE03 Part 1 sidecars <base>.<lang>.srt
bonus material <series>/trailers|featurettes|… — never a season
The series name and the episode title are the ones the IDENTITY authority — tvdb
— states for the id the metadata server matched, not the words a release folder
or a server's folder-derived Series name happens to carry.
PLAN is the TSV scripts/media-apply.py replays: `action src dst kind`, every
mkdir row before any move, and `keep` for a file already where the contract puts
it. EXCEPTIONS is the review list: everything parked, and why.
The authority chain, strongest first, for one video file:
1. a provider row — Jellyfin matched this exact path and numbered it.
1. a provider row — Jellyfin matched this exact path and numbered it. The
row's NAME is only evidence once an id backs it: the server names a Series
after the folder it scanned, so `24 (2001) Season 1` is a folder echo, and
the series' tvdb id is what undoes it.
2. the file's own SxxEyy / 1x04 marker, the series taken from a sibling's
provider row, else from the nearest ancestor folder that has one, else from
the release folder's own words.
a registered show its own words name, else — outside the review pile — from
the release folder's words. Inside a hidden top-level folder a file may
MATCH a registered show but never INVENT one: invention is what filled the
pile to begin with.
3. nothing. An unnumbered file is PARKED under --park with its relative path
kept: an invented `Season 01` steals an episode number from the file that
really is S01E01, and a wrong number is worse than no number.
@@ -47,8 +58,11 @@ Guards, and why each is here:
* Series art and the show nfo follow the SERIES, a season image follows its
SEASON, a subtitle or episode nfo follows the episode's new base — an episode
moved without its subtitles is a half-move.
* Hidden files, macOS forks, qBittorrent resume parts and the park pile itself
are never moved. Skipping the park is what makes re-planning stable.
* Hidden files, macOS forks, qBittorrent resume parts are never moved. A file
already inside --park is never moved either: that is what makes re-planning
stable. --park belongs OUTSIDE the library — parked files inside it get
indexed as one show named after the park folder, and a row like that is a
folder echo, never evidence (the guard in `row_names_the_park`).
The plan crosses three trees (the library, its park, the movie library), so the
executor runs with --root at the share that holds them, on its own journal —
@@ -56,7 +70,8 @@ one journal is one undo:
python3 scripts/media-shows.py --root /mnt/entropy/TVShows \
--enum /tmp/tv-enum.tsv --titles /tmp/jellyfin-tv.tsv \
--out /mnt/entropy/media-tv-plan.tsv
--tvdb --tmdb --out /mnt/entropy/media-tv-plan.tsv \
--park /mnt/entropy/media-park/TVShows
python3 scripts/media-apply.py apply --plan /mnt/entropy/media-tv-plan.tsv \
--root /mnt/entropy --journal /mnt/entropy/media-tv-relayout.jsonl
"""
@@ -80,14 +95,21 @@ ARGS.add_argument("--titles", default="/tmp/jellyfin-tv.tsv",
help="the provider's numbering (media-census.py jellyfin)")
ARGS.add_argument("--probed", default="",
help="ffprobe census for the movie rule; absent = no durations")
ARGS.add_argument("--tmdb-cache", default="/tmp/tmdb-cache.json",
help="TMDb answers as an input file, so a plan is reproducible")
ARGS.add_argument("--tmdb-cache", default="/tmp/tv-names.json",
help="name answers (TMDb and Tvdb) as ONE input file, so a plan "
"is reproducible; keys carry the provider they came from")
ARGS.add_argument("--tmdb", action="store_true",
help="look up cache misses (needs TMDB_API_KEY)")
help="look up cache misses at TMDb (needs TMDB_API_KEY)")
ARGS.add_argument("--tvdb", action="store_true",
help="look up cache misses at Tvdb (needs the credentials file)")
ARGS.add_argument("--tvdb-credentials",
default=os.path.expanduser("~/.config/secrets/tvdb"),
help="file with an api_key= line (and a pin= line for a "
"user-issued key); mode 600, and never the cache")
ARGS.add_argument("--movies", default="/mnt/entropy/Movies",
help="movie library a mis-filed film moves into")
ARGS.add_argument("--park", default="",
help="review pile (default: <root>/_unsorted)")
help="review pile OUTSIDE the library (default: <root>/_unsorted)")
ARGS.add_argument("--out", default="/tmp/tv-plan.tsv")
ARGS.add_argument("--exceptions", default="/tmp/tv-exceptions.txt")
ARGS = ARGS.parse_args()
@@ -251,6 +273,134 @@ def tmdb(kind, query):
return CACHE[ck]
# ---------------------------------------------------------------- tvdb, the identity
# tvdb names the show and the episode: it is the only provider whose strings are
# the ones the operator asked for, so its answer replaces a folder-derived Series
# name. The login mints a JWT per run from a mode-600 file; neither the key nor
# the token reaches argv, the cache, or the plan. A provider that will not answer
# never invents a name: every lookup here returns None, and None keeps the
# provider's own words and lands in the exceptions.
TVDB = {"jwt": None, "dead": False}
def http_json(url, body=None, headers=None):
req = urllib.request.Request(
url, data=json.dumps(body).encode() if body is not None else None,
headers={"Accept": "application/json", "Content-Type": "application/json",
**(headers or {})})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def tvdb_login():
"""The run's JWT, or None: a project key needs only api_key=, a user-issued
key also needs the pin."""
if TVDB["jwt"] is not None:
return TVDB["jwt"] or None
TVDB["jwt"] = ""
try:
lines = open(ARGS.tvdb_credentials).read().splitlines()
except OSError:
print(f"tvdb: no credentials at {ARGS.tvdb_credentials}", file=sys.stderr)
return None
sec = {}
for line in lines:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
sec[k.strip()] = v.strip()
if not sec.get("api_key"):
print(f"tvdb: no api_key in {ARGS.tvdb_credentials}", file=sys.stderr)
return None
body = {"apiKey": sec["api_key"]}
if sec.get("pin"):
body["token"] = sec["pin"]
try:
TVDB["jwt"] = (http_json("https://api4.thetvdb.com/v4/login", body)
.get("data") or {}).get("token") or ""
except Exception as e: # noqa: BLE001
print(f"tvdb login failed: {e}", file=sys.stderr)
return None
return TVDB["jwt"] or None
def tvdb(path):
"""One Tvdb v4 GET, or None when it cannot be answered."""
if not ARGS.tvdb or TVDB["dead"]:
return None
jwt = tvdb_login()
if not jwt:
TVDB["dead"] = True
return None
try:
return http_json("https://api4.thetvdb.com/v4" + path,
headers={"Authorization": "Bearer " + jwt}).get("data")
except Exception as e: # noqa: BLE001
print(f"tvdb {path} failed: {e}", file=sys.stderr)
return None
def tvdb_series(sid):
"""{name, year} for a tvdb series id."""
ck = f"tvdb:series:{sid}"
if ck in CACHE:
return CACHE[ck]
data = tvdb(f"/series/{sid}")
if not data:
return None
CACHE[ck] = {"name": (data.get("name") or "").strip(),
"year": (data.get("firstAired") or "")[:4]}
return CACHE[ck]
def tvdb_id_for_tmdb(mid):
"""The tvdb id TMDb records for a series id — the bridge for the bulk of this
library, which the server matched by TMDb."""
ck = f"tmdb:external:{mid}"
if ck in CACHE:
return CACHE[ck] or ""
if not (ARGS.tmdb and API_KEY):
return ""
try:
got = http_json(f"https://api.themoviedb.org/3/tv/{mid}/external_ids",
headers={"Authorization": "Bearer " + API_KEY})
except Exception as e: # noqa: BLE001
print(f"tmdb external ids failed for {mid}: {e}", file=sys.stderr)
return ""
CACHE[ck] = str(got.get("tvdb_id") or "")
return CACHE[ck]
def tvdb_search(name):
"""The tvdb series a provider name states, or None: only tvdb's own spelling
counts, so a near-miss never renames a show."""
ck = f"tvdb:search:{name}"
if ck in CACHE:
return CACHE[ck]
for hit in tvdb("/search?query=" + urllib.parse.quote(name) + "&type=series") or []:
if eng.norm(hit.get("name") or "") != eng.norm(name):
continue
CACHE[ck] = {"name": (hit.get("name") or "").strip(),
"year": str(hit.get("year") or ""),
"id": str(hit.get("tvdb_id") or "")}
return CACHE[ck]
CACHE[ck] = None
return None
def tvdb_episodes(sid):
"""`season/episode` -> title for one series, in the one call tvdb answers."""
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 []}
return CACHE[ck]
# ------------------------------------------------------------------- the inputs
rows = []
for line in open(ARGS.enum):
@@ -264,6 +414,23 @@ for line in open(ARGS.enum):
rows.append({"bytes": int(size), "ext": ext.lower(), "path": p, "base": base})
held = {r["path"]: r for r in rows}
def row_names_the_park(path, name):
"""The library's own review pile is not a show. When the park sat INSIDE the
library the server indexed the whole folder as one series named after it, and
a row like that would rename a real show to `_unsorted` — so a row whose
series is the hidden top-level folder the file itself sits in is dropped."""
top = rel(path).split("/")[0]
return top.startswith("_") and eng.norm(name) == eng.norm(top)
def in_review_pile(path):
"""A file under the library's hidden top-level folder is review material by
that folder's own convention: its filename may MATCH a show a provider names
somewhere else, but it may never INVENT one — inventing one from a file's own
words is what filled the pile to begin with."""
return rel(path).split("/")[0].startswith("_")
titles = {}
with open(ARGS.titles) as fh:
cols = fh.readline().rstrip("\n").split("\t")
@@ -272,7 +439,7 @@ with open(ARGS.titles) as fh:
f += [""] * (len(cols) - len(f))
row = dict(zip(cols, f))
p = os.path.abspath(row.get("file", ""))
if p in held:
if p in held and not row_names_the_park(p, row.get("series", "")):
titles[p] = row
dur = {}
@@ -379,14 +546,17 @@ def series_name(name):
return clean(re.sub(r"\s{2,}", " ", PACK_IDX_RE.sub("", name)).strip(" ."))
def note(name, year="", tmdb_id=""):
"""Register a series; the first name wins, the first year and id stick."""
def note(name, year="", tmdb_id="", tvdb_id=""):
"""Register a series; the first name wins, the first year and ids stick."""
k = eng.norm(name)
s = series.setdefault(k, {"name": series_name(name), "year": "", "tmdb": ""})
s = series.setdefault(k, {"name": series_name(name), "year": "", "tmdb": "",
"tvdb": ""})
if year and not s["year"]:
s["year"] = str(year)
if tmdb_id and not s["tmdb"]:
s["tmdb"] = str(tmdb_id)
if tvdb_id and not s["tvdb"]:
s["tvdb"] = str(tvdb_id)
@@ -413,7 +583,9 @@ for r in video:
# every file under SNL 16-20/Season 16..19 carries season 16 episode 20.
owner = (t["series"] if t and t["series"]
else series_of_dir(os.path.dirname(p))
or title_of(r["base"][:marked[2]])[0])
or known_in(r["base"])
or ("" if in_review_pile(p)
else title_of(r["base"][:marked[2]])[0]))
if not eng.norm(owner).replace(" ", ""):
unknown.append((r, "an episode marker with no series name to hang it on"))
continue
@@ -427,7 +599,8 @@ for r in video:
continue
eps = [ep]
claimants.append((r, owner, season, eps))
note(owner, t["year"] if t else "", t["tmdb"] if t else "")
note(owner, t["year"] if t else "", t["tmdb"] if t else "",
t["tvdb"] if t else "")
continue
owner = series_of_dir(os.path.dirname(p))
if fs is not None and owner and leading_index(r["base"]):
@@ -450,17 +623,80 @@ for k, s in series.items():
if got and eng.norm(got["name"]) == k:
s["year"] = got["year"]
if ARGS.tmdb:
# tvdb is the identity authority. The row's own tvdb id is the door; a TMDb id
# crosses through TMDb's external ids; a bare name counts only when tvdb spells
# it the same and the years do not disagree. A series nothing resolves keeps the
# provider's words AND says so in the exceptions, which is where the operator
# finds the folders the plan did not rename to authority.
for s in series.values():
got = tvdb_series(s["tvdb"]) if s["tvdb"] else None
if not got and s["tmdb"]:
sid = tvdb_id_for_tmdb(s["tmdb"])
if sid:
s["tvdb"] = sid
got = tvdb_series(sid)
if not got:
hit = tvdb_search(s["name"])
if hit and hit["id"] and (not s["year"] or not hit["year"]
or hit["year"] == s["year"]):
s["tvdb"] = hit["id"]
got = hit
if got and got["name"]:
s["canon"], s["canon_year"] = got["name"], got["year"]
# One identity, one folder: the provider's spellings collapse to the shows tvdb
# says they are, so a box set split over five release folders is ONE series and
# two files claiming one episode MEET inside the duplicate rule below.
alias, canon = {}, {}
for k, s in series.items():
key = f"id:{s['tvdb']}" if s["tvdb"] else k
alias[k] = key
keep = canon.setdefault(key, s)
if keep is not s:
for f in ("year", "tvdb", "canon", "canon_year"):
if not keep.get(f) and s.get(f):
keep[f] = s[f]
if ARGS.tmdb or ARGS.tvdb:
with open(ARGS.tmdb_cache, "w") as fh:
json.dump(CACHE, fh, indent=1, sort_keys=True)
def identity(s):
"""(name, year) a folder and its files carry: tvdb's whenever it answered,
the provider's own words only for a series nothing resolved."""
if s.get("canon"):
return s["canon"], s["canon_year"] or s["year"]
return s["name"], s["year"]
def series_key(name):
"""The merged identity a provider spelling belongs to."""
return alias.get(eng.norm(name), eng.norm(name))
def label_of(s):
"""`<Series> (<year>)`, the form both matchers read."""
nm, yr = identity(s)
nm = clean(nm)
return f"{nm} ({yr})" if yr else nm
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"]:
t = ((tvdb_episodes(s["tvdb"]) or {}).get(f"{season}/{ep}") or "").strip()
if t:
return clean(t)
t = ((titles.get(r["path"]) or {}).get("title") or "").strip()
return clean(t) if t else ""
def series_dir(name):
s = series.get(eng.norm(name))
if not s:
s = {"name": clean(name), "year": ""}
label = f"{s['name']} ({s['year']})" if s["year"] else s["name"]
return f"{ROOT}/{clean(label)}"
s = canon.get(series_key(name))
return f"{ROOT}/{label_of(s)}" if s else f"{ROOT}/{clean(series_name(name))}"
# ------------------------------------------------------------------- the plan
@@ -500,25 +736,28 @@ def park(r, reason):
by_ep = collections.defaultdict(list)
for r, name, season, eps in claimants:
for e in eps:
by_ep[(eng.norm(name), season, e)].append(r)
by_ep[(series_key(name), season, e)].append(r)
winners, losers = set(), {}
for (key, season, e), rs in by_ep.items():
rs.sort(key=lambda r: (-r["bytes"], r["path"]))
winners.add(rs[0]["path"])
for r in rs[1:]:
losers.setdefault(r["path"],
f"duplicate of {series[key]['name']} S{season:02d}E{e:02d}"
f"duplicate of {canon[key]['name']} S{season:02d}E{e:02d}"
" (the bigger copy keeps the standard name)")
for r, name, season, eps in claimants:
if r["path"] in losers:
park(r, losers[r["path"]])
continue
s = series[eng.norm(name)]
label = f" S{season:02d}E" + "-E".join(f"{e:02d}" for e in eps)
s = canon[series_key(name)]
stem = f"{label_of(s)} - S{season:02d}E" + "-E".join(f"{e:02d}" for e in eps)
title = episode_title(r, s, season, eps[0])
if title:
stem += f" - {title}"
part = PART_RE.search(os.path.splitext(r["base"])[0])
tail = f" Part {part.group(1).upper()}" if part and len(eps) == 1 else ""
book(r, f"{series_dir(name)}/Season {season:02d}", s["name"] + label + tail + r["ext"],
"tv")
if part and len(eps) == 1:
stem += f" Part {part.group(1).upper()}"
book(r, f"{series_dir(name)}/Season {season:02d}", stem + r["ext"], "tv")
for r, name, kind in extras:
book(r, f"{series_dir(name)}/{kind}",
@@ -680,7 +919,8 @@ def write_plan():
def write_exceptions():
no_year = sorted(s["name"] for s in series.values() if not s["year"])
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")})
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")
@@ -698,6 +938,12 @@ def write_exceptions():
fh.write(f"{len(no_year):>6} series carry no year from any provider, so their\n")
fh.write(" folders are named without one; Jellyfin still matches them.\n")
fh.write(" " + ", ".join(no_year[:300]) + "\n")
if no_tvdb:
fh.write(f"\n{len(no_tvdb):>6} series carry no tvdb identity, so their folders\n")
fh.write(" keep the name the metadata provider gave them and no episode\n")
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")
print(f"exceptions -> {ARGS.exceptions}")