mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
[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" />
113 lines
3.7 KiB
Python
113 lines
3.7 KiB
Python
"""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}"}
|