Skip to content
Open
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
49 changes: 37 additions & 12 deletions romarr/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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,
)
Expand Down Expand Up @@ -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()
Expand All @@ -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,
)
Expand Down
73 changes: 73 additions & 0 deletions tests/test_qbittorrent_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
Loading