@@ -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
782803class 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+
10931210class RemoveWeakIfSxxExx (Rule ):
10941211 """
10951212 Remove weak-episode tagged matches if SxxExx pattern is matched.
0 commit comments