fix(qbittorrent): recognize QBT_SID session cookie (newer qBittorrent)

Newer qBittorrent renamed its session cookie from "SID" to "QBT_SID" /
"QBT_SID_<port>" (the diagnostic revealed cookies=['QBT_SID_5080']). The
client only accepted "SID", so a valid login (cookie present in the jar) was
reported as "Unexpected response". Re-entering correct credentials never
helped because login was succeeding all along.

Treat any cookie named "SID" OR starting with "QBT_SID" as the session
cookie, checked in both the parsed jar and the raw Set-Cookie header.
qBittorrent only sets this cookie on a valid login, so it stays authoritative.
Diagnostic message updated to mention both names.

New regression test covers the QBT_SID_<port> case. 388/388 backend tests
pass; ruff clean.
This commit is contained in:
Developer
2026-07-12 11:09:10 +00:00
parent 7e53edfcc6
commit 39775a82ef
4 changed files with 32 additions and 14 deletions
@@ -75,14 +75,20 @@ class QbittorrentClient:
resp.raise_for_status()
body = resp.text.strip()
# qBittorrent signals a successful login with the body "Ok." and/or by
# setting a SID session cookie. Some setups (newer qBittorrent, or some
# reverse proxies) return 204 No Content with the SID cookie and no
# body. ``requests`` can also fail to populate the cookie jar for
# cookies with attributes it doesn't parse, so check the raw
# Set-Cookie header as well. qBittorrent only sets SID on a valid login.
# setting a session cookie. The cookie is named "SID" in older versions
# and "QBT_SID" / "QBT_SID_<port>" in newer ones. Some setups return 204
# No Content with the cookie and no body, and ``requests`` doesn't always
# populate the cookie jar, so check both the jar and the raw Set-Cookie
# header. qBittorrent only sets this cookie on a valid login.
def _is_session_cookie(name: str) -> bool:
upper = name.strip().upper()
return upper == "SID" or upper.startswith("QBT_SID")
set_cookie_hdr = resp.headers.get("Set-Cookie", "") or ""
first_cookie_name = set_cookie_hdr.split("=", 1)[0].strip().upper()
sid_ok = bool(resp.cookies.get("SID")) or first_cookie_name == "SID"
first_cookie_name = set_cookie_hdr.split("=", 1)[0].strip()
sid_ok = any(_is_session_cookie(k) for k in resp.cookies.keys()) or (
bool(first_cookie_name) and _is_session_cookie(first_cookie_name)
)
if body == "Ok." or sid_ok:
self._logged_in = True
logger.info("qBittorrent login successful for %s", self.base_url)
@@ -92,9 +98,10 @@ class QbittorrentClient:
cookie_names = sorted(resp.cookies.keys()) or (["<unparsed>"] if set_cookie_hdr else [])
raise RuntimeError(
f"Unexpected response from qBittorrent login endpoint (HTTP {resp.status_code}, "
f"body={body!r}, cookies={cookie_names}). Expected the text 'Ok.' or an SID cookie "
"from /api/v2/auth/login — this usually means base_url does not reach the qBittorrent "
"Web API (check the URL, path, and any reverse proxy in front of qBittorrent)."
f"body={body!r}, cookies={cookie_names}). Expected the text 'Ok.' or a session "
"cookie (SID / QBT_SID) from /api/v2/auth/login — this usually means base_url does "
"not reach the qBittorrent Web API (check the URL, path, and any reverse proxy in "
"front of qBittorrent)."
)
def _get(self, path: str, **params: Any) -> dict[str, Any]: