mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-07-19 13:11:19 +08:00
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:
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user