From 028a0420d596be38d5fc7cd12c3b5122e832e266 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 7 Jul 2026 10:22:27 +0200 Subject: [PATCH] fix(downloader): qBittorrent 5.x WebAPI compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against qB source at release tags 4.6.7/5.0.0/5.1.0/5.2.0: - torrents/add: accept qB 5.2 JSON response (200/202) — partial success and pending URL fetches count as ADDED, matching legacy "Ok." semantics; 409 Conflict is DUPLICATE for synchronous file uploads and hash-confirmed magnets, otherwise raised for retry. Send both paused and stopped form params (renamed in 5.0, each era ignores the other). - torrents/pause|resume: renamed to stop|start in qB 5.0 with no aliases; try the modern name first, fall back on 404, remember which era worked. - torrents/info: filter=paused was renamed and unknown filter values silently mean All — filter paused/stopped client-side by state. - torrents/delete, torrents/renameFile: treat any 2xx as success (qB 5.2 returns 204 for empty-body responses). - webui: map qB 5.0+ stoppedDL/stoppedUP states to the paused label. - .gitignore: exclude local runtime data (backend/data), agent workspace (.claude), and task notes (tasks). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ --- .gitignore | 7 + .../module/downloader/client/qb_downloader.py | 118 ++++++- backend/src/test/test_qb_downloader.py | 314 +++++++++++++++++- webui/src/pages/index/downloader.vue | 5 +- 4 files changed, 424 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 63eaf395..633c7f39 100644 --- a/.gitignore +++ b/.gitignore @@ -221,6 +221,13 @@ test.* /backend/config/ .claude/settings.local.json +# Local runtime data (dev server DB/logs) +/backend/data/ + +# Local agent workspace (worktrees, session state) and task notes +/.claude/ +/tasks/ + # Local dev environment (scripts/dev.sh) and design-tool state .dev/ .impeccable/ diff --git a/backend/src/module/downloader/client/qb_downloader.py b/backend/src/module/downloader/client/qb_downloader.py index 32d1314a..6f780c24 100644 --- a/backend/src/module/downloader/client/qb_downloader.py +++ b/backend/src/module/downloader/client/qb_downloader.py @@ -43,6 +43,9 @@ class QbDownloader: # 每完成一次真实登录尝试 +1:让排在锁后的并发等待者识别"我等待期间 # 已有一次登录被凭据拒绝",从而不再补发自己的 POST。 self._auth_generation = 0 + # qB 5.0 把 torrents/pause|resume 改名为 stop|start 且无别名(旧名 + # 404),4.x 则没有新名。记住哪套命名有效;None = 还没探测过。 + self._uses_stop_start: bool | None = None def _url(self, endpoint: str) -> str: return f"{self.host}/api/v2/{endpoint}" @@ -246,14 +249,25 @@ class QbDownloader: @qb_connect_failed_wait async def torrents_info(self, status_filter, category, tag=None): params = {} - if status_filter: + # qB 5.0 把 filter=paused 改名为 stopped,且未知 filter 值会静默 + # 退化成 All(返回全部种子)——服务端过滤无法跨版本,改为不带 + # filter 拉取后按 state 本地过滤。其余值(completed 等)未改名。 + paused_filter = status_filter in ("paused", "stopped") + if status_filter and not paused_filter: params["filter"] = status_filter if category: params["category"] = category if tag: params["tag"] = tag resp = await self._get("torrents/info", params=params) - return resp.json() + torrents = resp.json() + if paused_filter: + torrents = [ + t + for t in torrents + if t.get("state", "").startswith(("paused", "stopped")) + ] + return torrents @qb_connect_failed_wait async def torrents_files(self, torrent_hash: str): @@ -294,13 +308,29 @@ class QbDownloader: except (httpx.RequestError, ValueError): return False + @staticmethod + def _parse_add_response_json(resp: httpx.Response) -> dict | None: + """qB >= 5.2 的 torrents/add 回 JSON 计数;旧版本回 "Ok."/"Fails."。 + 不是该结构(如代理返回的 HTML 错误页)时返回 None,走通用失败路径。 + """ + try: + data = resp.json() + except ValueError: + return None + if isinstance(data, dict) and "success_count" in data: + return data + return None + async def add_torrents( self, torrent_urls, torrent_files, save_path, category, tags=None ): data = { "savepath": save_path, "category": category, + # qB 5.0 把 paused 参数改名为 stopped;双方都静默忽略未知参数, + # 两个都发才能覆盖所有版本。 "paused": "false", + "stopped": "false", "autoTMM": "false", "contentLayout": "NoSubfolder", } @@ -337,16 +367,45 @@ class QbDownloader: ) if resp.status_code == 200 and resp.text == "Ok.": return AddResult.ADDED - if resp.status_code == 200 and resp.text == "Fails.": - # "Fails." 覆盖所有被拒绝的 add(重复、种子损坏、磁力链 - # 无法解析……),不能一律当重复——否则损坏种子会被记成 - # 已下载、永远不重试。只有能通过 hash 确认任务已存在时 - # 才归类为重复,其余按失败抛出让上层重试。 + if resp.status_code in (200, 202): + # qBittorrent >= 5.2 返回逐种子 JSON 结果: + # {"added_torrent_ids": [...], "failure_count": 0, + # "pending_count": 0, "success_count": 1} + # URL 形式的 add 是异步下载,qB 回 202 + pending_count>0 + # ——已受理即算成功。部分成功也按 ADDED 处理:与旧版 + # "Ok."(>=1 成功即 Ok.)和 aria2 客户端的约定一致, + # 否则已投递的种子会被整批记成失败。计数全 0 说明什么 + # 都没发生,不算成功。 + counts = self._parse_add_response_json(resp) + if counts is not None and ( + counts.get("success_count") or counts.get("pending_count") + ): + return AddResult.ADDED + # "Fails."(qB <= 5.1)与 JSON failure_count > 0 都覆盖 + # 所有被拒绝的 add(重复、种子损坏、磁力链无法解析……), + # 不能一律当重复——否则损坏种子会被记成已下载、永远不 + # 重试。只有能通过 hash 确认任务已存在时才归类为重复, + # 其余按失败抛出让上层重试。 if await self._urls_already_added(torrent_urls): return AddResult.DUPLICATE raise ConnectionError( "qBittorrent rejected torrent add " - "(200 'Fails.') and no matching torrent found" + f"({resp.status_code} {resp.text!r}) " + "and no matching torrent found" + ) + if resp.status_code == 409: + # qBittorrent >= 5.2:全部为重复/失败且无 pending 时回 + # 409 Conflict (qbittorrent/qBittorrent#18361)。文件上传 + # 是同步解析的,损坏文件走 415(BadData),所以文件的 + # 409 就是重复;磁力链尽力用 hash 确认,确认不了的按 + # 失败抛出,避免把损坏的 add 记成已下载、永远不重试。 + if torrent_files is not None or await self._urls_already_added( + torrent_urls + ): + return AddResult.DUPLICATE + raise ConnectionError( + "qBittorrent rejected torrent add (409 Conflict) " + "and no matching torrent found" ) raise ConnectionError( "qBittorrent rejected torrent add: " @@ -383,7 +442,8 @@ class QbDownloader: "torrents/delete", data={"hashes": hashes, "deleteFiles": str(delete_files).lower()}, ) - if resp.status_code != 200: + # qB 5.2 起空响应体统一回 204,不能用 ==200 判定 + if resp.status_code >= 300: logger.error( "Failed to delete torrents %s: HTTP %s", hashes, @@ -392,17 +452,38 @@ class QbDownloader: return False return True + async def _start_stop( + self, hashes: str | list[str], modern: str, legacy: str + ) -> None: + """qB 5.0 把 pause/resume 改名为 stop/start 且无别名(旧名 404), + 4.x 没有新名。先试上次成功的那套命名(默认新名),404 时换另一套 + 并记住。操作幂等,重发一次无副作用。 + """ + data = {"hashes": self._normalize_hashes(hashes)} + prefer_modern = self._uses_stop_start is not False + first, second = (modern, legacy) if prefer_modern else (legacy, modern) + used = first + resp = await self._post(f"torrents/{first}", data=data) + if resp.status_code == 404: + used = second + resp = await self._post(f"torrents/{second}", data=data) + if resp.status_code != 404: + self._uses_stop_start = second == modern + else: + self._uses_stop_start = first == modern + if resp.status_code >= 300: + logger.error( + "Failed to %s torrents %s: HTTP %s", + used, + data["hashes"], + resp.status_code, + ) + async def torrents_pause(self, hashes: str | list[str]): - await self._post( - "torrents/pause", - data={"hashes": self._normalize_hashes(hashes)}, - ) + await self._start_stop(hashes, "stop", "pause") async def torrents_resume(self, hashes: str | list[str]): - await self._post( - "torrents/resume", - data={"hashes": self._normalize_hashes(hashes)}, - ) + await self._start_stop(hashes, "start", "resume") async def torrents_rename_file( self, torrent_hash, old_path, new_path, verify: bool = True @@ -415,7 +496,8 @@ class QbDownloader: if resp.status_code == 409: logger.debug("Conflict409Error: %s >> %s", old_path, new_path) return False - if resp.status_code != 200: + # qB 5.2 对成功的 renameFile 回 204(空响应体全局改为 204) + if resp.status_code >= 300: return False if not verify: diff --git a/backend/src/test/test_qb_downloader.py b/backend/src/test/test_qb_downloader.py index 01759dd1..43ff4c95 100644 --- a/backend/src/test/test_qb_downloader.py +++ b/backend/src/test/test_qb_downloader.py @@ -5,6 +5,7 @@ from self.host, so SSL/HTTPS behaviour is validated by observing auth() side-eff (log messages) rather than reading an instance attribute directly. """ +import json import logging from unittest.mock import AsyncMock, MagicMock, patch @@ -811,6 +812,300 @@ class TestAddTorrents: category="Bangumi", ) + async def test_add_returns_added_on_json_success_body(self): + # qBittorrent >= 5.2 返回逐种子 JSON 结果而非 "Ok." + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + body = ( + '{"added_torrent_ids":["5fab7547cd1f626f56a0b5492cfd25d60d0635c6"],' + '"failure_count":0,"pending_count":0,"success_count":1}' + ) + mock_resp = MagicMock(status_code=200, text=body) + mock_resp.json.return_value = json.loads(body) + qb._client.request = AsyncMock(return_value=mock_resp) + + result = await qb.add_torrents( + torrent_urls="magnet:?xt=urn:btih:abc", + torrent_files=None, + save_path="/downloads", + category="Bangumi", + ) + + assert result is AddResult.ADDED + + async def test_add_returns_added_on_202_json_pending_body(self): + # URL 形式的 add 是异步下载:qB 5.2 回 202 + pending_count>0—— + # 已受理,同样视为成功 + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + body = ( + '{"added_torrent_ids":[],' + '"failure_count":0,"pending_count":1,"success_count":0}' + ) + mock_resp = MagicMock(status_code=202, text=body) + mock_resp.json.return_value = json.loads(body) + qb._client.request = AsyncMock(return_value=mock_resp) + + result = await qb.add_torrents( + torrent_urls="magnet:?xt=urn:btih:abc", + torrent_files=None, + save_path="/downloads", + category="Bangumi", + ) + + assert result is AddResult.ADDED + + async def test_add_json_body_with_failures_and_existing_magnet_is_duplicate(self): + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + btih = "a" * 40 + body = ( + '{"added_torrent_ids":[],' + '"failure_count":1,"pending_count":0,"success_count":0}' + ) + add_resp = MagicMock(status_code=200, text=body) + add_resp.json.return_value = json.loads(body) + info_resp = MagicMock(status_code=200) + info_resp.json.return_value = [{"hash": btih}] + qb._client.request = AsyncMock(side_effect=[add_resp, info_resp]) + + result = await qb.add_torrents( + torrent_urls=f"magnet:?xt=urn:btih:{btih}", + torrent_files=None, + save_path="/downloads", + category="Bangumi", + ) + + assert result is AddResult.DUPLICATE + + async def test_add_json_body_with_failures_without_confirmed_duplicate_raises(self): + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + body = ( + '{"added_torrent_ids":[],' + '"failure_count":1,"pending_count":0,"success_count":0}' + ) + mock_resp = MagicMock(status_code=200, text=body) + mock_resp.json.return_value = json.loads(body) + qb._client.request = AsyncMock(return_value=mock_resp) + + with pytest.raises(ConnectionError): + await qb.add_torrents( + torrent_urls="https://mikanani.me/Download/broken.torrent", + torrent_files=None, + save_path="/downloads", + category="Bangumi", + ) + + async def test_add_409_with_torrent_file_is_duplicate(self): + # qBittorrent >= 5.2 对已存在的种子回 409 Conflict (qbittorrent#18361)。 + # 文件上传是同步解析的:损坏文件走 415,所以文件的 409 就是重复。 + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + mock_resp = MagicMock(status_code=409, text="Conflict") + qb._client.request = AsyncMock(return_value=mock_resp) + + result = await qb.add_torrents( + torrent_urls=None, + torrent_files=b"fake torrent bytes", + save_path="/downloads", + category="Bangumi", + ) + + assert result is AddResult.DUPLICATE + + async def test_add_409_with_existing_magnet_is_duplicate(self): + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + btih = "b" * 40 + add_resp = MagicMock(status_code=409, text="Conflict") + info_resp = MagicMock(status_code=200) + info_resp.json.return_value = [{"hash": btih}] + qb._client.request = AsyncMock(side_effect=[add_resp, info_resp]) + + result = await qb.add_torrents( + torrent_urls=f"magnet:?xt=urn:btih:{btih}", + torrent_files=None, + save_path="/downloads", + category="Bangumi", + ) + + assert result is AddResult.DUPLICATE + + async def test_add_409_with_unconfirmed_url_raises(self): + # URL 形式在 5.2 走异步(202),几乎不会 409;真出现且无法用 hash + # 确认时按失败抛出,避免把损坏的 add 记成已下载 + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + mock_resp = MagicMock(status_code=409, text="Conflict") + qb._client.request = AsyncMock(return_value=mock_resp) + + with pytest.raises(ConnectionError): + await qb.add_torrents( + torrent_urls="https://mikanani.me/Download/unknown.torrent", + torrent_files=None, + save_path="/downloads", + category="Bangumi", + ) + + async def test_add_json_partial_success_is_added(self): + # 批量投递部分成功:与旧版 "Ok."(>=1 成功即 Ok.)和 aria2 客户端 + # 的约定一致,按 ADDED 处理,不能把整批记成失败 + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + body = ( + '{"added_torrent_ids":["c" ],' + '"failure_count":1,"pending_count":0,"success_count":1}' + ) + mock_resp = MagicMock(status_code=200, text=body) + mock_resp.json.return_value = json.loads(body) + qb._client.request = AsyncMock(return_value=mock_resp) + + result = await qb.add_torrents( + torrent_urls=["magnet:?xt=urn:btih:abc", "magnet:?xt=urn:btih:def"], + torrent_files=None, + save_path="/downloads", + category="Bangumi", + ) + + assert result is AddResult.ADDED + + async def test_add_json_all_zero_counts_raises(self): + # 三个计数全 0:什么都没加进去,不能算 ADDED + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + body = ( + '{"added_torrent_ids":[],' + '"failure_count":0,"pending_count":0,"success_count":0}' + ) + mock_resp = MagicMock(status_code=200, text=body) + mock_resp.json.return_value = json.loads(body) + qb._client.request = AsyncMock(return_value=mock_resp) + + with pytest.raises(ConnectionError): + await qb.add_torrents( + torrent_urls="https://mikanani.me/Download/ignored.torrent", + torrent_files=None, + save_path="/downloads", + category="Bangumi", + ) + + async def test_add_sends_both_paused_and_stopped_params(self): + # qB 5.0 把 add 的 paused 参数改名为 stopped,双方都会静默忽略 + # 未知参数——必须两个都发才能覆盖所有版本 + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + mock_resp = MagicMock(status_code=200, text="Ok.") + qb._client.request = AsyncMock(return_value=mock_resp) + + await qb.add_torrents( + torrent_urls="magnet:?xt=urn:btih:abc", + torrent_files=None, + save_path="/downloads", + category="Bangumi", + ) + + sent = qb._client.request.call_args.kwargs["data"] + assert sent["paused"] == "false" + assert sent["stopped"] == "false" + + +# --------------------------------------------------------------------------- +# torrents/pause|resume vs torrents/stop|start (qB 5.0 rename, no aliases) +# --------------------------------------------------------------------------- + + +class TestPauseResumeCompat: + """qB 5.0 把 pause/resume 改名为 stop/start 且旧名 404;4.x 没有新名。 + 客户端先试新名,404 时回退旧名并记住选择。""" + + async def test_pause_uses_stop_endpoint_first(self): + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + mock_resp = MagicMock(status_code=200) + qb._client.request = AsyncMock(return_value=mock_resp) + + await qb.torrents_pause("h1") + + url = qb._client.request.call_args.args[1] + assert url.endswith("torrents/stop") + + async def test_pause_falls_back_to_legacy_endpoint_on_404(self): + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + resp_404 = MagicMock(status_code=404) + resp_200 = MagicMock(status_code=200) + qb._client.request = AsyncMock(side_effect=[resp_404, resp_200]) + + await qb.torrents_pause("h1") + + urls = [c.args[1] for c in qb._client.request.call_args_list] + assert urls[0].endswith("torrents/stop") + assert urls[1].endswith("torrents/pause") + + async def test_resume_remembers_legacy_era_after_fallback(self): + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + resp_404 = MagicMock(status_code=404) + resp_200 = MagicMock(status_code=200) + qb._client.request = AsyncMock(side_effect=[resp_404, resp_200]) + await qb.torrents_pause("h1") + + qb._client.request = AsyncMock(return_value=MagicMock(status_code=200)) + await qb.torrents_resume("h1") + + url = qb._client.request.call_args.args[1] + assert url.endswith("torrents/resume") + + async def test_resume_remembers_modern_era_after_success(self): + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + qb._client.request = AsyncMock(return_value=MagicMock(status_code=200)) + await qb.torrents_pause("h1") + + await qb.torrents_resume("h1") + + url = qb._client.request.call_args.args[1] + assert url.endswith("torrents/start") + + +# --------------------------------------------------------------------------- +# torrents/info filter=paused (qB 5.0 renamed the value; unknown value = All) +# --------------------------------------------------------------------------- + + +class TestTorrentsInfoPausedFilter: + async def test_paused_filter_is_applied_client_side(self): + # 5.x 把 filter=paused 改名 stopped,且未知 filter 值静默等于 All—— + # 服务端过滤不可跨版本,改为不带 filter 拉取后按 state 本地过滤 + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + mock_resp = MagicMock(status_code=200) + mock_resp.json.return_value = [ + {"hash": "a", "state": "pausedDL"}, + {"hash": "b", "state": "stoppedUP"}, + {"hash": "c", "state": "downloading"}, + ] + qb._client.request = AsyncMock(return_value=mock_resp) + + result = await qb.torrents_info(status_filter="paused", category="Bangumi") + + sent_params = qb._client.request.call_args.kwargs["params"] + assert "filter" not in sent_params + assert [t["hash"] for t in result] == ["a", "b"] + + async def test_completed_filter_still_server_side(self): + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + mock_resp = MagicMock(status_code=200) + mock_resp.json.return_value = [] + qb._client.request = AsyncMock(return_value=mock_resp) + + await qb.torrents_info(status_filter="completed", category="Bangumi") + + sent_params = qb._client.request.call_args.kwargs["params"] + assert sent_params["filter"] == "completed" + # --------------------------------------------------------------------------- # torrents_delete (#1046) @@ -837,7 +1132,7 @@ class TestTorrentsDelete: assert sent["deleteFiles"] == "true" async def test_delete_returns_false_on_error_status(self): - """Non-200 response returns False instead of silently succeeding.""" + """Error response returns False instead of silently succeeding.""" qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) qb._client = AsyncMock() mock_resp = MagicMock() @@ -848,6 +1143,18 @@ class TestTorrentsDelete: assert result is False + async def test_delete_accepts_204_empty_body(self): + """qB 5.2 回 204 表示成功(空响应体全局改为 204)。""" + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + mock_resp = MagicMock() + mock_resp.status_code = 204 + qb._client.request = AsyncMock(return_value=mock_resp) + + result = await qb.torrents_delete("aaa", delete_files=True) + + assert result is True + # --------------------------------------------------------------------------- # torrents_rename_file verify contract (#754 / #749, from PR #1037) @@ -887,3 +1194,8 @@ class TestRenameVerifyContract: """Target already exists (duplicate from another source) -> False.""" qb = self._make_qb([{"name": "old.mkv"}], post_code=409) assert await qb.torrents_rename_file("h", "old.mkv", "new.mkv") is False + + async def test_rename_204_with_verified_new_path_is_true(self): + """qB 5.2 对成功的 renameFile 回 204;不能再用 ==200 判定失败。""" + qb = self._make_qb([{"name": "new.mkv"}], post_code=204) + assert await qb.torrents_rename_file("h", "old.mkv", "new.mkv") is True diff --git a/webui/src/pages/index/downloader.vue b/webui/src/pages/index/downloader.vue index 0033799f..ba9a6d8c 100644 --- a/webui/src/pages/index/downloader.vue +++ b/webui/src/pages/index/downloader.vue @@ -92,6 +92,9 @@ function stateLabel(state: string): string { uploading: t('downloader.state.seeding'), pausedDL: t('downloader.state.paused'), pausedUP: t('downloader.state.paused'), + // qBittorrent 5.0+ 把 paused* 状态改名为 stopped* + stoppedDL: t('downloader.state.paused'), + stoppedUP: t('downloader.state.paused'), stalledDL: t('downloader.state.stalled'), stalledUP: t('downloader.state.seeding'), queuedDL: t('downloader.state.queued'), @@ -106,7 +109,7 @@ function stateLabel(state: string): string { } function stateType(state: string): string { - if (state.includes('paused')) return 'neutral'; + if (state.includes('paused') || state.includes('stopped')) return 'neutral'; if (state === 'downloading' || state === 'forcedDL') return 'success'; if (state.includes('UP') || state === 'uploading') return 'info'; if (state === 'error' || state === 'missingFiles') return 'danger';