feat(media): plan a TV relayout from the numbering Jellyfin already has #99

Merged
dank merged 4 commits from feat/media-tv-relayout into main 2026-10-30 23:38:36 -07:00
3 changed files with 141 additions and 64 deletions
Showing only changes of commit 337afac91e - Show all commits

No files matched your search

+104 -63
View File
@@ -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
+8 -1
View File
@@ -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
+29
View File
@@ -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")" \