fix(downloader): accept qBittorrent 5.2 login response and close leaked client (#1044, #1034, #1043)

qBittorrent >= 5.2 answers a successful login with HTTP 204 and an empty
body instead of 200 + 'Ok.', so every login was treated as bad credentials.
Accept both variants and keep 200 + 'Fails.' as a failure.

Also close the httpx.AsyncClient when auth() fails: DownloadClient.__aenter__
raises before __aexit__ can run, so each failed connect leaked a client and
its connection pool (qb and aria2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
This commit is contained in:
Estrella Pan
2026-07-02 11:39:10 +02:00
parent 8169a2cc13
commit 930dd01220
3 changed files with 138 additions and 4 deletions

View File

@@ -49,6 +49,10 @@ class Aria2Downloader:
)
await asyncio.sleep(5)
times += 1
# Failed auth: close the client here or its connection pool leaks,
# because DownloadClient.__aenter__ raises before __aexit__ can run.
await self._client.aclose()
self._client = None
return False
async def logout(self):

View File

@@ -47,7 +47,9 @@ class QbDownloader:
self._url("auth/login"),
data={"username": self.username, "password": self.password},
)
if resp.status_code == 200 and resp.text == "Ok.":
# qBittorrent < 5.2 answers 200 + "Ok." / "Fails.";
# qBittorrent >= 5.2 answers 204 with an empty body on success (#1044).
if resp.status_code in (200, 204) and "fails" not in resp.text.lower():
return True
elif resp.status_code == 403:
logger.error("Login refused by qBittorrent Server")
@@ -80,6 +82,10 @@ class QbDownloader:
else:
logger.error(f"Unknown error: {e}")
break
# Failed auth: DownloadClient.__aenter__ raises before __aexit__ can run,
# so the client must be closed here or its connection pool leaks.
await self._client.aclose()
self._client = None
return False
async def logout(self):
@@ -201,11 +207,22 @@ class QbDownloader:
resp = await self._client.get(self._url("torrents/info"), params={"tag": tag})
return resp.json()
async def torrents_delete(self, hash, delete_files: bool = True):
await self._client.post(
async def torrents_delete(self, hash, delete_files: bool = True) -> bool:
# qBittorrent expects one pipe-joined "hashes" field; a Python list would
# be form-encoded as repeated fields and silently ignored (#1046).
hashes = "|".join(hash) if isinstance(hash, (list, tuple)) else hash
resp = await self._client.post(
self._url("torrents/delete"),
data={"hashes": hash, "deleteFiles": str(delete_files).lower()},
data={"hashes": hashes, "deleteFiles": str(delete_files).lower()},
)
if resp.status_code != 200:
logger.error(
"[Downloader] Failed to delete torrents %s: HTTP %s",
hashes,
resp.status_code,
)
return False
return True
async def torrents_pause(self, hashes: str):
await self._client.post(

View File

@@ -528,3 +528,116 @@ class TestUrlHelper:
host="http://nas.local:8080", username="u", password="p", ssl=True
)
assert qb._url("torrents/info") == "http://nas.local:8080/api/v2/torrents/info"
# ---------------------------------------------------------------------------
# qBittorrent 5.2 login compatibility (#1044, #1034, #1043)
# ---------------------------------------------------------------------------
class TestAuthQb52Compat:
"""qBittorrent >= 5.2 returns HTTP 204 with an empty body on success."""
async def test_auth_returns_true_on_204_empty_body(self):
"""Returns True when server responds 204 + empty body (qB >= 5.2)."""
qb = QbDownloader(
host="localhost:8080", username="admin", password="pass", ssl=False
)
mock_client = AsyncMock()
mock_resp = MagicMock()
mock_resp.status_code = 204
mock_resp.text = ""
mock_client.post = AsyncMock(return_value=mock_resp)
with patch(
"module.downloader.client.qb_downloader.httpx.AsyncClient",
return_value=mock_client,
):
result = await qb.auth()
assert result is True
async def test_auth_returns_false_on_200_fails_body(self):
"""Returns False on 200 + 'Fails.' (bad credentials, qB < 5.2)."""
qb = QbDownloader(
host="localhost:8080", username="admin", password="wrong", ssl=False
)
mock_client = AsyncMock()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = "Fails."
mock_client.post = AsyncMock(return_value=mock_resp)
with (
patch(
"module.downloader.client.qb_downloader.httpx.AsyncClient",
return_value=mock_client,
),
patch(
"module.downloader.client.qb_downloader.asyncio.sleep",
new_callable=AsyncMock,
),
):
result = await qb.auth(retry=1)
assert result is False
async def test_auth_closes_client_on_failure(self):
"""A failed auth must aclose() the httpx client (leak fix)."""
qb = QbDownloader(
host="localhost:8080", username="admin", password="pass", ssl=False
)
mock_client = AsyncMock()
mock_resp = MagicMock()
mock_resp.status_code = 403
mock_resp.text = "Forbidden"
mock_client.post = AsyncMock(return_value=mock_resp)
with patch(
"module.downloader.client.qb_downloader.httpx.AsyncClient",
return_value=mock_client,
):
result = await qb.auth()
assert result is False
mock_client.aclose.assert_awaited_once()
assert qb._client is None
# ---------------------------------------------------------------------------
# torrents_delete (#1046)
# ---------------------------------------------------------------------------
class TestTorrentsDelete:
"""torrents_delete must pipe-join hash lists and report failures."""
async def test_delete_joins_hash_list_with_pipe(self):
"""A list of hashes is sent as a single pipe-joined string."""
qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False)
qb._client = AsyncMock()
mock_resp = MagicMock()
mock_resp.status_code = 200
qb._client.post = AsyncMock(return_value=mock_resp)
result = await qb.torrents_delete(["aaa", "bbb"], delete_files=True)
assert result is True
sent = qb._client.post.call_args.kwargs["data"]
assert sent["hashes"] == "aaa|bbb"
assert sent["deleteFiles"] == "true"
async def test_delete_returns_false_on_error_status(self):
"""Non-200 response returns False instead of silently succeeding."""
qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False)
qb._client = AsyncMock()
mock_resp = MagicMock()
mock_resp.status_code = 403
qb._client.post = AsyncMock(return_value=mock_resp)
result = await qb.torrents_delete("aaa", delete_files=True)
assert result is False