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
@@ -0,0 +1,35 @@
|
|||||||
|
# The operator's tvdb id map: `folder <TAB> tvdb id <TAB> note`.
|
||||||
|
#
|
||||||
|
# These are the pack folders whose show tvdb's NAME SEARCH cannot reach: the
|
||||||
|
# record exists, but tvdb titles it in the show's own language and the English
|
||||||
|
# name rides in aliases the search never reads. Without a row here the folder
|
||||||
|
# keeps the release folder's words and no episode title; with one, both matchers
|
||||||
|
# read `[tvdbid-NNNN]` and the files rename to tvdb's own name and titles.
|
||||||
|
#
|
||||||
|
# Every id was read from tvdb's own record (`/series/<id>/extended`) on
|
||||||
|
# 2026-10-31, and the note states what that record says plus the neighbouring
|
||||||
|
# record a name search would have picked instead — the row is checkable without
|
||||||
|
# re-searching, because one wrong id renames a whole pack to the wrong show. The
|
||||||
|
# plan refuses a line whose id is not a number and lists every folder the map
|
||||||
|
# touched in the exceptions file.
|
||||||
|
#
|
||||||
|
# Folders the name search DOES reach (the EXACT and PUNCT classes of the proposal
|
||||||
|
# table) stay off this file on purpose: a map row would hide a live lookup behind
|
||||||
|
# a number nobody re-checks.
|
||||||
|
[Anime Time] Pokemon Complete Series (Season 01-25+Movies+Specials+Shorts+OST) [DVD+BD][1080p][HEVC 10bit x265][AAC] 76703 tvdb: ポケットモンスター (1997), the umbrella record for every era this pack spans; 1268 files
|
||||||
|
[Anime Time] One Piece (0001-1071+Movies+Specials) [BD+CR] [Dual Audio] [1080p][HEVC 10bit x265][AAC][Multi Sub] 81797 tvdb: ワンピース (1999), alias ONE PIECE; the bare alias "One Piece" belongs to 392276, the 2023 live action; 1072 files
|
||||||
|
[Judas] Naruto (Complete Series) [BD 1080p][HEVC x265 10bit][Multi-Audio][Multi-Subs] 78857 tvdb: NARUTO-ナルト- (2002), alias "Naruto" carries the full-width dashes; 220 files
|
||||||
|
Star Trek Deep Space 9 Deluxe DVD Boxset + Extras DVDRip TSV 72073 tvdb: Star Trek: Deep Space Nine (1993), alias "Star Trek Deep Space 9"; the box set's own words match nothing; 279 files
|
||||||
|
Top Gear UK 1-17 74608 tvdb: Top Gear (2002), no "UK" alias — the US version is 199051; 131 files
|
||||||
|
Black Clover 001 331753 tvdb: ブラッククローバー (2017), which holds NO alias at all; 378762 is Mugyutto! Black Clover (2019), a different show; 108 files
|
||||||
|
Shin Chan - Complete Eng Dub Series 2160p Ai Upscaled -Mesc 437356 tvdb: Shin Chan (2006) — the English dub record this pack is; 79654 is the 1992 Japanese original; 78 files
|
||||||
|
Fairy Tail (2009-2014) (Seasons 1-8 + OVAs) (BD 1080p)(HEVC x265 10bit)(Dual-Audio)(Eng-Subs)-Judas[TGx] 114801 tvdb: フェアリーテイル (2009), alias FAIRY TAIL; 410031 is FAIRY TAIL 100 NEN QUEST (2024), the sequel; 59 files
|
||||||
|
(CBB) Tokyo Ghoul - The Complete Series (BD 1080p)(Dual-Audio)(HEVC-10bit-x265) 281249 tvdb: 東京喰種トーキョーグール (2014) — :re and √A are seasons of THIS record, confirmed by search; 58 files
|
||||||
|
[Anime Time] Demon Slayer - Kimetsu no Yaiba (S01+S02+S03+S04+Mugen Train Arc+Movie) [BD] [Dual Audio][1080p][HEVC 10bit x265][AAC][Eng Sub] 348545 tvdb: 鬼滅の刃 (2019), alias "Demon Slayer: Kimetsu no Yaiba" — the folder's hyphen splits it differently; 56 files
|
||||||
|
(CBB) Tengen Toppa Gurren Lagann - The Complete Series (BD 1080p)(Dual-Audio)(HEVC-x265-10bit) 80096 tvdb: 天元突破グレンラガン (2007), alias Tengen Toppa Gurren Lagann; 51 files
|
||||||
|
Fullmetal Alchemist - Brotherhood 85249 tvdb: 鋼の錬金術師 FULLMETAL ALCHEMIST (2009), alias "Full Metal Alchemist Brotherhood" — spaced, unlike this folder; 64 files
|
||||||
|
[NewbSubs] Attack on Titan~Shingeki no Kyojin Series (1080p Blu-ray x265 Dual Audio) 267440 tvdb: 進撃の巨人 (2013), aliases carry "Attack on Titan" and "Shingeki no Kyojin", joined here by a tilde; 27 files
|
||||||
|
Kimetsu no Yaiba 348545 the same record as the Anime Time pack above: two release folders, ONE show; 12 files
|
||||||
|
Rick and Morty The Anime 420279 tvdb: リック・アンド・モーティ:ジ・アニメ (2024), no English alias on the record; 10 files
|
||||||
|
[Anime Time] Dorohedoro (Season 1+OVA+Extra) [Dual Audio][BD][1080p][HEVC 10bit x265][AAC][Eng Sub] 370761 tvdb: ドロヘドロ (2020), alias Dorohedoro; 9 files
|
||||||
|
Ken Il Guerriero HOKUTO NO KEN 79156 tvdb: 北斗の拳 (1984), alias "Hokuto no Ken"; the Italian title matches nothing and 465216 is the 2026 remake; 5 files
|
||||||
|
Can't render this file because it contains an unexpected character in line 20 and column 182.
|
Executable
+306
@@ -0,0 +1,306 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
r"""Ask tvdb what it calls the shows a plan renamed WITHOUT a tvdb id.
|
||||||
|
|
||||||
|
media-shows-tvdb.py [--plan TSV] [--root DIR] [--park DIR] [--map TSV]
|
||||||
|
[--out TSV] [--credentials FILE]
|
||||||
|
|
||||||
|
`media-shows.py` renames a series to tvdb's own words only when an id backs the
|
||||||
|
name; everything else keeps the metadata provider's words and no episode title,
|
||||||
|
and the plan's mkdir rows are where those folders show up. This script turns that
|
||||||
|
list into a decision sheet: one row per destination series folder that carries no
|
||||||
|
`[tvdbid-NNNN]`, with what tvdb's search answers for it and a verdict that says
|
||||||
|
HOW MUCH was guessed to get there.
|
||||||
|
|
||||||
|
It changes nothing. The sheet is for the operator; the file that changes
|
||||||
|
behaviour is `scripts/media-shows-tvdb-ids.tsv`, whose rows he approves. That is
|
||||||
|
the loop: plan -> this sheet -> approved map rows -> re-plan.
|
||||||
|
|
||||||
|
Verdicts, weakest last:
|
||||||
|
|
||||||
|
EXACT tvdb's own name equals the folder's words as they stand.
|
||||||
|
PUNCT it equals them only after release junk is stripped (pack numbers,
|
||||||
|
codecs, group names, `Complete Series`); the difference is spelling,
|
||||||
|
not identity.
|
||||||
|
NEAR tvdb answered something whose name is NOT the query. The best hit is
|
||||||
|
printed so a human can judge it, and it is never applied by anything.
|
||||||
|
NONE nothing left to ask with, or no answer at all.
|
||||||
|
|
||||||
|
Acceptance is deliberately unforgiving — a hit counts only when tvdb's name
|
||||||
|
equals the query with every non-alphanumeric dropped from both sides, and the
|
||||||
|
series' year still has to fall inside any year span the folder names. The
|
||||||
|
cleaner is the only thing that got smarter over the passes this tool replaces;
|
||||||
|
the acceptance test never moved, because a wrong id renames a whole pack to the
|
||||||
|
wrong show. A query that needs the cleaner is reported as PUNCT for exactly that
|
||||||
|
reason: the machine stripped words, so a human reads the row.
|
||||||
|
|
||||||
|
Read-only against tvdb. The credential file is the planner's own
|
||||||
|
(`api_key=`, optional `pin=`), read here and never printed.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import collections
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ARGS = argparse.ArgumentParser(description=__doc__.splitlines()[0],
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ARGS.add_argument("--plan", default="/tmp/tv-plan.tsv",
|
||||||
|
help="the plan media-shows.py just wrote (its mkdir rows are "
|
||||||
|
"the folders this sheet asks about)")
|
||||||
|
ARGS.add_argument("--root", default=os.environ.get("TV_ROOT", "/mnt/entropy/TVShows"),
|
||||||
|
help="the TV library the plan renames")
|
||||||
|
ARGS.add_argument("--park", default="",
|
||||||
|
help="the review pile inside it (its folders are not shows)")
|
||||||
|
ARGS.add_argument("--map",
|
||||||
|
default=os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"media-shows-tvdb-ids.tsv"),
|
||||||
|
help="the operator's approved map; its folders are reported as "
|
||||||
|
"MAPPED and never asked again")
|
||||||
|
ARGS.add_argument("--credentials",
|
||||||
|
default=os.path.expanduser("~/.config/secrets/tvdb"),
|
||||||
|
help="file with an api_key= line (and a pin= for a user-issued "
|
||||||
|
"key); mode 600, and never printed")
|
||||||
|
ARGS.add_argument("--out", default="/tmp/tvdb-proposal.tsv",
|
||||||
|
help="the decision sheet")
|
||||||
|
ARGS = ARGS.parse_args()
|
||||||
|
|
||||||
|
ROOT = os.path.abspath(ARGS.root)
|
||||||
|
PARK = os.path.abspath(ARGS.park or os.path.join(ROOT, "_unsorted"))
|
||||||
|
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
"eng", os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"media-titles.py"))
|
||||||
|
eng = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(eng)
|
||||||
|
|
||||||
|
# Release metadata, not the show: everything a scene folder name says about the
|
||||||
|
# ENCODING, the packaging or the languages, which tvdb has never heard of.
|
||||||
|
JUNK = re.compile(
|
||||||
|
r"complete|series|dual|multi|audio|sub|subs|multisub|dualaudio|"
|
||||||
|
r"\d{3,4}p|1080p|720p|2160p|480p|4k|hd|hdtv|cam|hdcam|telesync|x264|x265|hevc|avc|"
|
||||||
|
r"\d+bit|bit|bluray|blu-?ray|bdrip|web-?rip|web-?dl|webrip|hdtvrip|"
|
||||||
|
r"dvdrip|dvdr|dvdscr|tsr|tsv|mkv|avi|mp4|aac|ac3|dts|eac3|flac|ddp?5[.1]1|"
|
||||||
|
r"bd|dvd|rip|boxset|box|season|seasons|episodes?|specials?|shorts|ost|"
|
||||||
|
r"ova|ovas|oad|repack|proper|internal|mi?xed|budgetbits|deluxe|remux|"
|
||||||
|
r"remastered|uncut|collector|edition|extras?|volume|volumes|part|"
|
||||||
|
r"disc|disk|cd|ntsc|pal|region|fixed|patched|proof|english|esp|"
|
||||||
|
r"espagnol|french|vostfr|subtitul\w*|spanish|german|italian|ita|eng|fre|spa|lat|"
|
||||||
|
r"upscaled?|ai|dd|movie|movies",
|
||||||
|
re.I)
|
||||||
|
GROUP = re.compile(r"KONTRAST|TGx|Judas|Silence|FreetheFilms|NTb|DEADPOOL|Xploit|"
|
||||||
|
r"SM737|TSA|3OISL3R|PLAYWEB|EPiC|FLUX|HONEY|Zaxton|Cca3rW|"
|
||||||
|
r"monikeres|frank|hdh|psi|rtfpemaker|cbm|web|asser|astro|"
|
||||||
|
r"yfn|uindex|www|org|com|net|mesc|ataraxiaprime|seph\d*|"
|
||||||
|
r"[a-z]+subs?|edge", re.I)
|
||||||
|
BRACKET = re.compile(r"[\[({]([^\])}]*)[\])}]")
|
||||||
|
NUM_RANGE = re.compile(r"(?<!\w)\d{1,4}\s*(?:-|–|to|through)\s*\d{1,4}(?!\w)", re.I)
|
||||||
|
INSIDE = re.compile(r"all|complete|full|dual|multi|audio|sub|subs|ova|ovas|"
|
||||||
|
r"movies?|episodes?|extras?|bonus|pack|remux|region\s*\d*|"
|
||||||
|
r"bd|dvd|cd|ntsc|pal|hr-?dvd", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def drop_bracket(m):
|
||||||
|
"""A bracket says nothing when its words are junk; keep it when they aren't."""
|
||||||
|
toks = [t for t in re.split(r"[\s_.,+~&]+", m.group(1)) if t]
|
||||||
|
if not toks or all(INSIDE.fullmatch(t) or JUNK.fullmatch(t)
|
||||||
|
or GROUP.fullmatch(t)
|
||||||
|
or re.fullmatch(r"\d+[a-z]?", t, re.I)
|
||||||
|
or re.fullmatch(r"(19|20)\d\d", t) for t in toks):
|
||||||
|
return " "
|
||||||
|
return " " + m.group(1) + " "
|
||||||
|
|
||||||
|
|
||||||
|
def clean(name):
|
||||||
|
"""The folder's words with everything about the RELEASE taken out, so what is
|
||||||
|
left is what a person would type into tvdb. `Star Trek Deep Space 9 Deluxe
|
||||||
|
DVD Boxset + Extras DVDRip TSV` becomes `Star Trek Deep Space 9`; `SNL 16-20`
|
||||||
|
becomes `SNL`."""
|
||||||
|
name = re.sub(r"^\s*www\b.*?\b(org|com|net)\b\s*[-–]\s*", " ", name, flags=re.I)
|
||||||
|
name = re.sub(r"\bS\d{1,2}([ .Ee]\d+)?[-–Ee\d.]*", " ", name)
|
||||||
|
name = re.sub(r"\b\d{1,2}[xX]\d{1,3}\b", " ", name)
|
||||||
|
name = NUM_RANGE.sub(" ", name)
|
||||||
|
name = re.sub(r"(19|20)\d\d(?:\s*[-/–]\s*(?:19|20)\d\d)?", " ", name)
|
||||||
|
name = re.sub(r"\bby\s+[A-Za-z0-9_.-]+$", " ", name)
|
||||||
|
name = BRACKET.sub(drop_bracket, name)
|
||||||
|
name = re.sub(r"[\[()\]:,;!?*]", " ", name)
|
||||||
|
toks = re.split(r"[\s_.\-+~&]+", name)
|
||||||
|
out = []
|
||||||
|
for i, t in enumerate(toks):
|
||||||
|
if not t or JUNK.fullmatch(t) or GROUP.fullmatch(t):
|
||||||
|
continue
|
||||||
|
if re.fullmatch(r"\d+", t):
|
||||||
|
after = sum(1 for x in toks[i + 1:] if x
|
||||||
|
and not JUNK.fullmatch(x) and not GROUP.fullmatch(x)
|
||||||
|
and not re.fullmatch(r"\d+", x))
|
||||||
|
# A pack index (`001`) and a lead-in number over a real title
|
||||||
|
# (`01 Charles Barkley`) are noise; `30 Rock`, `24`, `Deep Space 9`
|
||||||
|
# are the title itself.
|
||||||
|
if len(t) >= 3 or (not out and after >= 2):
|
||||||
|
continue
|
||||||
|
out.append(t)
|
||||||
|
while out and out[-1].lower() in ("and", "with", "the", "a", "an", "to", "of"):
|
||||||
|
out.pop()
|
||||||
|
return " ".join(out).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def years(name):
|
||||||
|
return [int(y) for y in re.findall(r"\b(19\d\d|20\d\d)\b", name)]
|
||||||
|
|
||||||
|
|
||||||
|
def bare(s):
|
||||||
|
return re.sub(r"[^a-z0-9]", "", s.lower())
|
||||||
|
|
||||||
|
|
||||||
|
CRED = {}
|
||||||
|
try:
|
||||||
|
for _line in open(ARGS.credentials):
|
||||||
|
if "=" in _line:
|
||||||
|
k, v = _line.strip().split("=", 1)
|
||||||
|
CRED[k.strip()] = v.strip()
|
||||||
|
except OSError:
|
||||||
|
print(f"tvdb: no credentials at {ARGS.credentials} — the sheet would be all "
|
||||||
|
"NEAR and NONE, which is not a decision sheet", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
if not CRED.get("api_key"):
|
||||||
|
print(f"tvdb: no api_key in {ARGS.credentials}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
TOKEN = {"jwt": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def call(path):
|
||||||
|
"""One tvdb v4 GET, logging in again once when the JWT expires mid-run."""
|
||||||
|
for attempt in (1, 2):
|
||||||
|
if not TOKEN["jwt"]:
|
||||||
|
body = {"apiKey": CRED["api_key"]}
|
||||||
|
if CRED.get("pin"):
|
||||||
|
body["token"] = CRED["pin"]
|
||||||
|
req = urllib.request.Request("https://api4.thetvdb.com/v4/login",
|
||||||
|
data=json.dumps(body).encode(),
|
||||||
|
headers={"Content-Type":
|
||||||
|
"application/json"})
|
||||||
|
TOKEN["jwt"] = json.load(
|
||||||
|
urllib.request.urlopen(req, timeout=30))["data"]["token"]
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request("https://api4.thetvdb.com/v4" + path,
|
||||||
|
headers={"Authorization":
|
||||||
|
"Bearer " + TOKEN["jwt"]})
|
||||||
|
return json.load(urllib.request.urlopen(req, timeout=30))["data"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 401 and attempt == 1:
|
||||||
|
TOKEN["jwt"] = ""
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
MAPPED = {}
|
||||||
|
if ARGS.map and os.path.exists(ARGS.map):
|
||||||
|
for _line in open(ARGS.map):
|
||||||
|
_line = _line.rstrip("\n")
|
||||||
|
if not _line.strip() or _line.startswith("#"):
|
||||||
|
continue
|
||||||
|
cols = _line.split("\t")
|
||||||
|
if len(cols) > 1 and cols[1].strip().isdigit():
|
||||||
|
MAPPED[cols[0].strip()] = cols[1].strip()
|
||||||
|
|
||||||
|
# The folders the plan created inside the library, with the file count each one
|
||||||
|
# will hold. The review pile is not a show: its files belong to no identity, and
|
||||||
|
# naming the pile would put one junk series on the sheet for all of them.
|
||||||
|
shows = collections.Counter()
|
||||||
|
for line in open(ARGS.plan).read().splitlines()[1:]:
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) < 4 or parts[0] != "mkdir":
|
||||||
|
continue
|
||||||
|
dst = os.path.abspath(parts[2])
|
||||||
|
if not dst.startswith(ROOT + os.sep) or dst.startswith(PARK + os.sep):
|
||||||
|
continue
|
||||||
|
top = os.path.relpath(dst, ROOT).split(os.sep)[0]
|
||||||
|
if top.startswith((".", "_")):
|
||||||
|
continue
|
||||||
|
if "[tvdbid-" in top:
|
||||||
|
continue # an id already backs it
|
||||||
|
shows.setdefault(top, 0)
|
||||||
|
for line in open(ARGS.plan).read().splitlines()[1:]:
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) < 4 or parts[0] != "move":
|
||||||
|
continue
|
||||||
|
dst = os.path.abspath(parts[2])
|
||||||
|
if not dst.startswith(ROOT + os.sep) or dst.startswith(PARK + os.sep):
|
||||||
|
continue
|
||||||
|
top = os.path.relpath(dst, ROOT).split(os.sep)[0]
|
||||||
|
if "[tvdbid-" in top:
|
||||||
|
continue
|
||||||
|
shows[top] += 1
|
||||||
|
|
||||||
|
tally = collections.Counter()
|
||||||
|
rows = []
|
||||||
|
for folder in sorted(shows, key=lambda f: (-shows[f], f)):
|
||||||
|
files, tag, note = shows[folder], "NONE", ""
|
||||||
|
if folder in MAPPED:
|
||||||
|
rows.append([files, "MAPPED", folder, "", "", "", MAPPED[folder],
|
||||||
|
"the operator's map already states this id"])
|
||||||
|
tally["MAPPED"] += 1
|
||||||
|
continue
|
||||||
|
query, untouched = clean(folder), clean(folder) == folder
|
||||||
|
ys = years(folder)
|
||||||
|
if not query:
|
||||||
|
rows.append([files, "NONE", folder, "", "", "", "",
|
||||||
|
"nothing is left of the folder's name once release junk"
|
||||||
|
" is gone"])
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
hits = call("/search?query=" + urllib.parse.quote(query)
|
||||||
|
+ "&type=series") or []
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
hits = []
|
||||||
|
note = f"tvdb did not answer: {e}"
|
||||||
|
want = bare(query)
|
||||||
|
pick = None
|
||||||
|
for h in hits:
|
||||||
|
if bare(h.get("name") or "") != want:
|
||||||
|
continue
|
||||||
|
hy = str(h.get("year") or "")
|
||||||
|
if ys and hy.isdigit() and not (min(ys) <= int(hy) <= max(ys)):
|
||||||
|
continue
|
||||||
|
pick = h
|
||||||
|
break
|
||||||
|
if pick:
|
||||||
|
tag = "EXACT" if untouched else "PUNCT"
|
||||||
|
rows.append([files, tag, folder, query, pick.get("name", ""),
|
||||||
|
str(pick.get("year", "") or ""),
|
||||||
|
str(pick.get("tvdb_id", "") or ""), ""])
|
||||||
|
elif hits:
|
||||||
|
best = hits[0]
|
||||||
|
rows.append([files, "NEAR", folder, query, best.get("name", ""),
|
||||||
|
str(best.get("year", "") or ""),
|
||||||
|
str(best.get("tvdb_id", "") or ""),
|
||||||
|
f"{len(hits)} answer(s); tvdb's own name is not the "
|
||||||
|
"query, so nothing would take it"])
|
||||||
|
else:
|
||||||
|
rows.append([files, "NONE", folder, query, "", "", "",
|
||||||
|
note or "tvdb has no series by that name"])
|
||||||
|
tally[rows[-1][1]] += 1
|
||||||
|
time.sleep(0.12)
|
||||||
|
|
||||||
|
# Two folders mapping to one record is not two shows; it is one show the release
|
||||||
|
# folders split. Say it on every row involved, or the operator approves the same
|
||||||
|
# id twice and reads the second as a mistake.
|
||||||
|
by_id = collections.Counter(r[6] for r in rows if r[6] and r[1] != "MAPPED")
|
||||||
|
for r in rows:
|
||||||
|
if r[6] and r[1] != "MAPPED" and by_id[r[6]] > 1:
|
||||||
|
r[7] = (f"MERGE: {by_id[r[6]]} folders map to tvdb {r[6]} — one show, "
|
||||||
|
"the releases split it; " + r[7]).strip("; ")
|
||||||
|
|
||||||
|
with open(ARGS.out, "w") as fh:
|
||||||
|
fh.write("files\tverdict\tfolder\tquery\ttvdb_name\ttvdb_year\ttvdb_id\tnote\n")
|
||||||
|
for r in rows:
|
||||||
|
fh.write("\t".join(str(c) for c in r) + "\n")
|
||||||
|
print(f"sheet: {len(rows)} folders"
|
||||||
|
+ "".join(f" {t}={n}" for t, n in sorted(tally.items()))
|
||||||
|
+ f" -> {ARGS.out}")
|
||||||
+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]
|
media-shows.py [--root DIR] [--enum TSV] [--titles TSV] [--probed TSV]
|
||||||
[--tmdb-cache JSON] [--tmdb] [--movies DIR] [--park DIR]
|
[--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
|
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
|
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
|
— 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.
|
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
|
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
|
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.
|
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"),
|
default=os.path.expanduser("~/.config/secrets/tvdb"),
|
||||||
help="file with an api_key= line (and a pin= line for a "
|
help="file with an api_key= line (and a pin= line for a "
|
||||||
"user-issued key); mode 600, and never the cache")
|
"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",
|
ARGS.add_argument("--movies", default="/mnt/entropy/Movies",
|
||||||
help="movie library a mis-filed film moves into")
|
help="movie library a mis-filed film moves into")
|
||||||
ARGS.add_argument("--park", default="",
|
ARGS.add_argument("--park", default="",
|
||||||
@@ -611,6 +629,63 @@ def extras_kind(path, extra_type):
|
|||||||
return "extras"
|
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
|
# ----------------------------------------------------------- classify every file
|
||||||
series = {} # norm(name) -> {name, year, tmdb}
|
series = {} # norm(name) -> {name, year, tmdb}
|
||||||
claimants = [] # (row, name, season, [episodes])
|
claimants = [] # (row, name, season, [episodes])
|
||||||
@@ -644,7 +719,7 @@ def as_extra(r, owner, extra_type):
|
|||||||
if not owner:
|
if not owner:
|
||||||
unknown.append((r, "bonus material with no series to own it"))
|
unknown.append((r, "bonus material with no series to own it"))
|
||||||
return
|
return
|
||||||
note(owner)
|
note(owner, tvdb_id=map_id_for(r["path"], ""))
|
||||||
extras.append((r, owner, extras_kind(r["path"], extra_type)))
|
extras.append((r, owner, extras_kind(r["path"], extra_type)))
|
||||||
|
|
||||||
for r in video:
|
for r in video:
|
||||||
@@ -686,12 +761,12 @@ for r in video:
|
|||||||
eps = [ep]
|
eps = [ep]
|
||||||
claimants.append((r, owner, season, eps))
|
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 "")
|
map_id_for(r["path"], t["tvdb"] if t else ""))
|
||||||
continue
|
continue
|
||||||
owner = series_of_dir(os.path.dirname(p))
|
owner = series_of_dir(os.path.dirname(p))
|
||||||
if fs is not None and owner and leading_index(r["base"]):
|
if fs is not None and owner and leading_index(r["base"]):
|
||||||
claimants.append((r, owner, fs, [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
|
continue
|
||||||
if eng.is_extra(p, title_of(r["base"])[0], dur.get(p, 0.0), ROOT):
|
if eng.is_extra(p, title_of(r["base"])[0], dur.get(p, 0.0), ROOT):
|
||||||
as_extra(r, owner, "")
|
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)
|
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"
|
print(f"plan: {len(moves) - len(parked)} moves, {len(parked)} parked"
|
||||||
f" ({sized / 2**40:.2f} TiB touched), {len(keeps)} already placed,"
|
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():
|
def write_exceptions():
|
||||||
@@ -1123,6 +1198,36 @@ def write_exceptions():
|
|||||||
fh.write(" " + "; ".join(
|
fh.write(" " + "; ".join(
|
||||||
f"{n} S{'/'.join(f'{x:02d}' for x in ss)}"
|
f"{n} S{'/'.join(f'{x:02d}' for x in ss)}"
|
||||||
for n, ss in clash[:300]) + "\n")
|
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}")
|
print(f"exceptions -> {ARGS.exceptions}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -301,17 +301,35 @@ visible series folder that real files get renamed into. `--park` outside the roo
|
|||||||
makes the next plan re-file the pile instead of skipping it. Inside a hidden
|
makes the next plan re-file the pile instead of skipping it. Inside a hidden
|
||||||
top-level folder a file's own words may MATCH a registered show, never invent one.
|
top-level folder a file's own words may MATCH a registered show, never invent one.
|
||||||
|
|
||||||
Release-group folder names are the one thing no provider can read, so the
|
**The operator's id map is the door for a name tvdb cannot be searched by.**
|
||||||
pending TVShows naming plan files 179 top-level directories (9,931 files) under
|
`scripts/media-shows.py` reads `scripts/media-shows-tvdb-ids.tsv` by default: one
|
||||||
the junk name they arrived with. A TVDB id map can name them, under the ladder's
|
line per pack folder, `folder <TAB> tvdb id <TAB> note`. The file exists because
|
||||||
own acceptance rule: a hit counts only when TVDB's spelling equals the cleaned
|
tvdb titles a record in the show's own language and the English name rides in
|
||||||
query, or differs from it by punctuation alone — the same letters are not a
|
*aliases* the name search never reads: `ブラッククローバー` (331753) answers to no
|
||||||
guess. Everything else is a choice for Frank, and the map is never applied
|
spelling of "Black Clover", and the bare alias `One Piece` belongs to 392276, the
|
||||||
unreviewed: `/mnt/entropy/media-relayout-shows-tvdb-proposal.tsv` carries one
|
2023 live action, not to the 1999 record (81797) the pack here is. The map
|
||||||
row per folder with `verdict` = `EXACT` / `PUNCT` / `NEAR` / `NONE` and a `note`
|
outranks a provider id — a box set's provider id is the server's own guess at a
|
||||||
naming the two traps — a folder marked `(US)` that matches the UK original, and
|
folder name — and it merges the release folders that are one show. Everything it
|
||||||
several folders mapping to one id, which is a MERGE whose duplicate episodes the
|
touched is written to the exceptions: every folder it named, every row that
|
||||||
executor will refuse until a keeper is chosen.
|
matched no folder, every disagreement with a provider id, every id tvdb never
|
||||||
|
answered. A line whose id is not a number stops the plan, because a typo renames
|
||||||
|
a whole pack to the wrong show. Only folders the search CANNOT reach belong in
|
||||||
|
it; a row for a folder the search would resolve hides a live lookup behind a
|
||||||
|
number nobody re-checks.
|
||||||
|
|
||||||
|
`scripts/media-shows-tvdb.py` makes the sheet the map is built from. It reads a
|
||||||
|
plan's `mkdir` rows, asks tvdb about every destination folder carrying no
|
||||||
|
`[tvdbid-`, and writes one row per folder with the verdict that says how much was
|
||||||
|
guessed to get there: `EXACT` (tvdb's own spelling), `PUNCT` (equal only after
|
||||||
|
release junk is stripped from the folder's words), `NEAR` (tvdb answered
|
||||||
|
something else — the best hit is printed for a human and nothing applies it),
|
||||||
|
`NONE`. Acceptance never moved across the passes that built this: a hit counts
|
||||||
|
only when tvdb's name equals the query with every non-alphanumeric dropped from
|
||||||
|
both sides, and the record's year still has to fall inside any year span the
|
||||||
|
folder names. That gate is what caught 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. It is why the map is approved by hand
|
||||||
|
and the sheet is never fed to the planner.
|
||||||
|
|
||||||
## The music and audiobook arms
|
## The music and audiobook arms
|
||||||
|
|
||||||
|
|||||||
@@ -566,3 +566,132 @@ PY
|
|||||||
run grep -i "numbering tvdb contradicts" "$EXC"
|
run grep -i "numbering tvdb contradicts" "$EXC"
|
||||||
[ "$status" -ne 0 ]
|
[ "$status" -ne 0 ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- the operator map
|
||||||
|
# Most of this library carries no tvdb id anywhere: the authority titles anime by
|
||||||
|
# its own record — in the show's language — and the English name rides in aliases
|
||||||
|
# the planner's name search never reads, so a search that demands tvdb's own
|
||||||
|
# spelling cannot reach it. The operator's map is the other door: one line per
|
||||||
|
# folder, `folder <TAB> tvdb id <TAB> note`, each id checked by hand against the
|
||||||
|
# record it names. It outranks a provider id, it merges the release folders that
|
||||||
|
# are one show, and every folder it touched is named in the exceptions: one wrong
|
||||||
|
# row there is 1,268 renames to the wrong show, so nothing about it is silent.
|
||||||
|
|
||||||
|
plan_map() { # the plan, with the operator's map in hand
|
||||||
|
run python3 "$PLAN" --root "$LIB" --enum "$ENUM" --titles "$TITLES" \
|
||||||
|
--probed "$PROBED" --tmdb-cache "$CACHE" --movies "$MOVIES" \
|
||||||
|
--tvdb-ids "$1" --out "$OUT" --exceptions "$EXC"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a folder the operator mapped to a tvdb id renames to that record" {
|
||||||
|
printf '{"tvdb:series:331753": %s}\n' \
|
||||||
|
'{"name": "Black Clover", "year": "2017"}' > "$CACHE"
|
||||||
|
{
|
||||||
|
printf '# the note column carries why the name search cannot reach it\n'
|
||||||
|
printf 'Black Clover 001\t331753\tthe record holds no alias\n'
|
||||||
|
} > "$BATS_TEST_TMPDIR/ids.tsv"
|
||||||
|
file "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" 5000
|
||||||
|
title_row "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" \
|
||||||
|
"Black Clover 001" "" 1 1
|
||||||
|
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
|
||||||
|
want="$LIB/Black Clover (2017) [tvdbid-331753]/Season 01"
|
||||||
|
want+="/Black Clover (2017) - S01E01.mkv"
|
||||||
|
[ "$(dst "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv")" = "$want" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "release folders one id maps are ONE series folder" {
|
||||||
|
printf '{"tvdb:series:81797": %s}\n' \
|
||||||
|
'{"name": "One Piece", "year": "1999"}' > "$CACHE"
|
||||||
|
{
|
||||||
|
printf '[Anime Time] One Piece (0001-1071)\t81797\tbare "One Piece" is the 2023 live action\n'
|
||||||
|
printf 'One Piece Ship Collection\t81797\tthe same record, split by release\n'
|
||||||
|
} > "$BATS_TEST_TMPDIR/ids.tsv"
|
||||||
|
file "$LIB/[Anime Time] One Piece (0001-1071)/One.Piece.0001.mkv" 5000
|
||||||
|
file "$LIB/One Piece Ship Collection/One.Piece.0002.mkv" 6000
|
||||||
|
title_row "$LIB/[Anime Time] One Piece (0001-1071)/One.Piece.0001.mkv" \
|
||||||
|
"One Piece" 1999 1 1
|
||||||
|
title_row "$LIB/One Piece Ship Collection/One.Piece.0002.mkv" \
|
||||||
|
"One Piece Ship Collection" "" 1 2
|
||||||
|
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
|
||||||
|
want="$LIB/One Piece (1999) [tvdbid-81797]/Season 01"
|
||||||
|
want+="/One Piece (1999) - S01E01.mkv"
|
||||||
|
[ "$(dst "$LIB/[Anime Time] One Piece (0001-1071)/One.Piece.0001.mkv")" \
|
||||||
|
= "$want" ]
|
||||||
|
want="$LIB/One Piece (1999) [tvdbid-81797]/Season 01"
|
||||||
|
want+="/One Piece (1999) - S01E02.mkv"
|
||||||
|
[ "$(dst "$LIB/One Piece Ship Collection/One.Piece.0002.mkv")" = "$want" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the map outranks a provider id and says which two disagreed" {
|
||||||
|
printf '{"tvdb:series:4242": %s, "tvdb:series:78850": %s}\n' \
|
||||||
|
'{"name": "Real Ship", "year": "1999"}' \
|
||||||
|
'{"name": "Box Junk", "year": "2016"}' > "$CACHE"
|
||||||
|
printf '%s\t4242\thand-verified against the record\n' \
|
||||||
|
"Show Box (2016) Season 1 S01 (1080p WEB-DL)" > "$BATS_TEST_TMPDIR/ids.tsv"
|
||||||
|
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
|
||||||
|
dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv" \
|
||||||
|
| grep -q "^$LIB/Real Ship (1999) \[tvdbid-4242\]/Season 01/"
|
||||||
|
run grep -E '78850.*4242|4242.*78850' "$EXC"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a map id the authority never answered keeps the provider's name and is named" {
|
||||||
|
printf 'Black Clover 001\t331753\ttvdb never answered\n' \
|
||||||
|
> "$BATS_TEST_TMPDIR/ids.tsv"
|
||||||
|
file "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" 5000
|
||||||
|
title_row "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" \
|
||||||
|
"Black Clover 001" "" 1 1
|
||||||
|
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
|
||||||
|
dst "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" \
|
||||||
|
| grep -q "^$LIB/Black Clover 001 \[tvdbid-331753\]/Season 01/"
|
||||||
|
run grep -q "331753" "$EXC"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a map row for a folder the array no longer holds is reported" {
|
||||||
|
printf 'Gone Pack\t4242\tmoved away\n' > "$BATS_TEST_TMPDIR/ids.tsv"
|
||||||
|
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
|
||||||
|
run grep "Gone Pack" "$EXC"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a map line whose id is not a number stops the plan" {
|
||||||
|
printf 'Black Clover 001\tnotanid\ttypo\n' > "$BATS_TEST_TMPDIR/ids.tsv"
|
||||||
|
file "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" 5000
|
||||||
|
title_row "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" \
|
||||||
|
"Black Clover 001" "" 1 1
|
||||||
|
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"notanid"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# The map the fleet ships is scripts/media-shows-tvdb-ids.tsv, and the planner
|
||||||
|
# reads it by default: an unusable line there stops every plan the operator runs
|
||||||
|
# from the checkout, so the seam validates the real file, not a fixture copy.
|
||||||
|
@test "the shipped operator map parses and never stops a plan" {
|
||||||
|
MAPS="$REPO_ROOT/scripts/media-shows-tvdb-ids.tsv"
|
||||||
|
run awk -F '\t' '!/^#/ && NF {
|
||||||
|
if (NF < 2 || $2 !~ /^[0-9]+$/) { print "unusable row: " $0; bad = 1 }
|
||||||
|
} END { exit bad }' "$MAPS"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
dup=$(awk -F '\t' '!/^#/ && NF {print $1}' "$MAPS" | sort | uniq -d)
|
||||||
|
[ -z "$dup" ]
|
||||||
|
plan_map "$MAPS"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
grep -qi "operator's map" "$EXC"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a map that names one folder twice stops the plan" {
|
||||||
|
printf '{"tvdb:series:78850": %s}\n' \
|
||||||
|
'{"name": "Naruto", "year": "2002"}' > "$CACHE"
|
||||||
|
{
|
||||||
|
printf 'Naruto Complete Series\t78850\tthe dub pack\n'
|
||||||
|
printf 'Naruto Complete Series\t331753\ta paste of another row\n'
|
||||||
|
} > "$BATS_TEST_TMPDIR/ids.tsv"
|
||||||
|
file "$LIB/Naruto Complete Series/Season 01/naruto 01.mkv" 5000
|
||||||
|
title_row "$LIB/Naruto Complete Series/Season 01/naruto 01.mkv" \
|
||||||
|
"Naruto Complete Series" "" 1 1
|
||||||
|
plan_map "$BATS_TEST_TMPDIR/ids.tsv"
|
||||||
|
[ "$status" -eq 2 ]
|
||||||
|
[[ "$output" == *"named twice"* ]]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user