add an unlikely series pattern
[smewt:guessit.git] / guessit / matcher.py
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
3 #
4 # GuessIt - A library for guessing information from filenames
5 # Copyright (c) 2011 Nicolas Wack <wackou@gmail.com>
6 # Copyright (c) 2011 Ricard Marxer <ricardmp@gmail.com>
7 #
8 # GuessIt is free software; you can redistribute it and/or modify it under
9 # the terms of the Lesser GNU General Public License as published by
10 # the Free Software Foundation; either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # GuessIt is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 # Lesser GNU General Public License for more details.
17 #
18 # You should have received a copy of the Lesser GNU General Public License
19 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
20 #
21
22 from guessit import fileutils, textutils
23 from guessit.guess import Guess, merge_similar_guesses, merge_all, choose_int, choose_string
24 from guessit.date import search_date, search_year
25 from guessit.language import search_language
26 from guessit.filetype import guess_filetype
27 from guessit.patterns import video_exts, subtitle_exts, sep, deleted, video_rexps, websites, episode_rexps, weak_episode_rexps, non_episode_title, find_properties, canonical_form, unlikely_series
28 from guessit.matchtree import get_group, find_group, leftover_valid_groups, tree_to_string
29 from guessit.textutils import find_first_level_groups, split_on_groups, blank_region, clean_string, to_utf8
30 from guessit.fileutils import split_path_components
31 import datetime
32 import os.path
33 import re
34 import copy
35 import logging
36 import mimetypes
37
38 log = logging.getLogger("guessit.matcher")
39
40
41
42 def split_explicit_groups(string):
43     """return the string split into explicit groups, that is, those either
44     between parenthese, square brackets or curly braces, and those separated
45     by a dash."""
46     result = find_first_level_groups(string, '()')
47     result = reduce(lambda l, x: l + find_first_level_groups(x, '[]'), result, [])
48     result = reduce(lambda l, x: l + find_first_level_groups(x, '{}'), result, [])
49     # do not do this at this moment, it is not strong enough and can break other
50     # patterns, such as dates, etc...
51     #result = reduce(lambda l, x: l + x.split('-'), result, [])
52
53     return result
54
55
56 def format_guess(guess):
57     """Format all the found values to their natural type.
58     For instance, a year would be stored as an int value, etc...
59
60     Note that this modifies the dictionary given as input.
61     """
62     for prop, value in guess.items():
63         if prop in ('season', 'episodeNumber', 'year', 'cdNumber', 'cdNumberTotal'):
64             guess[prop] = int(guess[prop])
65         elif isinstance(value, basestring):
66             if prop in ('edition',):
67                 value = clean_string(value)
68             guess[prop] = canonical_form(value)
69
70     return guess
71
72
73 def guess_groups(string, result, filetype):
74     # add sentinels so we can match a separator char at either end of
75     # our groups, even when they are at the beginning or end of the string
76     # we will adjust the span accordingly later
77     #
78     # filetype can either be movie, moviesubtitle, episode, episodesubtitle
79     current = ' ' + string + ' '
80
81     regions = [] # list of (start, end) of matched regions
82
83     def guessed(match_dict, confidence):
84         guess = format_guess(Guess(match_dict, confidence = confidence))
85         result.append(guess)
86         log.debug('Found with confidence %.2f: %s' % (confidence, guess))
87         return guess
88
89     def update_found(string, guess, span, span_adjust = (0,0)):
90         span = (span[0] + span_adjust[0],
91                 span[1] + span_adjust[1])
92         regions.append((span, guess))
93         return blank_region(string, span)
94
95     # try to find dates first, as they are very specific
96     date, span = search_date(current)
97     if date:
98         guess = guessed({ 'date': date }, confidence = 1.0)
99         current = update_found(current, guess, span)
100
101     # for non episodes only, look for year information
102     if filetype not in ('episode', 'episodesubtitle'):
103         year, span = search_year(current)
104         if year:
105             guess = guessed({ 'year': year }, confidence = 1.0)
106             current = update_found(current, guess, span)
107
108     # specific regexps (ie: cd number, season X episode, ...)
109     for rexp, confidence, span_adjust in video_rexps:
110         match = re.search(rexp, current, re.IGNORECASE)
111         if match:
112             metadata = match.groupdict()
113             # is this the better place to put it? (maybe, as it is at least the soonest that we can catch it)
114             if 'cdNumberTotal' in metadata and metadata['cdNumberTotal'] is None:
115                 del metadata['cdNumberTotal']
116
117             guess = guessed(metadata, confidence = confidence)
118             current = update_found(current, guess, match.span(), span_adjust)
119
120     if filetype in ('episode', 'episodesubtitle'):
121         for rexp, confidence, span_adjust in episode_rexps:
122             match = re.search(rexp, current, re.IGNORECASE)
123             if match:
124                 metadata = match.groupdict()
125                 guess = guessed(metadata, confidence = confidence)
126                 current = update_found(current, guess, match.span(), span_adjust)
127
128
129     # Now websites, but as exact string instead of regexps
130     clow = current.lower()
131     for site in websites:
132         pos = clow.find(site.lower())
133         if pos != -1:
134             guess = guessed({ 'website': site }, confidence = confidence)
135             current = update_found(current, guess, (pos, pos+len(site)))
136             clow = current.lower()
137
138
139     # release groups have certain constraints, cannot be included in the previous general regexps
140     group_names = [ r'\.(Xvid)-(?P<releaseGroup>.*?)[ \.]',
141                     r'\.(DivX)-(?P<releaseGroup>.*?)[\. ]',
142                     r'\.(DVDivX)-(?P<releaseGroup>.*?)[\. ]',
143                     ]
144     for rexp in group_names:
145         match = re.search(rexp, current, re.IGNORECASE)
146         if match:
147             metadata = match.groupdict()
148             metadata.update({ 'videoCodec': match.group(1) })
149             guess = guessed(metadata, confidence = 0.8)
150             current = update_found(current, guess, match.span(), span_adjust = (1, -1))
151
152
153     # common well-defined words and regexps
154     confidence = 1.0 # for all of them
155     for prop, value, pos, end in find_properties(current):
156         guess = guessed({ prop: value }, confidence = confidence)
157         current = update_found(current, guess, (pos, end))
158
159
160     # weak guesses for episode number, only run it if we don't have an estimate already
161     if filetype in ('episode', 'episodesubtitle'):
162         if not any('episodeNumber' in match for match in result):
163             for rexp, _, span_adjust in weak_episode_rexps:
164                 match = re.search(rexp, current, re.IGNORECASE)
165                 if match:
166                     metadata = match.groupdict()
167                     epnum = int(metadata['episodeNumber'])
168                     if epnum > 100:
169                         guess = guessed({ 'season': epnum // 100,
170                                           'episodeNumber': epnum % 100 }, confidence = 0.6)
171                     else:
172                         guess = guessed(metadata, confidence = 0.3)
173                     current = update_found(current, guess, match.span(), span_adjust)
174
175     # try to find languages now
176     language, span, confidence = search_language(current)
177     while language:
178         # is it a subtitle language?
179         if 'sub' in clean_string(current[:span[0]]).lower().split(' '):
180             guess = guessed({ 'subtitleLanguage': language }, confidence = confidence)
181         else:
182             guess = guessed({ 'language': language }, confidence = confidence)
183         current = update_found(current, guess, span)
184
185         language, span, confidence = search_language(current)
186
187
188     # remove our sentinels now and ajust spans accordingly
189     assert(current[0] == ' ' and current[-1] == ' ')
190     current = current[1:-1]
191     regions = [ ((start-1, end-1), guess) for (start, end), guess in regions ]
192
193     # split into '-' separated subgroups (with required separator chars
194     # around the dash)
195     didx = current.find('-')
196     while didx > 0:
197         regions.append(((didx, didx), None))
198         didx = current.find('-', didx+1)
199
200     # cut our final groups, and rematch the guesses to the group that created
201     # id, None if it is a leftover group
202     region_spans = [ span for span, guess in regions ]
203     string_groups = split_on_groups(string, region_spans)
204     remaining_groups = split_on_groups(current, region_spans)
205     guesses = []
206
207     pos = 0
208     for group in string_groups:
209         found = False
210         for span, guess in regions:
211             if span[0] == pos:
212                 guesses.append(guess)
213                 found = True
214         if not found:
215             guesses.append(None)
216
217         pos += len(group)
218
219     return  zip(string_groups,
220                 remaining_groups,
221                 guesses)
222
223
224 def match_from_epnum_position(match_tree, epnum_pos, guessed, update_found):
225     """guessed is a callback function to call with the guessed group
226     update_found is a callback to update the match group and returns leftover groups."""
227     pidx, eidx, gidx = epnum_pos
228
229     # a few helper functions to be able to filter using high-level semantics
230     def same_pgroup_before(group):
231         _, (ppidx, eeidx, ggidx) = group
232         return ppidx == pidx and (eeidx, ggidx) < (eidx, gidx)
233
234     def same_pgroup_after(group):
235         _, (ppidx, eeidx, ggidx) = group
236         return ppidx == pidx and (eeidx, ggidx) > (eidx, gidx)
237
238     def same_egroup_before(group):
239         _, (ppidx, eeidx, ggidx) = group
240         return ppidx == pidx and eeidx == eidx and ggidx < gidx
241
242     def same_egroup_after(group):
243         _, (ppidx, eeidx, ggidx) = group
244         return ppidx == pidx and eeidx == eidx and ggidx > gidx
245
246     leftover = leftover_valid_groups(match_tree)
247
248     # if we have at least 1 valid group before the episodeNumber, then it's probably
249     # the series name
250     series_candidates = filter(same_pgroup_before, leftover)
251     if len(series_candidates) >= 1:
252         guess = guessed({ 'series': series_candidates[0][0] }, confidence = 0.7)
253         leftover = update_found(leftover, series_candidates[0][1], guess)
254
255     # only 1 group after (in the same path group) and it's probably the episode title
256     title_candidates = filter(lambda g:g[0].lower() not in non_episode_title,
257                               filter(same_pgroup_after, leftover))
258     if len(title_candidates) == 1:
259         guess = guessed({ 'title': title_candidates[0][0] }, confidence = 0.5)
260         leftover = update_found(leftover, title_candidates[0][1], guess)
261     else:
262         # try in the same explicit group, with lower confidence
263         title_candidates = filter(lambda g:g[0].lower() not in non_episode_title,
264                                   filter(same_egroup_after, leftover))
265         if len(title_candidates) == 1:
266             guess = guessed({ 'title': title_candidates[0][0] }, confidence = 0.4)
267             leftover = update_found(leftover, title_candidates[0][1], guess)
268
269     # epnumber is the first group and there are only 2 after it in same path group
270     #  -> season title - episode title
271     already_has_title = (find_group(match_tree, 'title') != [])
272
273     title_candidates = filter(lambda g:g[0].lower() not in non_episode_title,
274                               filter(same_pgroup_after, leftover))
275     if (not already_has_title and                    # no title
276         not filter(same_pgroup_before, leftover) and # no groups before
277         len(title_candidates) == 2):                 # only 2 groups after
278
279         guess = guessed({ 'series': title_candidates[0][0] }, confidence = 0.4)
280         leftover = update_found(leftover, title_candidates[0][1], guess)
281         guess = guessed({ 'title': title_candidates[1][0] }, confidence = 0.4)
282         leftover = update_found(leftover, title_candidates[1][1], guess)
283
284
285     # if we only have 1 remaining valid group in the pathpart before the filename,
286     # then it's likely that it is the series name
287     series_candidates = [ group for group in leftover if group[1][0] == pidx-1 ]
288     if len(series_candidates) == 1:
289         guess = guessed({ 'series': series_candidates[0][0] }, confidence = 0.5)
290         leftover = update_found(leftover, series_candidates[0][1], guess)
291
292     return match_tree
293
294
295
296 class IterativeMatcher(object):
297     def __init__(self, filename, filetype = 'autodetect'):
298         """An iterative matcher tries to match different patterns that appear
299         in the filename.
300
301         The 'filetype' argument indicates which type of file you want to match.
302         If it is 'autodetect', the matcher will try to see whether it can guess
303         that the file corresponds to an episode, or otherwise will assume it is
304         a movie.
305
306         The recognized 'filetype' values are:
307         [ autodetect, subtitle, movie, moviesubtitle, episode, episodesubtitle ]
308
309
310         The IterativeMatcher works mainly in 2 steps:
311
312         First, it splits the filename into a match_tree, which is a tree of groups
313         which have a semantic meaning, such as episode number, movie title,
314         etc...
315
316         The match_tree created looks like the following:
317
318         0000000000000000000000000000000000000000000000000000000000000000000000000000000000 111
319         0000011111111111112222222222222233333333444444444444444455555555666777777778888888 000
320         0000000000000000000000000000000001111112011112222333333401123334000011233340000000 000
321         __________________(The.Prestige).______.[____.HP.______.{__-___}.St{__-___}.Chaps].___
322         xxxxxttttttttttttt               ffffff  vvvv    xxxxxx  ll lll     xx xxx         ccc
323         [XCT].Le.Prestige.(The.Prestige).DVDRip.[x264.HP.He-Aac.{Fr-Eng}.St{Fr-Eng}.Chaps].mkv
324
325         The first 3 lines indicates the group index in which a char in the
326         filename is located. So for instance, x264 is the group (0, 4, 1), and
327         it corresponds to a video codec, denoted by the letter'v' in the 4th line.
328         (for more info, see guess.matchtree.tree_to_string)
329
330
331          Second, it tries to merge all this information into a single object
332          containing all the found properties, and does some (basic) conflict
333          resolution when they arise.
334         """
335
336         if filetype not in ('autodetect', 'subtitle', 'video',
337                             'movie', 'moviesubtitle',
338                             'episode', 'episodesubtitle'):
339             raise ValueError, "filetype needs to be one of ('autodetect', 'subtitle', 'video', 'movie', 'moviesubtitle', 'episode', 'episodesubtitle')"
340         if not isinstance(filename, unicode):
341             log.debug('WARNING: given filename to matcher is not unicode...')
342
343         match_tree = []
344         result = [] # list of found metadata
345
346         def guessed(match_dict, confidence):
347             guess = format_guess(Guess(match_dict, confidence = confidence))
348             result.append(guess)
349             log.debug('Found with confidence %.2f: %s' % (confidence, guess))
350             return guess
351
352         def update_found(leftover, group_pos, guess):
353             pidx, eidx, gidx = group_pos
354             group = match_tree[pidx][eidx][gidx]
355             match_tree[pidx][eidx][gidx] = (group[0],
356                                             deleted * len(group[0]),
357                                             guess)
358             return [ g for g in leftover if g[1] != group_pos ]
359
360
361         # 1- first split our path into dirs + basename + ext
362         match_tree = split_path_components(filename)
363
364         # try to detect the file type
365         filetype, other = guess_filetype(filename, filetype)
366         guessed({ 'type': filetype }, confidence = 1.0)
367         extguess = guessed(other, confidence = 1.0)
368
369         # guess the mimetype of the filename
370         # TODO: handle other mimetypes not found on the default type_maps
371         # mimetypes.types_map['.srt']='text/subtitle'
372         mime, _ = mimetypes.guess_type(filename, strict=False)
373         if mime is not None:
374             guessed({ 'mimetype': mime }, confidence = 1.0)
375
376         # remove the extension from the match tree, as all indices relative
377         # the the filename groups assume the basename is the last one
378         fileext = match_tree.pop(-1)[1:].lower()
379
380
381         # 2- split each of those into explicit groups, if any
382         # note: be careful, as this might split some regexps with more confidence such as
383         #       Alfleni-Team, or [XCT] or split a date such as (14-01-2008)
384         match_tree = [ split_explicit_groups(part) for part in match_tree ]
385
386
387         # 3- try to match information in decreasing order of confidence and
388         #    blank the matching group in the string if we found something
389         for pathpart in match_tree:
390             for gidx, explicit_group in enumerate(pathpart):
391                 pathpart[gidx] = guess_groups(explicit_group, result, filetype = filetype)
392
393         # 4- try to identify the remaining unknown groups by looking at their position
394         #    relative to other known elements
395
396         if filetype in ('episode', 'episodesubtitle'):
397             eps = find_group(match_tree, 'episodeNumber')
398             if eps:
399                 match_tree = match_from_epnum_position(match_tree, eps[0], guessed, update_found)
400
401             leftover = leftover_valid_groups(match_tree)
402
403             if not eps:
404                 # if we don't have the episode number, but at least 2 groups in the
405                 # last path group, then it's probably series - eptitle
406                 title_candidates = filter(lambda g:g[0].lower() not in non_episode_title,
407                                           filter(lambda g: g[1][0] == len(match_tree)-1,
408                                                  leftover_valid_groups(match_tree)))
409                 if len(title_candidates) >= 2:
410                     guess = guessed({ 'series': title_candidates[0][0] }, confidence = 0.4)
411                     leftover = update_found(leftover, title_candidates[0][1], guess)
412                     guess = guessed({ 'title': title_candidates[1][0] }, confidence = 0.4)
413                     leftover = update_found(leftover, title_candidates[1][1], guess)
414
415
416             # if there's a path group that only contains the season info, then the previous one
417             # is most likely the series title (ie: .../series/season X/...)
418             eps = [ gpos for gpos in find_group(match_tree, 'season')
419                     if 'episodeNumber' not in get_group(match_tree, gpos)[2] ]
420
421             if eps:
422                 pidx, eidx, gidx = eps[0]
423                 previous = [ group for group in leftover if group[1][0] == pidx - 1 ]
424                 if len(previous) == 1:
425                     guess = guessed({ 'series': previous[0][0] }, confidence = 0.5)
426                     leftover = update_found(leftover, previous[0][1], guess)
427             
428             # reduce the confidence of unlikely series
429             for guess in result:
430                 if 'series' in guess:
431                   if guess['series'].lower() in unlikely_series:
432                       guess.set_confidence('series', guess.confidence('series') * 0.5)
433             
434             
435         elif filetype in ('movie', 'moviesubtitle'):
436             leftover_all = leftover_valid_groups(match_tree)
437
438             # specific cases:
439             #  - movies/tttttt (yyyy)/tttttt.ccc
440             try:
441                 if match_tree[-3][0][0][0].lower() == 'movies':
442                     # Note:too generic, might solve all the unittests as they all contain 'movies'
443                     # in their path
444                     #
445                     #if len(match_tree[-2][0]) == 1:
446                     #    title = match_tree[-2][0][0]
447                     #    guess = guessed({ 'title': clean_string(title[0]) }, confidence = 0.7)
448                     #    update_found(leftover_all, title, guess)
449
450                     year_group = filter(lambda gpos: gpos[0] == len(match_tree)-2,
451                                         find_group(match_tree, 'year'))[0]
452                     leftover = leftover_valid_groups(match_tree,
453                                                      valid = lambda g: ((g[0] and g[0][0] not in sep) and
454                                                                         g[1][0] == len(match_tree) - 2))
455                     if len(match_tree[-2]) == 2 and year_group[1] == 1:
456                         title = leftover[0]
457                         guess = guessed({ 'title': clean_string(title[0]) },
458                                         confidence = 0.8)
459                         update_found(leftover_all, title[1], guess)
460                         raise Exception # to exit the try catch now
461
462                     leftover = [ g for g in leftover_all if (g[1][0] == year_group[0] and
463                                                              g[1][1] < year_group[1] and
464                                                              g[1][2] < year_group[2]) ]
465                     leftover = sorted(leftover, key = lambda x:x[1])
466                     title = leftover[0]
467                     guess = guessed({ 'title': title[0] }, confidence = 0.8)
468                     leftover = update_found(leftover, title[1], guess)
469             except:
470                 pass
471
472             # if we have either format or videoCodec in the folder containing the file
473             # or one of its parents, then we should probably look for the title in
474             # there rather than in the basename
475             props = filter(lambda g: g[0] <= len(match_tree) - 2,
476                            find_group(match_tree, 'videoCodec') +
477                            find_group(match_tree, 'format') +
478                            find_group(match_tree, 'language'))
479             leftover = None
480             if props and all(g[0] == props[0][0] for g in props):
481                 leftover = [ g for g in leftover_all if g[1][0] == props[0][0] ]
482
483             if props and leftover:
484                 guess = guessed({ 'title': leftover[0][0] }, confidence = 0.7)
485                 leftover = update_found(leftover, leftover[0][1], guess)
486
487             else:
488                 # first leftover group in the last path part sounds like a good candidate for title,
489                 # except if it's only one word and that the first group before has at least 3 words in it
490                 # (case where the filename contains an 8 chars short name and the movie title is
491                 #  actually in the parent directory name)
492                 leftover = [ g for g in leftover_all if g[1][0] == len(match_tree)-1 ]
493                 if leftover:
494                     title, (pidx, eidx, gidx) = leftover[0]
495                     previous_pgroup_leftover = filter(lambda g: g[1][0] == pidx-1, leftover_all)
496
497                     if (title.count(' ') == 0 and
498                         previous_pgroup_leftover and
499                         previous_pgroup_leftover[0][0].count(' ') >= 2):
500
501                         guess = guessed({ 'title': previous_pgroup_leftover[0][0] }, confidence = 0.6)
502                         leftover = update_found(leftover, previous_pgroup_leftover[0][1], guess)
503
504                     else:
505                         guess = guessed({ 'title': title }, confidence = 0.6)
506                         leftover = update_found(leftover, leftover[0][1], guess)
507                 else:
508                     # if there were no leftover groups in the last path part, look in the one before that
509                     previous_pgroup_leftover = filter(lambda g: g[1][0] == len(match_tree)-2, leftover_all)
510                     if previous_pgroup_leftover:
511                         guess = guessed({ 'title': previous_pgroup_leftover[0][0] }, confidence = 0.6)
512                         leftover = update_found(leftover, previous_pgroup_leftover[0][1], guess)
513
514
515
516
517
518
519         # 5- perform some post-processing steps
520
521         # 5.1- try to promote language to subtitle language where it makes sense
522         for pidx, eidx, gidx in find_group(match_tree, 'language'):
523             string, remaining, guess = get_group(match_tree, (pidx, eidx, gidx))
524
525             def promote_subtitle():
526                 guess.set('subtitleLanguage', guess['language'], confidence = guess.confidence('language'))
527                 del guess['language']
528
529             # - if we matched a language in a file with a sub extension and that the group
530             #   is the last group of the filename, it is probably the language of the subtitle
531             #   (eg: 'xxx.english.srt')
532             if (fileext in subtitle_exts and
533                 pidx == len(match_tree) - 1 and
534                 eidx == len(match_tree[pidx]) - 1):
535                 promote_subtitle()
536
537             # - if a language is in an explicit group just preceded by "st", it is a subtitle
538             #   language (eg: '...st[fr-eng]...')
539             if eidx > 0:
540                 previous = get_group(match_tree, (pidx, eidx-1, -1))
541                 if previous[0][-2:].lower() == 'st':
542                     promote_subtitle()
543
544
545
546         # re-append the extension now
547         match_tree.append([[(fileext, deleted*len(fileext), extguess)]])
548
549         self.parts = result
550         self.match_tree = match_tree
551
552         if filename.startswith('/'):
553             filename = ' ' + filename
554
555         log.debug('Found match tree:\n%s\n%s' % (to_utf8(tree_to_string(match_tree)),
556                                                  to_utf8(filename)))
557
558
559     def matched(self):
560         # we need to make a copy here, as the merge functions work in place and
561         # calling them on the match tree would modify it
562         parts = copy.deepcopy(self.parts)
563
564         # 1- start by doing some common preprocessing tasks
565
566         # 1.1- ", the" at the end of a series title should be prepended to it
567         for part in parts:
568             if 'series' not in part:
569                 continue
570
571             series = part['series']
572             lseries = series.lower()
573
574             if lseries[-4:] == ',the':
575                 part['series'] = 'The ' + series[:-4]
576
577             if lseries[-5:] == ', the':
578                 part['series'] = 'The ' + series[:-5]
579
580
581         # 2- try to merge similar information together and give it a higher confidence
582         for int_part in ('year', 'season', 'episodeNumber'):
583             merge_similar_guesses(parts, int_part, choose_int)
584
585         for string_part in ('title', 'series', 'container', 'format', 'releaseGroup', 'website',
586                             'audioCodec', 'videoCodec', 'screenSize', 'episodeFormat'):
587             merge_similar_guesses(parts, string_part, choose_string)
588
589         result = merge_all(parts, append = ['language', 'subtitleLanguage', 'other'])
590
591         # 3- some last minute post-processing
592         if (result['type'] == 'episode' and
593             'season' not in result and
594             result.get('episodeFormat', '') == 'Minisode'):
595             result['season'] = 0
596
597         log.debug('Final result: ' + result.nice_string())
598         return result