""" Filename -> title/year/season/episode parsing for the Videos group app. Wraps `guessit` and layers the fixes from docs/MESHBAY_DESIGN.md §9.7 on top of it: none of them are per-title hacks, each is a generic rule found by validating guessit's raw output against real TMDB search results over a ~1950-file library (movies, TV shows, and a small franchise set). Scope is deliberately narrow: title, year, season, episode. Technical facts (resolution, codec, duration) come from ffprobe, never the filename — a mislabeled `1080p` tag is a real, observed failure mode. This module never touches the filesystem or the network. The orchestration that decides *which* file supplies a show's title (a representative episode filename, not the folder name) lives in the indexer, which has the directory listing; this module only parses strings it's handed. """ from __future__ import annotations import re from dataclasses import dataclass from guessit import guessit # French edition/release vocabulary guessit's (English-centric) edition list # doesn't recognize — left stuck to the title instead of stripped as a tag. _EDITION_PHRASES = ( r"version\s+longue", r"version\s+int[ée]grale", r"remasteris[ée]e?", r"non[\s._-]*censur[ée]e?", r"original\s+version", ) _EDITION_RE = re.compile("|".join(_EDITION_PHRASES), re.IGNORECASE) # A season-like ancestor folder: the English/French words plus a number or # Roman numeral. Vocabulary is a plain tuple so a deployment can extend it # per locale without touching the regex-building logic. "livre" ("book") is # real, observed vocabulary too — some shows name their seasons that way # (Roman numerals: "Livre I".."Livre VI") rather than "saison". SEASON_WORDS = ("season", "saison", "livre") _SEASON_RE = re.compile( r"(?:" + "|".join(SEASON_WORDS) + r")\s*([0-9]+|[ivxlc]+)\b", re.IGNORECASE, ) _SPECIALS_RE = re.compile(r"\b(?:bonus|extras?|specials?)\b", re.IGNORECASE) # A bare "S" + number as the *whole* folder name — "S1", "S2", "S02" — a # common abbreviated convention distinct from SEASON_WORDS' full words. # Anchored to the entire name, not just `\b`-bounded within a longer # string, so it only matches a folder actually named just that — never # some other word that merely starts with "s" followed by digits. _SEASON_ABBREV_RE = re.compile(r"^s(\d{1,2})$", re.IGNORECASE) def _roman_to_int(s: str) -> int | None: 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 total = 0 prev = 0 for c in reversed(s): v = values[c] total += v if v >= prev else -v prev = v 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"(? 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 (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 (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() def naive_title(filename: str) -> str: """ The mandated fallback: strip the extension, replace every `.`/`_`/`-` with a space, drop a trailing parenthesized year, collapse whitespace. Always computable, never fails, used both as the flat-mode display name of last resort and as a second TMDB query candidate. """ stem = filename.rsplit(".", 1)[0] if "." in filename else filename stem = re.sub(r"[._-]+", " ", stem) stem = re.sub(r"\(\s*(19|20)\d{2}\s*\)", " ", stem) stem = _strip_editions(stem) 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.+?)(?:\s+(?P" + _PART_KEYWORDS + r"))?" r"\s+(?P\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 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" (V10). Returns extra candidate titles to try — the index re-rendered as digit and as Roman numeral, plus (only when there is no "Part"/"Episode"/… keyword) the bare base. The bare base is withheld for a keyword'd index — " Chapter III" → "" — because a franchise's bare name is very often a real, *different* film (the series' first entry), and that variant matched every later entry to it (V14). Without the keyword (" 3") the number is decoration and the bare base is the right thing to try. """ m = _TRAILING_INDEX_RE.match(title.strip()) if not m: return [] 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() roman = _int_to_roman(n) if m.group("kw"): # keyword stripped ("… Part 2" -> base has neither the word nor the # number), so both renderings are new; the bare base is withheld. out = [f"{base} {n}"] if roman: out.append(f"{base} {roman}") else: # "Movie 2" — base already carries `tok`; only offer what differs. out = [base] 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: """ A season-like ancestor folder, vocabulary-driven rather than assuming a numeric convention everywhere. A specials/bonus/extras folder maps to season 0 (matching TMDB's own `season_number: 0`). Returns None if `name` doesn't look like a season folder at all. """ if _SPECIALS_RE.search(name): return 0 m = _SEASON_ABBREV_RE.match(name.strip()) if m: return int(m.group(1)) m = _SEASON_RE.search(name) if not m: return None token = m.group(1) if token.isdigit(): return int(token) return _roman_to_int(token) @dataclass class ParsedName: display_title: str | None # None => caller must supply from elsewhere (e.g. a sibling file) alt_title: str | None = None # guessit's alternative_title, a second # query candidate naive_title: str = "" # always available, fully punctuation-normalized fallback year: int | None = None season: int | None = None episode: int | None = None confidence: bool = False # True only when display_title is set and structurally corroborated def parse_movie_filename(filename: str) -> ParsedName: """Parse a standalone movie filename.""" g = guessit(filename) title = g.get("title") title = str(title).strip() if title else None if title: title = _strip_editions(title) # guessit peels a "Volume 2"/"Part 2" token off the title into its # own field, so both parts of a two-part film parse to the same bare # title. That collapsed the two on one TMDB search (the more popular # first part won for both), and — since "Fix match" groups every # entry sharing a display_title — left no way to correct one without # the other. Fold the number back on so the two stay distinct in the # query, the card and the override. part = g.get("part") or g.get("volume") if isinstance(part, int) and not isinstance(part, bool): title = f"{title} {part}" alt = g.get("alternative_title") alt = _strip_editions(str(alt).strip()) if alt else None year = g.get("year") nt = naive_title(filename) confidence = bool(title) and len(title) >= 2 and year is not None return ParsedName( display_title=title or None, alt_title=alt, naive_title=nt, year=year, confidence=confidence, ) # ── Music app (docs/MESHBAY_DESIGN.md §9.8) ──────────────────────────────────────── # # Filename parsing is the *fallback* here, not the primary source (unlike # Videos, where guessit does all the work): embedded ID3/Vorbis tags are read # first by enrich_audio.py, and this only fills whatever a tag left empty. # Scope is narrower than the video parser too — a track number and a title, # nothing guessit-shaped is needed since there is no season/episode grammar # to parse. # "01 - Venus As A Boy.mp3", "03. Human Behaviour.mp3", "12_Some_Title.mp3" — # a leading track number, optionally disc-prefixed ("1-01 "), then a # separator before the title. Capped at 3 digits so a filename that merely # starts with a year ("1999 - Some Title.mp3") isn't misread as track 199. _TRACK_PREFIX_RE = re.compile(r"^(?:\d+[\s._-]+)?(\d{1,3})[\s._-]+(?=\S)") @dataclass class ParsedTrack: title: str | None track_no: int | None naive_title: str = "" def parse_track_filename(filename: str) -> ParsedTrack: """ Split a leading track-number prefix from the rest of the filename and clean up the remainder into a title. `track_no` is None when there's no recognizable prefix — the caller (enrich_audio.py) then falls back to the tag or leaves it unset, never guesses a number. """ stem = filename.rsplit(".", 1)[0] if "." in filename else filename m = _TRACK_PREFIX_RE.match(stem) track_no = int(m.group(1)) if m else None rest = stem[m.end():] if m else stem rest = re.sub(r"[._]+", " ", rest) rest = re.sub(r"^[\s-]+", "", rest) # a leftover " - " separator title = re.sub(r"\s+", " ", rest).strip() or None return ParsedTrack(title=title, track_no=track_no, naive_title=naive_title(filename)) def strip_track_prefix(text: str) -> str: """ Some taggers copy the bare filename into the `title` tag verbatim, track-number prefix included (found live: a whole CD-single's worth of `title` tags reading "01 - Venus As A Boy" rather than "Venus As A Boy") — since a tag normally wins over the filename-parsed title (enrich_audio.py), that pollution would otherwise beat a cleaner parse. A no-op when there's no such prefix, so a genuinely clean tag is returned unchanged. """ m = _TRACK_PREFIX_RE.match(text) return re.sub(r"^[\s-]+", "", text[m.end():]).strip() if m else text # An *explicit* season/episode marker: SxxExx, 1x08, "Episode 8", "Ep 8", # "Season 1"/"Saison 1". guessit will also invent a season+episode from a # bare 3-4 digit run ("1080p" truncated to "108" -> S01E08; "1280" -> # S12E80), which is how a plain movie ends up shelved as a series # (V14). The indexer uses this to tell a real flat-library episode # from that hallucination. _EPISODE_MARKER_RE = re.compile( r"s\d{1,2}[\s._-]*e\d{1,3}" r"|\b\d{1,2}x\d{1,3}\b" r"|\bepisode[\s._-]*\d{1,3}\b" r"|\bep[\s._-]*\d{1,3}\b" r"|\b(?:season|saison)[\s._-]*\d{1,2}\b", re.IGNORECASE, ) def has_episode_marker(filename: str) -> bool: return bool(_EPISODE_MARKER_RE.search(filename)) def parse_episode_filename(filename: str) -> ParsedName: """ Parse an episode filename. `display_title` may come back None (e.g. `S08E02.SUBFRENCH.720p.mkv` carries no show name at all) — the indexer then supplies the show title from a representative sibling filename in the same folder rather than the folder name itself. """ g = guessit(filename) title = g.get("title") title = str(title).strip() if title else None if title: title = _strip_editions(title) season = g.get("season") episode = g.get("episode") # guessit returns a list when it finds more than one candidate (e.g. a # multi-episode file); take the first as the representative one. if isinstance(season, list): season = season[0] if season else None if isinstance(episode, list): episode = episode[0] if episode else None nt = naive_title(filename) confidence = bool(title) and len(title) >= 2 and season is not None and episode is not None return ParsedName( display_title=title or None, naive_title=nt, season=season, episode=episode, confidence=confidence, ) # A bare leading episode number, no show name attached — the same # shape as music's _TRACK_PREFIX_RE, capped at 3 digits for the same reason: # a leading year ("2010 - Episode.mkv") is 4 digits and must not match. # guessit's own `episode` is not a substitute here: given exactly 3 digits it # tries to read them as a concatenated SxxE/SEE season+episode pair instead # of a plain episode number — confirmed live, "100 Title.mkv" parses as # season=1, episode=0, not episode=100 — silently wrong in a way nothing # about its output distinguishes from a real 2-digit episode. This reads the # whole leading number as one value instead. _LEADING_NUMBER_RE = re.compile(r"^(\d{1,3})[\s._-]+(?=\S)") def leading_episode_number(filename: str) -> int | None: m = _LEADING_NUMBER_RE.match(filename) return int(m.group(1)) if m else None