# # 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 . import datetime import os import re import threading import traceback import exceptions_helper from exceptions_helper import ex from sg_helpers import write_file import sickgear from . import clients, common, db, failed_history, helpers, history, logger, \ notifiers, nzbget, nzbSplitter, show_name_helpers, sab, ui from .common import DOWNLOADED, SNATCHED, SNATCHED_BEST, SNATCHED_PROPER, MULTI_EP_RESULT, SEASON_RESULT, Quality from .providers.generic import GenericProvider from .tv import TVEpisode, TVShow from six import iteritems, itervalues, string_types # noinspection PyUnreachableCode, PyStatementEffect if False: from typing import AnyStr, Dict, List, Optional, Tuple, Union from .classes import NZBDataSearchResult, NZBSearchResult, SearchResult, TorrentSearchResult search_result_type = Union[NZBDataSearchResult, NZBSearchResult, SearchResult, TorrentSearchResult] def _download_result(result): # type: (search_result_type) -> bool """ Downloads a result to the appropriate black hole folder. :param result: SearchResult instance to download. :return: bool representing success """ res_provider = result.provider if None is res_provider: logger.error('Invalid provider name - this is a coding error, report it please') return False # NZB files with a URL can just be downloaded from the provider if 'nzb' == result.resultType: new_result = res_provider.download_result(result) # if it's an nzb data result elif 'nzbdata' == result.resultType: # get the final file path to the nzb file_name = os.path.join(sickgear.NZB_DIR, f'{result.name}.nzb') logger.log(f'Saving NZB to {file_name}') new_result = True # save the data to disk try: data = result.get_data() if not data: new_result = False else: write_file(file_name, data, raise_exceptions=True) except (EnvironmentError, IOError) as e: logger.error(f'Error trying to save NZB to black hole: {ex(e)}') new_result = False elif 'torrent' == res_provider.providerType: new_result = res_provider.download_result(result) else: logger.error('Invalid provider type - this is a coding error, report it please') new_result = False return new_result def snatch_episode(result, end_status=SNATCHED): # type: (search_result_type, int) -> bool """ Contains the internal logic necessary to actually "snatch" a result that has been found. :param result: SearchResult instance to be snatched. :param end_status: the episode status that should be used for the episode object once it's snatched. :return: bool representing success """ if None is result: return False if sickgear.ALLOW_HIGH_PRIORITY: # if it aired recently make it high priority for cur_ep_obj in result.ep_obj_list: if datetime.date.today() - cur_ep_obj.airdate <= datetime.timedelta(days=7) or \ datetime.date.fromordinal(1) >= cur_ep_obj.airdate: result.priority = 1 if 0 < result.properlevel: end_status = SNATCHED_PROPER # NZB files can be sent straight to SAB or saved to disk if result.resultType in ('nzb', 'nzbdata'): if 'blackhole' == sickgear.NZB_METHOD: dl_result = _download_result(result) elif 'sabnzbd' == sickgear.NZB_METHOD: dl_result = sab.send_nzb(result) elif 'nzbget' == sickgear.NZB_METHOD: dl_result = nzbget.send_nzb(result) else: logger.error(f'Unknown NZB action specified in config: {sickgear.NZB_METHOD}') dl_result = False # TORRENT files can be sent to clients or saved to disk elif 'torrent' == result.resultType: if not result.url.startswith('magnet') and None is not result.get_data_func: result.url = result.get_data_func(result.url) result.get_data_func = None # consume only once if not result.url: return False # torrents are saved to disk when blackhole mode if 'blackhole' == sickgear.TORRENT_METHOD: dl_result = _download_result(result) else: # make sure we have the torrent file content if not result.content and not result.url.startswith('magnet'): result.content = result.provider.get_url(result.url, as_binary=True) if result.provider.should_skip() or not result.content: logger.error(f'Torrent content failed to download from {result.url}') return False # Snatches torrent with client dl_result = clients.get_client_instance(sickgear.TORRENT_METHOD)().send_torrent(result) if result.cache_filepath: helpers.remove_file_perm(result.cache_filepath) else: logger.error('Unknown result type, unable to download it') dl_result = False if not dl_result: return False if sickgear.USE_FAILED_DOWNLOADS: failed_history.add_snatched(result) ui.notifications.message('Episode snatched', result.name) history.log_snatch(result) # don't notify when we re-download an episode sql_l = [] update_imdb_data = True for cur_ep_obj in result.ep_obj_list: with cur_ep_obj.lock: if is_first_best_match(cur_ep_obj.status, result): cur_ep_obj.status = Quality.composite_status(SNATCHED_BEST, result.quality) else: cur_ep_obj.status = Quality.composite_status(end_status, result.quality) item = cur_ep_obj.get_sql() if None is not item: sql_l.append(item) if cur_ep_obj.status not in Quality.DOWNLOADED: notifiers.notify_snatch(cur_ep_obj) update_imdb_data = update_imdb_data and cur_ep_obj.show_obj.load_imdb_info() if 0 < len(sql_l): my_db = db.DBConnection() my_db.mass_action(sql_l) return True def pass_show_wordlist_checks(name, show_obj): # type: (AnyStr, TVShow) -> bool """ check if string (release name) passes show object ignore/request list :param name: string to check :param show_obj: show object :return: passed check """ re_extras = dict(re_prefix='.*', re_suffix='.*') result = show_name_helpers.contains_any(name, show_obj.rls_ignore_words, rx=show_obj.rls_ignore_words_regex, **re_extras) if None is not result and result: logger.log(f'Ignored: {name} for containing ignore word') return False result = show_name_helpers.contains_any(name, show_obj.rls_require_words, rx=show_obj.rls_require_words_regex, **re_extras) if None is not result and not result: logger.log(f'Ignored: {name} for not containing any required word match') return False return True def pick_best_result( results, # type: List[search_result_type] show_obj, # type: TVShow quality_list=None, # type: List[int] filter_rls='' # type: AnyStr ): # type: (...) -> search_result_type """ picks best result from given search result list for given show object :param results: list of search result lists :param show_obj: show object :param quality_list: optional list of qualities :param filter_rls: optional thread name :return: best search result """ msg = ('Picking the best result out of %s', 'Checking the best result %s')[1 == len(results)] logger.debug(msg % [x.name for x in results]) # find the best result for the current episode best_result = None best_fallback_result = None scene_only = scene_or_contain = non_scene_fallback = scene_rej_nuked = scene_nuked_active = False if filter_rls: try: provider = getattr(results[0], 'provider', None) scene_only = getattr(provider, 'scene_only', False) scene_or_contain = getattr(provider, 'scene_or_contain', '') recent_task = 'RECENT' in filter_rls non_scene_fallback = (getattr(provider, 'scene_loose', False) and recent_task) \ or (getattr(provider, 'scene_loose_active', False) and not recent_task) scene_rej_nuked = getattr(provider, 'scene_rej_nuked', False) scene_nuked_active = getattr(provider, 'scene_nuked_active', False) and not recent_task except (BaseException, Exception): filter_rls = False addendum = '' for cur_result in results: if show_obj.is_anime and not show_obj.release_groups.is_valid(cur_result): continue if quality_list and cur_result.quality not in quality_list: logger.debug(f'Rejecting unwanted quality {Quality.qualityStrings[cur_result.quality]}' f' for [{cur_result.name}]') continue if not pass_show_wordlist_checks(cur_result.name, show_obj): continue cur_size = getattr(cur_result, 'size', None) if sickgear.USE_FAILED_DOWNLOADS and None is not cur_size and failed_history.has_failed( cur_result.name, cur_size, cur_result.provider.name): logger.log(f'Rejecting previously failed [{cur_result.name}]') continue if filter_rls and any([scene_only, non_scene_fallback, scene_rej_nuked, scene_nuked_active]): if show_obj.is_anime: addendum = 'anime (skipping scene/nuke filter) ' else: scene_contains = False if scene_only and scene_or_contain: re_extras = dict(re_prefix='.*', re_suffix='.*') r = show_name_helpers.contains_any(cur_result.name, scene_or_contain, **re_extras) if None is not r and r: scene_contains = True if scene_contains and not scene_rej_nuked: logger.debug(f'Considering title match to \'or contain\' [{cur_result.name}]') reject = False else: reject, url = can_reject(cur_result.name) if reject: if isinstance(reject, string_types): if scene_rej_nuked and not scene_nuked_active: logger.debug(f'Rejecting nuked release. Nuke reason [{reject}] source [{url}]') elif scene_nuked_active: best_fallback_result = best_candidate(best_fallback_result, cur_result) else: logger.debug(f'Considering nuked release. Nuke reason [{reject}] source [{url}]') reject = False elif scene_contains or non_scene_fallback: best_fallback_result = best_candidate(best_fallback_result, cur_result) else: logger.debug(f'Rejecting as not scene release listed at any [{url}]') if reject: continue best_result = best_candidate(best_result, cur_result) if best_result and scene_only and not show_obj.is_anime: addendum = 'scene release filtered ' elif not best_result and best_fallback_result: addendum = 'non scene release filtered ' best_result = best_fallback_result if best_result: msg = ('Picked as the best %s[%s]', 'Confirmed as the best %s[%s]')[1 == len(results)] logger.debug(msg % (addendum, best_result.name)) else: logger.debug('No result picked.') return best_result def best_candidate(best_result, cur_result): # type: (search_result_type, search_result_type) -> search_result_type """ compare 2 search results and return best :param best_result: possible best search result :param cur_result: current best search result :return: new best search result """ logger.log(f'Quality is {Quality.qualityStrings[cur_result.quality]} for [{cur_result.name}]') if not best_result or best_result.quality < cur_result.quality != Quality.UNKNOWN: best_result = cur_result elif best_result.quality == cur_result.quality: if cur_result.properlevel > best_result.properlevel and \ (not cur_result.is_repack or cur_result.release_group == best_result.release_group): best_result = cur_result elif cur_result.properlevel == best_result.properlevel: if 'xvid' in best_result.name.lower() and 'x264' in cur_result.name.lower(): logger.log(f'Preferring (x264 over xvid) [{cur_result.name}]') best_result = cur_result elif re.search('(?i)(h.?|x)264', best_result.name) and re.search('(?i)((h.?|x)265|hevc)', cur_result.name): logger.log(f'Preferring (x265 over x264) [{cur_result.name}]') best_result = cur_result elif 'internal' in best_result.name.lower() and 'internal' not in cur_result.name.lower(): best_result = cur_result return best_result def is_final_result(result): # type: (search_result_type) -> bool """ Checks if the given result is good enough quality that we can stop searching for other ones. :param result: search result to check :return: If the result is the highest quality in both any and best quality lists then this function returns True, if not then it's False """ logger.debug(f'Checking if searching should continue after finding {result.name}') show_obj = result.ep_obj_list[0].show_obj any_qualities, best_qualities = Quality.split_quality(show_obj.quality) # if there is a download that's higher than this then we definitely need to keep looking if best_qualities and max(best_qualities) > result.quality: return False # if it does not match the shows block and allow list its no good elif show_obj.is_anime and show_obj.release_groups.is_valid(result): return False # if there's no download that's higher (above) and this is the highest initial download then we're good elif any_qualities and result.quality in any_qualities: return True elif best_qualities and max(best_qualities) == result.quality: # if this is the best download, but we have a higher initial download then keep looking if any_qualities and max(any_qualities) > result.quality: return False # if this is the best download, and we don't have a higher initial download then we're done return True # if we got here than it's either not on the lists, they're empty, or it's lower than the highest required return False def is_first_best_match(ep_status, result): # type: (int, search_result_type) -> bool """ Checks if the given result is the best quality match and if we want to archive the episode on first match. :param ep_status: current episode object status :param result: search result to check :return: """ logger.debug(f'Checking if the first best quality match should be archived for episode {result.name}') show_obj = result.ep_obj_list[0].show_obj cur_status, cur_quality = Quality.split_composite_status(ep_status) any_qualities, best_qualities = Quality.split_quality(show_obj.quality) # if there is a download that's a match to one of our best qualities, and # we want to archive the episode then we are done if best_qualities and show_obj.upgrade_once and \ (result.quality in best_qualities and (cur_status in (SNATCHED, SNATCHED_PROPER, SNATCHED_BEST, DOWNLOADED) or result.quality not in any_qualities)): return True return False def set_wanted_aired(ep_obj, # type: TVEpisode unaired, # type: bool ep_count, # type: Dict[int, int] ep_count_scene, # type: Dict[int, int] manual=False # type: bool ): """ set wanted properties for given episode object :param ep_obj: episode object :param unaired: include unaried episodes :param ep_count: count of episodes in seasons :param ep_count_scene: count of episodes in scene seasons :param manual: manual search """ ep_status, ep_quality = common.Quality.split_composite_status(ep_obj.status) ep_obj.wanted_quality = get_wanted_qualities(ep_obj, ep_status, ep_quality, unaired=unaired, manual=manual) ep_obj.eps_aired_in_season = ep_count.get(ep_obj.season, 0) ep_obj.eps_aired_in_scene_season = ep_count_scene.get( ep_obj.scene_season, 0) if ep_obj.scene_season else ep_obj.eps_aired_in_season def get_wanted_qualities(ep_obj, # type: TVEpisode cur_status, # type: int cur_quality, # type: int unaired=False, # type: bool manual=False # type: bool ): # type: (...) -> List[int] """ get list of wanted qualities for given episode object :param ep_obj: episode object :param cur_status: current episode status :param cur_quality: current episode quality :param unaired: include unaired episodes :param manual: manual search :return: list of wanted qualities for episode object """ if isinstance(ep_obj, TVEpisode): return sickgear.WANTEDLIST_CACHE.get_wantedlist(ep_obj.show_obj.quality, ep_obj.show_obj.upgrade_once, cur_quality, cur_status, unaired, manual) return [] def get_aired_in_season(show_obj, return_sql=False): # type: (TVShow, bool) -> Union[Tuple[Dict[int, int], Dict[int, int]], Tuple[Dict[int, int], Dict[int, int], List]] """ returns tuple of dicts with episode count per (scene) season and optional sql results :param show_obj: show object :param return_sql: return sql :return: returns tuple of dicts with episode count per (scene) season """ ep_count = {} ep_count_scene = {} tomorrow = (datetime.date.today() + datetime.timedelta(days=1)).toordinal() my_db = db.DBConnection() if show_obj.air_by_date: sql_string = 'SELECT ep.status, ep.season, ep.scene_season, ep.episode, ep.airdate ' + \ 'FROM [tv_episodes] AS ep, [tv_shows] AS show ' + \ 'WHERE ep.showid = show.indexer_id AND show.paused = 0 AND season != 0 AND' \ ' ep.indexer = ? AND ep.showid = ?' \ ' AND show.air_by_date = 1' else: sql_string = 'SELECT status, season, scene_season, episode, airdate ' + \ 'FROM [tv_episodes] ' + \ 'WHERE indexer = ? AND showid = ?' \ ' AND season > 0' sql_result = my_db.select(sql_string, [show_obj.tvid, show_obj.prodid]) for cur_result in sql_result: if 1 < helpers.try_int(cur_result['airdate']) <= tomorrow: cur_season = helpers.try_int(cur_result['season']) ep_count[cur_season] = ep_count.setdefault(cur_season, 0) + 1 cur_scene_season = helpers.try_int(cur_result['scene_season'], -1) if -1 != cur_scene_season: ep_count_scene[cur_scene_season] = ep_count.setdefault(cur_scene_season, 0) + 1 if return_sql: return ep_count, ep_count_scene, sql_result return ep_count, ep_count_scene def wanted_episodes(show_obj, # type: TVShow from_date, # type: datetime.date make_dict=False, # type: bool unaired=False # type: bool ): # type: (...) -> Union[List[TVEpisode], Dict[int, TVEpisode]] """ :param show_obj: tv show object :param from_date: start date :param make_dict: make dict result :param unaired: include unaired episodes :return: list or dict of wanted episode objects """ ep_count, ep_count_scene, sql_result_org = get_aired_in_season(show_obj, return_sql=True) from_date_ord = from_date.toordinal() if unaired: sql_result = [s for s in sql_result_org if s['airdate'] > from_date_ord or s['airdate'] == 1] else: sql_result = [s for s in sql_result_org if s['airdate'] > from_date_ord] if make_dict: wanted = {} else: wanted = [] total_wanted = total_replacing = total_unaired = 0 if 0 < len(sql_result) and 2 < len(sql_result) - len(show_obj.sxe_ep_obj): my_db = db.DBConnection() ep_sql_result = my_db.select( 'SELECT * FROM tv_episodes' ' WHERE indexer = ? AND showid = ?', [show_obj.tvid, show_obj.prodid]) else: ep_sql_result = None for result in sql_result: ep_obj = show_obj.get_episode(int(result['season']), int(result['episode']), ep_result=ep_sql_result) cur_status, cur_quality = common.Quality.split_composite_status(ep_obj.status) ep_obj.wanted_quality = get_wanted_qualities(ep_obj, cur_status, cur_quality, unaired=unaired) if not ep_obj.wanted_quality: continue ep_obj.eps_aired_in_season = ep_count.get(helpers.try_int(result['season']), 0) ep_obj.eps_aired_in_scene_season = ep_count_scene.get( helpers.try_int(result['scene_season']), 0) if result['scene_season'] else ep_obj.eps_aired_in_season if make_dict: wanted.setdefault(ep_obj.scene_season if ep_obj.show_obj.is_scene else ep_obj.season, []).append(ep_obj) else: wanted.append(ep_obj) if cur_status in (common.WANTED, common.FAILED): total_wanted += 1 elif cur_status in (common.UNAIRED, common.SKIPPED, common.IGNORED, common.UNKNOWN): total_unaired += 1 else: total_replacing += 1 if 0 < total_wanted + total_replacing + total_unaired: actions = [] for msg, total in ['%d episode%s', total_wanted], \ ['to upgrade %d episode%s', total_replacing], \ ['%d unaired episode%s', total_unaired]: if 0 < total: actions.append(msg % (total, helpers.maybe_plural(total))) logger.log(f'We want {" and ".join(actions)} for {show_obj.unique_name}') return wanted def search_for_needed_episodes(ep_obj_list): # type: (List[TVEpisode]) -> List[search_result_type] """ search for episodes in list :param ep_obj_list: list of episode objects :return: list of found search results """ found_results = {} search_done = False orig_thread_name = threading.current_thread().name providers = list(filter(lambda x: x.is_active() and x.enable_recentsearch, sickgear.providers.sorted_sources())) for cur_provider in providers: threading.current_thread().name = '%s :: [%s]' % (orig_thread_name, cur_provider.name) ep_obj_search_result_list = cur_provider.search_rss(ep_obj_list) search_done = True # pick a single result for each episode, respecting existing results for cur_ep_obj in ep_obj_search_result_list: if cur_ep_obj.show_obj.paused: logger.debug(f'Show {cur_ep_obj.show_obj.unique_name} is paused,' f' ignoring all RSS items for {cur_ep_obj.pretty_name()}') continue # find the best result for the current episode best_result = pick_best_result(ep_obj_search_result_list[cur_ep_obj], cur_ep_obj.show_obj, filter_rls=orig_thread_name) # if all results were rejected move on to the next episode if not best_result: logger.debug(f'All found results for {cur_ep_obj.pretty_name()} were rejected.') continue # if it's already in the list (from another provider) and the newly found quality is no better, then skip it if cur_ep_obj in found_results and best_result.quality <= found_results[cur_ep_obj].quality: continue # filter out possible bad torrents from providers if 'torrent' == best_result.resultType and 'blackhole' != sickgear.TORRENT_METHOD: best_result.content = None if not best_result.url.startswith('magnet'): best_result.content = best_result.provider.get_url(best_result.url, as_binary=True) if best_result.provider.should_skip(): break if not best_result.content: continue found_results[cur_ep_obj] = best_result try: cur_provider.fails.save_list() except (BaseException, Exception): pass threading.current_thread().name = orig_thread_name if not len(providers): logger.warning('No NZB/Torrent providers in Media Providers/Options are enabled to match recent episodes') elif not search_done: logger.error(f'Failed recent search of {len(providers)} enabled provider{helpers.maybe_plural(providers)}.' f' More info in debug log.') return list(found_results.values()) def can_reject(release_name): # type: (AnyStr) -> Union[Tuple[None, None],Tuple[True or AnyStr, AnyStr]] """ Check if a release name should be rejected at external services. If any site reports result as a valid scene release, then return None, None. If predb reports result as nuked, then return nuke reason and url attempted. If fail to find result at all services, return reject and url details for each site. :param release_name: Release title :return: None, None if release has no issue otherwise True/Nuke reason, URLs that rejected """ rej_urls = [] srrdb_url = 'https://www.srrdb.com/api/search/r:%s/order:date-desc' % re.sub(r'[][]', '', release_name) resp = helpers.get_url(srrdb_url, parse_json=True) if not resp: srrdb_rej = True rej_urls += ['Failed contact \'%s\'' % srrdb_url] else: srrdb_rej = (not len(resp.get('results', [])) or release_name.lower() != resp.get('results', [{}])[0].get('release', '').lower()) rej_urls += ([], ['\'%s\'' % srrdb_url])[srrdb_rej] sane_name = helpers.full_sanitize_scene_name(release_name) # predb_url = 'https://predb.ovh/api/v1/?q=@name "%s"' % sane_name predb_url = 'https://predb.de/api/?q=%s' % release_name resp = helpers.get_url(predb_url, parse_json=True) predb_rej = True if not resp: rej_urls += ['Failed contact \'%s\'' % predb_url] elif 'success' == resp.get('status', '').lower(): rows = resp and resp.get('data') or [] for data in rows: if sane_name == helpers.full_sanitize_scene_name((data.get('release') or '').strip()): nuke_type = helpers.try_int(data.get('status')) if not nuke_type: predb_rej = not helpers.try_int(data.get('pretime')) else: predb_rej = nuke_type not in (2, 4) and (data.get('reason') or 'Reason not set') break rej_urls += ([], ['\'%s\'' % predb_url])[bool(predb_rej)] pred = any([not srrdb_rej, not predb_rej]) return pred and (None, None) or (predb_rej or True, ', '.join(rej_urls)) def _search_provider_thread(provider, provider_results, show_obj, ep_obj_list, manual_search, try_other_searches): # type: (GenericProvider, Dict, TVShow, List[TVEpisode], bool, bool) -> None """ perform a search on a provider for specified show, episodes :param provider: Provider to search :param provider_results: reference to dict to return results :param show_obj: show to search for :param ep_obj_list: list of episodes to search for :param manual_search: is manual search :param try_other_searches: try other search methods """ search_count = 0 search_mode = getattr(provider, 'search_mode', 'eponly') while True: search_count += 1 if 'eponly' == search_mode: logger.log(f'Performing episode search for {show_obj.unique_name}') else: logger.log(f'Performing season pack search for {show_obj.unique_name}') try: provider.cache.clear_cache() search_result_list = provider.find_search_results(show_obj, ep_obj_list, search_mode, manual_search, try_other_searches=try_other_searches) if any(search_result_list): logger.log(', '.join(['%s %s candidate%s' % ( len(v), (('multiep', 'season')[SEASON_RESULT == k], 'episode')['ep' in search_mode], helpers.maybe_plural(v)) for (k, v) in iteritems(search_result_list)])) except exceptions_helper.AuthException as e: logger.error(f'Authentication error: {ex(e)}') break except (BaseException, Exception) as e: logger.error(f'Error while searching {provider.name}, skipping: {ex(e)}') logger.error(traceback.format_exc()) break if len(search_result_list): # make a list of all the results for this provider for cur_search_result in search_result_list: # skip non-tv crap search_result_list[cur_search_result] = list(filter( lambda ep_item: ep_item.show_obj == show_obj and show_name_helpers.pass_wordlist_checks( ep_item.name, parse=False, indexer_lookup=False, show_obj=ep_item.show_obj), search_result_list[cur_search_result])) if cur_search_result in provider_results: provider_results[cur_search_result] += search_result_list[cur_search_result] else: provider_results[cur_search_result] = search_result_list[cur_search_result] break elif not getattr(provider, 'search_fallback', False) or 2 == search_count: break search_mode = '%sonly' % ('ep', 'sp')['ep' in search_mode] logger.log(f'Falling back to {("season pack", "episode")["ep" in search_mode]} search ...') if not provider_results: logger.log('No suitable result at [%s]' % provider.name) def cache_torrent_file( search_result, # type: Union[sickgear.classes.SearchResult, TorrentSearchResult] show_obj, # type: TVShow **kwargs ): # type: (...) -> Optional[TorrentSearchResult] cache_file = os.path.join(sickgear.CACHE_DIR or helpers.get_system_temp_dir(), '%s.torrent' % (helpers.sanitize_filename(search_result.name))) if not helpers.download_file( search_result.url, cache_file, session=search_result.provider.session, failure_monitor=False): return try: with open(cache_file, 'rb') as fh: torrent_content = fh.read() except (BaseException, Exception): return try: # verify header re.findall(r'\w+\d+:', ('%s' % torrent_content)[0:6])[0] except (BaseException, Exception): return try: import torrent_parser as tp torrent_meta = tp.decode(torrent_content, use_ordered_dict=True, errors='usebytes') except (BaseException, Exception): return search_result.cache_filepath = cache_file search_result.content = torrent_content if isinstance(torrent_meta, dict): torrent_name = torrent_meta.get('info', {}).get('name') if torrent_name: # verify the name in torrent also passes filtration result_name = search_result.name search_result.name = torrent_name if search_result.provider.get_id() in ['tvchaosuk'] \ and hasattr(search_result.provider, 'regulate_cache_torrent_file'): torrent_name = search_result.provider.regulate_cache_torrent_file(torrent_name) if not _pick_best_result_helper([search_result], show_obj=show_obj, **kwargs) or \ not show_name_helpers.pass_wordlist_checks(torrent_name, indexer_lookup=False, show_obj=show_obj): logger.log(f'Ignored {result_name} that contains {torrent_name} (debug log has detail)') return return search_result def _pick_best_result_helper( results, # type: List[search_result_type] show_obj, # type: TVShow old_status=None, # type: int use_quality_list=None, # type: List[int] best_qualities=None, # type: List[int] orig_thread_name='' # type: AnyStr ): # type: (...) -> search_result_type """ helper to apply pick_best_result with filters :param results: SearchResults :param show_obj: show object :param old_status: old status of checked episode :param use_quality_list: default quality list :param best_qualities: best qualites for the episode :param orig_thread_name: original thread name :return: best matching result """ internal_quality_list = None if None is not use_quality_list and any([results[0].ep_obj_list]): old_status = old_status or \ failed_history.find_old_status(results[0].ep_obj_list[0]) or results[0].ep_obj_list[0].status if old_status: status, quality = Quality.split_composite_status(old_status) internal_quality_list = (status not in ( common.WANTED, common.FAILED, common.UNAIRED, common.SKIPPED, common.IGNORED, common.UNKNOWN)) quality_list = use_quality_list or (internal_quality_list and (None, best_qualities)[any(best_qualities)] or None) return pick_best_result(results=results, show_obj=show_obj, quality_list=quality_list, filter_rls=orig_thread_name) def search_providers( show_obj, # type: TVShow ep_obj_list, # type: List[TVEpisode] manual_search=False, # type: bool torrent_only=False, # type: bool try_other_searches=False, # type: bool old_status=None, # type: int scheduled=False # type: bool ): # type: (...) -> List[search_result_type] """ search provider for given episode objects from given show object :param show_obj: tv show object :param ep_obj_list: list of episode objects :param manual_search: manual search :param torrent_only: torrents only :param try_other_searches: try other searches :param old_status: old status :param scheduled: scheduled search :return: list of search result objects """ found_results = {} final_results = [] # type: List[search_result_type] search_done = False search_threads = [] orig_thread_name = threading.current_thread().name provider_list = [x for x in sickgear.providers.sorted_sources() if x.is_active() and getattr(x, 'enable_backlog', None) and (not torrent_only or GenericProvider.TORRENT == x.providerType) and (not scheduled or getattr(x, 'enable_scheduled_backlog', None))] # create a thread for each provider to search for cur_provider in provider_list: if cur_provider.anime_only and not show_obj.is_anime: logger.debug(f'{show_obj.unique_name} is not an anime, skipping') continue provider_id = cur_provider.get_id() found_results[provider_id] = {} search_threads.append(threading.Thread(target=_search_provider_thread, kwargs=dict(provider=cur_provider, provider_results=found_results[provider_id], show_obj=show_obj, ep_obj_list=ep_obj_list, manual_search=manual_search, try_other_searches=try_other_searches), name='%s :: [%s]' % (orig_thread_name, cur_provider.name))) # start the provider search thread search_threads[-1].start() search_done = True # wait for all searches to finish for s_t in search_threads: s_t.join() any_qualities, best_qualities = Quality.split_quality(show_obj.quality) params = dict(show_obj=show_obj, old_status=old_status, best_qualities=best_qualities, orig_thread_name=orig_thread_name) # now look in all the results for cur_provider in provider_list: provider_id = cur_provider.get_id() # skip to next provider if we have no results to process if provider_id not in found_results or not len(found_results[provider_id]): continue # pick the best season NZB best_season_result = None if SEASON_RESULT in found_results[provider_id]: best_season_result = _pick_best_result_helper( found_results[provider_id][SEASON_RESULT], show_obj=show_obj, use_quality_list=any_qualities + best_qualities, orig_thread_name=orig_thread_name) highest_quality_overall = 0 for cur_episode in found_results[provider_id]: for cur_result in found_results[provider_id][cur_episode]: if Quality.UNKNOWN != cur_result.quality and highest_quality_overall < cur_result.quality: highest_quality_overall = cur_result.quality logger.debug(f'{Quality.qualityStrings[highest_quality_overall]} is the highest quality of any match') # see if every episode is wanted if best_season_result: # get the quality of the season nzb season_qual = best_season_result.quality logger.debug(f'{Quality.qualityStrings[season_qual]} is the quality of the season' f' {best_season_result.provider.providerType}') my_db = db.DBConnection() sql = 'SELECT season, episode' \ ' FROM tv_episodes' \ ' WHERE indexer = %s AND showid = %s AND (season IN (%s))' % \ (show_obj.tvid, show_obj.prodid, ','.join([str(x.season) for x in ep_obj_list])) ep_nums = [(int(x['season']), int(x['episode'])) for x in my_db.select(sql)] logger.log(f'Executed query: [{sql}]') logger.debug(f'Episode list: {ep_nums}') all_wanted = True any_wanted = False for ep_num in ep_nums: if not show_obj.want_episode(ep_num[0], ep_num[1], season_qual): all_wanted = False else: any_wanted = True # if we need every ep in the season and there's nothing better, # then download this and be done with it (unless single episodes are preferred) if all_wanted and highest_quality_overall == best_season_result.quality: logger.log(f'Every episode in this season is needed, downloading the whole' f' {best_season_result.provider.providerType} {best_season_result.name}') ep_obj_list = [] for ep_num in ep_nums: ep_obj_list.append(show_obj.get_episode(ep_num[0], ep_num[1])) best_season_result.ep_obj_list = ep_obj_list return [best_season_result] elif not any_wanted: logger.debug(f'No episodes from this season are wanted at this quality,' f' ignoring the result of {best_season_result.name}') else: if GenericProvider.NZB == best_season_result.provider.providerType: logger.debug('Breaking apart the NZB and adding the individual ones to our results') # if not, break it apart and add them as the lowest priority results individual_results = nzbSplitter.split_result(best_season_result) for cur_result in filter( lambda r: r.show_obj == show_obj and show_name_helpers.pass_wordlist_checks( r.name, parse=False, indexer_lookup=False, show_obj=r.show_obj), individual_results): ep_num = None if 1 == len(cur_result.ep_obj_list): ep_num = cur_result.ep_obj_list[0].episode elif 1 < len(cur_result.ep_obj_list): ep_num = MULTI_EP_RESULT if ep_num in found_results[provider_id]: found_results[provider_id][ep_num].append(cur_result) else: found_results[provider_id][ep_num] = [cur_result] # If this is a torrent all we can do is leech the entire torrent, # user will have to select which eps not do download in his torrent client else: # Season result from Torrent Provider must be a full-season torrent, creating multi-ep result for it logger.log('Adding multi episode result for full season torrent. In your torrent client,' ' set the episodes that you do not want to "don\'t download"') ep_obj_list = [] for ep_num in ep_nums: ep_obj_list.append(show_obj.get_episode(ep_num[0], ep_num[1])) best_season_result.ep_obj_list = ep_obj_list if not best_season_result.url.startswith('magnet'): best_season_result = cache_torrent_file( best_season_result, show_obj=show_obj, orig_thread_name=orig_thread_name) if best_season_result: ep_num = MULTI_EP_RESULT if ep_num in found_results[provider_id]: found_results[provider_id][ep_num].append(best_season_result) else: found_results[provider_id][ep_num] = [best_season_result] # go through multi-ep results and see if we really want them or not, get rid of the rest multi_result_groups = {} # type: Dict[AnyStr, Union[List, search_result_type]] if MULTI_EP_RESULT in found_results[provider_id]: for multi_result in found_results[provider_id][MULTI_EP_RESULT]: logger.debug(f'Checking usefulness of multi episode result [{multi_result.name}]') if sickgear.USE_FAILED_DOWNLOADS and failed_history.has_failed(multi_result.name, multi_result.size, multi_result.provider.name): logger.log(f'Rejecting previously failed multi episode result [{multi_result.name}]') continue group_name = '-'.join(f'{_num}' for _num in sorted(_ep.episode for _ep in multi_result.ep_obj_list)) multi_result_groups.setdefault(group_name, []).append(multi_result) remove_list = [] for multi_group_name, multi_group_result in multi_result_groups.items(): best_result = _pick_best_result_helper(multi_group_result, **params) if best_result: multi_result_groups[multi_group_name] = best_result else: remove_list.append(multi_group_name) if remove_list: multi_result_groups = {k: v for k, v in multi_result_groups.items() if k not in remove_list} for cur_search_result in found_results[provider_id]: # type: int if cur_search_result in (MULTI_EP_RESULT, SEASON_RESULT): continue if 0 == len(found_results[provider_id][cur_search_result]): continue best_result = _pick_best_result_helper(results=found_results[provider_id][cur_search_result], **params) # if all results were rejected move on to the next episode if not best_result: continue # filter out possible bad torrents from providers if 'torrent' == best_result.resultType: if not best_result.url.startswith('magnet') and None is not best_result.get_data_func: best_result.url = best_result.get_data_func(best_result.url) best_result.get_data_func = None # consume only once if not best_result.url: continue if best_result.url.startswith('magnet'): if 'blackhole' != sickgear.TORRENT_METHOD: best_result.content = None else: best_result = cache_torrent_file(best_result, **params) if not best_result: continue if 'blackhole' == sickgear.TORRENT_METHOD: best_result.content = None if None is not best_result.after_get_data_func: best_result.after_get_data_func(best_result) best_result.after_get_data_func = None # consume only once # add result if it's not a duplicate found = False for i, result in enumerate(final_results): for best_result_ep in best_result.ep_obj_list: if best_result_ep in result.ep_obj_list: if best_result.quality > result.quality: final_results.pop(i) else: found = True if not found: final_results += [best_result] # check if we have multi episode results that should be taken if len(multi_result_groups): found_eps = {_ep: _res.quality for _res in final_results for _ep in _res.ep_obj_list} for multi_group_name, multi_group_result in multi_result_groups.items(): # type: AnyStr, search_result_type # if a episode result is only available in multi result or is better quality then single if (any(_ep not in found_eps for _ep in multi_group_result.ep_obj_list) or any(multi_group_result.quality > found_eps[_ep] for _ep in multi_group_result.ep_obj_list if _ep in found_eps)): final_results = [_res for _res in final_results if not any(_ep in _res.ep_obj_list for _ep in multi_group_result.ep_obj_list)] final_results += [multi_group_result] # check that we got all the episodes we wanted first before doing a match and snatch wanted_ep_count = 0 for wanted_ep in ep_obj_list: for result in final_results: if wanted_ep in result.ep_obj_list and is_final_result(result): wanted_ep_count += 1 # make sure we search every provider for results unless we found everything we wanted if len(ep_obj_list) == wanted_ep_count: break if not len(provider_list): logger.warning('No NZB/Torrent providers in Media Providers/Options are allowed for active searching') elif not search_done: logger.error(f'Failed active search of {len(provider_list)}' f' enabled provider{helpers.maybe_plural(provider_list)}. More info in debug log.') elif not any(final_results): logger.log('No suitable candidates') return final_results