fix(downloader): qBittorrent 5.x WebAPI compatibility

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ
This commit is contained in:
Estrella Pan
2026-07-07 10:22:27 +02:00
parent c19da52497
commit 028a0420d5
4 changed files with 424 additions and 20 deletions

7
.gitignore vendored
View File

@@ -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/

View File

@@ -43,6 +43,9 @@ class QbDownloader:
# 每完成一次真实登录尝试 +1让排在锁后的并发等待者识别"我等待期间
# 已有一次登录被凭据拒绝",从而不再补发自己的 POST。
self._auth_generation = 0
# qB 5.0 把 torrents/pause|resume 改名为 stop|start 且无别名(旧名
# 4044.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)。文件上传
# 是同步解析的,损坏文件走 415BadData所以文件的
# 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:

View File

@@ -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 且旧名 4044.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

View File

@@ -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';