fix(media): a TV plan that follows the authorities it was given
A dry run of the TV planner against the real 58k-file library produced a root
series called "01 NCOP" out of a bonus subfolder, copied Jellyfin's stale
numbering (every file under SNL 16-20/Season 16..19 carries season 16 episode
20; one Gundam row says season 2004), and lost subtitles a scene pack had
buried in Subs/<release-name>/.
- Folders now RESOLVE a series and never invent one: a folder's words match a
registered name as a token run ("(CBB) Tengen Toppa Gurren Lagann - The
Complete Series (BD 1080p)" names Tengen Toppa Gurren Lagann), and folder
words alone name nothing, so an unknown pack's bonus folder parks instead of
opening a new show at the library root.
- Numbering priority: the marker the file's own name states, then the Season NN
folder plus the number the name leads with inside it, then the provider row.
A row that still insists on season 1989 parks its file rather than creating
Season 1989.
- The provider row keeps identity: the registered name and its year survive a
file whose own marker numbers it, and the pack index a release leads with
("4a. Seed DESTINY") is folded out of the folder name.
- A sidecar searches every ancestor folder for the video it names, not just the
parent, so Subs/<release-name>/<release>.jpn.srt follows its episode.
- The shared bonus-folder test no longer reads an index as a title: name_words
drops "extras" as release junk, so "01. Extras" reduced to "01" and matched
the "01" of "NCOP 01", cancelling the bonus evidence. Only words can name a
film.
This commit is contained in:
1 parent
c0c77590b1
commit
337afac91e
3 files changed
+141
-64
No files matched your search
+104
-63
@@ -126,12 +126,6 @@ SERIES_META = re.compile(r"^(tvshow|series|show)(\.nfo|\.xml)?$"
|
||||
SEASON_META = re.compile(r"^season\.nfo$", re.I)
|
||||
FOLDER_IMAGE = re.compile(r"^folder\.(jpg|jpeg|png|webp|bmp)$", re.I)
|
||||
SEASON_DIR_RE = re.compile(r"(?i)^\s*(?:season[\s._-]*(\d{1,3})|s(\d{1,2}))\s*$")
|
||||
# Release words a folder name carries that are not part of the show's name.
|
||||
FOLDER_JUNK = re.compile(r"(?i)^(complete|collection|box|set|batch|pack|series|season"
|
||||
r"|multigroup|dvd|remux|uncut|extended|dual|audio|multi|dub"
|
||||
r"|dubbed|sub|subbed|internal|repack|proper|webrip)$")
|
||||
STOP_WORDS = {"the", "a", "an", "of", "and", "to", "in", "on", "for", "with", "de",
|
||||
"la", "le", "il", "des", "der", "das", "y", "e", "el", "les"}
|
||||
BAD_CHARS = re.compile(r'[<>:"/|?*]')
|
||||
# SxxEyy with an optional second episode, plus the 1x04 form. The second number
|
||||
# is guarded so a resolution never reads as an episode: "S01E01-1080p" is ONE
|
||||
@@ -185,25 +179,6 @@ def title_of(text):
|
||||
return eng.title_of(text + ".mkv")
|
||||
|
||||
|
||||
def folder_series(name):
|
||||
"""The show's own words in a release folder's name: bracketed junk, years,
|
||||
season stamps and release vocabulary all fall away."""
|
||||
text = re.sub(r"[\[(][^\])]*[\])]", " ", name)
|
||||
text = re.sub(r"(?i)\b(19|20)\d\d\b", " ", text)
|
||||
out = []
|
||||
for w in re.split(r"[.\s_\-\[\](){}+!~,;:&@#/*'\"`´|]+", text):
|
||||
c = eng.norm(re.sub(r"[^A-Za-z0-9']", "", w))
|
||||
if not c or FOLDER_JUNK.match(c) or eng.JUNK_RE.match(c) or eng.RES_RE.match(c) \
|
||||
or eng.folder_is_bonus(c):
|
||||
break
|
||||
if c.isdigit() and out:
|
||||
break
|
||||
out.append(w)
|
||||
while out and eng.norm(out[-1]) in STOP_WORDS:
|
||||
out.pop()
|
||||
return clean(" ".join(out)) if out else ""
|
||||
|
||||
|
||||
def markers(name):
|
||||
"""(season, [episodes], position) the episode markers a name states; None when
|
||||
it states none. The position is where the series' own words stop."""
|
||||
@@ -218,6 +193,31 @@ def markers(name):
|
||||
return None
|
||||
|
||||
|
||||
LEAD_IDX_RE = re.compile(r"^\s*(\d{1,3})\s*[-\u2013.\s]\s*[A-Za-z]")
|
||||
PACK_IDX_RE = re.compile(r"^\s*\d+[a-z]?[.)]\s+")
|
||||
|
||||
|
||||
def num(x):
|
||||
try:
|
||||
return int(x)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def folder_season(path):
|
||||
"""The season the FOLDER states (`Season 3`, `S3`). A human wrote it, and where
|
||||
a scraped row disagrees this is the one that survives a re-plan."""
|
||||
m = SEASON_DIR_RE.match(os.path.basename(os.path.dirname(path)))
|
||||
return int(next(g for g in m.groups() if g)) if m else None
|
||||
|
||||
|
||||
def leading_index(name):
|
||||
"""The number a file leads with inside a season folder — `01 Kyle MacLachlan,
|
||||
Sinead OC.avi` is episode 1. Both matchers read it that way."""
|
||||
m = LEAD_IDX_RE.match(os.path.splitext(name)[0])
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def tmdb(kind, query):
|
||||
"""A provider answer for a query: the cache first, TMDb once, then never again."""
|
||||
ck = f"{kind}:{query}"
|
||||
@@ -301,38 +301,53 @@ for d, rs in by_dir.items():
|
||||
dir_series[d] = names.most_common(1)[0][0]
|
||||
|
||||
|
||||
# A series folder that sits above a folder of provider rows names its own show, and
|
||||
# that is what keeps an already-filed library stable when it is re-planned: extras in
|
||||
# "The Examples (2010)/trailers" belong to "The Examples", not to a series invented
|
||||
# out of the word "trailers".
|
||||
# The registry: every series a provider names, judged before any folder is. Folder
|
||||
# words may resolve a file to one of these names; they may never invent one. That is
|
||||
# the whole difference between "NCOP 01.mkv" inside the Gurren Lagann pack, which the
|
||||
# pack names, and a new root folder called "01 NCOP".
|
||||
registry = {}
|
||||
for _p, _t in titles.items():
|
||||
if _t["series"]:
|
||||
registry.setdefault(eng.norm(_t["series"]), _t["series"])
|
||||
|
||||
|
||||
def known_in(text):
|
||||
"""The registered series named inside this text: the longest registered name
|
||||
whose words appear in it as a run. "(CBB) Tengen Toppa Gurren Lagann - The
|
||||
Complete Series (BD 1080p)" names "Tengen Toppa Gurren Lagann"."""
|
||||
hay = " " + eng.norm(text) + " "
|
||||
best = ""
|
||||
for k, name in registry.items():
|
||||
if len(k) >= 5 and f" {k} " in hay and len(k) > len(best):
|
||||
best = name
|
||||
return best
|
||||
|
||||
|
||||
# Rows also name their own folders' ancestors, so an already-filed library stays
|
||||
# where it is when it is re-planned.
|
||||
for d, name in sorted(dir_series.items()):
|
||||
a = os.path.dirname(d)
|
||||
while a and a != ROOT and len(a) > len(ROOT):
|
||||
if eng.norm(folder_series(os.path.basename(a))) == eng.norm(name):
|
||||
dir_series.setdefault(a, name)
|
||||
if a not in dir_series:
|
||||
got = known_in(os.path.basename(a))
|
||||
if got:
|
||||
dir_series[a] = got
|
||||
a = os.path.dirname(a)
|
||||
|
||||
|
||||
def series_of_dir(d):
|
||||
"""The series an ancestor folder belongs to, nearest first."""
|
||||
"""The series an ancestor folder belongs to, nearest first: the folder holding
|
||||
provider rows, else a folder whose own words name a registered show."""
|
||||
while d and d != ROOT and len(d) > len(ROOT):
|
||||
if d in dir_series:
|
||||
return dir_series[d]
|
||||
got = known_in(os.path.basename(d))
|
||||
if got:
|
||||
return got
|
||||
d = os.path.dirname(d)
|
||||
return ""
|
||||
|
||||
|
||||
def head_series(path):
|
||||
"""The folder evidence for a file's series: its own folder's words, then the
|
||||
top-level release folder's."""
|
||||
parts = rel(path).split("/")[:-1]
|
||||
for d in reversed(parts):
|
||||
name = folder_series(d)
|
||||
if name:
|
||||
return name
|
||||
return ""
|
||||
|
||||
|
||||
def extras_kind(path, extra_type):
|
||||
"""The canonical bonus subfolder, from Jellyfin's own extra type first."""
|
||||
if extra_type:
|
||||
@@ -358,10 +373,16 @@ films = [] # film-shaped, no episode evidence
|
||||
unknown = [] # (row, reason)
|
||||
|
||||
|
||||
def series_name(name):
|
||||
"""The provider's name as a folder: the pack index a scene release leads with
|
||||
("4a. Seed DESTINY") falls away, and the doubled spaces it leaves fold."""
|
||||
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."""
|
||||
k = eng.norm(name)
|
||||
s = series.setdefault(k, {"name": clean(name), "year": "", "tmdb": ""})
|
||||
s = series.setdefault(k, {"name": series_name(name), "year": "", "tmdb": ""})
|
||||
if year and not s["year"]:
|
||||
s["year"] = str(year)
|
||||
if tmdb_id and not s["tmdb"]:
|
||||
@@ -380,29 +401,41 @@ def as_extra(r, owner, extra_type):
|
||||
for r in video:
|
||||
p, t = r["path"], titles.get(r["path"])
|
||||
marked = markers(r["base"])
|
||||
fs = folder_season(p)
|
||||
if t and t["extra"]:
|
||||
as_extra(r, t["series"] or series_of_dir(os.path.dirname(p)) or head_series(p),
|
||||
t["extra"])
|
||||
as_extra(r, t["series"] or series_of_dir(os.path.dirname(p)), t["extra"])
|
||||
continue
|
||||
if t and t["series"] and t["season"] and t["episode"]:
|
||||
season, eps = int(t["season"]), [int(t["episode"])]
|
||||
# One file can hold two episodes; the row gives the first, the name the range.
|
||||
if marked and marked[0] == season and len(marked[1]) > 1:
|
||||
eps = marked[1]
|
||||
claimants.append((r, t["series"], season, eps))
|
||||
note(t["series"], t["year"], t["tmdb"])
|
||||
continue
|
||||
if marked:
|
||||
owner = (series_of_dir(os.path.dirname(p)) or head_series(p)
|
||||
row = bool(t and t["series"] and (t["season"] or t["episode"]))
|
||||
if marked or row:
|
||||
# The provider row names the SHOW: its registered name, its year. The number
|
||||
# that orders the file comes from the file's own name first, then the folder
|
||||
# it sits in, then the row — scraped pack rows number episodes badly, where
|
||||
# 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])
|
||||
if not eng.norm(owner).replace(" ", ""):
|
||||
unknown.append((r, "an episode marker with no series name to hang it on"))
|
||||
continue
|
||||
claimants.append((r, owner, marked[0], marked[1]))
|
||||
if marked:
|
||||
season, eps = marked[0], marked[1]
|
||||
else:
|
||||
season = fs if fs is not None else num(t["season"])
|
||||
ep = leading_index(r["base"]) or num(t["episode"])
|
||||
if not 0 < season <= 200 or ep <= 0:
|
||||
unknown.append((r, f"a provider row of season {season} episode {ep}"))
|
||||
continue
|
||||
eps = [ep]
|
||||
claimants.append((r, owner, season, eps))
|
||||
note(owner, t["year"] if t else "", t["tmdb"] 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)
|
||||
continue
|
||||
if eng.is_extra(p, title_of(r["base"])[0], dur.get(p, 0.0), ROOT):
|
||||
as_extra(r, series_of_dir(os.path.dirname(p)) or head_series(p), "")
|
||||
as_extra(r, owner, "")
|
||||
continue
|
||||
if dur.get(p, 0.0) >= SHORT_MAX or (p not in dur and title_of(r["base"])[1] != "?"):
|
||||
films.append(r)
|
||||
@@ -520,12 +553,13 @@ for src, dst in moved.items():
|
||||
|
||||
|
||||
def series_name_at(d):
|
||||
"""The series the files of a folder belong to, else the folder's own words."""
|
||||
"""The series the files of a folder belong to — a registered name, or nothing.
|
||||
Folder words alone never name a show."""
|
||||
names = [titles[src]["series"] for src, _ in video_dst.get(d, [])
|
||||
if src in titles and titles[src]["series"]]
|
||||
if names:
|
||||
return collections.Counter(names).most_common(1)[0][0]
|
||||
return series_of_dir(d) or head_series(d + "/x")
|
||||
return series_of_dir(d)
|
||||
|
||||
|
||||
def season_at(d):
|
||||
@@ -551,9 +585,12 @@ def folder_dst(d):
|
||||
|
||||
|
||||
def anchor_of(path, stem):
|
||||
"""The video file this sidecar names: the longest file name it extends, in its
|
||||
own folder first, then the parent's (a Subs/ or Extras/ folder beside them)."""
|
||||
for d in (os.path.dirname(path), os.path.dirname(os.path.dirname(path))):
|
||||
"""The video file this sidecar names: the longest file name it extends, in the
|
||||
NEAREST folder that holds one. A scene pack buries its subtitles two levels
|
||||
down (`Pack/Subs/<release-name>/<release>.jpn.srt`), so the search climbs to
|
||||
the library root instead of stopping at the parent."""
|
||||
d = os.path.dirname(path)
|
||||
while len(d) >= len(ROOT) and d.startswith(ROOT):
|
||||
best = None
|
||||
for src, dst in video_dst.get(d, []):
|
||||
base = os.path.basename(src)
|
||||
@@ -563,6 +600,10 @@ def anchor_of(path, stem):
|
||||
best = (src, dst, len(prefix))
|
||||
if best:
|
||||
return best[0], best[1]
|
||||
up = os.path.dirname(d)
|
||||
if up == d:
|
||||
break
|
||||
d = up
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -275,7 +275,14 @@ def is_extra(path, title="", dur=0.0, root=ROOT, matched=False):
|
||||
bonus_folder = False
|
||||
for folder in parts[head:-1]:
|
||||
if folder_is_bonus(folder):
|
||||
bonus_folder = not mentions(name_words(name), name_words(folder))
|
||||
# The folder's words are often the film's own name ("Angel's Egg
|
||||
# Extras/"), and then they are not bonus evidence. Only WORDS can name
|
||||
# a film: "extras" is release junk that name_words drops, so a folder
|
||||
# like "01. Extras" reduces to its index — and an index matching an
|
||||
# index ("NCOP 01") is a coincidence, not a title.
|
||||
nw = [w for w in name_words(name) if not w.isdigit()]
|
||||
fw = [w for w in name_words(folder) if not w.isdigit()]
|
||||
bonus_folder = not fw or not mentions(nw, fw)
|
||||
break
|
||||
if dur and dur > SHORT_MAX:
|
||||
# Runtime vetoes vocabulary, with ONE exception: an explicit bonus folder
|
||||
|
||||
@@ -55,6 +55,8 @@ setup() {
|
||||
at "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.mkv" 9000
|
||||
at "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.en.srt" 200
|
||||
at "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.mkv.forced.srt" 250
|
||||
# A scene pack buries its subtitles two levels down, in Subs/<release-name>/.
|
||||
at "$SEASON/Subs/The.Examples.S01E01.Pilot.1080p.x265/The.Examples.S01E01.Pilot.1080p.x265.jpn.srt" 220
|
||||
at "$SEASON/folder.jpg" 300
|
||||
at "$SEASON/Trailers/teaser.mkv" 1000
|
||||
at "$SEASON/tvshow.nfo" 400
|
||||
@@ -69,6 +71,12 @@ setup() {
|
||||
# A file no authority numbers, and a long file the movie provider knows.
|
||||
at "$LIB/Orphan Tapes/clip001.mp4" 3000
|
||||
at "$LIB/Longform/Some Documentary Special (2015).mkv" 8000
|
||||
# A bonus folder inside a release pack whose own name is not the show's. The
|
||||
# pack's rows name the show; the bonus folder may never invent a series.
|
||||
at "$ANIME/01. Extras/NCOP 01.mkv" 1200
|
||||
# A season folder whose rows are stale: the database numbers every file of this
|
||||
# pack season 16 episode 20, four seasons' worth of them.
|
||||
at "$LIB/SNL Pack/Season 19/01 Charles Barkley, Nirvana.avi" 6000
|
||||
# One episode-shaped file loose at the library root.
|
||||
at "$LIB/Loose.Show.S02E03.720p.WEB-DL.mkv" 4000
|
||||
|
||||
@@ -93,6 +101,8 @@ PY
|
||||
title_row "$LIB/Dupes/Show.Two.S01E01.720p.other.mkv" "Show Two" 2016 1 1
|
||||
title_row "$LIB/Dupes/Show.Two.S01E02-E03.720p.mkv" "Show Two" 2016 1 2
|
||||
title_row "$LIB/Loose.Show.S02E03.720p.WEB-DL.mkv" "Loose Show" 2019 2 3
|
||||
title_row "$LIB/SNL Pack/Season 19/01 Charles Barkley, Nirvana.avi" \
|
||||
"SNL Pack" "" 16 20
|
||||
}
|
||||
|
||||
plan() {
|
||||
@@ -144,6 +154,12 @@ PY
|
||||
= "$LIB/The Examples (2010)/Season 01/The Examples S01E01.en.srt" ]
|
||||
}
|
||||
|
||||
@test "a subtitle two folders down inside Subs/<release-name>/ still follows its episode" {
|
||||
plan
|
||||
[ "$(dst "$SEASON/Subs/The.Examples.S01E01.Pilot.1080p.x265/The.Examples.S01E01.Pilot.1080p.x265.jpn.srt")" \
|
||||
= "$LIB/The Examples (2010)/Season 01/The Examples S01E01.jpn.srt" ]
|
||||
}
|
||||
|
||||
@test "a subtitle that carries the container's word in its own name still follows the episode" {
|
||||
plan
|
||||
[ "$(dst "$SEASON/The.Examples.S01E01.Pilot.1080p.x265.mkv.forced.srt")" \
|
||||
@@ -168,6 +184,19 @@ PY
|
||||
= "$LIB/Anime Pack/Season 01/Anime Pack S01E05.mkv" ]
|
||||
}
|
||||
|
||||
@test "a bonus folder inside a release pack belongs to the pack's show, not to its own name" {
|
||||
plan
|
||||
[ "$(dst "$ANIME/01. Extras/NCOP 01.mkv")" = "$LIB/Anime Pack/extras/NCOP 01.mkv" ]
|
||||
[ -z "$(awk -F '\t' '$3 ~ /\/01\. Extras\// {print $3}' "$OUT")" ]
|
||||
}
|
||||
|
||||
@test "a Season folder outvotes the stale row its files carry" {
|
||||
plan
|
||||
[ "$(dst "$LIB/SNL Pack/Season 19/01 Charles Barkley, Nirvana.avi")" \
|
||||
= "$LIB/SNL Pack/Season 19/SNL Pack S19E01.avi" ]
|
||||
}
|
||||
|
||||
|
||||
@test "one file holding two episodes keeps both numbers in its name" {
|
||||
plan
|
||||
[ "$(dst "$LIB/Dupes/Show.Two.S01E02-E03.720p.mkv")" \
|
||||
|
||||
Reference in new issue
Block a user