feat(media): the operator's map gives a folder the id its name cannot reach

Tvdb titles a record in the show's own language and keeps the English name in
aliases the name search never reads, so the rule that accepts only tvdb's own
spelling of a query can never resolve them: ブラッククローバー (331753) holds no
alias at all, and the bare alias "One Piece" belongs to 392276, the 2023 live
action, not to the 1999 record (81797) that 1,072-file pack is. Seventeen pack
folders on the array — 3,507 files — are in that class.

media-shows.py now reads scripts/media-shows-tvdb-ids.tsv by default: one line
per pack folder, folder <TAB> tvdb id <TAB> note, every id read from tvdb's own
record. The map outranks a provider id, because a box set's provider id is the
server's own guess at a folder name, and it merges the release folders that are
one show. Everything it touched goes to the exceptions: every folder it named,
every row that matched no folder, every disagreement with a provider id, every id
tvdb never answered. The file is read fail-closed — an id that is not a number,
or one folder named twice, stops the plan, because a typo there renames a pack of
1,268 files to the wrong show.

scripts/media-shows-tvdb.py is the sheet the map was built from, promoted out of
/tmp so the next run is reproducible: it reads a plan's mkdir rows, asks tvdb
about every destination folder carrying no [tvdbid-], and grades the answer
EXACT / PUNCT / NEAR / NONE by how much was guessed to reach it. Acceptance is
unchanged from the passes it replaces — bare-form equality plus the year-span
guard — and that gate is what rejected three confident picks: Fairy Tail's 2024
sequel (410031), the 2026 Hokuto no Ken remake (465216) and Mugyutto! Black
Clover (378762) offered for Black Clover. The sheet is for the operator; the map
is what the planner reads, and only after he approves the rows.

Tests: seven for the map's behaviour — rename to the record it names, two release
folders becoming one series, precedence over a provider id and the line stating
the disagreement, an id tvdb never answered, a row whose folder is gone, a
malformed id refusing the run, a folder named twice refusing the run — plus one
that the shipped file parses and never stops a plan.
This commit is contained in:
Frank committed 2026-10-31 13:10:32 -07:00
1 parent 576c3421b8
commit 7aa32d1b64
5 files changed
+609 -16

No files matched your search

+110 -5
View File
@@ -3,7 +3,7 @@ r"""Plan a Jellyfin-format relayout of a TV library. DRY RUN ONLY: nothing moves
media-shows.py [--root DIR] [--enum TSV] [--titles TSV] [--probed TSV]
[--tmdb-cache JSON] [--tmdb] [--movies DIR] [--park DIR]
[--out TSV] [--exceptions TXT]
[--tvdb-ids TSV] [--out TSV] [--exceptions TXT]
Inputs are the TSVs scripts/media-census.py exports: `enumerate` lists what
exists, `jellyfin` says which episode each file IS (its series' TMDb and Tvdb
@@ -24,6 +24,14 @@ The series name and the episode title are the ones the IDENTITY authority — tv
— 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.
A folder tvdb's search cannot state is not a folder tvdb does not know: the
record exists, it is titled in the show's own language, and the English name
rides in aliases the search never reads. --tvdb-ids is the human door for those:
one line per pack folder, `folder <TAB> tvdb id <TAB> note`, every id checked by
hand against the record it names. It is an input file like the census, so a plan
stays reproducible, and every folder it touched is listed in the exceptions —
one wrong row is a whole pack renamed to the wrong show.
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.
@@ -106,6 +114,16 @@ 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("--tvdb-ids",
default=os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"media-shows-tvdb-ids.tsv"),
help="the operator's map: folder <TAB> tvdb id <TAB> note, for the\n"
"packs whose name tvdb's search cannot state (anime is titled\n"
"by its own record, the English name rides in aliases). Used\n"
"only when the file exists; a line without a numeric id stops\n"
"the plan, because a typo renames a whole pack to the wrong\n"
"show.")
ARGS.add_argument("--movies", default="/mnt/entropy/Movies",
help="movie library a mis-filed film moves into")
ARGS.add_argument("--park", default="",
@@ -611,6 +629,63 @@ def extras_kind(path, extra_type):
return "extras"
# --------------------------------------------------------- the operator's map
# The map is the human answer to a name the authority cannot be searched by. It
# is read as an input file — like the census and the cache — so a plan stays
# reproducible from the files beside it, and it outranks a provider id: a box
# set's provider id is the server's own guess at a folder name, while a map row
# is an id someone checked against the record it names.
MAP = {} # top-level pack folder -> {"id", "note"}
MAP_SEEN, MAP_CLASH = set(), [] # what the map actually touched
if ARGS.tvdb_ids and os.path.exists(ARGS.tvdb_ids):
for _n, _line in enumerate(open(ARGS.tvdb_ids), 1):
_line = _line.rstrip("\n")
if not _line.strip() or _line.startswith("#"):
continue
cols = _line.split("\t")
folder = cols[0].strip()
sid = cols[1].strip() if len(cols) > 1 else ""
if not folder or not sid.isdigit():
print(f"{ARGS.tvdb_ids}:{_n}: not a numeric tvdb id: {_line}",
file=sys.stderr)
sys.exit(2)
if folder in MAP:
print(f"{ARGS.tvdb_ids}:{_n}: folder named twice: {folder}",
file=sys.stderr)
sys.exit(2)
MAP[folder] = {"id": sid,
"note": cols[2].strip() if len(cols) > 2 else ""}
def top_folder(path):
"""The library folder a file's pack sits in. An id belongs to a show, and a
show is a folder here, so that is what the map keys on."""
return os.path.relpath(path, ROOT).split(os.sep)[0]
def map_id_for(path, provider_id):
"""The id the operator states for this file's folder. The map wins over the
provider's — it is the only door for a name tvdb cannot state — and a
disagreement between the two is written where the operator reads it, never
resolved in silence."""
if not MAP:
return provider_id
top = top_folder(path)
entry = MAP.get(top)
if not entry:
return provider_id
MAP_SEEN.add(top)
if provider_id and provider_id != entry["id"]:
MAP_CLASH.append((top, provider_id, entry["id"]))
return entry["id"]
def map_note():
if not MAP:
return ""
return f" (+{len(MAP_SEEN)} folders from the operator's map)"
# ----------------------------------------------------------- classify every file
series = {} # norm(name) -> {name, year, tmdb}
claimants = [] # (row, name, season, [episodes])
@@ -644,7 +719,7 @@ def as_extra(r, owner, extra_type):
if not owner:
unknown.append((r, "bonus material with no series to own it"))
return
note(owner)
note(owner, tvdb_id=map_id_for(r["path"], ""))
extras.append((r, owner, extras_kind(r["path"], extra_type)))
for r in video:
@@ -686,12 +761,12 @@ for r in video:
eps = [ep]
claimants.append((r, owner, season, eps))
note(owner, t["year"] if t else "", t["tmdb"] if t else "",
t["tvdb"] if t else "")
map_id_for(r["path"], 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"]):
claimants.append((r, owner, fs, [leading_index(r["base"])]))
note(owner)
note(owner, tvdb_id=map_id_for(r["path"], ""))
continue
if eng.is_extra(p, title_of(r["base"])[0], dur.get(p, 0.0), ROOT):
as_extra(r, owner, "")
@@ -1073,7 +1148,7 @@ def write_plan():
sized = sum(held[s]["bytes"] for s, _d, _k in moves if s in held)
print(f"plan: {len(moves) - len(parked)} moves, {len(parked)} parked"
f" ({sized / 2**40:.2f} TiB touched), {len(keeps)} already placed,"
f" {len(dirs)} folders{provider_note()} -> {ARGS.out}")
f" {len(dirs)} folders{map_note()}{provider_note()} -> {ARGS.out}")
def write_exceptions():
@@ -1123,6 +1198,36 @@ def write_exceptions():
fh.write(" " + "; ".join(
f"{n} S{'/'.join(f'{x:02d}' for x in ss)}"
for n, ss in clash[:300]) + "\n")
map_ids = {e["id"] for e in MAP.values()}
unseen = sorted(f for f in MAP if f not in MAP_SEEN)
unanswered = sorted({f"{identity(s)[0]} ({s['tvdb']})"
for s in canon.values()
if s.get("tvdb") in map_ids and not s.get("canon")})
if MAP:
fh.write(f"\n{len(MAP_SEEN):>6} folders took their tvdb id from the\n")
fh.write(" operator's map — the human answer to a name tvdb's search\n"
" cannot state. A wrong row renames a whole pack to the wrong\n"
" show, so every folder it touched is listed here; fix the map\n"
" and re-plan, because a rename is idempotent.\n")
fh.write(" " + "; ".join(
f"{f} -> {MAP[f]['id']}" for f in sorted(MAP_SEEN)) + "\n")
if unseen:
fh.write(f"\n{len(unseen):>6} map rows name no folder on the array: the\n")
fh.write(" pack moved, or the row carries a name the array never held.\n")
fh.write(" " + "; ".join(unseen[:300]) + "\n")
if MAP_CLASH:
fh.write(f"\n{len(MAP_CLASH):>6} folders carry a provider tvdb id the map\n")
fh.write(" contradicts. The map won: a box set's provider id is the\n")
fh.write(" server's guess at a folder name. Read each one:\n")
fh.write(" " + "; ".join(
f"{f} provider {p} map {m}"
for f, p, m in sorted(MAP_CLASH)[:300]) + "\n")
if unanswered:
fh.write(f"\n{len(unanswered):>6} map ids tvdb never answered, so those\n")
fh.write(" folders carry the id and the provider's own name; both\n")
fh.write(" matchers resolve by the id in the folder tag, so the next\n"
" re-plan takes tvdb's title. Nothing was invented.\n")
fh.write(" " + "; ".join(unanswered[:300]) + "\n")
print(f"exceptions -> {ARGS.exceptions}")