mirror of
https://github.com/SickGear/SickGear.git
synced 2024-11-24 22:05:05 +00:00
1115 lines
48 KiB
Python
1115 lines
48 KiB
Python
#
|
|
# 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/>.
|
|
|
|
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
|