1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
|
"""
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"(?<!\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()
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<base>.+?)(?:\s+(?P<kw>" + _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 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
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 — "Star Wars Episode
III" → "Star Wars" — because a franchise's bare name is very often a
real, *different* film (the 1977 original), and that variant matched
every episode to it (§10.1/V14). Without the keyword ("Jurassic Park 3")
the number is decoration and "Jurassic Park" is the right base 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:
"""
§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
# 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
# (§10.1/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, §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
|