Fix: qBittorrent 5.2.0+ login probe fails (HTTP 204 not handled)

qBittorrent 5.2.0 changed /api/v2/auth/login to return HTTP 204 (No Content)
on success instead of HTTP 200 with body 'Ok.'. The adapter required the body
to equal 'Ok.', so every login on 5.2.0+ failed with 'HTTP 204 body=' — the
connection probe and all torrent actions were broken.

Treat login as successful on the SID auth cookie and/or a success body: 'Ok.'
(<=5.1) or an empty HTTP 204 (>=5.2.0). Still reject bad creds, which
qBittorrent reports as HTTP 200 + 'Fails.' (not a 4xx).

Tests: 204-empty -> success, SID-cookie+empty-body -> success, 'Fails.' (even
with a stale cookie) -> failure.
pull/784/head
BoulderBadgeDad 1 week ago
parent 807ac39570
commit 45f91fd318

@ -117,7 +117,19 @@ class QBittorrentAdapter:
headers={'Referer': self._url},
timeout=self.DEFAULT_TIMEOUT,
)
if not resp.ok or resp.text.strip() != 'Ok.':
body = resp.text.strip()
has_sid = bool(sess.cookies.get('SID'))
# qBittorrent reports BAD credentials as HTTP 200 + body "Fails."
# (it does NOT use a 4xx). SUCCESS is the SID auth cookie and/or a
# success body: "Ok." on <= 5.1, or an empty HTTP 204 on 5.2.0+,
# which changed /api/v2/auth/login to return 204 No Content.
# The old check required body == "Ok." and so rejected 5.2.0+.
login_ok = (
resp.ok
and body.lower() != 'fails.'
and (has_sid or resp.status_code == 204 or body in ('', 'Ok.'))
)
if not login_ok:
logger.error("qBittorrent login failed: HTTP %s body=%r", resp.status_code, resp.text[:200])
return None
self._session = sess

@ -150,6 +150,51 @@ def test_qbit_login_failure_returns_none() -> None:
assert sess is None
def test_qbit_login_accepts_204_no_content() -> None:
"""qBittorrent 5.2.0+ returns HTTP 204 with an empty body on a successful
login (was HTTP 200 + 'Ok.'). The adapter must treat that as success even
when no SID cookie is visible to us."""
adapter = _qbit_with_config()
fake_session = MagicMock()
fake_session.cookies.get.return_value = None # no SID surfaced
resp = _mock_response(204, text='')
resp.text = ''
fake_session.post.return_value = resp
with patch('core.torrent_clients.qbittorrent.http_requests.Session',
return_value=fake_session):
sess = adapter._ensure_session_sync()
assert sess is not None
def test_qbit_login_accepts_sid_cookie_with_empty_body() -> None:
"""A SID auth cookie is the authoritative success signal regardless of body."""
adapter = _qbit_with_config()
fake_session = MagicMock()
fake_session.cookies.get.return_value = 'SID-abc123'
resp = _mock_response(200, text='')
resp.text = ''
fake_session.post.return_value = resp
with patch('core.torrent_clients.qbittorrent.http_requests.Session',
return_value=fake_session):
sess = adapter._ensure_session_sync()
assert sess is not None
def test_qbit_login_rejects_fails_even_with_stale_cookie() -> None:
"""Bad creds: qBittorrent returns HTTP 200 'Fails.' (not a 4xx). Must fail
even if a stale SID cookie lingers on the session."""
adapter = _qbit_with_config()
fake_session = MagicMock()
fake_session.cookies.get.return_value = 'SID-stale'
resp = _mock_response(200, text='Fails.')
resp.text = 'Fails.'
fake_session.post.return_value = resp
with patch('core.torrent_clients.qbittorrent.http_requests.Session',
return_value=fake_session):
sess = adapter._ensure_session_sync()
assert sess is None
def test_qbit_parse_status_normalises_native_fields() -> None:
adapter = _qbit_with_config()
status = adapter._parse_status({

Loading…
Cancel
Save