From cdfd2d7b9eee34f4f30749962fa2a661ef5d571f Mon Sep 17 00:00:00 2001 From: badkid Date: Thu, 13 Aug 2026 22:40:19 -0500 Subject: [PATCH] fix: refresh expired qBittorrent sessions --- romarr/clients.py | 49 ++++++++++++++++------ tests/test_qbittorrent_login.py | 73 +++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 12 deletions(-) diff --git a/romarr/clients.py b/romarr/clients.py index 79bb712..f9be9e4 100644 --- a/romarr/clients.py +++ b/romarr/clients.py @@ -137,14 +137,39 @@ def login(self) -> bool: log.warning("qbittorrent login rejected (status %s)", response.status_code) return self._authed + def _get(self, path: str, **kwargs): + """GET once more after qBittorrent expires the WebUI session. + + qBittorrent's SID has a finite lifetime, while ROMarr keeps this + client for the life of the process. Without the retry, the first + expired SID turns every health check and import poll into a permanent + 401/403 until ROMarr is restarted. + """ + response = self._session.get(self._url(path), **kwargs) + if response.status_code not in (401, 403) or not self._config.username: + return response + self._authed = False + if not self.login(): + return response + return self._session.get(self._url(path), **kwargs) + + def _post(self, path: str, **kwargs): + """POST once more after refreshing an expired WebUI session.""" + response = self._session.post(self._url(path), **kwargs) + if response.status_code not in (401, 403) or not self._config.username: + return response + self._authed = False + if not self.login(): + return response + return self._session.post(self._url(path), **kwargs) + def add(self, magnet_or_url: str, *, save_path: str | None = None) -> bool: if not self._authed: self.login() data = {"urls": magnet_or_url, "category": self._config.category} if save_path: data["savepath"] = save_path - response = self._session.post(self._url("torrents/add"), data=data, - timeout=self._config.timeout) + response = self._post("torrents/add", data=data, timeout=self._config.timeout) if not response.ok: log.warning("qbittorrent add failed: %s", response.status_code) return False @@ -159,9 +184,9 @@ def reachable(self) -> bool: if not self._config.base_url: return False try: - if not self._authed: - self.login() - r = self._session.get(self._url("app/version"), timeout=self._config.timeout) + if not self._authed and not self.login(): + return False + r = self._get("app/version", timeout=self._config.timeout) return r.ok except requests.RequestException as err: log.warning("qbittorrent unreachable: %s", err) @@ -176,8 +201,8 @@ def completed(self) -> list[dict]: """ if not self._authed: self.login() - response = self._session.get( - self._url("torrents/info"), + response = self._get( + "torrents/info", params={"category": self._config.category, "filter": "completed"}, timeout=self._config.timeout, ) @@ -210,9 +235,9 @@ def reselect(self, row: dict) -> bool: Only a torrent with *nothing* selected is touched. A part-selected one is somebody's deliberate choice and is left alone. """ - files = self._session.get(self._url("torrents/files"), - params={"hash": row.get("hash", "")}, - timeout=self._config.timeout) + files = self._get("torrents/files", + params={"hash": row.get("hash", "")}, + timeout=self._config.timeout) if not files.ok: return False listing = files.json() @@ -221,8 +246,8 @@ def reselect(self, row: dict) -> bool: # `index` arrives with the file on any qBittorrent worth talking to, # but it was added mid-life to the API and position is what it means. ids = "|".join(str(f.get("index", i)) for i, f in enumerate(listing)) - response = self._session.post( - self._url("torrents/filePrio"), + response = self._post( + "torrents/filePrio", data={"hash": row.get("hash", ""), "id": ids, "priority": 1}, timeout=self._config.timeout, ) diff --git a/tests/test_qbittorrent_login.py b/tests/test_qbittorrent_login.py index 0706438..394903d 100644 --- a/tests/test_qbittorrent_login.py +++ b/tests/test_qbittorrent_login.py @@ -91,3 +91,76 @@ def test_a_rejected_login_is_not_recorded_as_authenticated(): qbit, _ = client(_Response(200, "Fails.")) qbit.login() assert getattr(qbit, "_authed", False) is False + + +class _ExpiredSession: + """A live SID expires, then a fresh login makes the same call work.""" + + def __init__(self, login_response=None): + self.login_response = login_response or _Response(200, "Ok.") + self.gets = 0 + self.posts = 0 + + def get(self, url, **kwargs): + self.gets += 1 + return _Response(403, "Forbidden") if self.gets == 1 else _Response(200, "v5.1.4") + + def post(self, url, **kwargs): + self.posts += 1 + return self.login_response + + +def test_an_expired_session_is_reauthenticated_without_restarting_romarr(): + session = _ExpiredSession() + qbit = QBittorrent( + QbitConfig(base_url="http://qbit:8090", username="admin", password="pw"), + session=session, + ) + qbit._authed = True # the cached SID was valid when ROMarr started + + assert qbit.reachable() is True + assert session.posts == 1 + assert session.gets == 2 + + +def test_a_rejected_session_refresh_stays_unhealthy(): + session = _ExpiredSession(_Response(200, "Fails.")) + qbit = QBittorrent( + QbitConfig(base_url="http://qbit:8090", username="admin", password="wrong"), + session=session, + ) + qbit._authed = True + + assert qbit.reachable() is False + assert session.posts == 1 + assert session.gets == 1 + + +class _ExpiredWriteSession: + def __init__(self): + self.paths = [] + self.add_attempts = 0 + + def post(self, url, **kwargs): + self.paths.append(url) + if url.endswith("/auth/login"): + return _Response(200, "Ok.") + self.add_attempts += 1 + if self.add_attempts == 1: + return _Response(403, "Forbidden") + return _Response(200, "Ok.") + + +def test_an_add_is_retried_after_refreshing_an_expired_session(): + session = _ExpiredWriteSession() + qbit = QBittorrent( + QbitConfig(base_url="http://qbit:8090", username="admin", password="pw"), + session=session, + ) + qbit._authed = True + + assert qbit.add("magnet:?xt=urn:btih:example") is True + assert session.add_attempts == 2 + assert [path.rsplit('/api/v2/', 1)[-1] for path in session.paths] == [ + "torrents/add", "auth/login", "torrents/add" + ]