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:
1 parent
576c3421b8
commit
7aa32d1b64
5 files changed
+609
-16
No files matched your search
+110
-5
@@ -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}")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user