diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 33e4aac7..57e9c963 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -1501,6 +1501,7 @@ How long to keep cached search results before they expire. | `QBITTORRENT_URL` | Web UI URL of your qBittorrent instance | string | _none_ | | `QBITTORRENT_USERNAME` | qBittorrent Web UI username | string | _none_ | | `QBITTORRENT_PASSWORD` | qBittorrent Web UI password | string (secret) | _none_ | +| `QBITTORRENT_API_KEY` | Found in qBittorrent: Options > Web UI > API Key (qBittorrent 5.2.0+). Used instead of the username and password when set. | string (secret) | _none_ | | `QBITTORRENT_CATEGORY` | Category to assign to book downloads in qBittorrent | string | `books` | | `QBITTORRENT_CATEGORY_AUDIOBOOK` | Category for audiobook downloads. Leave empty to use the book category. | string | _empty string_ | | `QBITTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses qBittorrent default if not specified) | string | _none_ | @@ -1575,6 +1576,15 @@ qBittorrent Web UI password - **Type:** string (secret) - **Default:** _none_ +#### `QBITTORRENT_API_KEY` + +**API Key** + +Found in qBittorrent: Options > Web UI > API Key (qBittorrent 5.2.0+). Used instead of the username and password when set. + +- **Type:** string (secret) +- **Default:** _none_ + #### `QBITTORRENT_CATEGORY` **Book Category** diff --git a/pyproject.toml b/pyproject.toml index ac1eec7f..dc9594ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "psutil", "emoji", "rarfile", - "qbittorrent-api", + "qbittorrent-api>=2026.5.3", "transmission-rpc", "authlib>=1.7.2,<1.8", "apprise>=1.12.0", diff --git a/shelfmark/download/clients/qbittorrent.py b/shelfmark/download/clients/qbittorrent.py index 904afd7e..431b7558 100644 --- a/shelfmark/download/clients/qbittorrent.py +++ b/shelfmark/download/clients/qbittorrent.py @@ -182,15 +182,15 @@ class QBittorrentClient(DownloadClient): params = {"hash": torrent_hash} try: - self._client.auth_log_in() + self._ensure_authenticated() response = self._client._session.get(url, params=params, timeout=10) # Re-authenticate and retry once on 403 - if response.status_code == _HTTP_STATUS_FORBIDDEN: + if response.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate: logger.debug( "qBittorrent returned 403 for properties; re-authenticating and retrying" ) - self._client.auth_log_in() + self._ensure_authenticated() response = self._client._session.get(url, params=params, timeout=10) if response.status_code == _HTTP_STATUS_FORBIDDEN: @@ -244,6 +244,7 @@ class QBittorrentClient(DownloadClient): username = config_text(config.get("QBITTORRENT_USERNAME", "")) password = config_text(config.get("QBITTORRENT_PASSWORD", "")) + self._api_key = config_text(config.get("QBITTORRENT_API_KEY", "")) # qbittorrent-api accepts either a full URL or host:port; prefer the normalized URL # for consistency. @@ -251,12 +252,28 @@ class QBittorrentClient(DownloadClient): host=self._base_url, username=username, password=password, + api_key=self._api_key or None, VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(self._base_url), ) self._category = config_text(config.get("QBITTORRENT_CATEGORY", "books")) self._download_dir = config_text(config.get("QBITTORRENT_DOWNLOAD_DIR", "")) self._tags = _normalize_tags(config.get("QBITTORRENT_TAG", [])) + @property + def _can_reauthenticate(self) -> bool: + """Whether a 403 is worth retrying; a bearer token cannot be refreshed like a session.""" + return not self._api_key + + def _ensure_authenticated(self) -> None: + """Authenticate the underlying HTTP session before it is used directly. + + API keys (qBittorrent 5.2.0+) are sent as a bearer header on every request and + have no login endpoint, so there is no session to establish up front. + """ + if self._api_key: + return + self._client.auth_log_in() + def _get_torrents_info( self, torrent_hash: str | None = None, category: str | None = None ) -> tuple[list[SimpleNamespace], str | None]: @@ -276,8 +293,7 @@ class QBittorrentClient(DownloadClient): url = f"{self._base_url}/api/v2/torrents/info" def do_request(params: dict[str, str]) -> requests.Response: - # Ensure session is authenticated before using it directly - self._client.auth_log_in() + self._ensure_authenticated() return self._client._session.get(url, params=params, timeout=10) def parse_response( @@ -285,9 +301,9 @@ class QBittorrentClient(DownloadClient): *, request_params: dict[str, str], ) -> tuple[list[SimpleNamespace], str | None]: - if response.status_code == _HTTP_STATUS_FORBIDDEN: + if response.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate: logger.debug("qBittorrent returned 403; re-authenticating and retrying") - self._client.auth_log_in() + self._ensure_authenticated() response = self._client._session.get(url, params=request_params, timeout=10) if response.status_code == _HTTP_STATUS_FORBIDDEN: @@ -409,7 +425,7 @@ class QBittorrentClient(DownloadClient): def test_connection(self) -> tuple[bool, str]: """Test connection to qBittorrent.""" try: - self._client.auth_log_in() + self._ensure_authenticated() api_version = self._client.app.web_api_version except _QBITTORRENT_CLIENT_ERRORS as e: return False, f"Connection failed: {e!s}" @@ -728,11 +744,11 @@ class QBittorrentClient(DownloadClient): import os def get_with_auth(url: str, params: dict[str, str]) -> requests.Response: - self._client.auth_log_in() + self._ensure_authenticated() resp = self._client._session.get(url, params=params, timeout=10) - if resp.status_code == _HTTP_STATUS_FORBIDDEN: + if resp.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate: logger.debug("qBittorrent returned 403; re-authenticating and retrying") - self._client.auth_log_in() + self._ensure_authenticated() resp = self._client._session.get(url, params=params, timeout=10) return resp diff --git a/shelfmark/download/clients/settings.py b/shelfmark/download/clients/settings.py index 21084c27..abccd742 100644 --- a/shelfmark/download/clients/settings.py +++ b/shelfmark/download/clients/settings.py @@ -159,6 +159,7 @@ def _test_qbittorrent_connection(current_values: dict[str, Any] | None = None) - raw_url = _resolve_string_setting(current_values, config.get, "QBITTORRENT_URL") username = _resolve_string_setting(current_values, config.get, "QBITTORRENT_USERNAME") password = _resolve_string_setting(current_values, config.get, "QBITTORRENT_PASSWORD") + api_key = _resolve_string_setting(current_values, config.get, "QBITTORRENT_API_KEY") if not raw_url: return {"success": False, "message": "qBittorrent URL is required"} @@ -174,6 +175,7 @@ def _test_qbittorrent_connection(current_values: dict[str, Any] | None = None) - host=url, username=username, password=password, + api_key=api_key or None, VERIFY_WEBUI_CERTIFICATE=get_ssl_verify(url), ) client.auth_log_in() @@ -181,9 +183,18 @@ def _test_qbittorrent_connection(current_values: dict[str, Any] | None = None) - except ImportError: return {"success": False, "message": "qbittorrent-api package not installed"} except _QBITTORRENT_SETTINGS_ERRORS as e: + if isinstance(e, _QBittorrentLoginFailed): + # LoginFailed carries no message of its own, so name the rejected credential. + rejected = "API key" if api_key else "username or password" + return {"success": False, "message": f"qBittorrent rejected the {rejected}"} return {"success": False, "message": f"Connection failed: {e!s}"} else: - return {"success": True, "message": f"Connected to qBittorrent (API v{api_version})"} + # Both credentials can be set at once, so name the one that actually authenticated. + used = " using the API key" if api_key else "" + return { + "success": True, + "message": f"Connected to qBittorrent (API v{api_version}){used}", + } def _test_transmission_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]: @@ -572,6 +583,12 @@ def prowlarr_clients_settings() -> list[SettingsField]: description="qBittorrent Web UI password", show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, ), + PasswordField( + key="QBITTORRENT_API_KEY", + label="API Key", + description="Found in qBittorrent: Options > Web UI > API Key (qBittorrent 5.2.0+). Used instead of the username and password when set.", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, + ), ActionButton( key="test_qbittorrent", label="Test Connection", diff --git a/tests/prowlarr/test_qbittorrent_client.py b/tests/prowlarr/test_qbittorrent_client.py index 8d2fbb6a..540fb52f 100644 --- a/tests/prowlarr/test_qbittorrent_client.py +++ b/tests/prowlarr/test_qbittorrent_client.py @@ -5,6 +5,7 @@ These tests mock the qbittorrentapi library to test the client logic without requiring a running qBittorrent instance. """ +from contextlib import contextmanager from unittest.mock import MagicMock, patch import pytest @@ -179,6 +180,76 @@ class TestQBittorrentClientTestConnection: assert "401" in message or "failed" in message.lower() +class TestQBittorrentClientApiKeyAuth: + """Tests for API key authentication (qBittorrent 5.2.0+).""" + + API_KEY = "qbt_0123456789abcdefghijklmnopqr" + + @contextmanager + def _build_client(self, monkeypatch, api_key, mock_client_instance=None): + """Construct the client against a stubbed qbittorrentapi, yielding it and the stub.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_API_KEY": api_key, + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "shelfmark.download.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_client_class = MagicMock(return_value=mock_client_instance or MagicMock()) + + with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}): + import importlib + + import shelfmark.download.clients.qbittorrent as qb_module + + importlib.reload(qb_module) + yield qb_module.QBittorrentClient(), mock_client_class + + @pytest.mark.parametrize( + ("api_key", "expected_kwarg"), + [(API_KEY, API_KEY), ("", None)], + ) + def test_api_key_forwarded_to_qbittorrentapi(self, monkeypatch, api_key, expected_kwarg): + """A configured key is handed to qbittorrent-api; without one it falls back to password.""" + with self._build_client(monkeypatch, api_key) as (_client, mock_client_class): + assert mock_client_class.call_args.kwargs["api_key"] == expected_kwarg + + @pytest.mark.parametrize(("api_key", "logs_in"), [(API_KEY, False), ("", True)]) + def test_direct_requests_log_in_only_for_cookie_auth(self, monkeypatch, api_key, logs_in): + """Bearer auth is stateless, so only cookie auth needs a login before direct calls.""" + mock_client_instance = MagicMock() + mock_client_instance._session.get.return_value = create_mock_session_response( + [MockTorrent(progress=0.5, state="downloading")], status_code=200 + ) + + with self._build_client(monkeypatch, api_key, mock_client_instance) as (client, _): + status = client.get_status("abc123") + + assert status.progress == 50.0 + assert mock_client_instance.auth_log_in.called is logs_in + + @pytest.mark.parametrize(("api_key", "expected_requests"), [(API_KEY, 1), ("", 2)]) + def test_403_retried_only_when_a_login_can_refresh_it( + self, monkeypatch, api_key, expected_requests + ): + """A bearer token cannot be refreshed, so re-issuing a 403 would just waste a request.""" + mock_client_instance = MagicMock() + mock_client_instance._session.get.return_value = create_mock_session_response( + [], status_code=403 + ) + + with self._build_client(monkeypatch, api_key, mock_client_instance) as (client, _): + status = client.get_status("abc123") + + assert status.state_value == "error" + assert mock_client_instance._session.get.call_count == expected_requests + + class TestQBittorrentClientGetStatus: """Tests for QBittorrentClient.get_status().""" diff --git a/tests/prowlarr/test_qbittorrent_settings.py b/tests/prowlarr/test_qbittorrent_settings.py new file mode 100644 index 00000000..6b164952 --- /dev/null +++ b/tests/prowlarr/test_qbittorrent_settings.py @@ -0,0 +1,112 @@ +"""qBittorrent download client settings fields and test-connection callback.""" + +import types +from unittest.mock import patch + +import pytest +from qbittorrentapi import LoginFailed + +from shelfmark.core.settings_registry import PasswordField + +API_KEY = "qbt_0123456789abcdefghijklmnopqr" + + +def make_config_getter(values): + """Create a config.get function that returns values from a dict.""" + + def getter(key, default=""): + return values.get(key, default) + + return getter + + +def _get_field(fields, key): + """Find a field by key.""" + return next((f for f in fields if f.key == key), None) + + +def fake_qbittorrentapi(*, web_api_version="2.15.1", reject_auth=False, captured=None): + """Build a stand-in qbittorrentapi module recording Client kwargs into `captured`.""" + + class FakeClient: + def __init__(self, **kwargs): + if captured is not None: + captured.update(kwargs) + self.app = types.SimpleNamespace(web_api_version=web_api_version) + + def auth_log_in(self): + if reject_auth: + raise LoginFailed + + module = types.ModuleType("qbittorrentapi") + module.Client = FakeClient + return module + + +def _run_test_connection(monkeypatch, current_values, fake_module): + """Invoke the Test Connection callback against a stubbed qbittorrentapi.""" + from shelfmark.core.config import config as config_obj + from shelfmark.download.clients import settings as settings_module + + monkeypatch.setattr(config_obj, "get", make_config_getter(current_values)) + monkeypatch.setattr(settings_module, "get_ssl_verify", lambda _url: True) + + with patch.dict("sys.modules", {"qbittorrentapi": fake_module}): + return settings_module._test_qbittorrent_connection(current_values=current_values) + + +def test_api_key_field_is_registered(): + """The API key is offered alongside the other qBittorrent credentials.""" + from shelfmark.download.clients.settings import prowlarr_clients_settings + + field = _get_field(prowlarr_clients_settings(), "QBITTORRENT_API_KEY") + + assert isinstance(field, PasswordField) + assert field.show_when == {"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"} + + +@pytest.mark.parametrize( + ("api_key", "expected_kwarg", "expected_suffix"), + [(API_KEY, API_KEY, " using the API key"), ("", None, "")], +) +def test_settings_test_connection_forwards_api_key( + monkeypatch, api_key, expected_kwarg, expected_suffix +): + """The Test Connection button authenticates with the key when set, and says which it used.""" + captured = {} + result = _run_test_connection( + monkeypatch, + { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_API_KEY": api_key, + }, + fake_qbittorrentapi(captured=captured), + ) + + assert captured["api_key"] == expected_kwarg + assert result == { + "success": True, + "message": f"Connected to qBittorrent (API v2.15.1){expected_suffix}", + } + + +@pytest.mark.parametrize( + ("api_key", "rejected"), + [(API_KEY, "API key"), ("", "username or password")], +) +def test_settings_test_connection_names_rejected_credential(monkeypatch, api_key, rejected): + """LoginFailed carries no message of its own, so the callback names the credential.""" + result = _run_test_connection( + monkeypatch, + { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_API_KEY": api_key, + }, + fake_qbittorrentapi(reject_auth=True), + ) + + assert result == {"success": False, "message": f"qBittorrent rejected the {rejected}"} diff --git a/uv.lock b/uv.lock index 81b76756..2d39917f 100644 --- a/uv.lock +++ b/uv.lock @@ -1390,7 +1390,7 @@ requires-dist = [ { name = "python-socketio" }, { name = "python-xlib", marker = "extra == 'browser'" }, { name = "pyvirtualdisplay", marker = "extra == 'browser'" }, - { name = "qbittorrent-api" }, + { name = "qbittorrent-api", specifier = ">=2026.5.3" }, { name = "rarfile" }, { name = "requests", extras = ["socks"] }, { name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.51.6" },