Skip to content

Commit 6564212

Browse files
authored
Merge pull request #367 from jrhager84/qbit-api-key-353
Support qBittorrent 5.2 API key authentication
2 parents 5050cc4 + 701f67f commit 6564212

6 files changed

Lines changed: 300 additions & 39 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
@@ -71,8 +71,9 @@ instances:
7171
download_clients:
7272
qbittorrent:
7373
- base_url: "http://qbittorrent:8080" # You can use decluttarr without qbit (not all features available, see readme).
74-
# username: xxxx # (optional -> if not provided, assuming not needed)
75-
# password: xxxx # (optional -> if not provided, assuming not needed)
74+
# api_key: "qbt_xxxx" # (recommended -> requires qBittorrent 5.2.0+; generate under Web UI settings. Takes precedence over username/password.)
75+
# username: xxxx # (legacy -> for qBittorrent < 5.2; ignored if api_key is set)
76+
# password: xxxx # (legacy -> for qBittorrent < 5.2; ignored if api_key is set)
7677
# name: "qBittorrent" # (optional -> if not provided, assuming "qBittorrent". Must correspond with what is specified in your *arr as download client name)
7778
# timeout: 30 # (optional -> overrides general timeout for this instance)
7879
# sabnzbd:

src/settings/_download_clients_qbit.py

Lines changed: 106 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def __init__(
5959
password: str = None,
6060
name: str = None,
6161
timeout: int | None = None,
62+
api_key: str = None,
6263
):
6364
self.settings = settings
6465
self._timeout = timeout
@@ -72,13 +73,20 @@ def __init__(
7273
self.min_version = MinVersions.qbittorrent
7374
self.username = username
7475
self.password = password
76+
# Treat an empty/whitespace api_key as unset so it falls back to password auth.
77+
self.api_key = api_key.strip() if isinstance(api_key, str) else api_key
7578
self.name = name
7679
if not self.name:
7780
logger.verbose(
7881
"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",
7982
)
8083
self.name = "qBittorrent"
8184

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

8492
@property
@@ -94,8 +102,27 @@ def _remove_none_attributes(self):
94102
if getattr(self, attr) is None:
95103
delattr(self, attr)
96104

105+
def _auth_kwargs(self) -> dict:
106+
"""Return the make_request auth kwargs for the configured mode.
107+
108+
Key mode (qBit >= 5.2): stateless 'Authorization: Bearer' header on every
109+
request. Password mode (legacy): the session SID cookie from refresh_cookie().
110+
Must stay a method (not cached) so password mode reads the freshly
111+
refreshed self.cookie each cycle.
112+
"""
113+
api_key = getattr(self, "api_key", None)
114+
if api_key:
115+
return {"headers": {"Authorization": f"Bearer {api_key}"}}
116+
return {"cookies": getattr(self, "cookie", None)}
117+
97118
async def refresh_cookie(self):
98119
"""Refresh the qBittorrent session cookie."""
120+
if getattr(self, "api_key", None):
121+
# Key mode is stateless; qBit rejects /auth/login under API-key auth.
122+
logger.debug(
123+
"_download_clients_qBit.py/refresh_cookie: API-key mode, skipping login",
124+
)
125+
return
99126

100127
def _connection_error():
101128
error = "Login failed."
@@ -155,7 +182,7 @@ async def fetch_version(self):
155182
endpoint,
156183
self.settings,
157184
timeout=self.timeout,
158-
cookies=self.cookie,
185+
**self._auth_kwargs(),
159186
)
160187
self.version = response.text[1:] # Remove the '_v' prefix
161188
logger.debug(
@@ -177,13 +204,38 @@ async def validate_version(self):
177204
"[Tip!] Consider upgrading to qBittorrent v5.0.0 or newer to reduce network overhead.",
178205
)
179206

207+
def log_auth_version_guidance(self):
208+
"""Log authentication guidance once the qBittorrent version is known."""
209+
qbit_version = version.parse(self.version)
210+
api_key = getattr(self, "api_key", None)
211+
212+
if api_key and qbit_version < version.parse("5.2.0"):
213+
logger.warning(
214+
"qBittorrent %s does not support API-key authentication; API keys "
215+
"require qBittorrent 5.2.0 or newer. The configured API key did not "
216+
"authenticate this connection.",
217+
self.version,
218+
)
219+
elif (
220+
not api_key
221+
and (getattr(self, "username", None) or getattr(self, "password", None))
222+
and qbit_version >= version.parse("5.2.0")
223+
):
224+
logger.info(
225+
"[Tip!] qBittorrent %s supports API-key authentication. Consider "
226+
"replacing username/password with api_key.",
227+
self.version,
228+
)
229+
180230
async def create_tag(self, tag: str):
181231
"""Ensure a tag exists in qBittorrent; create it if it doesn't."""
182232
logger.debug(
183233
"_download_clients_qBit.py/create_tag: Checking if tag '{tag}' exists (and creating it if not)",
184234
)
185235
url = f"{self.api_url}/torrents/tags"
186-
response = await make_request("get", url, self.settings, timeout=self.timeout, cookies=self.cookie)
236+
response = await make_request(
237+
"get", url, self.settings, timeout=self.timeout, **self._auth_kwargs()
238+
)
187239
current_tags = response.json()
188240

189241
if tag not in current_tags:
@@ -195,7 +247,7 @@ async def create_tag(self, tag: str):
195247
self.settings,
196248
timeout=self.timeout,
197249
data=data,
198-
cookies=self.cookie,
250+
**self._auth_kwargs(),
199251
)
200252

201253
async def create_required_tags(self):
@@ -220,7 +272,7 @@ async def set_unwanted_folder(self):
220272
endpoint,
221273
self.settings,
222274
timeout=self.timeout,
223-
cookies=self.cookie,
275+
**self._auth_kwargs(),
224276
)
225277
qbit_settings = response.json()
226278

@@ -235,7 +287,7 @@ async def set_unwanted_folder(self):
235287
self.settings,
236288
timeout=self.timeout,
237289
data=data,
238-
cookies=self.cookie,
290+
**self._auth_kwargs(),
239291
)
240292

241293
async def check_qbit_reachability(self):
@@ -244,24 +296,44 @@ async def check_qbit_reachability(self):
244296
logger.debug(
245297
"_download_clients_qBit.py/check_qbit_reachability: Checking if qbit is reachable",
246298
)
247-
endpoint = f"{self.api_url}/auth/login"
248-
data = {
249-
"username": getattr(self, "username", ""),
250-
"password": getattr(self, "password", ""),
251-
}
252-
headers = {"content-type": "application/x-www-form-urlencoded"}
253-
response = await make_request(
254-
"post",
255-
endpoint,
256-
self.settings,
257-
timeout=self.timeout,
258-
data=data,
259-
headers=headers,
260-
log_error=False,
261-
ignore_test_run=True,
262-
)
299+
if getattr(self, "api_key", None):
300+
# Key mode: qBit rejects /auth/login, so probe a normal authed
301+
# endpoint with the Bearer header instead.
302+
await make_request(
303+
"get",
304+
f"{self.api_url}/app/version",
305+
self.settings,
306+
timeout=self.timeout,
307+
log_error=False,
308+
ignore_test_run=True,
309+
**self._auth_kwargs(),
310+
)
311+
response = None # key mode: no login body to inspect below
312+
else:
313+
endpoint = f"{self.api_url}/auth/login"
314+
data = {
315+
"username": getattr(self, "username", ""),
316+
"password": getattr(self, "password", ""),
317+
}
318+
headers = {"content-type": "application/x-www-form-urlencoded"}
319+
response = await make_request(
320+
"post",
321+
endpoint,
322+
self.settings,
323+
timeout=self.timeout,
324+
data=data,
325+
headers=headers,
326+
log_error=False,
327+
ignore_test_run=True,
328+
)
263329
except Exception as e: # noqa: BLE001
264-
tip = "💡 Tip: Did you specify the URL (and username/password if required) correctly?"
330+
if getattr(self, "api_key", None):
331+
tip = (
332+
"💡 Tip: Is the qBittorrent API key correct, and is your qBittorrent "
333+
"at least v5.2.0? API-key auth requires qBit 5.2+ (WebAPI 2.14.1+)."
334+
)
335+
else:
336+
tip = "💡 Tip: Did you specify the URL (and username/password if required) correctly?"
265337
if str(e) != self.last_error: # Only report new failure modes in full
266338
logger.error(f"-- | qBittorrent\n❗️ {e}\n{tip}\n")
267339
raise QbitError(e, tip=tip) from e
@@ -289,14 +361,15 @@ async def check_connected(self):
289361
self.api_url + "/sync/maindata",
290362
self.settings,
291363
timeout=self.timeout,
292-
cookies=self.cookie,
364+
**self._auth_kwargs(),
293365
)
294366
).json()
295367
)["server_state"]["connection_status"]
296-
except Exception:
368+
except Exception as e:
297369
logger.warning(
298-
">>> %s: Failed to reach /sync/maindata. Treating as disconnected.",
370+
">>> %s: Failed to reach /sync/maindata (%s). Treating as disconnected.",
299371
self.name,
372+
e,
300373
)
301374
return False
302375
if qbit_connection_status == "disconnected":
@@ -314,6 +387,7 @@ async def setup(self):
314387
# Fetch version and validate it
315388
await self.fetch_version()
316389
await self.validate_version()
390+
self.log_auth_version_guidance()
317391

318392
await self.create_required_tags()
319393
await self.set_unwanted_folder()
@@ -411,7 +485,7 @@ async def set_tag(self, tags, hashes):
411485
self.settings,
412486
timeout=self.timeout,
413487
data=data,
414-
cookies=self.cookie,
488+
**self._auth_kwargs(),
415489
)
416490

417491
async def fetch_download_progress(self, download_id):
@@ -435,7 +509,7 @@ async def get_qbit_items(self, hashes: list[str] | str | None = None) -> list[di
435509
settings=self.settings,
436510
timeout=self.timeout,
437511
params=None, # Retrieve all torrents
438-
cookies=self.cookie,
512+
**self._auth_kwargs(),
439513
)
440514

441515
all_items = response.json()
@@ -458,7 +532,7 @@ async def get_torrent_properties(self, qbit_hash):
458532
self.settings,
459533
timeout=self.timeout,
460534
params=params,
461-
cookies=self.cookie,
535+
**self._auth_kwargs(),
462536
)
463537
return response.json()
464538

@@ -471,7 +545,7 @@ async def get_torrent_files(self, download_id):
471545
settings=self.settings,
472546
timeout=self.timeout,
473547
params={"hash": download_id.lower()},
474-
cookies=self.cookie,
548+
**self._auth_kwargs(),
475549
)
476550
return response.json()
477551

@@ -490,7 +564,7 @@ async def set_torrent_file_priority(self, download_id, file_id, priority=0):
490564
self.settings,
491565
timeout=self.timeout,
492566
data=data,
493-
cookies=self.cookie,
567+
**self._auth_kwargs(),
494568
)
495569

496570
async def set_bandwidth_usage(self):
@@ -501,7 +575,7 @@ async def set_bandwidth_usage(self):
501575
endpoint=self.api_url + "/transfer/info",
502576
settings=self.settings,
503577
timeout=self.timeout,
504-
cookies=self.cookie,
578+
**self._auth_kwargs(),
505579
)
506580
records = extract_json_from_response(response)
507581
limit = records["dl_rate_limit"]
@@ -539,5 +613,5 @@ async def remove_download(self, download_hash: str, delete_files: bool = True):
539613
self.settings,
540614
timeout=self.timeout,
541615
data=data,
542-
cookies=self.cookie,
616+
**self._auth_kwargs(),
543617
)

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)