mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-07-11 14:36:56 +08:00
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:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user