Skip to content

Commit b888a5b

Browse files
authored
Merge pull request #952 from guessit-io/develop
release: v4.3.0
2 parents 0b30c0a + ad9786b commit b888a5b

11 files changed

Lines changed: 468 additions & 54 deletions

File tree

.github/workflows/ci.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,12 @@ jobs:
178178
git config --global user.name "github-actions"
179179
180180
- name: Bump version
181+
# A pull_request checkout is a detached merge commit, which python-semantic-release
182+
# refuses to version ("Detached HEAD state cannot match any release groups") with a
183+
# non-zero exit — unlike a branch outside the release groups, which it just skips.
184+
# Artifacts built from a pull request are never published, so they keep the version
185+
# already in pyproject.toml.
186+
if: github.event_name != 'pull_request'
181187
run: uvx --from python-semantic-release semantic-release version --no-commit --no-tag --no-push
182188
env:
183189
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -217,6 +223,8 @@ jobs:
217223
git config --global user.name "github-actions"
218224
219225
- name: Bump version
226+
# Skipped on a pull request: detached merge commit, see the build job.
227+
if: github.event_name != 'pull_request'
220228
run: uvx --from python-semantic-release semantic-release version --no-commit --no-tag --no-push
221229
env:
222230
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -259,6 +267,8 @@ jobs:
259267
git config --global user.name "github-actions"
260268
261269
- name: Bump version
270+
# Skipped on a pull request: detached merge commit, see the build job.
271+
if: github.event_name != 'pull_request'
262272
run: uvx --from python-semantic-release semantic-release version --no-commit --no-tag --no-push
263273
env:
264274
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -301,6 +311,8 @@ jobs:
301311
git config --global user.name "github-actions"
302312
303313
- name: Bump version
314+
# Skipped on a pull request: detached merge commit, see the build job.
315+
if: github.event_name != 'pull_request'
304316
run: uvx --from python-semantic-release semantic-release version --no-commit --no-tag --no-push
305317
env:
306318
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

docs/known-limitations.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,19 @@ The `SxxExx` chain extends across a weak `.` separator to the **consecutive** nu
7777
`S01E02.5.Kings` → episode `2`, episode_title `5 Kings`. Telling `S01E02.3.Kings` (title) apart from
7878
`S01E02.03` (range) needs to know `Kings` is a title, which is the same ambiguity as #743/#746.
7979

80+
### #948 — a half-episode number
81+
82+
```text
83+
[GroupName].Show.Name.-.02.5.(Special).[BD.1080p]
84+
episode: 2 episode_title: "5" episode_details: Special
85+
wanted -> a single "episode 2.5" notion
86+
```
87+
88+
`02.5` numbers a special sitting between episodes 2 and 3. Episode numbers are integers, so the
89+
fractional part has nowhere to go: guessit keeps it out of the numbering — it is neither a second
90+
episode nor a range — and it falls back to the episode title. Carrying it would need a new property,
91+
or a non-integer `episode` every consumer of the schema would have to follow.
92+
8093
### #744 — episode title made of numbers and hyphens
8194

8295
```text

guessit/config/options.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,13 @@
362362
"ordinal_suffix": "(?:ª|º|°|a|o|-?(?:й|я|е|го|ая|ый|ое))?",
363363
"of_words": [
364364
"of",
365-
"sur"
365+
"sur",
366+
"de",
367+
"di",
368+
"von",
369+
"van",
370+
"din",
371+
"из"
366372
],
367373
"all_words": [
368374
"All"
@@ -588,7 +594,7 @@
588594
"Proof": {"string": "Proof", "tags": ["at-end", "not-a-release-group"]},
589595
"Obfuscated": {"string": ["Obfuscated", "Scrambled"], "tags": ["at-end", "not-a-release-group"]},
590596
"Repost": {"string": ["xpost", "postbot", "asrequested"], "tags": "not-a-release-group"},
591-
"_complete_words": {"callable": "import:guessit.rules.properties.other:complete_words", "season_words": ["seasons?", "series?"], "complete_article_words": ["The"]}
597+
"_complete_words": {"callable": "import:guessit.rules.properties.other:complete_words", "season_words": ["seasons?", "series?"], "complete_article_words": ["The"], "season_number_separators": ["&", "and"]}
592598
},
593599
"art": {
594600
"poster": "Poster",

guessit/rules/properties/episodes.py

Lines changed: 127 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -382,13 +382,16 @@ def season_word_not_year(match: Match) -> bool:
382382

383383
# Non-English convention where the number precedes the keyword:
384384
# "1ª Temporada", "3 сезон", "5-й сезон", "2.Sezon" (season); "24 серия", "7.Bölüm" (episode).
385-
# An optional ordinal suffix (ª/º/°, Portuguese a/o, Russian -й/-я/…) may sit between the two.
385+
# An optional ordinal suffix (ª/º/°, Portuguese a/o, Russian -й/-я/…) may sit between the two,
386+
# and the total may follow the keyword ("5 серия из 12"), as it does in the word-first patterns.
387+
of_count = r"(?:@?" + build_or_pattern(of_words) + r"@?(?P<count>\d+))?"
386388
rebulk.regex(
387389
r"(?P<season>\d{1,2})"
388390
+ ordinal_suffix
389391
+ r"@?@?"
390392
+ build_or_pattern(season_words_numfirst, name="seasonMarker")
391-
+ r"(?![^\W\d_])",
393+
+ r"(?![^\W\d_])"
394+
+ of_count,
392395
tags=["SxxExx", "numfirst"],
393396
formatter={"season": parse_numeral},
394397
disabled=is_season_episode_disabled,
@@ -398,7 +401,8 @@ def season_word_not_year(match: Match) -> bool:
398401
+ ordinal_suffix
399402
+ r"@?@?"
400403
+ build_or_pattern(episode_words_numfirst, name="episodeMarker")
401-
+ r"(?![^\W\d_])",
404+
+ r"(?![^\W\d_])"
405+
+ of_count,
402406
tags=["SxxExx", "numfirst"],
403407
formatter={"episode": parse_numeral},
404408
disabled=lambda context: is_disabled(context, "episode"),
@@ -416,8 +420,12 @@ def season_word_not_year(match: Match) -> bool:
416420
disabled=lambda context: context.get("type") == "episode" or is_disabled(context, "episode"),
417421
)
418422

423+
# A roman numeral can be read out of the letters a longer marker continues with ("Ep" then
424+
# "i" of "Episodio"), so the marker only counts when it ends on a word boundary. The digit
425+
# variant above needs no such guard: a letter can never start its number.
419426
rebulk.regex(
420427
build_or_pattern(episode_words, name="episodeMarker")
428+
+ r"(?![^\W\d_])"
421429
+ r"-?-?(?:(?:№|#)-?)?(?P<episode>"
422430
+ numeral
423431
+ ")"
@@ -546,6 +554,7 @@ def season_word_not_year(match: Match) -> bool:
546554
EpisodeNumberSeparatorRange(range_separators),
547555
SeasonSeparatorRange(range_separators),
548556
RemoveWeakIfMovie(episode_words),
557+
RemoveMisleadingLoneDigitEpisode,
549558
RemoveWeakIfSxxExx,
550559
RemoveWeakDuplicate,
551560
EpisodeDetailValidator,
@@ -766,18 +775,30 @@ def when(self, matches: Matches, context: dict[str, Any] | None) -> Any:
766775
season_count: list[Match] = []
767776

768777
for count in matches.named("count"):
769-
previous = matches.previous(count, lambda match: match.name in ["episode", "season"], 0)
770-
if previous:
771-
if previous.name == "episode":
772-
episode_count.append(count)
773-
elif previous.name == "season":
774-
season_count.append(count)
775-
else:
778+
numbered = self._numbered_by(matches, count)
779+
if numbered is None:
776780
to_remove.append(count)
781+
elif numbered.name == "episode":
782+
episode_count.append(count)
783+
else:
784+
season_count.append(count)
777785
if to_remove or episode_count or season_count:
778786
return to_remove, episode_count, season_count
779787
return False
780788

789+
@staticmethod
790+
def _numbered_by(matches: Matches, count: Match) -> Match | None:
791+
"""The episode or season the count belongs to: the last one its own match holds.
792+
793+
Scoped to the count's match rather than to the nearest neighbour, because another property
794+
can sit on the linking word itself — "de" is the German language code as much as it is the
795+
Spanish "of" — and would then hide the number the count completes.
796+
"""
797+
numbers = matches.range(
798+
count.initiator.start, count.start, predicate=lambda match: match.name in ("episode", "season")
799+
)
800+
return numbers[-1] if numbers else None
801+
781802

782803
class SeePatternRange(Rule):
783804
"""
@@ -1090,6 +1111,102 @@ def when(self, matches: Matches, context: dict[str, Any] | None) -> Any:
10901111
return False
10911112

10921113

1114+
class RemoveMisleadingLoneDigitEpisode(Rule):
1115+
"""
1116+
Remove a lone digit read as an episode while it plainly numbers something else.
1117+
1118+
Only a forced `type=episode` reads a single digit as an episode, and that pattern fires
1119+
wherever a digit sits — including inside a release group, in a parent directory, or in the
1120+
tail of a title — where it then evicts whatever owned that span. Such a digit is discarded
1121+
when it cannot be part of the episode numbering: quoted in a bracketed group, sitting in a
1122+
non-final path part, behind the dot of a decimal number, or unable to extend a marked episode
1123+
list (a list grows rightwards from its marker and stays glued to it, as in "E01 02 03")
1124+
(#943, #948).
1125+
1126+
Runs before anything is carved out of the name (rebulk executes rules by decreasing priority),
1127+
so the freed span goes back to the title instead of leaving a hole behind.
1128+
"""
1129+
1130+
priority = PRE_PROCESS + 1
1131+
consequence = RemoveMatch
1132+
1133+
def enabled(self, context: dict[str, Any] | None) -> bool:
1134+
return bool(context and context.get("type") == "episode")
1135+
1136+
def when(self, matches: Matches, context: dict[str, Any] | None) -> Any:
1137+
to_remove: list[Match] = []
1138+
fileparts = matches.markers.named("path")
1139+
for index, filepart in enumerate(fileparts):
1140+
episodes_ = sorted(
1141+
matches.range(filepart.start, filepart.end, predicate=lambda m: m.name == "episode"),
1142+
key=lambda m: (m.start, m.end),
1143+
)
1144+
in_final_part = index == len(fileparts) - 1
1145+
misplaced = [
1146+
episode
1147+
for episode in episodes_
1148+
if self._is_lone_digit(episode)
1149+
and (
1150+
self._numbers_something_else(matches, episode, in_final_part) or self._fraction_of_a_number(episode)
1151+
)
1152+
]
1153+
kept = [episode for episode in episodes_ if episode not in misplaced]
1154+
misplaced.extend(self._detached_from_list(kept))
1155+
1156+
for episode in misplaced:
1157+
to_remove.extend(self._whole_match(matches, episode))
1158+
1159+
return to_remove
1160+
1161+
@staticmethod
1162+
def _is_lone_digit(episode: Match) -> bool:
1163+
return "weak-episode" in episode.tags and len(episode.raw or "") == 1
1164+
1165+
@staticmethod
1166+
def _numbers_something_else(matches: Matches, episode: Match, in_final_part: bool) -> bool:
1167+
"""A digit numbering a parent directory ("/Volumes/data-1/…") or a quoted group ("[t.3.3.d]")."""
1168+
quoted = matches.markers.at_match(episode, lambda marker: marker.name == "group", 0)
1169+
return not in_final_part or bool(quoted)
1170+
1171+
@staticmethod
1172+
def _fraction_of_a_number(episode: Match) -> bool:
1173+
"""A digit behind the dot of a decimal number ("02.5"): a half episode, not a second one."""
1174+
before = (episode.input_string or "")[: episode.start]
1175+
return len(before) > 1 and before[-1] == "." and before[-2].isdigit()
1176+
1177+
@classmethod
1178+
def _detached_from_list(cls, episodes_: list[Match]) -> list[Match]:
1179+
"""Lone digits that cannot extend the marked episode list of their filepart."""
1180+
anchor = next((episode for episode in episodes_ if "weak-episode" not in episode.tags), None)
1181+
if anchor is None:
1182+
return []
1183+
1184+
detached: list[Match] = []
1185+
previous = anchor
1186+
for episode in episodes_:
1187+
if episode.end <= anchor.start:
1188+
if cls._is_lone_digit(episode):
1189+
detached.append(episode)
1190+
continue
1191+
1192+
between = (episode.input_string or "")[previous.end : episode.start].strip(seps)
1193+
if between and cls._is_lone_digit(episode):
1194+
detached.append(episode)
1195+
else:
1196+
previous = episode
1197+
1198+
return detached
1199+
1200+
@staticmethod
1201+
def _whole_match(matches: Matches, episode: Match) -> list[Match]:
1202+
"""The match and the private ones holding its span, so the freed text becomes a hole again."""
1203+
return matches.range(
1204+
episode.start,
1205+
episode.end,
1206+
predicate=lambda m: "weak-episode" in m.tags and m.start >= episode.start and m.end <= episode.end,
1207+
)
1208+
1209+
10931210
class RemoveWeakIfSxxExx(Rule):
10941211
"""
10951212
Remove weak-episode tagged matches if SxxExx pattern is matched.

guessit/rules/properties/other.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,22 @@ def add(pattern: str, value: str, *, ignore_case: bool) -> None:
142142
add(r"ED", "Ending Credits", ignore_case=False)
143143

144144

145-
def complete_words(rebulk: Rebulk, season_words: Iterable[str], complete_article_words: Iterable[str]) -> None:
145+
def complete_words(
146+
rebulk: Rebulk,
147+
season_words: Iterable[str],
148+
complete_article_words: Iterable[str],
149+
season_number_separators: Iterable[str],
150+
) -> None:
146151
"""
147152
Custom pattern to find complete seasons from words.
153+
154+
``season_number_separators`` are the tokens that may join the season numbers a marker spans,
155+
e.g. the ``&`` of "Seasons 1 & 2 - Complete"; plain separators are always allowed.
148156
"""
149157
season_words_pattern = build_or_pattern(season_words)
150158
complete_article_words_pattern = build_or_pattern(complete_article_words)
159+
# The season numbers listed between the season word and the marker: "1", "1 & 2", "1 and 2".
160+
season_numbers_pattern = r"(?:-+(?:\d+|" + build_or_pattern(season_number_separators, escape=True) + r"))+-+"
151161

152162
def validate_complete(match: Match) -> bool:
153163
"""
@@ -177,6 +187,20 @@ def validate_complete(match: Match) -> bool:
177187
validator={"__parent__": and_(seps_surround, validate_complete)},
178188
)
179189

190+
# "Season 1 Complete", "Seasons 1 & 2 - Complete": the season numbers sit between the season
191+
# word and the marker, so the adjacency pattern above cannot see the word. Anchoring on the
192+
# numbered season word keeps the marker alive even when nothing matched the numbers — the
193+
# season chain is off under a forced ``type=movie`` (#944).
194+
rebulk.regex(
195+
season_words_pattern + season_numbers_pattern + "(?P<other>Complete)",
196+
children=True,
197+
private_parent=True,
198+
validate_all=True,
199+
value={"other": "Complete"},
200+
tags=["release-group-prefix"],
201+
validator={"__parent__": seps_surround},
202+
)
203+
180204

181205
class ProperCountRule(Rule):
182206
"""

guessit/rules/properties/release_group.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,16 @@ def leading_anime_group(matches: Matches, filepart: Match) -> Match | None:
140140
return None
141141

142142

143+
_SEPS_CHARS = re.escape(seps)
144+
145+
#: A trailing hole made of a numeric run and a final dash-separated word, e.g. ``.313-314-GROUP``.
146+
#: Digits and separators alone cannot be a title, so the last word stays a release group even when
147+
#: nothing claimed the numbers.
148+
_NUMERIC_RUN_THEN_GROUP = re.compile(
149+
rf"^[{_SEPS_CHARS}]?\d+(?:[{_SEPS_CHARS}]+\d+)*-(?P<group>[^{_SEPS_CHARS}]{{2,}})$"
150+
)
151+
152+
143153
class DashSeparatedReleaseGroup(Rule):
144154
"""
145155
Detect dash separated release groups that might appear at the end or at the beginning of a release name.
@@ -288,8 +298,35 @@ def detect(self, matches: Matches, start: int, end: int, at_end: bool) -> Any:
288298

289299
if candidate and self.is_valid(matches, candidate, start, end, at_end):
290300
return candidate
301+
if at_end:
302+
return self.detect_after_numeric_run(matches, start, end)
291303
return None
292304

305+
@classmethod
306+
def detect_after_numeric_run(cls, matches: Matches, start: int, end: int) -> Match | None:
307+
"""
308+
Detach a trailing dash separated group from an unclaimed numeric run.
309+
310+
``Bleach.s16e03-04.313-314-GROUP`` leaves ``.313-314-GROUP`` as a single hole whenever no
311+
rule claims the absolute episode run — the weak episode chains are off under a forced
312+
``type=movie`` (#944). The run is anchored on the season/episode markers it follows, so the
313+
trailing word behind it is a release group rather than the tail of a title.
314+
"""
315+
hole = matches.holes(start, end, index=-1, predicate=lambda m: m.end == end and m.raw)
316+
if not hole or hole.raw is None:
317+
return None
318+
319+
numeric_run = _NUMERIC_RUN_THEN_GROUP.match(hole.raw)
320+
if not numeric_run or int_coercable(numeric_run.group("group")):
321+
return None
322+
323+
previous = matches.range(start, hole.start, index=-1, predicate=lambda m: not m.private)
324+
if not previous or previous.name not in ("season", "episode"):
325+
return None
326+
327+
# The candidate keeps its leading dash, as the ones the branches above return do.
328+
return Match(hole.start + numeric_run.start("group") - 1, hole.end, input_string=hole.input_string)
329+
293330
def when(self, matches: Matches, context: dict[str, Any] | None) -> Any:
294331
if matches.named("release_group"):
295332
return None

0 commit comments

Comments
 (0)