""" Filename -> title/year/season/episode parsing for the Videos group app. Wraps `guessit` and layers the fixes from docs/mediacenter.md §3.3/§3.4 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 (§3.5): 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 — §3.4) 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, field 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) _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} 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 _strip_editions(title: str) -> str: return re.sub(r"\s+", " ", _EDITION_RE.sub(" ", title)).strip() def naive_title(filename: str) -> str: """ The mandated fallback (§3.6, §4.1): 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() 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. """ m = re.match(r"^(.*\S)\s+([2-9])$", title) 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 def season_from_folder_name(name: str) -> int | None: """ §3.4: 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_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 (§3.3 row 1) 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) 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/musicbay.md §2.1) ──────────────────────────────────────── # # 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 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, §3.2) — the indexer then supplies the show title from a representative sibling filename in the same folder rather than the folder name itself (§3.4). """ 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 (§3.4c) — 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