"""
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)
# 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 (§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()
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()
_PART_KEYWORDS = r"part|chapter|volume|vol|partie|chapitre|volet|livre|book|episode"
_TRAILING_INDEX_RE = re.compile(
r"^(?P.+?)(?:\s+(?:" + _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"
(§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 = _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()
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:
"""
§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_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 (§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)
# 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/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