# # This file is part of SickGear. # # SickGear is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # SickGear is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with SickGear. If not, see <http://www.gnu.org/licenses/>. from functools import reduce import operator import os.path import platform import re import traceback import uuid import sickgear from six import integer_types, iterkeys, string_types # noinspection PyUnresolvedReferences # noinspection PyUnreachableCode if False: from typing import List, Tuple try: INSTANCE_ID = str(uuid.uuid1()) except ValueError: INSTANCE_ID = str(uuid.uuid4()) USER_AGENT = ('SickGear/(%s; %s; %s)' % (platform.system(), platform.release(), INSTANCE_ID)) mediaExtensions = ['avi', 'mkv', 'mpg', 'mpeg', 'wmv', 'ogm', 'mp4', 'iso', 'img', 'divx', 'm2ts', 'm4v', 'ts', 'flv', 'f4v', 'mov', 'rmvb', 'vob', 'dvr-ms', 'wtv', 'ogv', '3gp', 'webm'] subtitleExtensions = ['srt', 'sub', 'ass', 'idx', 'ssa'] cpu_presets = {'DISABLED': 0, 'LOW': 0.01, 'NORMAL': 0.05, 'HIGH': 0.1} # Other constants MULTI_EP_RESULT = -1 SEASON_RESULT = -2 # Episode statuses UNKNOWN = -1 # should never happen UNAIRED = 1 # episodes that haven't aired yet SNATCHED = 2 # qualified with quality WANTED = 3 # episodes we don't have but want to get DOWNLOADED = 4 # qualified with quality SKIPPED = 5 # episodes we don't want ARCHIVED = 6 # episodes that you don't have locally (counts toward download completion stats) IGNORED = 7 # episodes that you don't want included in your download stats SNATCHED_PROPER = 9 # qualified with quality SUBTITLED = 10 # qualified with quality FAILED = 11 # episode downloaded or snatched we don't want SNATCHED_BEST = 12 # episode redownloaded using best quality SNATCHED_ANY = [SNATCHED, SNATCHED_PROPER, SNATCHED_BEST] NAMING_REPEAT = 1 NAMING_EXTEND = 2 NAMING_DUPLICATE = 4 NAMING_LIMITED_EXTEND = 8 NAMING_SEPARATED_REPEAT = 16 NAMING_LIMITED_EXTEND_E_PREFIXED = 32 multiEpStrings = {NAMING_REPEAT: 'Repeat', NAMING_SEPARATED_REPEAT: 'Repeat (Separated)', NAMING_DUPLICATE: 'Duplicate', NAMING_EXTEND: 'Extend', NAMING_LIMITED_EXTEND: 'Extend (Limited)', NAMING_LIMITED_EXTEND_E_PREFIXED: 'Extend (Limited, E-prefixed)'} class Quality(object): NONE = 0 # 0 SDTV = 1 # 1 SDDVD = 1 << 1 # 2 HDTV = 1 << 2 # 4 RAWHDTV = 1 << 3 # 8 -- 720p/1080i mpeg2 (trollhd releases) FULLHDTV = 1 << 4 # 16 -- 1080p HDTV (QCF releases) HDWEBDL = 1 << 5 # 32 FULLHDWEBDL = 1 << 6 # 64 -- 1080p web-dl HDBLURAY = 1 << 7 # 128 FULLHDBLURAY = 1 << 8 # 256 # UHD4KTV = 1 << 9 # reserved for the future UHD4KWEB = 1 << 10 UHD4KBLURAY = 1 << 11 # put these bits at the other end of the spectrum, far enough out that they shouldn't interfere UNKNOWN = 1 << 15 # 32768 qualityStrings = {NONE: 'N/A', UNKNOWN: 'Unknown', SDTV: 'SD TV', SDDVD: 'SD DVD', HDTV: 'HD TV', RAWHDTV: 'RawHD TV', FULLHDTV: '1080p HD TV', HDWEBDL: '720p WEB-DL', FULLHDWEBDL: '1080p WEB-DL', HDBLURAY: '720p BluRay', FULLHDBLURAY: '1080p BluRay', UHD4KWEB: '2160p UHD 4K WEB', UHD4KBLURAY: '2160p UHD BluRay'} statusPrefixes = {DOWNLOADED: 'Downloaded', SNATCHED: 'Snatched', SNATCHED_PROPER: 'Snatched (Proper)', FAILED: 'Failed', SNATCHED_BEST: 'Snatched (Best)'} real_check = r'\breal\b\W?' \ r'(?=proper|repack|e?ac3|aac|dts|read\Wnfo|(ws\W)?[ph]dtv|(ws\W)?dsr|web|dvd|blu|\d{2,3}0([pi]))' \ r'(?!.*\d+([ex])\d+)' proper_levels = [(re.compile(r'\brepack\b(?!.*\d+([ex])\d+)', flags=re.I), True), (re.compile(r'\bproper\b(?!.*\d+([ex])\d+)', flags=re.I), False), (re.compile(real_check, flags=re.I), False)] @staticmethod def get_proper_level(extra_no_name, version, is_anime=False, check_is_repack=False): """ :param extra_no_name: extra info :type extra_no_name: AnyStr :param version: version :type version: int :param is_anime: is anime :type is_anime: bool :param check_is_repack: check for repack :type check_is_repack: bool :return: proper level or tuple of is_repack, proper level :rtype: int or Tuple[bool, int] """ level = 0 is_repack = False if is_anime: if isinstance(version, integer_types): level = (0, version - 1)[1 < version] elif isinstance(extra_no_name, string_types): for p, r_check in Quality.proper_levels: a = len(p.findall(extra_no_name)) level += a if 0 < a and r_check: is_repack = True if check_is_repack: return is_repack, level return level @staticmethod def get_quality_css(quality): """ :param quality: quality :type quality: int :return: :rtype: AnyStr """ return (Quality.qualityStrings[quality].replace('2160p', 'UHD2160p').replace('1080p', 'HD1080p') .replace('720p', 'HD720p').replace('HD TV', 'HD720p').replace('RawHD TV', 'RawHD')) @staticmethod def get_quality_ui(quality): """ :param quality: quality :type quality: int :return: :rtype: AnyStr """ return Quality.qualityStrings[quality].replace('SD DVD', 'SD DVD/BR/BD') @staticmethod def _get_status_strings(status): """ :param status: status :type status: int :return: :rtype: AnyStr """ to_return = {} for _x in Quality.qualityStrings: to_return[Quality.composite_status(status, _x)] = '%s (%s)' % ( Quality.statusPrefixes[status], Quality.qualityStrings[_x]) return to_return @staticmethod def combine_qualities(any_qualities, best_qualities): # type: (List[int], List[int]) -> int """ :param any_qualities: any qualities :param best_qualities: best qualities """ any_quality = 0 best_quality = 0 if any_qualities: any_quality = reduce(operator.or_, any_qualities) if best_qualities: best_quality = reduce(operator.or_, best_qualities) return any_quality | (best_quality << 16) @staticmethod def split_quality(quality): # type: (int) -> Tuple[List[int], List[int]] """ :param quality: show quality """ any_qualities = [] best_qualities = [] for cur_quality in Quality.qualityStrings: if cur_quality & quality: any_qualities.append(cur_quality) if cur_quality << 16 & quality: best_qualities.append(cur_quality) return sorted(any_qualities), sorted(best_qualities) @staticmethod def name_quality(name, anime=False): """ Return The quality from an episode File renamed by SickGear If no quality is achieved it will try scene_quality regex :param name: name :type name: AnyStr :param anime: is anmie :type anime: bool :return: :rtype: int """ name = os.path.basename(name) # if we have our exact text then assume we put it there for _x in sorted(iterkeys(Quality.qualityStrings), reverse=True): if Quality.UNKNOWN == _x: continue if Quality.NONE == _x: # Last chance return Quality.scene_quality(name, anime) regex = r'\W' + Quality.qualityStrings[_x].replace(' ', r'\W') + r'\W' regex_match = re.search(regex, name, re.I) if regex_match: return _x @staticmethod def scene_quality(name, anime=False): """ Return The quality from the scene episode File :param name: name :type name: AnyStr :param anime: is anmie :type anime: bool :return: :rtype: int """ from sickgear import logger name = os.path.basename(name) name_has = (lambda quality_list, func=all: func([re.search(q, name, re.I) for q in quality_list])) if anime: sd_options = name_has(['360p', '480p', '848x480', 'XviD'], any) sd_options |= name_has([r'^SD\.|\.SD$'], any) # specific to a provider dvd_options = name_has(['dvd', 'dvdrip'], any) blue_ray_options = name_has(['bluray', 'blu-ray', 'BD'], any) if sd_options and not dvd_options and not blue_ray_options: return Quality.SDTV if dvd_options: return Quality.SDDVD hd_options = name_has(['720p', r'\[720\]', '1280x720', '960x720'], any) hd_options |= name_has([r'^HD\s*720\.|\.HD\s*720$'], any) # specific to a provider full_hd = name_has(['1080p', r'\[1080\]', '1920x1080'], any) full_hd |= name_has([r'^HD(\s*1080)?\.|\.HD(\s*1080)?$'], any) # specific to a provider if not blue_ray_options: if hd_options and not full_hd: return Quality.HDTV if not hd_options and full_hd: return Quality.FULLHDTV # this cond already checked above, commented out for now # if hd_options and not full_hd: # return Quality.HDWEBDL else: if hd_options and not full_hd: return Quality.HDBLURAY if not hd_options and full_hd: return Quality.FULLHDBLURAY if sickgear.ANIME_TREAT_AS_HDTV: logger.debug(f'Treating file: {name} with "unknown" quality as HDTV per user settings') return Quality.HDTV return Quality.UNKNOWN fmt = '((h.?|x)26[45]|vp9|av1|hevc)' webfmt = 'web.?(dl|rip|.%s)' % fmt rips = 'b[r|d]rip' hd_rips = 'blu.?ray|hddvd|%s' % rips if not name_has(['(720|1080|2160)[pi]|720hd']): if name_has(['(dvd.?rip|%s)(.ws)?(.(xvid|divx|%s))?' % (rips, fmt)]): return Quality.SDDVD if (not name_has(['hr.ws.pdtv.(h.?|x)264']) and (name_has([r'(hdtv|pdtv|dsr|tvrip)([-]|.((aac|ac3|dd).?\d\.?\d.)*(xvid|%s))' % fmt]) or name_has(['(xvid|divx|480p|hevc|x265)']))) \ or name_has([webfmt, 'xvid|%s' % fmt]): return Quality.SDTV if not name_has(['(1080|2160)[pi]']): if name_has(['720p']): if name_has([hd_rips, fmt]): return Quality.HDBLURAY if name_has([webfmt]) or name_has(['itunes', fmt]): return Quality.HDWEBDL if name_has([fmt]): return Quality.HDTV # p2p if name_has(['720hd']) \ or name_has(['hr.ws.pdtv.%s' % fmt]): return Quality.HDTV if name_has(['720p|1080i', 'hdtv', 'mpeg-?2']) or name_has(['1080[pi].hdtv', 'h.?264']): return Quality.RAWHDTV if name_has(['1080[pi]', 'remux']) and not name_has(['hdtv']): return Quality.FULLHDBLURAY if name_has(['1080p']): if name_has([hd_rips, fmt]) or name_has([hd_rips, 'avc|vc[ -.]?1']): return Quality.FULLHDBLURAY if name_has([webfmt]) or name_has(['itunes', fmt]): return Quality.FULLHDWEBDL if name_has([fmt]): return Quality.FULLHDTV if name_has(['2160p']): if name_has(['bluray']): return Quality.UHD4KBLURAY if name_has([webfmt]): return Quality.UHD4KWEB return Quality.UNKNOWN @staticmethod def file_quality(filename): """ :param filename: filename :type filename: AnyStr :return: :rtype: int """ from exceptions_helper import ex from sickgear import logger if os.path.isfile(filename): from hachoir.parser import createParser from hachoir.metadata import extractMetadata from hachoir.stream import InputStreamError parser = height = None msg = 'Hachoir can\'t parse file "%s" content quality because it found error: %s' try: parser = createParser(filename) except InputStreamError as e: logger.warning(msg % (filename, ex(e))) except (BaseException, Exception) as e: logger.error(msg % (filename, ex(e))) logger.error(traceback.format_exc()) if parser: extract = None try: args = ({}, {'scan_index': False})['.avi' == filename[-4::].lower()] parser.parse_exif = False parser.parse_photoshop_content = False parser.parse_comments = False extract = extractMetadata(parser, **args) except (BaseException, Exception) as e: logger.warning(msg % (filename, ex(e))) if extract: try: height = extract.get('height') except (AttributeError, ValueError): try: for metadata in extract.iterGroups(): if re.search('(?i)video', metadata.header): height = metadata.get('height') break except (AttributeError, ValueError): pass # noinspection PyProtectedMember parser.stream._input.close() tolerance = (lambda value, percent: int(round(value - (value * percent / 100.0)))) if None is not height and height >= tolerance(352, 5): if height <= tolerance(720, 2): return Quality.SDTV return (Quality.HDTV, Quality.FULLHDTV)[height >= tolerance(1080, 1)] return Quality.UNKNOWN @staticmethod def assume_quality(name): """ :param name: name :type name: AnyStr :return: :rtype: int """ if name.lower().endswith(('.avi', '.mp4', '.mkv')): return Quality.SDTV elif name.lower().endswith('.ts'): return Quality.RAWHDTV return Quality.UNKNOWN @staticmethod def composite_status(status, quality): """ :param status: status :type status: int :param quality: quality :type quality: int :return: :rtype: int or long """ return status + 100 * quality @staticmethod def quality_downloaded(status): # type: (int) -> int """ :param status: status :type status: int or long :return: :rtype: int or long """ return (status - DOWNLOADED) // 100 @staticmethod def split_composite_status(status): # type: (int) -> Tuple[int, int] """Returns a tuple containing (status, quality) :param status: status """ if UNKNOWN == status: return UNKNOWN, Quality.UNKNOWN for q in sorted(iterkeys(Quality.qualityStrings), reverse=True): if status > q * 100: return status - q * 100, q return status, Quality.NONE @staticmethod def status_from_name(name, assume=True, anime=False): """ :param name: name :type name: AnyStr :param assume: :type assume: bool :param anime: is anime :type anime: bool :return: :rtype: int or long """ quality = Quality.name_quality(name, anime) if assume and Quality.UNKNOWN == quality: quality = Quality.assume_quality(name) return Quality.composite_status(DOWNLOADED, quality) @staticmethod def status_from_name_or_file(file_path, assume=True, anime=False): """ :param file_path: file path :type file_path: AnyStr :param assume: :type assume: bool :param anime: is anime :type anime: bool :return: :rtype: int or long """ quality = Quality.name_quality(file_path, anime) if Quality.UNKNOWN == quality: quality = Quality.file_quality(file_path) if assume and Quality.UNKNOWN == quality: quality = Quality.assume_quality(file_path) return Quality.composite_status(DOWNLOADED, quality) SNATCHED = None SNATCHED_PROPER = None SNATCHED_BEST = None SNATCHED_ANY = None DOWNLOADED = None ARCHIVED = None FAILED = None class WantedQualities(dict): wantedlist = 1 bothlists = 2 upgradelist = 3 def __init__(self, **kwargs): super(WantedQualities, self).__init__(**kwargs) def _generate_wantedlist(self, qualities): initial_qualities, upgrade_qualities = Quality.split_quality(qualities) max_initial_quality = max(initial_qualities or [Quality.NONE]) min_upgrade_quality = min(upgrade_qualities or [1 << 16]) self[qualities] = {0: {self.bothlists: False, self.wantedlist: initial_qualities, self.upgradelist: False}} for q in Quality.qualityStrings: if 0 < q: self[qualities][q] = {self.wantedlist: [i for i in upgrade_qualities if q < i], self.upgradelist: False} if q not in upgrade_qualities and q in initial_qualities: # quality is only in initial_qualities w = {self.bothlists: False} elif q in upgrade_qualities and q in initial_qualities: # quality is in initial_qualities and upgrade_qualities w = {self.bothlists: True, self.upgradelist: True} elif q in upgrade_qualities: # quality is only in upgrade_qualities w = {self.bothlists: False, self.upgradelist: True} else: # quality is not in any selected quality for the show (known as "unwanted") w = {self.bothlists: max_initial_quality >= q >= min_upgrade_quality} self[qualities][q].update(w) def __getitem__(self, k): if k not in self: self._generate_wantedlist(k) return super(WantedQualities, self).__getitem__(k) def get(self, k, *args, **kwargs): if k not in self: self._generate_wantedlist(k) return super(WantedQualities, self).get(k, *args, **kwargs) def get_wantedlist(self, qualities, upgradeonce, quality, status, unaired=False, manual=False): if not manual: if status in [ARCHIVED, IGNORED, SKIPPED] + ([UNAIRED], [])[unaired]: return [] if upgradeonce: if status == SNATCHED_BEST or \ (not self[qualities][quality][self.bothlists] and self[qualities][quality][self.upgradelist] and status in (DOWNLOADED, SNATCHED, SNATCHED_BEST, SNATCHED_PROPER)): return [] return self[qualities][quality][self.wantedlist] for (attr_name, qual_val) in [ ('SNATCHED', SNATCHED), ('SNATCHED_PROPER', SNATCHED_PROPER), ('SNATCHED_BEST', SNATCHED_BEST), ('DOWNLOADED', DOWNLOADED), ('ARCHIVED', ARCHIVED), ('FAILED', FAILED), ]: setattr(Quality, attr_name, list(map(lambda qk: Quality.composite_status(qual_val, qk), iterkeys(Quality.qualityStrings)))) Quality.SNATCHED_ANY = Quality.SNATCHED + Quality.SNATCHED_PROPER + Quality.SNATCHED_BEST SD = Quality.combine_qualities([Quality.SDTV, Quality.SDDVD], []) HD = Quality.combine_qualities( [Quality.HDTV, Quality.FULLHDTV, Quality.HDWEBDL, Quality.FULLHDWEBDL, Quality.HDBLURAY, Quality.FULLHDBLURAY], []) # HD720p + HD1080p HD720p = Quality.combine_qualities([Quality.HDTV, Quality.HDWEBDL, Quality.HDBLURAY], []) HD1080p = Quality.combine_qualities([Quality.FULLHDTV, Quality.FULLHDWEBDL, Quality.FULLHDBLURAY], []) UHD2160p = Quality.combine_qualities([Quality.UHD4KWEB, Quality.UHD4KBLURAY], []) ANY = Quality.combine_qualities( [Quality.SDTV, Quality.SDDVD, Quality.HDTV, Quality.FULLHDTV, Quality.HDWEBDL, Quality.FULLHDWEBDL, Quality.HDBLURAY, Quality.FULLHDBLURAY, Quality.UNKNOWN], []) # SD + HD # legacy template, can't remove due to reference in mainDB upgrade? BEST = Quality.combine_qualities([Quality.SDTV, Quality.HDTV, Quality.HDWEBDL], [Quality.HDTV]) qualityPresets = (SD, HD, HD720p, HD1080p, UHD2160p, ANY) qualityPresetStrings = {SD: 'SD', HD: 'HD', HD720p: 'HD720p', HD1080p: 'HD1080p', UHD2160p: 'UHD2160p', ANY: 'Any'} class StatusStrings(object): def __init__(self): self.statusStrings = {UNKNOWN: 'Unknown', UNAIRED: 'Unaired', SNATCHED: 'Snatched', SNATCHED_PROPER: 'Snatched (Proper)', SNATCHED_BEST: 'Snatched (Best)', DOWNLOADED: 'Downloaded', ARCHIVED: 'Archived', SKIPPED: 'Skipped', WANTED: 'Wanted', IGNORED: 'Ignored', SUBTITLED: 'Subtitled', FAILED: 'Failed'} def __getitem__(self, name): if name in Quality.SNATCHED_ANY + Quality.DOWNLOADED + Quality.ARCHIVED: status, quality = Quality.split_composite_status(name) if quality == Quality.NONE: return self.statusStrings[status] return '%s (%s)' % (self.statusStrings[status], Quality.qualityStrings[quality]) return self.statusStrings[name] if name in self.statusStrings else '' def __contains__(self, item): return item in self.statusStrings or item in Quality.SNATCHED_ANY + Quality.DOWNLOADED + Quality.ARCHIVED statusStrings = StatusStrings() class Overview(object): UNAIRED = UNAIRED # 1 QUAL = 2 WANTED = WANTED # 3 GOOD = 4 SKIPPED = SKIPPED # 5 # For both snatched statuses. Note: SNATCHED/QUAL have same value and break dict. SNATCHED = SNATCHED_PROPER = SNATCHED_BEST # 9 SNATCHED_QUAL = 15 overviewStrings = {UNKNOWN: 'unknown', SKIPPED: 'skipped', WANTED: 'wanted', QUAL: 'qual', GOOD: 'good', UNAIRED: 'unaired', SNATCHED: 'snatched', SNATCHED_QUAL: 'snatched/qual'} countryList = {'Australia': 'AU', 'Canada': 'CA', 'USA': 'US'} class NeededQualities(object): def __init__(self, need_anime=False, need_sports=False, need_sd=False, need_hd=False, need_uhd=False, need_webdl=False, need_all_qualities=False, need_all_types=False, need_all=False): self.need_anime = need_anime or need_all_types or need_all self.need_sports = need_sports or need_all_types or need_all self.need_sd = need_sd or need_all_qualities or need_all self.need_hd = need_hd or need_all_qualities or need_all self.need_uhd = need_uhd or need_all_qualities or need_all self.need_webdl = need_webdl or need_all_qualities or need_all max_sd = Quality.SDDVD hd_qualities = [Quality.HDTV, Quality.FULLHDTV, Quality.HDWEBDL, Quality.FULLHDWEBDL, Quality.HDBLURAY, Quality.FULLHDBLURAY] webdl_qualities = [Quality.SDTV, Quality.HDWEBDL, Quality.FULLHDWEBDL, Quality.UHD4KWEB] max_hd = Quality.FULLHDBLURAY @property def all_needed(self): """ :rtype: bool """ return self.all_qualities_needed and self.all_types_needed @property def all_types_needed(self): """ :rtype: bool """ return self.need_anime and self.need_sports @property def all_qualities_needed(self): """ :rtype: bool """ return self.need_sd and self.need_hd and self.need_uhd and self.need_webdl @all_qualities_needed.setter def all_qualities_needed(self, v): """ :param v: :type v: bool """ if isinstance(v, bool) and True is v: self.need_sd = self.need_hd = self.need_uhd = self.need_webdl = True def all_show_qualities_needed(self, show_obj): """ :param show_obj: show object :type show_obj: sickgear.tv.TVShow :return: :rtype: bool """ from sickgear.tv import TVShow if isinstance(show_obj, TVShow): init, upgrade = Quality.split_quality(show_obj.quality) all_qual = set(init + upgrade) need_sd = need_hd = need_uhd = need_webdl = False for wanted_qualities in all_qual: if not need_sd and wanted_qualities <= NeededQualities.max_sd: need_sd = True if not need_hd and wanted_qualities in NeededQualities.hd_qualities: need_hd = True if not need_webdl and wanted_qualities in NeededQualities.webdl_qualities: need_webdl = True if not need_uhd and wanted_qualities > NeededQualities.max_hd: need_uhd = True return self.need_sd == need_sd and self.need_hd == need_hd and self.need_webdl == need_webdl and \ self.need_uhd == need_uhd def check_needed_types(self, show_obj): """ :param show_obj: show object :type show_obj: sickgear.tv.TVShow """ if getattr(show_obj, 'is_anime', False): self.need_anime = True if getattr(show_obj, 'is_sports', False): self.need_sports = True def check_needed_qualities(self, wanted_qualities): # type: (List[int]) -> None """ :param wanted_qualities: wanted qualities list """ if wanted_qualities: if Quality.UNKNOWN in wanted_qualities: self.need_sd = self.need_hd = self.need_uhd = self.need_webdl = True else: if not self.need_sd and min(wanted_qualities) <= NeededQualities.max_sd: self.need_sd = True if not self.need_hd and any(i in NeededQualities.hd_qualities for i in wanted_qualities): self.need_hd = True if not self.need_webdl and any(i in NeededQualities.webdl_qualities for i in wanted_qualities): self.need_webdl = True if not self.need_uhd and max(wanted_qualities) > NeededQualities.max_hd: self.need_uhd = True