diff --git a/backend/src/module/downloader/client/aria2_downloader.py b/backend/src/module/downloader/client/aria2_downloader.py index dfa78a5f..d68e52b8 100644 --- a/backend/src/module/downloader/client/aria2_downloader.py +++ b/backend/src/module/downloader/client/aria2_downloader.py @@ -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): diff --git a/backend/src/module/downloader/client/qb_downloader.py b/backend/src/module/downloader/client/qb_downloader.py index fc08bde7..bbf229dc 100644 --- a/backend/src/module/downloader/client/qb_downloader.py +++ b/backend/src/module/downloader/client/qb_downloader.py @@ -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( diff --git a/backend/src/test/test_qb_downloader.py b/backend/src/test/test_qb_downloader.py index f90a370c..81ee209f 100644 --- a/backend/src/test/test_qb_downloader.py +++ b/backend/src/test/test_qb_downloader.py @@ -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