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:
Adam Vigneaux
2026-07-28 00:54:19 -04:00
committed by GitHub
parent 7c457ff83e
commit 0d02c6db47
7 changed files with 240 additions and 14 deletions
+10
View File
@@ -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
View File
@@ -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",
+27 -11
View File
@@ -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
+18 -1
View File
@@ -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",
+71
View File
@@ -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()."""
+112
View File
@@ -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}"}
Generated
+1 -1
View File
@@ -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" },