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
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/clients
## role
Provides external service integration clients for authenticating users and aggregating media, filesystem, and torrent data from APIs and remote/local hosts.
Package of API and protocol client wrappers that standardize communication with external services (media servers, identity providers, torrent clients, remote/local hosts) for the media library viewer backend.
## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md
@@ -4,7 +4,7 @@ dir: backend/src/media_library_viewer_api/clients
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
## role
Provides external service integration clients for authenticating users and aggregating media, filesystem, and torrent data from APIs and remote/local hosts.
Package of API and protocol client wrappers that standardize communication with external services (media servers, identity providers, torrent clients, remote/local hosts) for the media library viewer backend.
## files
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
- authentik.py | API client wrapper for Authentik directory service providing paginated user browsing and search via REST API. | exp: class:AuthentikClient, method:__init__(self, base_url: str, api_token: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self, search, page, page_size) → dict[str, Any], call:self.get, call:isinstance, call:logger.warning, call:type, call:payload.get, call:int, call:pagination.get, call:logger.info, call:len | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
@@ -12,10 +12,10 @@ Provides external service integration clients for authenticating users and aggre
- jellyfin.py | Wraps the Jellyfin/Emby HTTP API to provide methods for fetching users, libraries, media items, playback sessions, and image URLs as plain Python dictionaries. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, call:logger.info, call:len, method:resolve_user_id(self, identifier: str | None) → str, call:self.users, call:any, call:str, call:u.get, call:next, call:logger.info, call:logger.warning, raise:RuntimeError, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, call:logger.info, call:len, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:logger.debug, call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, call:logger.debug, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:cast, call:isinstance, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.sessions, call:session.get, call:logger.info, call:len, method:image_url(self, item_id: str, image_type) → str | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
- jellyseerr.py | HTTP API client wrapper for Jellyseerr to fetch user data and enrich Jellyfin user lists. | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:absolute_url(self, path: str | None) → str, call:path.startswith, method:jellyfin_users(self) → list[dict[str, Any]], call:self.get, call:isinstance, call:logger.info, call:len, call:payload.get, method:users(self, page_size) → list[dict[str, Any]], call:max, call:int, call:self.get, call:isinstance, call:payload.get, call:results.extend, call:page_info.get, call:logger.debug, call:len, call:logger.info | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
- local.py | Provides a local command execution client that mirrors remote SSH helpers to run POSIX shell commands, list directories, stat paths, and run ffprobe on the API host for built-in local monitoring. | exp: class:CommandResult, class:LocalCommandClient, method:__init__(self, timeout), method:run(self, command: str, timeout) → CommandResult, call:logger.debug, call:subprocess.run, call:CommandResult, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:ffprobe_json(self, path: str) → dict[str, object], call:shlex.quote, call:self.run, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, subprocess, dataclasses
- qbittorrent.py | Provides a minimal, read-only qBittorrent Web API client that manages session authentication and fetches sync data. | exp: class:QbittorrentClient, method:__init__(self, base_url: str, username: str, password: str, timeout) → None, call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, raise:ValueError, method:_login(self) → None, call:self._session.post, call:resp.raise_for_status, call:resp.text.strip, call:resp.headers.get, call:set_cookie_hdr.split("=", 1)[0].strip().upper, call:bool, call:resp.cookies.get, call:logger.info, call:sorted, call:resp.cookies.keys, raise:RuntimeError, method:_get(self, path: str, **params: Any) → dict[str, Any], call:self._login, call:self._session.get, call:logger.debug, call:resp.raise_for_status, call:resp.json, method:maindata(self) → dict[str, Any], call:self._get | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
- qbittorrent.py | Minimal read-only qBittorrent Web API client that manages authenticated sessions to fetch `/sync/maindata`. | exp: class:QbittorrentClient, method:__init__(self, base_url: str, username: str, password: str, timeout) → None, call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, raise:ValueError, method:_login(self) → None, call:self._session.post, call:resp.raise_for_status, call:resp.text.strip, call:name.strip().upper, call:upper.startswith, call:resp.headers.get, call:set_cookie_hdr.split("=", 1)[0].strip, call:any, call:_is_session_cookie, call:resp.cookies.keys, call:bool, call:logger.info, call:sorted, raise:RuntimeError, method:_get(self, path: str, **params: Any) → dict[str, Any], call:self._login, call:self._session.get, call:logger.debug, call:resp.raise_for_status, call:resp.json, method:maindata(self) → dict[str, Any], call:self._get | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
- ssh.py | Provides an SSH client wrapper for remote filesystem inspection and media analysis using paramiko, with POSIX shell command execution and host key management. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, private_key, private_key_passphrase, password, known_hosts_path, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:Path, call:bool, call:has_known_host, call:known_hosts_file.is_file, call:client.load_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:paramiko.AutoAddPolicy, call:self._load_private_key, call:client.connect, call:str(exc).lower, call:known_hosts_file.parent.mkdir, call:client.save_host_keys, raise:RuntimeError, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:logger.debug, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:logger.info, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, dataclasses, io, pathlib, typing, paramiko, media_library_viewer_api.services.known_hosts
## arch
Thin wrapper pattern around `requests` and SSH/local execution, with each client class encapsulating connection management, authentication, and response normalization for a specific upstream service.
Adapter/wrapper pattern where each module encapsulates a specific external service's API or protocol behind a uniform Python interface, returning plain dictionaries and using shared HTTP timeout configuration.
## tags
call:logger.info, error, call:logger.debug, call:self.get, client, init, timeout, call:logger.warning
## symbols
@@ -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]: