Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,9 @@ services:
# --- Download Clients ---
QBITTORRENT: >
- base_url: "http://qbittorrent:8080"
# username: "$QBIT_USERNAME" # (optional -> if not provided, assuming not needed)
# password: "$QBIT_PASSWORD" # (optional -> if not provided, assuming not needed)
# api_key: "$QBIT_API_KEY" # (recommended -> requires qBittorrent 5.2.0+; takes precedence over username/password)
# username: "$QBIT_USERNAME" # (legacy -> for qBittorrent < 5.2; ignored if api_key is set)
# password: "$QBIT_PASSWORD" # (legacy -> for qBittorrent < 5.2; ignored if api_key is set)
name: "qBittorrent 1" # (optional -> if not provided, assuming "qBittorrent". Must correspond with what is specified in your *arr as download client name)
- base_url: "http://qbittorrent:8080"
name: "qBittorrent 2"
Expand Down Expand Up @@ -704,8 +705,9 @@ Supported download clients: **qBittorrent** and **SABnzbd**.
- Type: List of qbit instances
- Keys per instance
- base_url: URL under which the qbit can be reached (mandatory)
- 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)
- password: Optional - see above
- 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.
- 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.
- password: Legacy - see above
- name: Optional. Needs to correspond with the name that you have set up in your Arr instance. Defaults to "qBittorrent"

#### SABNZBD
Expand Down
5 changes: 3 additions & 2 deletions config/config_example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,9 @@ instances:
download_clients:
qbittorrent:
- base_url: "http://qbittorrent:8080" # You can use decluttarr without qbit (not all features available, see readme).
# username: xxxx # (optional -> if not provided, assuming not needed)
# password: xxxx # (optional -> if not provided, assuming not needed)
# api_key: "qbt_xxxx" # (recommended -> requires qBittorrent 5.2.0+; generate under Web UI settings. Takes precedence over username/password.)
# username: xxxx # (legacy -> for qBittorrent < 5.2; ignored if api_key is set)
# password: xxxx # (legacy -> for qBittorrent < 5.2; ignored if api_key is set)
# name: "qBittorrent" # (optional -> if not provided, assuming "qBittorrent". Must correspond with what is specified in your *arr as download client name)
# timeout: 30 # (optional -> overrides general timeout for this instance)
# sabnzbd:
Expand Down
138 changes: 106 additions & 32 deletions src/settings/_download_clients_qbit.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ def __init__(
password: str = None,
name: str = None,
timeout: int | None = None,
api_key: str = None,
):
self.settings = settings
self._timeout = timeout
Expand All @@ -72,13 +73,20 @@ def __init__(
self.min_version = MinVersions.qbittorrent
self.username = username
self.password = password
# Treat an empty/whitespace api_key as unset so it falls back to password auth.
self.api_key = api_key.strip() if isinstance(api_key, str) else api_key
self.name = name
if not self.name:
logger.verbose(
"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",
)
self.name = "qBittorrent"

if self.api_key and (username or password):

@ManiMatter ManiMatter Jul 12, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to also add
a) an error that qbit needs to be updated for api key to be supported (if api_key and qbitversion < 5.2)?
b) a recommendation to switch from password to api key (if (username or password) and qbitversion >= 5.2)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, both are useful, with one limitation:
a) If api_key is configured and we successfully retrieve a qBittorrent version below 5.2, we should warn that the configured API key is unsupported and is not providing authentication. We should not fail the application or remove support for older qBittorrent versions.
If the initial Bearer request returns 403, we cannot retrieve the version because /app/version itself requires authentication. That response can mean either an invalid key on 5.2+ or an older server without API-key support, so the existing combined troubleshooting message remains necessary.
b) If username/password is configured and the retrieved version is 5.2+, we can reliably log an informational recommendation to switch to an API key while continuing normally.
I’ll add both post-version guidance checks without changing the existing qBittorrent 4.3 minimum or making API keys mandatory.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry - forgot to post that earlier. Anywho:

Updated in 701f67f. Added the post-version warning for API key + qBittorrent below 5.2 and the recommendation for username/password + qBittorrent 5.2+. These are guidance-only and do not change supported versions or authentication behavior. The existing ambiguous-403 handling remains unchanged. All 37 relevant tests pass. Ready for re-review.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me

logger.info(
f"qBittorrent '{self.name}': both api_key and username/password provided; using api_key (credentials ignored).",
)

self._remove_none_attributes()

@property
Expand All @@ -94,8 +102,27 @@ def _remove_none_attributes(self):
if getattr(self, attr) is None:
delattr(self, attr)

def _auth_kwargs(self) -> dict:
"""Return the make_request auth kwargs for the configured mode.

Key mode (qBit >= 5.2): stateless 'Authorization: Bearer' header on every
request. Password mode (legacy): the session SID cookie from refresh_cookie().
Must stay a method (not cached) so password mode reads the freshly
refreshed self.cookie each cycle.
"""
api_key = getattr(self, "api_key", None)
if api_key:
return {"headers": {"Authorization": f"Bearer {api_key}"}}
return {"cookies": getattr(self, "cookie", None)}

async def refresh_cookie(self):
"""Refresh the qBittorrent session cookie."""
if getattr(self, "api_key", None):
# Key mode is stateless; qBit rejects /auth/login under API-key auth.
logger.debug(
"_download_clients_qBit.py/refresh_cookie: API-key mode, skipping login",
)
return

def _connection_error():
error = "Login failed."
Expand Down Expand Up @@ -155,7 +182,7 @@ async def fetch_version(self):
endpoint,
self.settings,
timeout=self.timeout,
cookies=self.cookie,
**self._auth_kwargs(),
)
self.version = response.text[1:] # Remove the '_v' prefix
logger.debug(
Expand All @@ -177,13 +204,38 @@ async def validate_version(self):
"[Tip!] Consider upgrading to qBittorrent v5.0.0 or newer to reduce network overhead.",
)

def log_auth_version_guidance(self):
"""Log authentication guidance once the qBittorrent version is known."""
qbit_version = version.parse(self.version)
api_key = getattr(self, "api_key", None)

if api_key and qbit_version < version.parse("5.2.0"):
logger.warning(
"qBittorrent %s does not support API-key authentication; API keys "
"require qBittorrent 5.2.0 or newer. The configured API key did not "
"authenticate this connection.",
self.version,
)
elif (
not api_key
and (getattr(self, "username", None) or getattr(self, "password", None))
and qbit_version >= version.parse("5.2.0")
):
logger.info(
"[Tip!] qBittorrent %s supports API-key authentication. Consider "
"replacing username/password with api_key.",
self.version,
)

async def create_tag(self, tag: str):
"""Ensure a tag exists in qBittorrent; create it if it doesn't."""
logger.debug(
"_download_clients_qBit.py/create_tag: Checking if tag '{tag}' exists (and creating it if not)",
)
url = f"{self.api_url}/torrents/tags"
response = await make_request("get", url, self.settings, timeout=self.timeout, cookies=self.cookie)
response = await make_request(
"get", url, self.settings, timeout=self.timeout, **self._auth_kwargs()
)
current_tags = response.json()

if tag not in current_tags:
Expand All @@ -195,7 +247,7 @@ async def create_tag(self, tag: str):
self.settings,
timeout=self.timeout,
data=data,
cookies=self.cookie,
**self._auth_kwargs(),
)

async def create_required_tags(self):
Expand All @@ -220,7 +272,7 @@ async def set_unwanted_folder(self):
endpoint,
self.settings,
timeout=self.timeout,
cookies=self.cookie,
**self._auth_kwargs(),
)
qbit_settings = response.json()

Expand All @@ -235,7 +287,7 @@ async def set_unwanted_folder(self):
self.settings,
timeout=self.timeout,
data=data,
cookies=self.cookie,
**self._auth_kwargs(),
)

async def check_qbit_reachability(self):
Expand All @@ -244,24 +296,44 @@ async def check_qbit_reachability(self):
logger.debug(
"_download_clients_qBit.py/check_qbit_reachability: Checking if qbit is reachable",
)
endpoint = f"{self.api_url}/auth/login"
data = {
"username": getattr(self, "username", ""),
"password": getattr(self, "password", ""),
}
headers = {"content-type": "application/x-www-form-urlencoded"}
response = await make_request(
"post",
endpoint,
self.settings,
timeout=self.timeout,
data=data,
headers=headers,
log_error=False,
ignore_test_run=True,
)
if getattr(self, "api_key", None):
# Key mode: qBit rejects /auth/login, so probe a normal authed
# endpoint with the Bearer header instead.
await make_request(
"get",
f"{self.api_url}/app/version",
self.settings,
timeout=self.timeout,
log_error=False,
ignore_test_run=True,
**self._auth_kwargs(),
)
response = None # key mode: no login body to inspect below
else:
endpoint = f"{self.api_url}/auth/login"
data = {
"username": getattr(self, "username", ""),
"password": getattr(self, "password", ""),
}
headers = {"content-type": "application/x-www-form-urlencoded"}
response = await make_request(
"post",
endpoint,
self.settings,
timeout=self.timeout,
data=data,
headers=headers,
log_error=False,
ignore_test_run=True,
)
except Exception as e: # noqa: BLE001
tip = "💡 Tip: Did you specify the URL (and username/password if required) correctly?"
if getattr(self, "api_key", None):
tip = (
"💡 Tip: Is the qBittorrent API key correct, and is your qBittorrent "
"at least v5.2.0? API-key auth requires qBit 5.2+ (WebAPI 2.14.1+)."
)
else:
tip = "💡 Tip: Did you specify the URL (and username/password if required) correctly?"
if str(e) != self.last_error: # Only report new failure modes in full
logger.error(f"-- | qBittorrent\n❗️ {e}\n{tip}\n")
raise QbitError(e, tip=tip) from e
Expand Down Expand Up @@ -289,14 +361,15 @@ async def check_connected(self):
self.api_url + "/sync/maindata",
self.settings,
timeout=self.timeout,
cookies=self.cookie,
**self._auth_kwargs(),
)
).json()
)["server_state"]["connection_status"]
except Exception:
except Exception as e:
logger.warning(
">>> %s: Failed to reach /sync/maindata. Treating as disconnected.",
">>> %s: Failed to reach /sync/maindata (%s). Treating as disconnected.",
self.name,
e,
)
return False
if qbit_connection_status == "disconnected":
Expand All @@ -314,6 +387,7 @@ async def setup(self):
# Fetch version and validate it
await self.fetch_version()
await self.validate_version()
self.log_auth_version_guidance()

await self.create_required_tags()
await self.set_unwanted_folder()
Expand Down Expand Up @@ -411,7 +485,7 @@ async def set_tag(self, tags, hashes):
self.settings,
timeout=self.timeout,
data=data,
cookies=self.cookie,
**self._auth_kwargs(),
)

async def fetch_download_progress(self, download_id):
Expand All @@ -435,7 +509,7 @@ async def get_qbit_items(self, hashes: list[str] | str | None = None) -> list[di
settings=self.settings,
timeout=self.timeout,
params=None, # Retrieve all torrents
cookies=self.cookie,
**self._auth_kwargs(),
)

all_items = response.json()
Expand All @@ -458,7 +532,7 @@ async def get_torrent_properties(self, qbit_hash):
self.settings,
timeout=self.timeout,
params=params,
cookies=self.cookie,
**self._auth_kwargs(),
)
return response.json()

Expand All @@ -471,7 +545,7 @@ async def get_torrent_files(self, download_id):
settings=self.settings,
timeout=self.timeout,
params={"hash": download_id.lower()},
cookies=self.cookie,
**self._auth_kwargs(),
)
return response.json()

Expand All @@ -490,7 +564,7 @@ async def set_torrent_file_priority(self, download_id, file_id, priority=0):
self.settings,
timeout=self.timeout,
data=data,
cookies=self.cookie,
**self._auth_kwargs(),
)

async def set_bandwidth_usage(self):
Expand All @@ -501,7 +575,7 @@ async def set_bandwidth_usage(self):
endpoint=self.api_url + "/transfer/info",
settings=self.settings,
timeout=self.timeout,
cookies=self.cookie,
**self._auth_kwargs(),
)
records = extract_json_from_response(response)
limit = records["dl_rate_limit"]
Expand Down Expand Up @@ -539,5 +613,5 @@ async def remove_download(self, download_hash: str, delete_files: bool = True):
self.settings,
timeout=self.timeout,
data=data,
cookies=self.cookie,
**self._auth_kwargs(),
)
9 changes: 8 additions & 1 deletion src/utils/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ def sanitize_kwargs(data):
for key, value in data.items():
if (
key.lower()
in {"username", "password", "x-api-key", "apikey", "cookies"}
in {
"username",
"password",
"x-api-key",
"apikey",
"cookies",
"authorization",
}
and value
):
redacted[key] = "[**redacted**]"
Expand Down
Loading
Loading