def get_avg_indexer_response_times(after, before): result = [] response_times = [] for p in Indexer.select().order_by(Indexer.name): try: indexer = getIndexerByName(p.name) if not indexer.settings.enabled: logger.debug("Skipping download stats for %s" % p.name) continue except IndexerNotFoundException: logger.error("Unable to find indexer %s in configuration" % p.name) continue where = (IndexerApiAccess.response_successful) & ( IndexerApiAccess.indexer == p ) & (IndexerApiAccess.time > after) & (IndexerApiAccess.time < before) avg_response_time = IndexerApiAccess().select( fn.AVG(IndexerApiAccess.response_time)).where(where).tuples()[0][0] if avg_response_time: response_times.append({ "name": p.name, "avgResponseTime": int(avg_response_time) }) where = (IndexerApiAccess.response_successful) & ( IndexerApiAccess.response_time is not None) & ( IndexerApiAccess.time > after) & (IndexerApiAccess.time < before) avg_response_time = IndexerApiAccess().select( fn.AVG(IndexerApiAccess.response_time)).where(where).tuples()[0][0] for i in response_times: delta = i["avgResponseTime"] - avg_response_time i["delta"] = delta result.append(i) result = sorted(result, key=lambda x: x["name"]) result = sorted(result, key=lambda x: x["avgResponseTime"]) return result
def get_indexer_nzb_link(searchResultId, mode, log_api_access, internal=False): """ Build a link that leads to the actual NZB of the indexer using the given informations. We log this as indexer API access and NZB download because this is only called when the NZB will be actually downloaded later (by us or a downloader) :return: str """ searchResult = SearchResult.get(SearchResult.id == searchResultId) indexerName = searchResult.indexer.name indexer = indexers.getIndexerByName(indexerName) link = searchResult.link # Log to database papiaccess = IndexerApiAccess( indexer=indexer.indexer, type="nzb", url=link, response_successful=None) if log_api_access else None try: papiaccess.username = request.authorization.username if request.authorization is not None else None except RuntimeError: pass papiaccess.save() pnzbdl = IndexerNzbDownload(searchResult=searchResult, apiAccess=papiaccess, mode=mode, title=searchResult.title, internal=internal) pnzbdl.save() return link, papiaccess, pnzbdl
def get_url_with_papi_access(self, url, type, cookies=None, timeout=None, saveToDb=True): papiaccess = IndexerApiAccess(indexer=self.indexer, type=type, url=url, time=arrow.utcnow().datetime) try: papiaccess.username = request.authorization.username if request.authorization is not None else None except RuntimeError: #Is thrown when we're searching which is run in a thread. When downloading NFOs or whatever this will work pass indexerStatus = None try: time_before = arrow.utcnow() response = self.get(url, cookies=cookies, timeout=timeout) response.raise_for_status() time_after = arrow.utcnow() papiaccess.response_time = (time_after - time_before).seconds * 1000 + ((time_after - time_before).microseconds / 1000) papiaccess.response_successful = True indexerStatus = self.handle_indexer_success(saveIndexerStatus=saveToDb) except RequestException as e: self.logger.error("Error while connecting to URL %s: %s" % (url, str(e))) papiaccess.error = "Connection failed: %s" % removeSensitiveData(str(e)) response = None indexerStatus = self.handle_indexer_failure("Connection failed: %s" % removeSensitiveData(str(e)), saveIndexerStatus=saveToDb) finally: if saveToDb: papiaccess.save() return response, papiaccess, indexerStatus
def testIndexersApiLimits(self): config.settings.searching.generate_queries = [] self.newznab1.hitLimit = 3 self.newznab1.hitLimitResetTime = None config.settings.indexers = [self.newznab1] read_indexers_from_config() search_request = SearchRequest() indexers = search.pick_indexers(search_request) self.assertEqual(1, len(indexers)) dbsearch = Search(internal=True, time=arrow.utcnow().datetime) dbsearch.save() indexer = Indexer().get(name="newznab1") #Two accesses one and 12 hours ago IndexerApiAccess(indexer=indexer, search=dbsearch, time=arrow.utcnow().replace(hours=-1).datetime, type="search", url="", response_successful=True).save() IndexerApiAccess(indexer=indexer, search=dbsearch, time=arrow.utcnow().replace(hours=-12).datetime, type="search", url="", response_successful=True).save() self.assertEqual(1, len(search.pick_indexers(search_request))) #Another one 20 hours ago, so limit should be reached IndexerApiAccess(indexer=indexer, search=dbsearch, time=arrow.utcnow().replace(hours=-20).datetime, type="search", url="", response_successful=True).save() self.assertEqual(0, len(search.pick_indexers(search_request)))
def get_indexer_response_times(): result = [] for p in Indexer.select().order_by(Indexer.name): result.append({ "key": p.name, "values": [{ "responseTime": x.response_time, "date": x.time.timestamp } for x in IndexerApiAccess().select( IndexerApiAccess.response_time, IndexerApiAccess.time).where( (IndexerApiAccess.response_successful) & (IndexerApiAccess.indexer == p)).join(Indexer).limit(1)] }) return result
def get_avg_indexer_response_times(): result = [] response_times = [] for p in Indexer.select().order_by(Indexer.name): avg_response_time = IndexerApiAccess().select( fn.AVG(IndexerApiAccess.response_time)).where( (IndexerApiAccess.response_successful) & (IndexerApiAccess.indexer == p)).tuples()[0][0] if avg_response_time: response_times.append({ "name": p.name, "avgResponseTime": avg_response_time }) avg_response_time = IndexerApiAccess().select( fn.AVG(IndexerApiAccess.response_time)).where( (IndexerApiAccess.response_successful) & (IndexerApiAccess.response_time is not None)).tuples()[0][0] for i in response_times: delta = i["avgResponseTime"] - avg_response_time i["delta"] = delta result.append(i) return result
def checkHitOrDownloadLimit(p): if p.settings.hitLimit > 0 or p.settings.downloadLimit > 0: if p.settings.hitLimitResetTime: comparisonTime = arrow.utcnow().replace(hour=p.settings.hitLimitResetTime, minute=0, second=0) if comparisonTime > arrow.utcnow(): comparisonTime = arrow.get(comparisonTime.datetime - datetime.timedelta(days=1)) # Arrow is too dumb to properly subtract 1 day (throws an error on every first of the month) else: # Use rolling time window comparisonTime = arrow.get(arrow.utcnow().datetime - datetime.timedelta(days=1)) if p.settings.hitLimit > 0: apiHitsQuery = IndexerApiAccess().select().where((IndexerApiAccess.indexer == p.indexer) & (IndexerApiAccess.time > comparisonTime) & IndexerApiAccess.response_successful) apiHits = apiHitsQuery.count() if apiHits >= p.settings.hitLimit: if p.settings.hitLimitResetTime: logger.info("Did not pick %s because its API hit limit of %d was reached. Will pick again after %02d:00" % (p, p.settings.hitLimit, p.settings.hitLimitResetTime)) else: try: firstHitTimeInWindow = arrow.get(list(apiHitsQuery.order_by(IndexerApiAccess.time.desc()).offset(p.settings.hitLimit - 1).dicts())[0]["time"]).to("local") nextHitAfter = arrow.get(firstHitTimeInWindow + datetime.timedelta(days=1)) logger.info("Did not pick %s because its API hit limit of %d was reached. Next possible hit at %s" % (p, p.settings.hitLimit, nextHitAfter.format('YYYY-MM-DD HH:mm'))) except IndexerApiAccess.DoesNotExist: logger.info("Did not pick %s because its API hit limit of %d was reached" % (p, p.settings.hitLimit)) return False, "API limit reached" else: logger.debug("%s has had %d of a maximum of %d API hits since %02d:%02d" % (p, apiHits, p.settings.hitLimit, comparisonTime.hour, comparisonTime.minute)) if p.settings.downloadLimit > 0: downloadsQuery = IndexerNzbDownload().select(IndexerApiAccess, IndexerNzbDownload).join(IndexerApiAccess).where((IndexerApiAccess.indexer == p.indexer) & (IndexerApiAccess.time > comparisonTime)) downloads = downloadsQuery.count() if downloads >= p.settings.downloadLimit: if p.settings.hitLimitResetTime: logger.info("Did not pick %s because its download limit of %d was reached. Will pick again after %02d:00" % (p, p.settings.downloadLimit, p.settings.hitLimitResetTime)) else: try: firstHitTimeInWindow = arrow.get(list(downloadsQuery.order_by(IndexerApiAccess.time.desc()).offset(p.settings.downloadLimit - 1).limit(1).dicts())[0]["time"]).to("local") nextHitAfter = arrow.get(firstHitTimeInWindow + datetime.timedelta(days=1)) logger.info("Did not pick %s because its download limit of %d was reached. Next possible hit at %s" % (p, p.settings.downloadLimit, nextHitAfter.format('YYYY-MM-DD HH:mm'))) except IndexerApiAccess.DoesNotExist: logger.info("Did not pick %s because its download limit of %d was reached" % (p, p.settings.downloadLimit)) return False, "Download limit reached" else: logger.debug("%s has had %d of a maximum of %d downloads since %02d:%02d" % (p, downloads, p.settings.downloadLimit, comparisonTime.hour, comparisonTime.minute)) return True, None
def get_indexer_nzb_link(indexer_name, indexerguid, title, searchid, mode, log_api_access): """ Build a link that leads to the actual NZB of the indexer using the given informations. We log this as indexer API access and NZB download because this is only called when the NZB will be actually downloaded later (by us or a downloader) :return: str """ for p in indexers.enabled_indexers: if p.name.strip() == indexer_name.strip(): link = p.get_nzb_link(indexerguid, title) # Log to database indexer = Indexer.get( fn.lower(Indexer.name) == indexer_name.lower()) papiaccess = IndexerApiAccess( indexer=p.indexer, type="nzb", url=link, response_successful=None, indexer_search=searchid) if log_api_access else None try: papiaccess.username = request.authorization.username if request.authorization is not None else None except RuntimeError: pass papiaccess.save() pnzbdl = IndexerNzbDownload(indexer=indexer, indexer_search=searchid, api_access=papiaccess, mode=mode, title=title, guid=indexerguid) pnzbdl.save() return link, papiaccess, pnzbdl else: logger.error("Did not find indexer with name %s" % indexer_name) return None, None, None
def execute_queries(self, queries, searchRequest): if len(queries) == 0: return QueriesExecutionResult(didsearch=False, results=[], indexerSearchEntry=None, indexerApiAccessEntry=None, indexerStatus=None, total=0, loaded_results=0, total_known=True, has_more=False, rejected=self.getRejectedCountDict()) results = [] executed_queries = set() psearch = IndexerSearch(indexer=self.indexer) papiaccess = IndexerApiAccess() indexerStatus = None total_results = 0 total_known = False has_more = False rejected = self.getRejectedCountDict() while len(queries) > 0: query = queries.pop() if query in executed_queries: # To make sure that in case an offset is reported wrong or we have a bug we don't get stuck in an endless loop continue try: request, papiaccess, indexerStatus = self.get_url_with_papi_access( query, "search", saveToDb=False) papiaccess.indexer_search = psearch executed_queries.add(query) if request is not None: if request.text == "": raise IndexerResultParsingException( "Indexer returned an empty page", self) self.check_auth(request.text) self.debug("Successfully loaded URL %s" % request.url) try: parsed_results = self.process_query_result( request.content, searchRequest) results.extend(parsed_results.entries ) # Retrieve the processed results queries.extend( parsed_results.queries ) # Add queries that were added as a result of the parsing, e.g. when the next result page should also be loaded total_results += parsed_results.total total_known = parsed_results.total_known has_more = parsed_results.has_more rejected = parsed_results.rejected papiaccess.response_successful = True indexerStatus = self.handle_indexer_success(False) except Exception: self.exception( "Error while processing search results from indexer %s" % self) raise IndexerResultParsingException( "Error while parsing the results from indexer", self) except IndexerAuthException as e: papiaccess.error = "Authorization error :%s" % e.message self.error(papiaccess.error) indexerStatus = self.handle_indexer_failure( reason="Authentication failed", disable_permanently=True) papiaccess.response_successful = False except IndexerAccessException as e: papiaccess.error = "Access error: %s" % e.message self.error(papiaccess.error) indexerStatus = self.handle_indexer_failure( reason="Access failed") papiaccess.response_successful = False except IndexerResultParsingException as e: papiaccess.error = "Access error: %s" % e.message self.error(papiaccess.error) indexerStatus = self.handle_indexer_failure( reason="Parsing results failed") papiaccess.response_successful = False except Exception as e: self.exception("An error error occurred while searching: %s", e) if papiaccess is not None: papiaccess.error = "Unknown error :%s" % e papiaccess.response_successful = False finally: if papiaccess is not None: psearch.successful = papiaccess.response_successful else: self.error("Unable to save API response to database") psearch.resultsCount = total_results return QueriesExecutionResult(didsearch=True, results=results, indexerSearchEntry=psearch, indexerApiAccessEntry=papiaccess, indexerStatus=indexerStatus, total=total_results, loaded_results=len(results), total_known=total_known, has_more=has_more, rejected=rejected)
def pick_indexers(search_request): # type: (nzbhydra.search.SearchRequest, bool) -> List[nzbhydra.search_modules.SearchModule] query_supplied = True if search_request.query else False queryCanBeGenerated = None # Store if we can generate a query from IDs. Initiall true but when we need this the first time and query generation fails we set it to false picked_indexers = [] selected_indexers = search_request.indexers.split("|") if search_request.indexers is not None else None notPickedReasons = {} for p in indexers.enabled_indexers: if not p.settings.enabled: logger.debug("Did not pick %s because it is disabled" % p) add_not_picked_indexer(notPickedReasons, "Disabled", p.name) continue if search_request.internal and p.settings.accessType == "external": logger.debug("Did not pick %s because it is only enabled for external searches" % p) add_not_picked_indexer(notPickedReasons, "Disabled for API searches", p.name) continue if not search_request.internal and p.settings.accessType == "internal": logger.debug("Did not pick %s because it is only enabled for internal searches" % p) add_not_picked_indexer(notPickedReasons, "Disabled for API searches", p.name) continue if selected_indexers and p.name not in selected_indexers: logger.debug("Did not pick %s because it was not selected by the user" % p) add_not_picked_indexer(notPickedReasons, "Not selected by user", p.name) continue try: status = p.indexer.status.get() if status.disabled_until and status.disabled_until > arrow.utcnow() and not config.settings.searching.ignoreTemporarilyDisabled: logger.info("Did not pick %s because it is disabled temporarily due to an error: %s" % (p, status.reason)) add_not_picked_indexer(notPickedReasons, "Temporarily disabled", p.name) continue except IndexerStatus.DoesNotExist: pass if hasattr(p.settings, "categories") and len(p.settings.categories) > 0: if search_request.category.category.name != "all" and search_request.category.category.name not in p.settings.categories: logger.debug("Did not pick %s because it is not enabled for category %s" % (p, search_request.category.category.pretty)) add_not_picked_indexer(notPickedReasons, "Disabled for this category %s" % search_request.category.category.pretty, p.name) continue if p.settings.hitLimit > 0: if p.settings.hitLimitResetTime: comparisonTime = arrow.utcnow().replace(hour=p.settings.hitLimitResetTime, minute=0, second=0) if comparisonTime > arrow.utcnow(): comparisonTime = arrow.get(comparisonTime.datetime - datetime.timedelta(days=1)) # Arrow is too dumb to properly subtract 1 day (throws an error on every first of the month) else: # Use rolling time window comparisonTime = arrow.get(arrow.utcnow().datetime - datetime.timedelta(days=1)) apiHits = IndexerApiAccess().select().where((IndexerApiAccess.indexer == p.indexer) & (IndexerApiAccess.time > comparisonTime) & IndexerApiAccess.response_successful).count() if apiHits >= p.settings.hitLimit: if p.settings.hitLimitResetTime: logger.info("Did not pick %s because its API hit limit of %d was reached. Will pick again after %02d:00" % (p, p.settings.hitLimit, p.settings.hitLimitResetTime)) else: try: firstHitTimeInWindow = IndexerApiAccess().select().where(IndexerApiAccess.indexer == p.indexer & IndexerApiAccess.response_successful).order_by(IndexerApiAccess.time.desc()).offset(p.settings.hitLimit).limit(1).get().time.datetime nextHitAfter = arrow.get(firstHitTimeInWindow + datetime.timedelta(days=1)) logger.info("Did not pick %s because its API hit limit of %d was reached. Next possible hit at %s" % (p, p.settings.hitLimit, nextHitAfter.format('YYYY-MM-DD HH:mm'))) except IndexerApiAccess.DoesNotExist: logger.info("Did not pick %s because its API hit limit of %d was reached" % (p, p.settings.hitLimit)) add_not_picked_indexer(notPickedReasons, "API limit reached", p.name) continue else: logger.debug("%s has had %d of a maximum of %d API hits since %02d:%02d" % (p, apiHits, p.settings.hitLimit, comparisonTime.hour, comparisonTime.minute)) if (query_supplied or search_request.identifier_key is not None) and not p.supports_queries: logger.debug("Did not pick %s because a query was supplied but the indexer does not support queries" % p) add_not_picked_indexer(notPickedReasons, "Does not support queries", p.name) continue # Here on we check if we could supply the indexer with generated/retrieved data like the title of a series if not query_supplied and p.needs_queries and search_request.identifier_key is None: logger.debug("Did not pick %s because no query was supplied but the indexer needs queries" % p) add_not_picked_indexer(notPickedReasons, "Query needed", p.name) continue # If we can theoretically do that we must try to actually get the title, otherwise the indexer won't be able to search allow_query_generation = (config.InternalExternalSelection.internal in config.settings.searching.generate_queries and search_request.internal) or (config.InternalExternalSelection.external in config.settings.searching.generate_queries and not search_request.internal) if search_request.identifier_key is not None and not canUseIdKey(p, search_request.identifier_key): if not (allow_query_generation and p.generate_queries): logger.debug("Did not pick %s because search will be done by an identifier and the indexer or system wide settings don't allow query generation" % p) add_not_picked_indexer(notPickedReasons, "Does not support ID based searches", p.name) continue else: if queryCanBeGenerated is None: try: title = infos.convertId(search_request.identifier_key, "title", search_request.identifier_value) if title: search_request.title = title queryCanBeGenerated = True else: queryCanBeGenerated = False except: queryCanBeGenerated = False logger.debug("Unable to get title for supplied ID. Indexers that don't support the ID will be skipped") if not queryCanBeGenerated: logger.debug("Did not pick %s because search will be done by an identifier and retrieval of the title for query generation failed" % p) add_not_picked_indexer(notPickedReasons, "Does not support ID based searches", p.name) continue logger.debug("Picked %s" % p) picked_indexers.append(p) if len(picked_indexers) == 0: warning = "No indexeres were selected for this search:" for reason, notPickedIndexers in notPickedReasons.items(): warning += "\r\n%s: %s" % (reason, ", ".join(notPickedIndexers)) logger.warn(warning) return picked_indexers
def pick_indexers(search_request, internal=True): # type: (nzbhydra.search.SearchRequest, bool) -> List[nzbhydra.search_modules.SearchModule] query_supplied = True if search_request.query else False queryCanBeGenerated = None #Store if we can generate a query from IDs. Initiall true but when we need this the first time and query generation fails we set it to false picked_indexers = [] selected_indexers = search_request.indexers.split( "|") if search_request.indexers is not None else None for p in indexers.enabled_indexers: if not p.settings.enabled: logger.debug("Did not pick %s because it is disabled" % p) continue if internal and p.settings.accessType == "external": logger.debug( "Did not pick %s because it is only enabled for external searches" % p) continue if not internal and p.settings.accessType == "internal": logger.debug( "Did not pick %s because it is only enabled for internal searches" % p) continue if selected_indexers and p.name not in selected_indexers: logger.debug( "Did not pick %s because it was not selected by the user" % p) continue try: status = p.indexer.status.get() if status.disabled_until > arrow.utcnow( ) and not config.settings.searching.ignoreTemporarilyDisabled: logger.info( "Did not pick %s because it is disabled temporarily due to an error: %s" % (p, status.reason)) continue except IndexerStatus.DoesNotExist: pass if p.settings.hitLimit > 0: if p.settings.hitLimitResetTime: hitLimitResetTime = arrow.get(p.settings.hitLimitResetTime) comparisonTime = arrow.now().replace( hour=hitLimitResetTime.hour, minute=hitLimitResetTime.minute, second=0) if comparisonTime > arrow.now(): comparisonTime = arrow.get( comparisonTime.datetime - timedelta(days=1) ) #Arrow is too dumb to properly subtract 1 day (throws an error on every first of the month) else: comparisonTime = arrow.now().replace(hour=0, minute=0, second=0) apiHits = IndexerApiAccess().select().where( (IndexerApiAccess.indexer == p.indexer) & (IndexerApiAccess.time > comparisonTime) & IndexerApiAccess.response_successful).count() if apiHits > p.settings.hitLimit: logger.info( "Did not pick %s because its API hit limit of %d was reached" % (p, p.settings.hitLimit)) continue else: logger.debug( "%s has had %d of a maximum of %d API hits since %02d:%02d" % (p, apiHits, p.settings.hitLimit, comparisonTime.hour, comparisonTime.minute)) if (query_supplied or search_request.identifier_key is not None) and not p.supports_queries: logger.debug( "Did not pick %s because a query was supplied but the indexer does not support queries" % p) continue # Here on we check if we could supply the indexer with generated/retrieved data like the title of a series if not query_supplied and p.needs_queries and search_request.identifier_key is None: logger.debug( "Did not pick %s because no query was supplied but the indexer needs queries" % p) continue # If we can theoretically do that we must try to actually get the title, otherwise the indexer won't be able to search allow_query_generation = ( config.InternalExternalSelection.internal in config.settings.searching.generate_queries and internal) or (config.InternalExternalSelection.external in config.settings.searching.generate_queries and not internal) if search_request.identifier_key is not None and not canUseIdKey( p, search_request.identifier_key): if not (allow_query_generation and p.generate_queries): logger.debug( "Did not pick %s because search will be done by an identifier and the indexer or system wide settings don't allow query generation" % p) continue else: if queryCanBeGenerated is None: try: title = infos.convertId( search_request.identifier_key, "title", search_request.identifier_value) if title: search_request.title = title queryCanBeGenerated = True else: queryCanBeGenerated = False except: queryCanBeGenerated = False logger.debug( "Unable to get title for supplied ID. Indexers that don't support the ID will be skipped" ) if not queryCanBeGenerated: logger.debug( "Did not pick %s because search will be done by an identifier and retrieval of the title for query generation failed" % p) continue logger.debug("Picked %s" % p) picked_indexers.append(p) return picked_indexers