#!/usr/bin/env python # -*- coding: utf-8 -*- import re,logging from .patterns import patterns, types, delimiters, langs, patterns_ordered from .extras import exceptions, patterns_ignore_title class PTN(object): @staticmethod def _escape_regex(string): return re.sub(r'[\-\[\]{}()*+?.,\\^$|#\s]', '\\$&', string) def __init__(self): self.torrent = None self.excess_raw = None self.group_raw = None self.start = None self.end = None self.title_raw = None self.parts = None self.post_title_pattern = '{}|{}'.format(patterns['season'], patterns['year']) def _part(self, name, match, raw, clean, overwrite=False): if overwrite or name not in self.parts: if isinstance(clean, list) and len(clean) == 1: clean = clean[0] # Avoids making a list if it only has 1 element self.parts[name] = clean if len(match) != 0: # The instructions for extracting title index = self.torrent['name'].find(match[0]) if index == 0: self.start = len(match[0]) elif self.end is None or index < self.end: self.end = index if name != 'excess': # The instructions for adding excess if name == 'group': self.group_raw = raw if raw is not None: self.excess_raw = self.excess_raw.replace(raw, '') @staticmethod def _clean_string(string): clean = re.sub(r'^ -', '', string) if clean.find(' ') == -1 and clean.find('.') != -1: clean = re.sub(r'\.', ' ', clean) clean = re.sub(r'_', ' ', clean) clean = re.sub(r'([\[(_]|- )$', '', clean).strip() clean = clean.strip(' _-') return clean def parse(self, name, standardise): name = name.strip() self.parts = {} self.torrent = {'name': name} self.excess_raw = name self.group_raw = '' self.start = 0 self.end = None self.title_raw = None for key, pattern_options in [(key, patterns[key]) for key in patterns_ordered]: pattern_options = self.normalise_pattern_options(pattern_options) for (pattern, replace, transform) in pattern_options: if key not in ('season', 'episode', 'episodeName', 'website'): pattern = r'\b{}\b'.format(pattern) clean_name = self.get_clean_name(key) match = re.findall(pattern, clean_name, re.IGNORECASE) if len(match) == 0: continue index = {} # With multiple matches, we will usually want to use the first match. # For 'year', we instead use the last instance of a year match since, # if a title includes a year, we don't want to use this for the year field. match_index = 0 if key == 'year': match_index = -1 if isinstance(match[match_index], tuple): match = list(match[match_index]) if len(match) > 1: index['raw'] = 0 index['clean'] = 0 # for season we might have it in index 1 or index 2 # e.g. "5x09" for i in range(1, len(match)): if match[i]: index['clean'] = i break else: index['raw'] = 0 index['clean'] = 0 # patterns for multiseason/episode make the range, and only the range, appear in match[0] if (key == 'season' or key == 'episode') and index['clean'] == 0: # handle multi season/episode # i.e. S01-S09 m = re.findall(r'[0-9]+', match[0]) if m and len(m) > 1: clean = list(range(int(m[0]), int(m[1]) + 1)) elif m: clean = int(m[0]) elif key == 'language' or key == 'subtitles': # handle multi language m = re.split(r'{}+'.format(delimiters), match[index['clean']]) clean = list(filter(None, m)) elif key in types.keys() and types[key] == 'boolean': clean = True else: clean = match[index['clean']] if key in types.keys() and types[key] == 'integer': clean = int(clean) if standardise: if replace: clean = replace if transform: clean = getattr(clean, transform)() # For python2 compatibility if key == 'subtitles' and len(clean) == 1 and clean[0].lower() == 'subs': clean = 'Available' elif key == 'language' or key == 'subtitles': clean = self.standardise_languages(clean) self._part(key, match, match[index['raw']], clean) self.process_title() self.fix_known_exceptions() # Start process for end, where more general fields (episode name, group, and # encoder) get set. clean = re.sub(r'(^[-. ()]+)|([-. ]+$)', '', self.excess_raw) clean = re.sub(r'[()/]', ' ', clean) clean = self.try_episode_name(clean) clean = re.sub(r'(^[-_. ()]+)|([-. ]+$)', '', clean) clean = re.sub(r'[()/]', ' ', clean) match = re.split(r'\.\.+| +', clean) if len(match) > 0 and isinstance(match[0], tuple): match = list(match[0]) clean = filter(bool, match) clean = [item for item in filter(lambda a: a != '-', clean)] clean = [item.strip('-') for item in clean] self.try_group(clean) self.try_encoder() clean = self.clean_excess(clean) if len(clean) != 0: self._part('excess', [], self.excess_raw, clean) return self.parts # Handles all the optional/missing tuple elements into a consistent list. @staticmethod def normalise_pattern_options(pattern_options): pattern_options_norm = list() if isinstance(pattern_options, str): pattern_options = [(pattern_options, None, None)] elif isinstance(pattern_options, tuple): pattern_options = [pattern_options] for options in pattern_options: if isinstance(options, str): pattern_options_norm.append((options, None, None)) elif len(options) == 2: # No transformation pattern_options_norm.append(options + (None,)) else: pattern_options_norm.append(options) pattern_options = pattern_options_norm return pattern_options @staticmethod def standardise_languages(clean): cleaned_langs = list() for lang in clean: for (lang_regex, lang_clean) in langs: if re.match(lang_regex, lang, re.IGNORECASE): cleaned_langs.append(lang_clean) break clean = cleaned_langs return clean def process_title(self): raw = self.torrent['name'] if self.end is not None: raw = raw[self.start:self.end].split('(')[0] clean = self._clean_string(raw) self._part('title', [], raw, clean) def get_clean_name(self, key): clean_name = re.sub(r'_', ' ', self.torrent['name']) # Only use part of the torrent name after the (guessed) title (split at a season or year) # to avoid matching certain patterns that could show up in a release title. for (ignore_key, ignore_patterns) in patterns_ignore_title: if ignore_key == key and not ignore_patterns: clean_name = re.split(self.post_title_pattern, clean_name, 1, re.IGNORECASE)[-1] elif ignore_key == key: for ignore_pattern in ignore_patterns: if re.findall(ignore_pattern, clean_name, re.IGNORECASE): clean_name = re.split(self.post_title_pattern, clean_name, 1, re.IGNORECASE)[-1] return clean_name def fix_known_exceptions(self): # Considerations for results that are known to cause issues, such # as media with years in them but without a release year. for exception in exceptions: try: incorrect_key, incorrect_value = exception['incorrect_parse'] if self.parts['title'] == exception['parsed_title'] \ and self.parts[incorrect_key] == incorrect_value: self.parts.pop(incorrect_key) self.parts['title'] = exception['actual_title'] except Exception as e: logging.warning(e) pass def try_episode_name(self, clean): match = re.findall(r'((?:(?:[A-Za-z][a-z]+|[A-Za-z])(?:[. \-+_]|$))+)', clean) if match: match = re.findall(patterns['episode'] + r'[._\-\s+]*(' + re.escape(match[0]) + ')', self.torrent['name'], re.IGNORECASE) if match: self._part('episodeName', match, match[0], self._clean_string(match[0])) clean = clean.replace(match[0], '') return clean def try_group(self, clean): if len(clean) != 0: group = clean.pop() + self.group_raw self._part('group', [], group, group) # clean group name from having a container name if 'group' in self.parts and 'container' in self.parts: group = self.parts['group'] container = self.parts['container'] if group.lower().endswith('.' + container.lower()): group = group[:-(len(container) + 1)] self.parts['group'] = group def try_encoder(self): # split group name and encoder, adding the latter to self.parts if 'group' in self.parts: group = self.parts['group'] pat = r'(\[(.*)\])' match = re.findall(pat, group, flags=re.IGNORECASE) if match: match = match[0] raw = match[0] if match: self._part('encoder', match, raw, match[1]) self.parts['group'] = group.replace(raw, '') if not self.parts['group'].strip(): self.parts.pop('group') @staticmethod def clean_excess(excess): cleaned_excess = list() for extra in excess: # re.fullmatch() is not available in python 2.7, so we manually do it with \Z. if not re.match(r'(?:Complete|Season|Full)?[\]\[,.+\-]*(?:Complete|Season|Full)?\Z', extra, re.IGNORECASE): cleaned_excess.append(extra) return cleaned_excess