#!/usr/bin/env python # -*- coding: utf-8 -*- """ Episode title """ from collections import defaultdict from rebulk import Rebulk, Rule, AppendMatch, RemoveMatch, RenameMatch, POST_PROCESS from ..common import seps, title_seps from ..common.formatters import cleanup from ..common.pattern import is_disabled from ..common.validators import or_ from ..properties.title import TitleFromPosition, TitleBaseRule from ..properties.type import TypeProcessor def episode_title(config): # pylint:disable=unused-argument """ Builder for rebulk object. :param config: rule configuration :type config: dict :return: Created Rebulk object :rtype: Rebulk """ previous_names = ('episode', 'episode_count', 'season', 'season_count', 'date', 'title', 'year') rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'episode_title')) rebulk = rebulk.rules(RemoveConflictsWithEpisodeTitle(previous_names), EpisodeTitleFromPosition(previous_names), AlternativeTitleReplace(previous_names), TitleToEpisodeTitle, Filepart3EpisodeTitle, Filepart2EpisodeTitle, RenameEpisodeTitleWhenMovieType) return rebulk class RemoveConflictsWithEpisodeTitle(Rule): """ Remove conflicting matches that might lead to wrong episode_title parsing. """ priority = 64 consequence = RemoveMatch def __init__(self, previous_names): super().__init__() self.previous_names = previous_names self.next_names = ('streaming_service', 'screen_size', 'source', 'video_codec', 'audio_codec', 'other', 'container') self.affected_if_holes_after = ('part', ) self.affected_names = ('part', 'year') def when(self, matches, context): to_remove = [] for filepart in matches.markers.named('path'): for match in matches.range(filepart.start, filepart.end, predicate=lambda m: m.name in self.affected_names): before = matches.range(filepart.start, match.start, predicate=lambda m: not m.private, index=-1) if not before or before.name not in self.previous_names: continue after = matches.range(match.end, filepart.end, predicate=lambda m: not m.private, index=0) if not after or after.name not in self.next_names: continue group = matches.markers.at_match(match, predicate=lambda m: m.name == 'group', index=0) def has_value_in_same_group(current_match, current_group=group): """Return true if current match has value and belongs to the current group.""" return current_match.value.strip(seps) and ( current_group == matches.markers.at_match(current_match, predicate=lambda mm: mm.name == 'group', index=0) ) holes_before = matches.holes(before.end, match.start, predicate=has_value_in_same_group) holes_after = matches.holes(match.end, after.start, predicate=has_value_in_same_group) if not holes_before and not holes_after: continue if match.name in self.affected_if_holes_after and not holes_after: continue to_remove.append(match) if match.parent: to_remove.append(match.parent) return to_remove class TitleToEpisodeTitle(Rule): """ If multiple different title are found, convert the one following episode number to episode_title. """ dependency = TitleFromPosition def when(self, matches, context): titles = matches.named('title') title_groups = defaultdict(list) for title in titles: title_groups[title.value].append(title) episode_titles = [] if len(title_groups) < 2: return episode_titles for title in titles: if matches.previous(title, lambda match: match.name == 'episode'): episode_titles.append(title) return episode_titles def then(self, matches, when_response, context): for title in when_response: matches.remove(title) title.name = 'episode_title' matches.append(title) class EpisodeTitleFromPosition(TitleBaseRule): """ Add episode title match in existing matches Must run after TitleFromPosition rule. """ dependency = TitleToEpisodeTitle def __init__(self, previous_names): super().__init__('episode_title', ['title']) self.previous_names = previous_names def hole_filter(self, hole, matches): episode = matches.previous(hole, lambda previous: previous.named(*self.previous_names), 0) crc32 = matches.named('crc32') return episode or crc32 def filepart_filter(self, filepart, matches): # Filepart where title was found. if matches.range(filepart.start, filepart.end, lambda match: match.name == 'title'): return True return False def should_remove(self, match, matches, filepart, hole, context): if match.name == 'episode_details': return False return super().should_remove(match, matches, filepart, hole, context) def when(self, matches, context): # pylint:disable=inconsistent-return-statements if matches.named('episode_title'): return return super().when(matches, context) class AlternativeTitleReplace(Rule): """ If alternateTitle was found and title is next to episode, season or date, replace it with episode_title. """ dependency = EpisodeTitleFromPosition consequence = RenameMatch def __init__(self, previous_names): super().__init__() self.previous_names = previous_names def when(self, matches, context): # pylint:disable=inconsistent-return-statements if matches.named('episode_title'): return alternative_title = matches.range(predicate=lambda match: match.name == 'alternative_title', index=0) if alternative_title: main_title = matches.chain_before(alternative_title.start, seps=seps, predicate=lambda match: 'title' in match.tags, index=0) if main_title: episode = matches.previous(main_title, lambda previous: previous.named(*self.previous_names), 0) crc32 = matches.named('crc32') if episode or crc32: return alternative_title def then(self, matches, when_response, context): matches.remove(when_response) when_response.name = 'episode_title' when_response.tags.append('alternative-replaced') matches.append(when_response) class RenameEpisodeTitleWhenMovieType(Rule): """ Rename episode_title by alternative_title when type is movie. """ priority = POST_PROCESS dependency = TypeProcessor consequence = RenameMatch def when(self, matches, context): # pylint:disable=inconsistent-return-statements if matches.named('episode_title', lambda m: 'alternative-replaced' not in m.tags) \ and not matches.named('type', lambda m: m.value == 'episode'): return matches.named('episode_title') def then(self, matches, when_response, context): for match in when_response: matches.remove(match) match.name = 'alternative_title' matches.append(match) class Filepart3EpisodeTitle(Rule): """ If we have at least 3 filepart structured like this: Serie name/SO1/E01-episode_title.mkv AAAAAAAAAA/BBB/CCCCCCCCCCCCCCCCCCCC Serie name/SO1/episode_title-E01.mkv AAAAAAAAAA/BBB/CCCCCCCCCCCCCCCCCCCC If CCCC contains episode and BBB contains seasonNumber Then title is to be found in AAAA. """ consequence = AppendMatch('title') def when(self, matches, context): # pylint:disable=inconsistent-return-statements if matches.tagged('filepart-title'): return fileparts = matches.markers.named('path') if len(fileparts) < 3: return filename = fileparts[-1] directory = fileparts[-2] subdirectory = fileparts[-3] episode_number = matches.range(filename.start, filename.end, lambda match: match.name == 'episode', 0) if episode_number: season = matches.range(directory.start, directory.end, lambda match: match.name == 'season', 0) if season: hole = matches.holes(subdirectory.start, subdirectory.end, ignore=or_(lambda match: 'weak-episode' in match.tags, TitleBaseRule.is_ignored), formatter=cleanup, seps=title_seps, predicate=lambda match: match.value, index=0) if hole: return hole class Filepart2EpisodeTitle(Rule): """ If we have at least 2 filepart structured like this: Serie name SO1/E01-episode_title.mkv AAAAAAAAAAAAA/BBBBBBBBBBBBBBBBBBBBB If BBBB contains episode and AAA contains a hole followed by seasonNumber then title is to be found in AAAA. or Serie name/SO1E01-episode_title.mkv AAAAAAAAAA/BBBBBBBBBBBBBBBBBBBBB If BBBB contains season and episode and AAA contains a hole then title is to be found in AAAA. """ consequence = AppendMatch('title') def when(self, matches, context): # pylint:disable=inconsistent-return-statements if matches.tagged('filepart-title'): return fileparts = matches.markers.named('path') if len(fileparts) < 2: return filename = fileparts[-1] directory = fileparts[-2] episode_number = matches.range(filename.start, filename.end, lambda match: match.name == 'episode', 0) if episode_number: season = (matches.range(directory.start, directory.end, lambda match: match.name == 'season', 0) or matches.range(filename.start, filename.end, lambda match: match.name == 'season', 0)) if season: hole = matches.holes(directory.start, directory.end, ignore=or_(lambda match: 'weak-episode' in match.tags, TitleBaseRule.is_ignored), formatter=cleanup, seps=title_seps, predicate=lambda match: match.value, index=0) if hole: hole.tags.append('filepart-title') return hole