mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 13:40:21 +01:00
Add qBittorrent API key authentication (#1143)
[qBittorrent 5.2.0](https://www.qbittorrent.org/news#sun-may-03rd-2026---qbittorrent-v5.2.0-release) (May 2026) added support for API key-based authentication in addition to the existing username/password-based authentication. This commit adds support for qBittorrent API key authentication to Shelfmark, configurable via environment variable or settings UI. If an API key is set at the same time as the username/password, API key will be preferred for authentication. Requires `qbittorrent-api` 2026.5.3, the version that added the `api_key` argument, or newer. `403 Forbidden` responses are not retried with API key authentication because a retry has no chance of succeeding. Tested end-to-end with my live qBittorrent 5.2.3 instance (WebAPI v2.15.1). <img width="785" height="616" alt="image" src="https://github.com/user-attachments/assets/8064e10e-9a7f-49f0-804e-4c441d23a1fc" />
This commit is contained in:
@@ -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**
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()."""
|
||||
|
||||
|
||||
@@ -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}"}
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user