# Read that for examples/rules: https://github.com/pymedusa/Medusa/blob/master/medusa/name_parser/rules/rules.py from logging import getLogger from typing import Any, List, Optional, cast from rebulk import Rule, RemoveMatch, AppendMatch, POST_PROCESS from rebulk.match import Matches, Match from copy import copy from providers.implementations.thexem import clean logger = getLogger(__name__) class UnlistTitles(Rule): """Join titles to a single string instead of a list Example: '/media/series/Demon Slayer - Kimetsu no Yaiba/Season 4/Demon Slayer - Kimetsu no Yaiba - S04E10 - Love Hashira Mitsuri Kanroji WEBDL-1080p.mkv' Default: ```json { "title": [ "Demon Slayer", "Kimetsu no Yaiba" ], "season": 4, "episode_title": "Demon Slayer", "alternative_title": "Kimetsu no Yaiba", "episode": 10, "source": "Web", "screen_size": "1080p", "container": "mkv", "mimetype": "video/x-matroska", "type": "episode" } ``` Expected: ```json { "title": "Demon Slayer - Kimetsu no Yaiba", "season": 4, "episode_title": "Demon Slayer", "alternative_title": "Kimetsu no Yaiba", "episode": 10, "source": "Web", "screen_size": "1080p", "container": "mkv", "mimetype": "video/x-matroska", "type": "episode" } ``` """ priority = POST_PROCESS consequence = [RemoveMatch, AppendMatch] def when(self, matches: Matches, context) -> Any: fileparts: List[Match] = matches.markers.named("path") # type: ignore for part in fileparts: titles: List[Match] = matches.range( part.start, part.end, lambda x: x.name == "title" ) # type: ignore if not titles or len(titles) <= 1: continue title = copy(titles[0]) for nmatch in titles[1:]: # Check if titles are next to each other, if they are not ignore it. next: List[Match] = matches.next(title) # type: ignore if not next or next[0] != nmatch: logger.warn(f"Ignoring potential part of title: {nmatch.value}") continue title.end = nmatch.end return [titles, [title]] class EpisodeTitlePromotion(Rule): """Promote "episode_title" to "episode" when the title is in fact the episode number Example: '[Erai-raws] Youkoso Jitsuryoku Shijou Shugi no Kyoushitsu e S3 - 05 [1080p][Multiple Subtitle][0DDEAFCD].mkv' Default: ```json { "release_group": "Erai-raws", "title": "Youkoso Jitsuryoku Shijou Shugi no Kyoushitsu e", "season": 3, "episode_title": "05", } ``` Expected: ```json { "release_group": "Erai-raws", "title": "Youkoso Jitsuryoku Shijou Shugi no Kyoushitsu e", "season": 3, "episode": 5, } ``` """ priority = POST_PROCESS consequence = [RemoveMatch, AppendMatch] def when(self, matches: Matches, context) -> Any: ep_title: List[Match] = matches.named("episode_title") # type: ignore if not ep_title: return # Do not promote an episode title if there is already a know episode number ep_nbr: List[Match] = matches.named("episode") # type: ignore if ep_nbr and len(ep_nbr) > 0: return to_remove = [match for match in ep_title if str(match.value).isdecimal()] to_add = [] for tmatch in to_remove: match = copy(tmatch) match.name = "episode" match.value = int(str(tmatch.value)) to_add.append(match) return [to_remove, to_add] class TitleNumberFixup(Rule): """Fix titles having numbers in them Example: '[Erai-raws] Zom 100 - Zombie ni Naru made ni Shitai 100 no Koto - 01 [1080p][Multiple Subtitle][8AFBB298].mkv' (or '[SubsPlease] Mob Psycho 100 Season 3 - 12 (1080p) [E5058D7B].mkv') Default: ```json { "release_group": "Erai-raws", "title": "Zom", "episode": [ 100, 1 ], "episode_title": "Zombie ni Naru made ni Shitai", } ``` Expected: ```json { "release_group": "Erai-raws", "title": "Zom 100", "episode": 1, "episode_title": "Zombie ni Naru made ni Shitai 100 no Koto", } ``` """ priority = POST_PROCESS consequence = [RemoveMatch, AppendMatch] def when(self, matches: Matches, context) -> Any: episodes: List[Match] = matches.named("episode") # type: ignore if len(episodes) < 2 or all(x.value == episodes[0].value for x in episodes): return to_remove = [] to_add = [] for episode in episodes: prevs: List[Match] = matches.previous(episode) # type: ignore title = prevs[0] if prevs and prevs[0].tagged("title") else None if not title: continue # do not fixup if there was a - or any separator between the title and the episode number holes: List[Match] = matches.holes(title.end, episode.start) # type: ignore if holes: continue to_remove.extend([title, episode]) new_title = copy(title) new_title.end = episode.end nmatch: List[Match] = matches.next(episode) # type: ignore if nmatch: end = ( nmatch[0].initiator.start if isinstance(nmatch[0].initiator, Match) else nmatch[0].start ) # If an hole was created to parse the episode at the current pos, merge it back into the title holes: List[Match] = matches.holes(start=episode.end, end=end) # type: ignore if holes and holes[0].start == episode.end: new_title.end = holes[0].end to_add.append(new_title) return [to_remove, to_add] class MultipleSeasonRule(Rule): """Understand `abcd Season 2 - 5.mkv` as S2E5 Example: '[Erai-raws] Spy x Family Season 2 - 08 [1080p][Multiple Subtitle][00C44E2F].mkv' Default: ```json { "title": "Spy x Family", "season": [ 2, 3, 4, 5, 6, 7, 8 ], } ``` Expected: ```json { "title": "Spy x Family", "season": 2, "episode": 8, } ``` """ priority = POST_PROCESS consequence = [RemoveMatch, AppendMatch] def when(self, matches: Matches, context) -> Any: seasons: List[Match] = matches.named("season") # type: ignore if not seasons: return # Only apply this rule if all seasons are due to the same match initiator: Optional[Match] = seasons[0].initiator if not initiator or any( True for match in seasons if match.initiator != initiator ): return value: str = initiator.value # type: ignore if "-" not in value: return new_season, *new_episodes = (x.strip() for x in value.split("-")) to_remove = [x for x in seasons if cast(Match, x.parent).value != new_season] to_add = [] try: episodes = [int(x) for x in new_episodes] parents: List[Match] = [match.parent for match in to_remove] # type: ignore for episode in episodes: smatch = next( x for x in parents if int(str(x.value).replace("-", "").strip()) == episode ) match = copy(smatch) match.name = "episode" match.value = episode to_add.append(match) except (ValueError, StopIteration): return return [to_remove, to_add] class XemFixup(Rule): """Fix both alternate names and seasons that are known on the xem but parsed differently by guessit Example: "JoJo's Bizarre Adventure - Diamond is Unbreakable - 12.mkv" Default: ```json { "title": "JoJo's Bizarre Adventure", "alternative_title": "Diamond is Unbreakable", "episode": 12, } ``` Expected: ```json { "title": "JoJo's Bizarre Adventure - Diamond is Unbreakable", "episode": 12, } ``` Or Example: 'Owarimonogatari S2 E15.mkv' Default: ```json { "title": "Owarimonogatari", "season": 2, "episode": 15 } ``` Expected: ```json { "title": "Owarimonogatari S2", "episode": 15 } ``` """ priority = POST_PROCESS consequence = [RemoveMatch, AppendMatch] def when(self, matches: Matches, context) -> Any: titles: List[Match] = matches.named("title", lambda m: m.tagged("title")) # type: ignore if not titles or not context["xem_titles"]: return title = titles[0] nmatch: List[Match] = matches.next(title) # type: ignore if not nmatch or not (nmatch[0].tagged("title") or nmatch[0].named("season")): return holes: List[Match] = matches.holes(title.end, nmatch[0].start) # type: ignore hole = " ".join(f" {h.value}" if h.value != "-" else " - " for h in holes) new_title = copy(title) new_title.end = nmatch[0].end new_title.value = f"{title.value}{hole}{nmatch[0].value}" if clean(new_title.value) in context["xem_titles"]: return [[title, nmatch[0]], [new_title]] class SeasonYearDedup(Rule): """Remove "season" when it's the same as "year" Example: "One Piece (1999) 152.mkv" Default: ```json { "title": "One Piece", "year": 1999, "season": 1999, "episode": 152, "container": "mkv", "mimetype": "video/x-matroska", "type": "episode" } ``` Expected: ```json { "title": "One Piece", "year": 1999, "episode": 152, "container": "mkv", "mimetype": "video/x-matroska", "type": "episode" } ``` """ # This rules does the opposite of the YearSeason rule of guessit (with POST_PROCESS priority) # To overide it, we need the -1. (rule: https://github.com/guessit-io/guessit/blob/develop/guessit/rules/processors.py#L195) priority = POST_PROCESS - 1 consequence = [RemoveMatch] def when(self, matches: Matches, context) -> Any: season: List[Match] = matches.named("season") # type: ignore year: List[Match] = matches.named("year") # type: ignore if len(season) == 1 and len(year) == 1 and season[0].value == year[0].value: return [season]