1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3"""
4Episode title
5"""
6from collections import defaultdict
7
8from rebulk import Rebulk, Rule, AppendMatch, RemoveMatch, RenameMatch, POST_PROCESS
9
10from ..common import seps, title_seps
11from ..common.formatters import cleanup
12from ..common.pattern import is_disabled
13from ..common.validators import or_
14from ..properties.title import TitleFromPosition, TitleBaseRule
15from ..properties.type import TypeProcessor
16
17
18def episode_title(config):  # pylint:disable=unused-argument
19    """
20    Builder for rebulk object.
21
22    :param config: rule configuration
23    :type config: dict
24    :return: Created Rebulk object
25    :rtype: Rebulk
26    """
27    previous_names = ('episode', 'episode_count',
28                      'season', 'season_count', 'date', 'title', 'year')
29
30    rebulk = Rebulk(disabled=lambda context: is_disabled(context, 'episode_title'))
31    rebulk = rebulk.rules(RemoveConflictsWithEpisodeTitle(previous_names),
32                          EpisodeTitleFromPosition(previous_names),
33                          AlternativeTitleReplace(previous_names),
34                          TitleToEpisodeTitle,
35                          Filepart3EpisodeTitle,
36                          Filepart2EpisodeTitle,
37                          RenameEpisodeTitleWhenMovieType)
38    return rebulk
39
40
41class RemoveConflictsWithEpisodeTitle(Rule):
42    """
43    Remove conflicting matches that might lead to wrong episode_title parsing.
44    """
45
46    priority = 64
47    consequence = RemoveMatch
48
49    def __init__(self, previous_names):
50        super().__init__()
51        self.previous_names = previous_names
52        self.next_names = ('streaming_service', 'screen_size', 'source',
53                           'video_codec', 'audio_codec', 'other', 'container')
54        self.affected_if_holes_after = ('part', )
55        self.affected_names = ('part', 'year')
56
57    def when(self, matches, context):
58        to_remove = []
59        for filepart in matches.markers.named('path'):
60            for match in matches.range(filepart.start, filepart.end,
61                                       predicate=lambda m: m.name in self.affected_names):
62                before = matches.range(filepart.start, match.start, predicate=lambda m: not m.private, index=-1)
63                if not before or before.name not in self.previous_names:
64                    continue
65
66                after = matches.range(match.end, filepart.end, predicate=lambda m: not m.private, index=0)
67                if not after or after.name not in self.next_names:
68                    continue
69
70                group = matches.markers.at_match(match, predicate=lambda m: m.name == 'group', index=0)
71
72                def has_value_in_same_group(current_match, current_group=group):
73                    """Return true if current match has value and belongs to the current group."""
74                    return current_match.value.strip(seps) and (
75                        current_group == matches.markers.at_match(current_match,
76                                                                  predicate=lambda mm: mm.name == 'group', index=0)
77                    )
78
79                holes_before = matches.holes(before.end, match.start, predicate=has_value_in_same_group)
80                holes_after = matches.holes(match.end, after.start, predicate=has_value_in_same_group)
81
82                if not holes_before and not holes_after:
83                    continue
84
85                if match.name in self.affected_if_holes_after and not holes_after:
86                    continue
87
88                to_remove.append(match)
89                if match.parent:
90                    to_remove.append(match.parent)
91
92        return to_remove
93
94
95class TitleToEpisodeTitle(Rule):
96    """
97    If multiple different title are found, convert the one following episode number to episode_title.
98    """
99    dependency = TitleFromPosition
100
101    def when(self, matches, context):
102        titles = matches.named('title')
103        title_groups = defaultdict(list)
104        for title in titles:
105            title_groups[title.value].append(title)
106
107        episode_titles = []
108        if len(title_groups) < 2:
109            return episode_titles
110
111        for title in titles:
112            if matches.previous(title, lambda match: match.name == 'episode'):
113                episode_titles.append(title)
114
115        return episode_titles
116
117    def then(self, matches, when_response, context):
118        for title in when_response:
119            matches.remove(title)
120            title.name = 'episode_title'
121            matches.append(title)
122
123
124class EpisodeTitleFromPosition(TitleBaseRule):
125    """
126    Add episode title match in existing matches
127    Must run after TitleFromPosition rule.
128    """
129    dependency = TitleToEpisodeTitle
130
131    def __init__(self, previous_names):
132        super().__init__('episode_title', ['title'])
133        self.previous_names = previous_names
134
135    def hole_filter(self, hole, matches):
136        episode = matches.previous(hole,
137                                   lambda previous: previous.named(*self.previous_names),
138                                   0)
139
140        crc32 = matches.named('crc32')
141
142        return episode or crc32
143
144    def filepart_filter(self, filepart, matches):
145        # Filepart where title was found.
146        if matches.range(filepart.start, filepart.end, lambda match: match.name == 'title'):
147            return True
148        return False
149
150    def should_remove(self, match, matches, filepart, hole, context):
151        if match.name == 'episode_details':
152            return False
153        return super().should_remove(match, matches, filepart, hole, context)
154
155    def when(self, matches, context):  # pylint:disable=inconsistent-return-statements
156        if matches.named('episode_title'):
157            return
158        return super().when(matches, context)
159
160
161class AlternativeTitleReplace(Rule):
162    """
163    If alternateTitle was found and title is next to episode, season or date, replace it with episode_title.
164    """
165    dependency = EpisodeTitleFromPosition
166    consequence = RenameMatch
167
168    def __init__(self, previous_names):
169        super().__init__()
170        self.previous_names = previous_names
171
172    def when(self, matches, context):  # pylint:disable=inconsistent-return-statements
173        if matches.named('episode_title'):
174            return
175
176        alternative_title = matches.range(predicate=lambda match: match.name == 'alternative_title', index=0)
177        if alternative_title:
178            main_title = matches.chain_before(alternative_title.start, seps=seps,
179                                              predicate=lambda match: 'title' in match.tags, index=0)
180            if main_title:
181                episode = matches.previous(main_title,
182                                           lambda previous: previous.named(*self.previous_names),
183                                           0)
184
185                crc32 = matches.named('crc32')
186
187                if episode or crc32:
188                    return alternative_title
189
190    def then(self, matches, when_response, context):
191        matches.remove(when_response)
192        when_response.name = 'episode_title'
193        when_response.tags.append('alternative-replaced')
194        matches.append(when_response)
195
196
197class RenameEpisodeTitleWhenMovieType(Rule):
198    """
199    Rename episode_title by alternative_title when type is movie.
200    """
201    priority = POST_PROCESS
202
203    dependency = TypeProcessor
204    consequence = RenameMatch
205
206    def when(self, matches, context):  # pylint:disable=inconsistent-return-statements
207        if matches.named('episode_title', lambda m: 'alternative-replaced' not in m.tags) \
208                and not matches.named('type', lambda m: m.value == 'episode'):
209            return matches.named('episode_title')
210
211    def then(self, matches, when_response, context):
212        for match in when_response:
213            matches.remove(match)
214            match.name = 'alternative_title'
215            matches.append(match)
216
217
218class Filepart3EpisodeTitle(Rule):
219    """
220    If we have at least 3 filepart structured like this:
221
222    Serie name/SO1/E01-episode_title.mkv
223    AAAAAAAAAA/BBB/CCCCCCCCCCCCCCCCCCCC
224
225    Serie name/SO1/episode_title-E01.mkv
226    AAAAAAAAAA/BBB/CCCCCCCCCCCCCCCCCCCC
227
228    If CCCC contains episode and BBB contains seasonNumber
229    Then title is to be found in AAAA.
230    """
231    consequence = AppendMatch('title')
232
233    def when(self, matches, context):  # pylint:disable=inconsistent-return-statements
234        if matches.tagged('filepart-title'):
235            return
236
237        fileparts = matches.markers.named('path')
238        if len(fileparts) < 3:
239            return
240
241        filename = fileparts[-1]
242        directory = fileparts[-2]
243        subdirectory = fileparts[-3]
244
245        episode_number = matches.range(filename.start, filename.end, lambda match: match.name == 'episode', 0)
246        if episode_number:
247            season = matches.range(directory.start, directory.end, lambda match: match.name == 'season', 0)
248
249            if season:
250                hole = matches.holes(subdirectory.start, subdirectory.end,
251                                     ignore=or_(lambda match: 'weak-episode' in match.tags, TitleBaseRule.is_ignored),
252                                     formatter=cleanup, seps=title_seps, predicate=lambda match: match.value,
253                                     index=0)
254                if hole:
255                    return hole
256
257
258class Filepart2EpisodeTitle(Rule):
259    """
260    If we have at least 2 filepart structured like this:
261
262    Serie name SO1/E01-episode_title.mkv
263    AAAAAAAAAAAAA/BBBBBBBBBBBBBBBBBBBBB
264
265    If BBBB contains episode and AAA contains a hole followed by seasonNumber
266    then title is to be found in AAAA.
267
268    or
269
270    Serie name/SO1E01-episode_title.mkv
271    AAAAAAAAAA/BBBBBBBBBBBBBBBBBBBBB
272
273    If BBBB contains season and episode and AAA contains a hole
274    then title is to be found in AAAA.
275    """
276    consequence = AppendMatch('title')
277
278    def when(self, matches, context):  # pylint:disable=inconsistent-return-statements
279        if matches.tagged('filepart-title'):
280            return
281
282        fileparts = matches.markers.named('path')
283        if len(fileparts) < 2:
284            return
285
286        filename = fileparts[-1]
287        directory = fileparts[-2]
288
289        episode_number = matches.range(filename.start, filename.end, lambda match: match.name == 'episode', 0)
290        if episode_number:
291            season = (matches.range(directory.start, directory.end, lambda match: match.name == 'season', 0) or
292                      matches.range(filename.start, filename.end, lambda match: match.name == 'season', 0))
293            if season:
294                hole = matches.holes(directory.start, directory.end,
295                                     ignore=or_(lambda match: 'weak-episode' in match.tags, TitleBaseRule.is_ignored),
296                                     formatter=cleanup, seps=title_seps,
297                                     predicate=lambda match: match.value, index=0)
298                if hole:
299                    hole.tags.append('filepart-title')
300                    return hole
301