Skip to content

Commit ded24b1

Browse files
EstrellaXDclaude
andcommitted
fix(downloader): fix qBittorrent SSL connection and rename verification (#923)
- Decouple HTTPS scheme selection from TLS certificate verification: `verify=False` always, since self-signed certs are the norm for home-server/NAS/Docker qBittorrent setups - Bump connect timeout from 3.1s to 5.0s for slow TLS handshakes - Add actionable error messages when HTTPS connection fails - Fix `continue` → `break` bug in torrents_rename_file verification loop - Consolidate json imports to top-level - Add 31 unit tests for QbDownloader constructor, auth, and error handling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fe1858f commit ded24b1

5 files changed

Lines changed: 491 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
- 新增番剧放送日手动设置 API (`PATCH /api/v1/bangumi/{id}/weekday`),支持锁定放送日防止日历刷新覆盖
1414
- 数据库迁移 v9:`bangumi` 表新增 `weekday_locked`
1515

16+
### Fixed
17+
18+
- 修复 qBittorrent 下载器 SSL 连接问题:解耦 HTTPS 协议选择与证书验证,自签名证书不再导致连接失败 (#923)
19+
- 修复 `torrents_rename_file` 重命名验证循环中 `continue` 应为 `break` 的逻辑错误
20+
1621
### Changed
1722

1823
- 重构认证模块:提取 `_issue_token` 公共方法,消除 3 处重复的 JWT 签发逻辑

backend/src/module/downloader/client/qb_downloader.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import json
23
import logging
34

45
import httpx
@@ -25,8 +26,11 @@ def _url(self, endpoint: str) -> str:
2526

2627
async def auth(self, retry=3):
2728
times = 0
28-
timeout = httpx.Timeout(connect=3.1, read=10.0, write=10.0, pool=10.0)
29-
self._client = httpx.AsyncClient(timeout=timeout, verify=self.ssl)
29+
use_https = self.host.startswith("https://")
30+
timeout = httpx.Timeout(connect=5.0, read=10.0, write=10.0, pool=10.0)
31+
# Never verify certificates - self-signed certs are the norm for
32+
# home-server / NAS / Docker qBittorrent setups.
33+
self._client = httpx.AsyncClient(timeout=timeout, verify=False)
3034
while times < retry:
3135
try:
3236
resp = await self._client.post(
@@ -45,13 +49,26 @@ async def auth(self, retry=3):
4549
)
4650
await asyncio.sleep(5)
4751
times += 1
48-
except httpx.ConnectError:
49-
logger.error("Cannot connect to qBittorrent Server")
52+
except httpx.ConnectError as e:
53+
if use_https:
54+
logger.error(
55+
"Cannot connect to qBittorrent Server via HTTPS. "
56+
"If your qBittorrent uses plain HTTP, disable SSL in download settings."
57+
)
58+
else:
59+
logger.error("Cannot connect to qBittorrent Server")
5060
logger.info("Please check the IP and port in WebUI settings")
61+
logger.debug("Connection error detail: %s", e)
5162
await asyncio.sleep(10)
5263
times += 1
5364
except Exception as e:
54-
logger.error(f"Unknown error: {e}")
65+
if use_https and "ssl" in str(e).lower():
66+
logger.error(
67+
"TLS/SSL error connecting to qBittorrent. "
68+
"If your qBittorrent uses plain HTTP, disable SSL in download settings."
69+
)
70+
else:
71+
logger.error(f"Unknown error: {e}")
5572
break
5673
return False
5774

@@ -82,7 +99,7 @@ def check_rss(self, rss_link: str):
8299
async def prefs_init(self, prefs):
83100
resp = await self._client.post(
84101
self._url("app/setPreferences"),
85-
data={"json": __import__("json").dumps(prefs)},
102+
data={"json": json.dumps(prefs)},
86103
)
87104
return resp
88105

@@ -171,7 +188,6 @@ async def add_torrents(
171188
raise
172189

173190
async def get_torrents_by_tag(self, tag: str) -> list[dict]:
174-
"""Get all torrents with a specific tag."""
175191
resp = await self._client.get(self._url("torrents/info"), params={"tag": tag})
176192
return resp.json()
177193

@@ -221,9 +237,9 @@ async def torrents_rename_file(
221237
if f.get("name") == new_path:
222238
return True
223239
if f.get("name") == old_path:
224-
# File still has old name - try again
240+
# File still has old name - break inner loop and retry
225241
if attempt < 2:
226-
continue
242+
break
227243
# Final attempt failed
228244
logger.debug(
229245
"[Downloader] Rename API returned 200 but file unchanged: %s", old_path
@@ -257,8 +273,6 @@ async def rss_get_feeds(self):
257273
return resp.json()
258274

259275
async def rss_set_rule(self, rule_name, rule_def):
260-
import json
261-
262276
await self._client.post(
263277
self._url("rss/setRule"),
264278
data={"ruleName": rule_name, "ruleDef": json.dumps(rule_def)},

backend/src/test/test_issue_bugs.py

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -306,24 +306,23 @@ class TestIssue990NumberPrefixTitle:
306306

307307
PROBLEM_TITLE = "[ANi] 29 岁单身中坚冒险家的日常 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"
308308

309-
def test_raw_parser_misparses_leading_number_as_episode(self):
310-
"""raw_parser matches leading '29 ' as episode number, losing the title."""
309+
def test_raw_parser_correctly_parses_leading_number_title(self):
310+
"""raw_parser correctly parses title starting with number and extracts episode."""
311311
result = raw_parser(self.PROBLEM_TITLE)
312-
# The regex matches "29 " as the episode pattern, so episode=29
313-
# and all title fields are None
314312
assert result is not None
315-
assert result.episode == 29
316-
assert result.title_en is None
317-
assert result.title_zh is None
318-
assert result.title_jp is None
313+
assert result.episode == 7
314+
assert result.title_zh == "29 岁单身中坚冒险家的日常"
315+
assert result.resolution == "1080P"
316+
assert result.group == "ANi"
319317

320-
def test_title_parser_returns_none_when_title_raw_empty(self):
321-
"""TitleParser.raw_parser returns None when no title can be extracted."""
318+
def test_title_parser_returns_bangumi_for_number_prefix_title(self):
319+
"""TitleParser.raw_parser returns a valid Bangumi for number-prefixed titles."""
322320
from module.parser.title_parser import TitleParser
323321

324322
result = TitleParser.raw_parser(self.PROBLEM_TITLE)
325-
# Should return None instead of a Bangumi with title_raw=None
326-
assert result is None
323+
assert result is not None
324+
assert result.official_title == "29 岁单身中坚冒险家的日常"
325+
assert result.title_raw == "29 岁单身中坚冒险家的日常"
327326

328327
def test_add_title_alias_rejects_none(self, db_session):
329328
"""add_title_alias should reject None as alias."""

0 commit comments

Comments
 (0)