aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/indexer/title_parse.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/title_parse.py100
1 files changed, 83 insertions, 17 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
index 24c23bc..018746b 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
@@ -52,14 +52,8 @@ _SPECIALS_RE = re.compile(r"\b(?:bonus|extras?|specials?)\b", re.IGNORECASE)
# some other word that merely starts with "s" followed by digits.
_SEASON_ABBREV_RE = re.compile(r"^s(\d{1,2})$", re.IGNORECASE)
-_ROMAN_NUMERALS = {
- 2: "II", 3: "III", 4: "IV", 5: "V", 6: "VI",
- 7: "VII", 8: "VIII", 9: "IX", 10: "X",
-}
-
-
def _roman_to_int(s: str) -> int | None:
- values = {"i": 1, "v": 5, "x": 10, "l": 50, "c": 100}
+ values = {"i": 1, "v": 5, "x": 10, "l": 50, "c": 100, "d": 500, "m": 1000}
s = s.lower()
if not s or any(c not in values for c in s):
return None
@@ -72,6 +66,49 @@ def _roman_to_int(s: str) -> int | None:
return total or None
+def _int_to_roman(n: int) -> str | None:
+ if not 1 <= n <= 39:
+ return None
+ out = []
+ for val, sym in ((10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")):
+ while n >= val:
+ out.append(sym)
+ n -= val
+ return "".join(out)
+
+
+# Spelled-out sequel indices, English + French, plus a few ordinals.
+_NUMBER_WORDS: dict[str, int] = {
+ "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7,
+ "eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12,
+ "first": 1, "second": 2, "third": 3,
+ "un": 1, "deux": 2, "trois": 3, "quatre": 4, "cinq": 5, "sept": 7,
+ "huit": 8, "neuf": 9, "dix": 10, "onze": 11, "douze": 12,
+ "premier": 1, "première": 1, "deuxième": 2, "seconde": 2, "troisième": 3,
+}
+_YEAR_RE = re.compile(r"(?<!\d)(?:19|20)\d{2}(?!\d)")
+
+
+def year_in(text: str) -> int | None:
+ """First 19xx/20xx in `text`, or None — used to lift a year off a show
+ folder name ("Some.Show.2022.S01") for the search fallback (§10.1/V8)."""
+ m = _YEAR_RE.search(text or "")
+ return int(m.group(0)) if m else None
+
+
+def clean_query(s: str) -> str:
+ """
+ Punctuation → spaces for a TMDB query, *without* the extension /
+ parenthesized-year stripping `naive_title` does. `naive_title` assumes
+ a real filename; a show's `display_title` is a folder basename
+ ("Some.Show.Name" — `rsplit('.', 1)` would eat ".Name"), so it needs a
+ gentler normaliser (§10.1/V8).
+ """
+ s = re.sub(r"[._-]+", " ", s or "")
+ s = _strip_editions(s)
+ return re.sub(r"\s+", " ", s).strip()
+
+
def _strip_editions(title: str) -> str:
return re.sub(r"\s+", " ", _EDITION_RE.sub(" ", title)).strip()
@@ -90,21 +127,50 @@ def naive_title(filename: str) -> str:
return re.sub(r"\s+", " ", stem).strip()
+_PART_KEYWORDS = r"part|chapter|volume|vol|partie|chapitre|volet|livre|book|episode"
+_TRAILING_INDEX_RE = re.compile(
+ r"^(?P<base>.+?)(?:\s+(?:" + _PART_KEYWORDS + r"))?"
+ r"\s+(?P<num>\d{1,2}|[ivxlcdm]{1,6}|" + "|".join(_NUMBER_WORDS) + r")$",
+ re.IGNORECASE,
+)
+
+
+def _index_value(tok: str) -> int | None:
+ tok = tok.strip().lower()
+ if tok.isdigit():
+ v = int(tok)
+ return v if 1 <= v <= 39 else None
+ if tok in _NUMBER_WORDS:
+ return _NUMBER_WORDS[tok]
+ return _roman_to_int(tok)
+
+
def sequel_variants(title: str) -> list[str]:
"""
- A trailing sequel digit sometimes has no equivalent in the real TMDB
- title, or the real title uses a Roman numeral instead (§3.3 row 4).
- Returns extra candidates to try — empty if `title` has no trailing digit.
+ A trailing sequel index often has no exact match in the real TMDB
+ title: the file has a digit where TMDB uses a Roman numeral (or the
+ reverse), spells the number out, or wraps it as "Part N" / "Chapitre N"
+ (§3.3 row 4, §10.1/V10). Returns extra candidate titles to try — the
+ base alone, and the index re-rendered as digit and as Roman numeral.
+ Empty when `title` carries no recognisable trailing index.
"""
- m = re.match(r"^(.*\S)\s+([2-9])$", title)
+ m = _TRAILING_INDEX_RE.match(title.strip())
if not m:
return []
- base, digit = m.group(1), int(m.group(2))
- variants = [base]
- roman = _ROMAN_NUMERALS.get(digit)
- if roman:
- variants.append(f"{base} {roman}")
- return variants
+ base = m.group("base").strip()
+ if len(base) < 2:
+ return []
+ n = _index_value(m.group("num"))
+ if n is None:
+ return []
+ tok = m.group("num").lower()
+ out = [base]
+ roman = _int_to_roman(n)
+ if roman and roman.lower() != tok:
+ out.append(f"{base} {roman}")
+ if str(n) != tok:
+ out.append(f"{base} {n}")
+ return [v for v in dict.fromkeys(out) if v != title]
def season_from_folder_name(name: str) -> int | None: