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:
1 parent
5dbbfcb8a0
commit
c3ab565d9b
4 files changed
+463
-96
No files matched your search
+44
-23
@@ -21,11 +21,12 @@ scripts/media-plan.py and scripts/media-shows.py read them instead of touching m
|
||||
the titles Plex already matched — the authority over any filename guess
|
||||
|
||||
jellyfin file <TAB> series <TAB> year <TAB> season <TAB> episode <TAB> extra
|
||||
<TAB> tmdb (see JF_COLS)
|
||||
<TAB> tmdb <TAB> title <TAB> tvdb (see JF_COLS)
|
||||
one row per episode Jellyfin has matched and numbered, its path mapped
|
||||
from the container's view back to the host; `year` and `tmdb` are the
|
||||
SERIES' values, `extra` is Jellyfin's own extra type when the row is
|
||||
bonus material rather than an episode
|
||||
from the container's view back to the host; `year`, `tmdb` and `tvdb`
|
||||
are the SERIES' values, `title` is the episode title the row carries,
|
||||
`extra` is Jellyfin's own extra type when the row is bonus material
|
||||
rather than an episode
|
||||
|
||||
music file <TAB> artist <TAB> album <TAB> year <TAB> disc <TAB> track <TAB> title
|
||||
<TAB> mb <TAB> performer (see MUSIC_COLS)
|
||||
@@ -83,13 +84,14 @@ left join metadata_items par on par.id = mi.parent_id
|
||||
where p.file like ? and p.deleted_at is null and mi.deleted_at is null
|
||||
"""
|
||||
JF_DB = "/home/dank/Containers/jellyfin/config/data/jellyfin.db"
|
||||
JF_COLS = ["file", "series", "year", "season", "episode", "extra", "tmdb"]
|
||||
JF_COLS = ["file", "series", "year", "season", "episode", "extra",
|
||||
"tmdb", "title", "tvdb"]
|
||||
# Jellyfin stores the entity's .NET class as its type; the TV tree is these three.
|
||||
JF_TV = ("MediaBrowser.Controller.Entities.TV.Episode",
|
||||
"MediaBrowser.Controller.Entities.TV.Season",
|
||||
"MediaBrowser.Controller.Entities.TV.Series")
|
||||
JF_SQL = ("""select Id, Path, IndexNumber, ParentIndexNumber, ExtraType, IsVirtualItem
|
||||
from BaseItems where Type = ? and Path like ?""")
|
||||
JF_SQL = ("""select Id, Path, IndexNumber, ParentIndexNumber, ExtraType,
|
||||
IsVirtualItem, Name from BaseItems where Type = ? and Path like ?""")
|
||||
|
||||
# The music tree is these two entity classes. The track row carries the tags
|
||||
# Jellyfin read out of the file; the album row carries what the provider matched,
|
||||
@@ -254,9 +256,10 @@ def jellyfin(root, db, container, out):
|
||||
|
||||
The numbering is what matters, not the name: a release group's file name says
|
||||
nothing about which episode a file is, and Jellyfin has already answered that
|
||||
question against a provider. Season/episode come from the episode row; the
|
||||
series name, its year and its TMDb id come from the Series row the episode
|
||||
climbs to, because those are the only place Jellyfin keeps them.
|
||||
question against a provider. Season/episode and the episode's own title come
|
||||
from the episode row; the series name, its year and its TMDb and Tvdb ids come
|
||||
from the Series row the episode climbs to, because those are the only place
|
||||
Jellyfin keeps them.
|
||||
"""
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
@@ -277,21 +280,38 @@ def jellyfin(root, db, container, out):
|
||||
" where Type in (?,?,?)", JF_TV):
|
||||
node[i] = (pid, typ, name, year, data)
|
||||
|
||||
# Jellyfin 12 keeps provider ids in their own table; older builds kept them in
|
||||
# the row's Data blob. Both shapes are read, because the tvdb id the series
|
||||
# naming depends on is wherever this build happened to write it.
|
||||
prov = {}
|
||||
if "BaseItemProviders" in {r[0] for r in con.execute(
|
||||
"select name from sqlite_master where type='table'")}:
|
||||
for iid, pid, val in con.execute(
|
||||
"select ItemId, ProviderId, ProviderValue from BaseItemProviders"
|
||||
" where ProviderId in ('Tmdb', 'Tvdb')"):
|
||||
prov[(iid, pid)] = val
|
||||
|
||||
def provider_of(item_id, want):
|
||||
got = prov.get((item_id, want))
|
||||
if got:
|
||||
return str(got)
|
||||
try:
|
||||
blob = json.loads(node[item_id][4] or "{}").get("ProviderIds") or {}
|
||||
except (KeyError, ValueError, TypeError):
|
||||
return ""
|
||||
return str(blob.get(want, "") or "")
|
||||
|
||||
def series_of(item_id):
|
||||
"""Climb episode -> season -> series; empty when the chain is broken."""
|
||||
for _ in range(4):
|
||||
if item_id not in node:
|
||||
return "", "", ""
|
||||
pid, typ, name, year, data = node[item_id]
|
||||
return "", "", "", ""
|
||||
pid, typ = node[item_id][0], node[item_id][1]
|
||||
if typ.endswith(".Series"):
|
||||
try:
|
||||
tmdb = (json.loads(data or "{}")
|
||||
.get("ProviderIds") or {}).get("Tmdb", "")
|
||||
except ValueError:
|
||||
tmdb = ""
|
||||
return name or "", year or "", tmdb
|
||||
return (node[item_id][2] or "", node[item_id][3] or "",
|
||||
provider_of(item_id, "Tmdb"), provider_of(item_id, "Tvdb"))
|
||||
item_id = pid
|
||||
return "", "", ""
|
||||
return "", "", "", ""
|
||||
|
||||
# The fleet's quadlets bind the share at /data, so the container's view of
|
||||
# this library is /data/<the library's own folder name> unless told otherwise.
|
||||
@@ -302,17 +322,18 @@ def jellyfin(root, db, container, out):
|
||||
with open(out, "w", newline="") as fh:
|
||||
w = csv.writer(fh, delimiter="\t", lineterminator="\n")
|
||||
w.writerow(JF_COLS)
|
||||
for iid, path, idx, pidx, extra, virt in con.execute(JF_SQL,
|
||||
(JF_TV[0], prefix + "%")):
|
||||
for iid, path, idx, pidx, extra, virt, epname in con.execute(
|
||||
JF_SQL, (JF_TV[0], prefix + "%")):
|
||||
if virt or not path or not path.startswith(prefix):
|
||||
skipped += 1 # a virtual item is a variant, not a file
|
||||
continue
|
||||
sname, syear, tmdb = series_of(iid)
|
||||
sname, syear, tmdb, tvdb = series_of(iid)
|
||||
w.writerow([root + "/" + path[len(prefix):],
|
||||
sname.replace("\t", " "), syear,
|
||||
"" if pidx is None else pidx,
|
||||
"" if idx is None else idx,
|
||||
(extra or "").replace("\t", " "), tmdb])
|
||||
(extra or "").replace("\t", " "), tmdb,
|
||||
(epname or "").replace("\t", " "), tvdb])
|
||||
written += 1
|
||||
print(f"episodes={written} virtual-or-blank={skipped} -> {out}")
|
||||
|
||||
|
||||
+280
-34
@@ -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}")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user