diff --git a/nova3/engines/jackett.py b/nova3/engines/jackett.py index 0ea675fa..429ed90b 100644 --- a/nova3/engines/jackett.py +++ b/nova3/engines/jackett.py @@ -1,4 +1,4 @@ -# VERSION: 4.13 +# VERSION: 4.14 # AUTHORS: Diego de las Heras (ngosang@hotmail.es) # CONTRIBUTORS: ukharley # hannsen (github.com/hannsen) @@ -7,6 +7,7 @@ import datetime import json import os +import sys import urllib.request import xml.etree.ElementTree from http.cookiejar import CookieJar @@ -146,15 +147,15 @@ def search(self, what: str, cat: str = 'all') -> None: # check api_key if self.api_key == "YOUR_API_KEY_HERE": - self.handle_error("api key error", what) + self.handle_error("API key not configured in jackett.json", what) return # search in Jackett API - if self.thread_count > 1: - indexers = self.get_jackett_indexers(what) - if not indexers: - return + indexers = self.get_jackett_indexers(what) + if not indexers: + return + if self.thread_count > 1: args = ((what, category, indexer) for indexer in indexers) with Pool(min(len(indexers), self.thread_count)) as pool: pool.starmap(self.search_jackett_indexer, args) @@ -170,13 +171,21 @@ def get_jackett_indexers(self, what: str) -> List[str]: jacket_url = f"{self.url}/api/v2.0/indexers/all/results/torznab/api?{params}" response = self.get_response(jacket_url) if response is None: - self.handle_error("connection error getting indexer list", what) + self.handle_error("cannot contact Jackett; check that it is running and the API key is correct", what) return [] # process results - response_xml = xml.etree.ElementTree.fromstring(response) + try: + response_xml = xml.etree.ElementTree.fromstring(response) + except xml.etree.ElementTree.ParseError as error: + raise RuntimeError(f"Invalid response from Jackett. {what}") from error + if response_xml.tag == 'error': + description = response_xml.attrib.get('description', 'unknown error') + raise RuntimeError(f"Jackett API error: {description}. {what}") indexers: List[str] = [] for indexer in response_xml.findall('indexer'): indexers.append(indexer.attrib['id']) + if not indexers: + self.handle_error("no indexers configured in Jackett; add one in the Jackett UI", what) return indexers def search_jackett_indexer(self, what: str, category: Union[List[str], None], indexer_id: str) -> None: @@ -278,6 +287,7 @@ def get_response(self, query: str) -> Union[str, None]: return response def handle_error(self, error_msg: str, what: str) -> None: + print(f"Jackett: {error_msg}. Search: {what}", file=sys.stderr) # we need to print the search text to be displayed in qBittorrent when # 'Torrent names only' is enabled self.pretty_printer_thread_safe({ diff --git a/nova3/engines/versions.txt b/nova3/engines/versions.txt index 3c684d05..c7829a47 100644 --- a/nova3/engines/versions.txt +++ b/nova3/engines/versions.txt @@ -1,5 +1,5 @@ eztv: 1.24 -jackett: 4.13 +jackett: 4.14 limetorrents: 4.15 piratebay: 3.10 solidtorrents: 2.9 diff --git a/nova3/tests/test_jackett.py b/nova3/tests/test_jackett.py index a47a0992..a59c7344 100644 --- a/nova3/tests/test_jackett.py +++ b/nova3/tests/test_jackett.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from unittest.mock import Mock + import pytest from ..engines import jackett @@ -8,5 +12,56 @@ def test_jackett(capfd: pytest.CaptureFixture[str]) -> None: engine.search('linux', 'all') capturedOutput = capfd.readouterr() - assert capturedOutput.err == "" - assert len(capturedOutput.out) >= 0 + assert capturedOutput.err or capturedOutput.out + + +def test_jackett_reports_missing_api_key(capfd: pytest.CaptureFixture[str]) -> None: + engine = jackett.jackett() + engine.api_key = 'YOUR_API_KEY_HERE' + engine.search('linux', 'all') + + assert capfd.readouterr().err + + +@pytest.mark.parametrize( + 'response', + [ + None, + '', + ], +) +def test_jackett_reports_setup_errors(monkeypatch: pytest.MonkeyPatch, capfd: pytest.CaptureFixture[str], response: str | None) -> None: + engine = jackett.jackett() + engine.api_key = 'test-key' + engine.thread_count = 1 + + monkeypatch.setattr(engine, 'get_response', Mock(return_value=response)) + engine.search('linux', 'all') + + assert capfd.readouterr().err + + +@pytest.mark.parametrize('response', [ + '', +]) +def test_jackett_raises_for_bad_response(monkeypatch: pytest.MonkeyPatch, response: str) -> None: + engine = jackett.jackett() + engine.api_key = 'test-key' + + monkeypatch.setattr(engine, 'get_response', Mock(return_value=response)) + + with pytest.raises(RuntimeError): + engine.search('linux', 'all') + + +def test_jackett_checks_indexers_without_concurrency(monkeypatch: pytest.MonkeyPatch) -> None: + engine = jackett.jackett() + engine.api_key = 'test-key' + engine.thread_count = 1 + search_mock = Mock() + monkeypatch.setattr(engine, 'get_response', Mock(return_value='')) + monkeypatch.setattr(engine, 'search_jackett_indexer', search_mock) + engine.search('linux', 'all') + + search_mock.assert_called_once_with('linux', None, 'all')