Skip to content

Commit 929017c

Browse files
committed
Support qBittorrent 5.2 API-key authentication (closes ManiMatter#353)
qBittorrent 5.2 added stateless API-key auth (Authorization: Bearer), which lets users avoid storing username/password credentials. This adds an optional `api_key` field to qBittorrent clients and makes it the recommended auth path; username/password remain as a legacy fallback for qBit < 5.2. - QbitClient accepts `api_key`; a new `_auth_kwargs()` helper returns the Bearer header in key mode or the SID cookie in password mode, used at every authenticated request site. - refresh_cookie() and check_qbit_reachability() branch for key mode: login is skipped (qBit rejects /auth/login under key auth) and reachability probes /app/version with the Bearer header instead. A bad key or a <5.2 server both surface a clear tip and degrade instead of crashing. - If both api_key and username/password are set, the key wins (mirrors qBit), logged once at init. - Redact `Authorization` in sanitize_kwargs so the token never leaks in DEBUG logs. - Docs (config example, README) present api_key as recommended, creds as legacy. Tests cover header-vs-cookie selection, login skip, precedence, and the 403 degrade path.
1 parent 6c037b8 commit 929017c

5 files changed

Lines changed: 215 additions & 37 deletions

File tree

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,9 @@ services:
288288
# --- Download Clients ---
289289
QBITTORRENT: >
290290
- base_url: "http://qbittorrent:8080"
291-
# username: "$QBIT_USERNAME" # (optional -> if not provided, assuming not needed)
292-
# password: "$QBIT_PASSWORD" # (optional -> if not provided, assuming not needed)
291+
# api_key: "$QBIT_API_KEY" # (recommended -> requires qBittorrent 5.2.0+; takes precedence over username/password)
292+
# username: "$QBIT_USERNAME" # (legacy -> for qBittorrent < 5.2; ignored if api_key is set)
293+
# password: "$QBIT_PASSWORD" # (legacy -> for qBittorrent < 5.2; ignored if api_key is set)
293294
name: "qBittorrent 1" # (optional -> if not provided, assuming "qBittorrent". Must correspond with what is specified in your *arr as download client name)
294295
- base_url: "http://qbittorrent:8080"
295296
name: "qBittorrent 2"
@@ -704,8 +705,9 @@ Supported download clients: **qBittorrent** and **SABnzbd**.
704705
- Type: List of qbit instances
705706
- Keys per instance
706707
- base_url: URL under which the qbit can be reached (mandatory)
707-
- username: Optional - only needed if your qbit requires authentication (which you may not need if you have configured qbit in a way that it disables it for local connections)
708-
- password: Optional - see above
708+
- api_key: Recommended - qBittorrent API key (requires qBittorrent 5.2.0 or newer; generate it under Web UI settings). Authenticates without storing credentials, and takes precedence over username/password if both are set.
709+
- username: Legacy - only for qBittorrent < 5.2, or if your qbit requires authentication (which you may not need if qbit disables it for local connections). Ignored when api_key is set.
710+
- password: Legacy - see above
709711
- name: Optional. Needs to correspond with the name that you have set up in your Arr instance. Defaults to "qBittorrent"
710712

711713
#### SABNZBD

config/config_example.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,9 @@ instances:
6969
download_clients:
7070
qbittorrent:
7171
- base_url: "http://qbittorrent:8080" # You can use decluttarr without qbit (not all features available, see readme).
72-
# username: xxxx # (optional -> if not provided, assuming not needed)
73-
# password: xxxx # (optional -> if not provided, assuming not needed)
72+
# api_key: "qbt_xxxx" # (recommended -> requires qBittorrent 5.2.0+; generate under Web UI settings. Takes precedence over username/password.)
73+
# username: xxxx # (legacy -> for qBittorrent < 5.2; ignored if api_key is set)
74+
# password: xxxx # (legacy -> for qBittorrent < 5.2; ignored if api_key is set)
7475
# name: "qBittorrent" # (optional -> if not provided, assuming "qBittorrent". Must correspond with what is specified in your *arr as download client name)
7576
# sabnzbd:
7677
# - base_url: "http://sabnzbd:8080" # SABnzbd server URL

src/settings/_download_clients_qbit.py

Lines changed: 75 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def __init__(
5858
username: str = None,
5959
password: str = None,
6060
name: str = None,
61+
api_key: str = None,
6162
):
6263
self.settings = settings
6364
if not base_url:
@@ -70,13 +71,20 @@ def __init__(
7071
self.min_version = MinVersions.qbittorrent
7172
self.username = username
7273
self.password = password
74+
# Treat an empty/whitespace api_key as unset so it falls back to password auth.
75+
self.api_key = api_key.strip() if isinstance(api_key, str) else api_key
7376
self.name = name
7477
if not self.name:
7578
logger.verbose(
7679
"No name provided for qbittorrent client, assuming 'qBitorrent'. If the name used in your *arr is different, please correct either the name in your *arr, or set the name in your config",
7780
)
7881
self.name = "qBittorrent"
7982

83+
if self.api_key and (username or password):
84+
logger.info(
85+
f"qBittorrent '{self.name}': both api_key and username/password provided; using api_key (credentials ignored).",
86+
)
87+
8088
self._remove_none_attributes()
8189

8290
def _remove_none_attributes(self):
@@ -85,8 +93,27 @@ def _remove_none_attributes(self):
8593
if getattr(self, attr) is None:
8694
delattr(self, attr)
8795

96+
def _auth_kwargs(self) -> dict:
97+
"""Return the make_request auth kwargs for the configured mode.
98+
99+
Key mode (qBit >= 5.2): stateless 'Authorization: Bearer' header on every
100+
request. Password mode (legacy): the session SID cookie from refresh_cookie().
101+
Must stay a method (not cached) so password mode reads the freshly
102+
refreshed self.cookie each cycle.
103+
"""
104+
api_key = getattr(self, "api_key", None)
105+
if api_key:
106+
return {"headers": {"Authorization": f"Bearer {api_key}"}}
107+
return {"cookies": getattr(self, "cookie", None)}
108+
88109
async def refresh_cookie(self):
89110
"""Refresh the qBittorrent session cookie."""
111+
if getattr(self, "api_key", None):
112+
# Key mode is stateless; qBit rejects /auth/login under API-key auth.
113+
logger.debug(
114+
"_download_clients_qBit.py/refresh_cookie: API-key mode, skipping login",
115+
)
116+
return
90117

91118
def _connection_error():
92119
error = "Login failed."
@@ -144,7 +171,7 @@ async def fetch_version(self):
144171
"get",
145172
endpoint,
146173
self.settings,
147-
cookies=self.cookie,
174+
**self._auth_kwargs(),
148175
)
149176
self.version = response.text[1:] # Remove the '_v' prefix
150177
logger.debug(
@@ -172,7 +199,7 @@ async def create_tag(self, tag: str):
172199
"_download_clients_qBit.py/create_tag: Checking if tag '{tag}' exists (and creating it if not)",
173200
)
174201
url = f"{self.api_url}/torrents/tags"
175-
response = await make_request("get", url, self.settings, cookies=self.cookie)
202+
response = await make_request("get", url, self.settings, **self._auth_kwargs())
176203
current_tags = response.json()
177204

178205
if tag not in current_tags:
@@ -183,7 +210,7 @@ async def create_tag(self, tag: str):
183210
self.api_url + "/torrents/createTags",
184211
self.settings,
185212
data=data,
186-
cookies=self.cookie,
213+
**self._auth_kwargs(),
187214
)
188215

189216
async def create_required_tags(self):
@@ -207,7 +234,7 @@ async def set_unwanted_folder(self):
207234
"get",
208235
endpoint,
209236
self.settings,
210-
cookies=self.cookie,
237+
**self._auth_kwargs(),
211238
)
212239
qbit_settings = response.json()
213240

@@ -221,33 +248,51 @@ async def set_unwanted_folder(self):
221248
self.api_url + "/app/setPreferences",
222249
self.settings,
223250
data=data,
224-
cookies=self.cookie,
251+
**self._auth_kwargs(),
225252
)
226253

227254
async def check_qbit_reachability(self):
228-
"""Check if the qBittorrent URL is reachable."""
255+
"""Check if the qBittorrent URL is reachable (and the credentials work)."""
229256
try:
230257
logger.debug(
231258
"_download_clients_qBit.py/check_qbit_reachability: Checking if qbit is reachable",
232259
)
233-
endpoint = f"{self.api_url}/auth/login"
234-
data = {
235-
"username": getattr(self, "username", ""),
236-
"password": getattr(self, "password", ""),
237-
}
238-
headers = {"content-type": "application/x-www-form-urlencoded"}
239-
await make_request(
240-
"post",
241-
endpoint,
242-
self.settings,
243-
data=data,
244-
headers=headers,
245-
log_error=False,
246-
ignore_test_run=True,
247-
)
260+
if getattr(self, "api_key", None):
261+
# Key mode: qBit rejects /auth/login, so probe a normal authed
262+
# endpoint with the Bearer header instead.
263+
await make_request(
264+
"get",
265+
f"{self.api_url}/app/version",
266+
self.settings,
267+
log_error=False,
268+
ignore_test_run=True,
269+
**self._auth_kwargs(),
270+
)
271+
else:
272+
endpoint = f"{self.api_url}/auth/login"
273+
data = {
274+
"username": getattr(self, "username", ""),
275+
"password": getattr(self, "password", ""),
276+
}
277+
headers = {"content-type": "application/x-www-form-urlencoded"}
278+
await make_request(
279+
"post",
280+
endpoint,
281+
self.settings,
282+
data=data,
283+
headers=headers,
284+
log_error=False,
285+
ignore_test_run=True,
286+
)
248287

249288
except Exception as e: # noqa: BLE001
250-
tip = "💡 Tip: Did you specify the URL (and username/password if required) correctly?"
289+
if getattr(self, "api_key", None):
290+
tip = (
291+
"💡 Tip: Is the qBittorrent API key correct, and is your qBittorrent "
292+
"at least v5.2.0? API-key auth requires qBit 5.2+ (WebAPI 2.14.1+)."
293+
)
294+
else:
295+
tip = "💡 Tip: Did you specify the URL (and username/password if required) correctly?"
251296
if str(e) != self.last_error: # Only report new failure modes in full
252297
logger.error(f"-- | qBittorrent\n❗️ {e}\n{tip}\n")
253298
raise QbitError(e, tip=tip) from e
@@ -263,7 +308,7 @@ async def check_connected(self):
263308
"get",
264309
self.api_url + "/sync/maindata",
265310
self.settings,
266-
cookies=self.cookie,
311+
**self._auth_kwargs(),
267312
)
268313
).json()
269314
)["server_state"]["connection_status"]
@@ -378,7 +423,7 @@ async def set_tag(self, tags, hashes):
378423
self.api_url + "/torrents/addTags",
379424
self.settings,
380425
data=data,
381-
cookies=self.cookie,
426+
**self._auth_kwargs(),
382427
)
383428

384429
async def fetch_download_progress(self, download_id):
@@ -401,7 +446,7 @@ async def get_qbit_items(self, hashes: list[str] | str | None = None) -> list[di
401446
endpoint=f"{self.api_url}/torrents/info",
402447
settings=self.settings,
403448
params=None, # Retrieve all torrents
404-
cookies=self.cookie,
449+
**self._auth_kwargs(),
405450
)
406451

407452
all_items = response.json()
@@ -423,7 +468,7 @@ async def get_torrent_properties(self, qbit_hash):
423468
self.api_url + "/torrents/properties",
424469
self.settings,
425470
params=params,
426-
cookies=self.cookie,
471+
**self._auth_kwargs(),
427472
)
428473
return response.json()
429474

@@ -435,7 +480,7 @@ async def get_torrent_files(self, download_id):
435480
endpoint=self.api_url + "/torrents/files",
436481
settings=self.settings,
437482
params={"hash": download_id.lower()},
438-
cookies=self.cookie,
483+
**self._auth_kwargs(),
439484
)
440485
return response.json()
441486

@@ -453,7 +498,7 @@ async def set_torrent_file_priority(self, download_id, file_id, priority=0):
453498
self.api_url + "/torrents/filePrio",
454499
self.settings,
455500
data=data,
456-
cookies=self.cookie,
501+
**self._auth_kwargs(),
457502
)
458503

459504
async def set_bandwidth_usage(self):
@@ -463,7 +508,7 @@ async def set_bandwidth_usage(self):
463508
method="get",
464509
endpoint=self.api_url + "/transfer/info",
465510
settings=self.settings,
466-
cookies=self.cookie,
511+
**self._auth_kwargs(),
467512
)
468513
records = extract_json_from_response(response)
469514
limit = records["dl_rate_limit"]
@@ -500,5 +545,5 @@ async def remove_download(self, download_hash: str, delete_files: bool = True):
500545
f"{self.api_url}/torrents/delete",
501546
self.settings,
502547
data=data,
503-
cookies=self.cookie,
548+
**self._auth_kwargs(),
504549
)

src/utils/common.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,14 @@ def sanitize_kwargs(data):
2626
for key, value in data.items():
2727
if (
2828
key.lower()
29-
in {"username", "password", "x-api-key", "apikey", "cookies"}
29+
in {
30+
"username",
31+
"password",
32+
"x-api-key",
33+
"apikey",
34+
"cookies",
35+
"authorization",
36+
}
3037
and value
3138
):
3239
redacted[key] = "[**redacted**]"

0 commit comments

Comments
 (0)