diff --git a/scripts/media-shows.py b/scripts/media-shows.py index ccee97c..9d55613 100755 --- a/scripts/media-shows.py +++ b/scripts/media-shows.py @@ -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//.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 diff --git a/scripts/media-titles.py b/scripts/media-titles.py index 79eb774..6b70192 100755 --- a/scripts/media-titles.py +++ b/scripts/media-titles.py @@ -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 diff --git a/tests/media_shows.bats b/tests/media_shows.bats index db5e349..fd48a5f 100755 --- a/tests/media_shows.bats +++ b/tests/media_shows.bats @@ -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//. + 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// 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")" \