fix: batch 3.2.8 — qB 5.2 login, torrent deletion, setup hardening, RSS throttling (#1044 #1034 #1043 #1046 #1041 #1026)

This commit is contained in:
Estrella Pan
2026-07-02 11:59:18 +02:00
21 changed files with 480 additions and 71 deletions

View File

@@ -83,10 +83,13 @@ if VERSION != "DEV_VERSION":
# app.mount("/icons", StaticFiles(directory="dist/icons"), name="icons")
templates = Jinja2Templates(directory="dist")
# dist/ is immutable inside the container — snapshot once instead of
# hitting the filesystem on every request.
_DIST_FILES = frozenset(os.listdir("dist"))
@app.get("/{path:path}")
def html(request: Request, path: str):
files = os.listdir("dist")
if path in files:
if path in _DIST_FILES:
return FileResponse(f"dist/{path}")
else:
context = {"request": request}

View File

@@ -53,8 +53,14 @@ async def resume_torrents(req: TorrentHashesRequest):
async def delete_torrents(req: TorrentDeleteRequest):
hashes = "|".join(req.hashes)
async with DownloadClient() as client:
await client.delete_torrent(hashes, delete_files=req.delete_files)
return {"msg_en": "Torrents deleted", "msg_zh": "种子已删除"}
ok = await client.delete_torrent(hashes, delete_files=req.delete_files)
if not ok:
return {
"status": False,
"msg_en": "Failed to delete torrents",
"msg_zh": "删除种子失败",
}
return {"status": True, "msg_en": "Torrents deleted", "msg_zh": "种子已删除"}
@router.post("/torrents/tag", dependencies=[Depends(get_current_user)])

View File

@@ -31,14 +31,19 @@ def _require_setup_needed():
raise HTTPException(status_code=403, detail="Setup already completed.")
def _validate_url(url: str) -> None:
"""Reject non-HTTP schemes and private/reserved/loopback IPs."""
def _validate_scheme(url: str) -> None:
"""Reject non-HTTP schemes and URLs without a hostname."""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise HTTPException(status_code=400, detail="Only http/https URLs are allowed.")
hostname = parsed.hostname
if not hostname:
if not parsed.hostname:
raise HTTPException(status_code=400, detail="Invalid URL: no hostname.")
def _validate_url(url: str) -> None:
"""Reject non-HTTP schemes and private/reserved/loopback IPs."""
_validate_scheme(url)
hostname = urlparse(url).hostname
try:
addrs = socket.getaddrinfo(hostname, None)
except socket.gaierror:
@@ -132,6 +137,9 @@ async def test_downloader(req: TestDownloaderRequest):
scheme = "https" if req.ssl else "http"
host = req.host if "://" in req.host else f"{scheme}://{req.host}"
# Private/loopback IPs stay allowed (a LAN NAS is the normal case), but
# only http/https schemes may be probed from this pre-auth endpoint (#1041).
_validate_scheme(host)
try:
async with httpx.AsyncClient(timeout=5.0) as client:
@@ -153,7 +161,13 @@ async def test_downloader(req: TestDownloaderRequest):
login_url,
data={"username": req.username, "password": req.password},
)
if login_resp.status_code == 200 and "ok" in login_resp.text.lower():
# qBittorrent < 5.2 answers 200 + "Ok."; >= 5.2 answers 204 with
# an empty body on success (#1044). Keep the positive body check
# for 200 so a proxy answering 200 + HTML is not reported as a
# working login.
if login_resp.status_code == 204 or (
login_resp.status_code == 200 and "ok" in login_resp.text.lower()
):
return TestResultResponse(
success=True,
message_en="Connection successful.",
@@ -184,11 +198,13 @@ async def test_downloader(req: TestDownloaderRequest):
message_zh="无法连接到主机。",
)
except Exception as e:
# Log the detail server-side only — this endpoint is reachable before
# authentication, so raw errors must not be echoed back (#1041).
logger.error(f"[Setup] Downloader test failed: {e}")
return TestResultResponse(
success=False,
message_en=f"Connection failed: {e}",
message_zh=f"连接失败{e}",
message_en="Connection failed.",
message_zh="连接失败",
)
@@ -221,8 +237,8 @@ async def test_rss(req: TestRSSRequest):
logger.error(f"[Setup] RSS test failed: {e}")
return TestResultResponse(
success=False,
message_en=f"Failed to fetch RSS feed: {e}",
message_zh=f"获取 RSS 源失败{e}",
message_en="Failed to fetch RSS feed.",
message_zh="获取 RSS 源失败",
)
@@ -266,8 +282,8 @@ async def test_notification(req: TestNotificationRequest):
logger.error(f"[Setup] Notification test failed: {e}")
return TestResultResponse(
success=False,
message_en=f"Notification test failed: {e}",
message_zh=f"通知测试失败{e}",
message_en="Notification test failed.",
message_zh="通知测试失败",
)
@@ -338,6 +354,6 @@ async def complete_setup(req: SetupCompleteRequest):
return ResponseModel(
status=False,
status_code=500,
msg_en=f"Setup failed: {e}",
msg_zh=f"设置失败{e}",
msg_en="Setup failed. Check the server log for details.",
msg_zh="设置失败,请查看服务器日志。",
)

View File

@@ -1,6 +1,7 @@
import json
import logging
import re
import threading
import time
from typing import Optional
@@ -64,16 +65,24 @@ def _set_aliases_list(bangumi: Bangumi, aliases: list[str]) -> None:
bangumi.title_aliases = json.dumps(unique_aliases, ensure_ascii=False)
# Module-level TTL cache for search_all results
# Module-level TTL cache for search_all results.
# Guarded by a lock: notification paths read/write it from asyncio.to_thread
# worker threads while the event loop uses it concurrently.
_bangumi_cache: list[Bangumi] | None = None
_bangumi_cache_time: float = 0
_BANGUMI_CACHE_TTL: float = 300.0 # 5 minutes - extended from 60s to reduce DB queries
_bangumi_cache_lock = threading.Lock()
# Bumped on every invalidation so a search_all() that was already past the
# cache check cannot overwrite a newer invalidation with its stale snapshot.
_bangumi_cache_gen = 0
def _invalidate_bangumi_cache():
global _bangumi_cache, _bangumi_cache_time
_bangumi_cache = None
_bangumi_cache_time = 0
global _bangumi_cache, _bangumi_cache_time, _bangumi_cache_gen
with _bangumi_cache_lock:
_bangumi_cache = None
_bangumi_cache_time = 0
_bangumi_cache_gen += 1
class BangumiDatabase:
@@ -365,11 +374,13 @@ class BangumiDatabase:
def search_all(self) -> list[Bangumi]:
global _bangumi_cache, _bangumi_cache_time
now = time.time()
if (
_bangumi_cache is not None
and (now - _bangumi_cache_time) < _BANGUMI_CACHE_TTL
):
return _bangumi_cache
with _bangumi_cache_lock:
if (
_bangumi_cache is not None
and (now - _bangumi_cache_time) < _BANGUMI_CACHE_TTL
):
return _bangumi_cache
gen_at_query = _bangumi_cache_gen
statement = select(Bangumi)
result = self.session.execute(statement)
bangumis = list(result.scalars().all())
@@ -377,9 +388,11 @@ class BangumiDatabase:
# cached objects are accessed from a different session/request context
for b in bangumis:
self.session.expunge(b)
_bangumi_cache = bangumis
_bangumi_cache_time = now
return _bangumi_cache
with _bangumi_cache_lock:
if _bangumi_cache_gen == gen_at_query:
_bangumi_cache = bangumis
_bangumi_cache_time = now
return bangumis
def search_id(self, _id: int) -> Optional[Bangumi]:
statement = select(Bangumi).where(Bangumi.id == _id)

View File

@@ -110,13 +110,19 @@ class MockDownloader:
)
return True
async def torrents_delete(self, hash: str, delete_files: bool = True):
hashes = hash.split("|") if "|" in hash else [hash]
async def torrents_delete(
self, hash: str | list, delete_files: bool = True
) -> bool:
if isinstance(hash, (list, tuple)):
hashes = list(hash)
else:
hashes = hash.split("|") if "|" in hash else [hash]
for h in hashes:
self._torrents.pop(h, None)
logger.debug(
"[MockDownloader] torrents_delete(%s, delete_files=%s)", hash, delete_files
)
return True
async def torrents_pause(self, hashes: str):
for h in hashes.split("|"):

View File

@@ -47,7 +47,14 @@ 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). Keep the positive body check for 200 so a proxy or
# non-qB service answering 200 + HTML is not mistaken for a
# successful login.
if (
resp.status_code == 200 and resp.text.startswith("Ok")
) or resp.status_code == 204:
return True
elif resp.status_code == 403:
logger.error("Login refused by qBittorrent Server")
@@ -201,11 +208,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(

View File

@@ -52,6 +52,10 @@ class DownloadClient(TorrentPath):
if not self.authed:
await self.auth()
if not self.authed:
# __aexit__ never runs when we raise here, so close the
# concrete client's connection pool now or it leaks on every
# failed connect (#1043).
await self.client.logout()
raise ConnectionError("Download client authentication failed")
else:
logger.error("[Downloader] Already authed.")
@@ -144,9 +148,13 @@ class DownloadClient(TorrentPath):
logger.debug("[Downloader] Rename failed: %s >> %s", old_path, new_path)
return result
async def delete_torrent(self, hashes, delete_files: bool = True):
await self.client.torrents_delete(hashes, delete_files=delete_files)
logger.info("[Downloader] Remove torrents.")
async def delete_torrent(self, hashes, delete_files: bool = True) -> bool:
ok = await self.client.torrents_delete(hashes, delete_files=delete_files)
if ok:
logger.info("[Downloader] Remove torrents.")
else:
logger.error("[Downloader] Failed to remove torrents.")
return ok
async def pause_torrent(self, hashes: str):
await self.client.torrents_pause(hashes)

View File

@@ -25,7 +25,13 @@ class TorrentManager(Database):
async def delete_torrents(self, data: Bangumi, client: DownloadClient):
hash_list = await self.__match_torrents_list(data)
if hash_list:
await client.delete_torrent(hash_list)
if not await client.delete_torrent(hash_list):
return ResponseModel(
status_code=500,
status=False,
msg_en=f"Failed to delete torrents for {data.official_title}",
msg_zh=f"删除 {data.official_title} 种子失败",
)
logger.info(f"Delete rule and torrents for {data.official_title}")
return ResponseModel(
status_code=200,
@@ -52,6 +58,14 @@ class TorrentManager(Database):
torrent_message = None
if file:
torrent_message = await self.delete_torrents(data, client)
if torrent_message.status_code == 500:
return ResponseModel(
status_code=500,
status=False,
msg_en=f"Deleted rule for {data.official_title}, "
"but deleting its torrents failed.",
msg_zh=f"已删除 {data.official_title} 规则,但删除种子失败。",
)
logger.info(f"[Manager] Delete rule for {data.official_title}")
return ResponseModel(
status_code=200,

View File

@@ -1,9 +1,8 @@
import json
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Optional
from openai import AzureOpenAI, OpenAI
from openai import AsyncAzureOpenAI, AsyncOpenAI
from pydantic import BaseModel
from module.models import Bangumi
@@ -62,19 +61,19 @@ class OpenAIParser:
if not api_key:
raise ValueError("API key is required.")
if api_type == "azure":
self.client = AzureOpenAI(
self.client = AsyncAzureOpenAI(
api_key=api_key,
base_url=api_base,
azure_deployment=kwargs.get("deployment_id", ""),
api_version=kwargs.get("api_version", "2023-05-15"),
)
else:
self.client = OpenAI(api_key=api_key, base_url=api_base)
self.client = AsyncOpenAI(api_key=api_key, base_url=api_base)
self.model = model
self.openai_kwargs = kwargs
def parse(
async def parse(
self, text: str, prompt: str | None = None, asdict: bool = True
) -> dict | str:
"""parse text with openai
@@ -96,11 +95,8 @@ class OpenAIParser:
params = self._prepare_params(text, prompt)
with ThreadPoolExecutor(max_workers=1) as worker:
future = worker.submit(self.client.beta.chat.completions.parse, **params)
resp = future.result()
result = resp.choices[0].message.parsed
resp = await self.client.beta.chat.completions.parse(**params)
result = resp.choices[0].message.parsed
if asdict:
if hasattr(result, "model_dump"):

View File

@@ -57,14 +57,14 @@ class TitleParser:
logger.warning("Please change bangumi info manually.")
@staticmethod
def raw_parser(raw: str) -> Bangumi | None:
async def raw_parser(raw: str) -> Bangumi | None:
language = settings.rss_parser.language
try:
# use OpenAI ChatGPT to parse raw title and get structured data
if settings.experimental_openai.enable:
kwargs = settings.experimental_openai.dict(exclude={"enable"})
gpt = OpenAIParser(**kwargs)
episode_dict = gpt.parse(raw, asdict=True)
episode_dict = await gpt.parse(raw, asdict=True)
episode = Episode(**episode_dict)
else:
episode = raw_parser(raw)

View File

@@ -49,7 +49,7 @@ class RSSAnalyser(TitleParser):
new_data = []
seen_titles: set[str] = set()
for torrent in torrents:
bangumi = self.raw_parser(raw=torrent.name)
bangumi = await self.raw_parser(raw=torrent.name)
if bangumi and bangumi.title_raw not in seen_titles:
await self.official_title_parser(bangumi=bangumi, rss=rss, torrent=torrent)
if not full_parse:
@@ -60,7 +60,7 @@ class RSSAnalyser(TitleParser):
return new_data
async def torrent_to_data(self, torrent: Torrent, rss: RSSItem) -> Bangumi:
bangumi = self.raw_parser(raw=torrent.name)
bangumi = await self.raw_parser(raw=torrent.name)
if bangumi:
await self.official_title_parser(bangumi=bangumi, rss=rss, torrent=torrent)
bangumi.rss_link = rss.url

View File

@@ -1,8 +1,10 @@
import asyncio
import logging
import re
from collections import defaultdict
from datetime import datetime, timezone
from typing import Optional
from urllib.parse import urlparse
from module.database import Database, engine
from module.downloader import DownloadClient
@@ -11,6 +13,10 @@ from module.network import RequestContent
logger = logging.getLogger(__name__)
# Delay between consecutive requests to the same host. Firing all feeds of one
# site at once gets the whole batch rate-limited with HTTP 429 (#1026).
RSS_PER_HOST_DELAY = 2.0
class RSSEngine(Database):
def __init__(self, _engine=engine):
@@ -149,18 +155,35 @@ class RSSEngine(Database):
else:
rss_item = self.rss.search_id(rss_id)
rss_items = [rss_item] if rss_item else []
# From RSS Items, fetch all torrents with concurrency limit
# From RSS Items, fetch all torrents: parallel across hosts, serial
# (with a delay) within one host so the site never sees a burst (#1026).
logger.debug("[Engine] Get %s RSS items", len(rss_items))
semaphore = asyncio.Semaphore(5)
async def _limited_pull(item):
async with semaphore:
return await self._pull_rss_with_status(item)
async def _pull_host_group(items: list[RSSItem]):
group_results = []
for i, item in enumerate(items):
if i and RSS_PER_HOST_DELAY:
await asyncio.sleep(RSS_PER_HOST_DELAY)
async with semaphore:
group_results.append(await self._pull_rss_with_status(item))
return group_results
results = await asyncio.gather(*[_limited_pull(item) for item in rss_items])
host_groups: dict[str, list[RSSItem]] = defaultdict(list)
for item in rss_items:
host_groups[urlparse(item.url).netloc].append(item)
group_lists = list(host_groups.values())
grouped_results = await asyncio.gather(
*[_pull_host_group(items) for items in group_lists]
)
item_results = [
(item, result)
for items, results in zip(group_lists, grouped_results)
for item, result in zip(items, results)
]
now = datetime.now(timezone.utc).isoformat()
# Process results sequentially (DB operations)
for rss_item, (new_torrents, error) in zip(rss_items, results):
for rss_item, (new_torrents, error) in item_results:
# Update connection status
rss_item.connection_status = "error" if error else "healthy"
rss_item.last_checked_at = now

View File

@@ -222,5 +222,5 @@ def mock_download_client():
]
client.pause_torrent.return_value = None
client.resume_torrent.return_value = None
client.delete_torrent.return_value = None
client.delete_torrent.return_value = True
return client

View File

@@ -62,7 +62,7 @@ def mock_download_client():
]
client.pause_torrent.return_value = None
client.resume_torrent.return_value = None
client.delete_torrent.return_value = None
client.delete_torrent.return_value = True
return client
@@ -433,3 +433,24 @@ class TestAutoTagTorrents:
assert data["unmatched_count"] == 1
assert len(data["unmatched"]) == 1
mock_download_client.add_tag.assert_not_called()
class TestDeleteTorrentsFailure:
def test_delete_failure_is_reported(self, authed_client, mock_download_client):
"""A rejected qB delete must not be reported as success (#1046)."""
mock_download_client.delete_torrent.return_value = False
with patch("module.api.downloader.DownloadClient") as MockClient:
MockClient.return_value.__aenter__ = AsyncMock(
return_value=mock_download_client
)
MockClient.return_value.__aexit__ = AsyncMock(return_value=False)
response = authed_client.post(
"/api/v1/downloader/torrents/delete",
json={"hashes": ["abc123"], "delete_files": True},
)
assert response.status_code == 200
data = response.json()
assert data["status"] is False
assert data["msg_en"] == "Failed to delete torrents"

View File

@@ -48,6 +48,19 @@ class TestAuth:
await download_client.auth()
assert download_client.authed is False
async def test_aenter_closes_client_on_failed_auth(
self, download_client, mock_qb_client
):
"""__aenter__ must close the concrete client's pool before raising,
because __aexit__ never runs on a failed connect (leak fix, #1043)."""
mock_qb_client.auth.return_value = False
with pytest.raises(ConnectionError):
async with download_client:
pass
mock_qb_client.logout.assert_awaited_once()
# ---------------------------------------------------------------------------
# init_downloader

View File

@@ -340,11 +340,11 @@ class TestIssue990NumberPrefixTitle:
assert result.resolution == "1080P"
assert result.group == "ANi"
def test_title_parser_returns_bangumi_for_number_prefix_title(self):
async def test_title_parser_returns_bangumi_for_number_prefix_title(self):
"""TitleParser.raw_parser returns a valid Bangumi for number-prefixed titles."""
from module.parser.title_parser import TitleParser
result = TitleParser.raw_parser(self.PROBLEM_TITLE)
result = await TitleParser.raw_parser(self.PROBLEM_TITLE)
assert result is not None
assert result.official_title == "29 岁单身中坚冒险家的日常"
assert result.title_raw == "29 岁单身中坚冒险家的日常"
@@ -504,11 +504,11 @@ class TestIssue992NonEpisodicAttributeError:
]
@pytest.mark.parametrize("title", NON_EPISODIC_TITLES)
def test_title_parser_returns_none_for_non_episodic(self, title):
async def test_title_parser_returns_none_for_non_episodic(self, title):
"""TitleParser.raw_parser should return None instead of crashing."""
from module.parser.title_parser import TitleParser
result = TitleParser.raw_parser(title)
result = await TitleParser.raw_parser(title)
assert result is None
def test_raw_parser_returns_none_for_unparseable(self):

View File

@@ -51,7 +51,7 @@ class TestOpenAIParser:
params = azure_parser._prepare_params(text, DEFAULT_PROMPT)
assert expected == params
def test_parse(self):
async def test_parse(self):
text = "[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]"
expected = {
"group": "梦蓝字幕组",
@@ -69,5 +69,5 @@ class TestOpenAIParser:
with mock.patch("module.parser.analyser.OpenAIParser.parse") as mocker:
mocker.return_value = json.dumps(expected)
result = self.parser.parse(text=text, asdict=False)
result = await self.parser.parse(text=text, asdict=False)
assert json.loads(result) == expected

View File

@@ -528,3 +528,120 @@ 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_returns_false_on_200_html_body(self):
"""A 200 + HTML page (proxy, wrong service) is not a successful login."""
qb = QbDownloader(
host="localhost:8080", username="admin", password="pass", ssl=False
)
mock_client = AsyncMock()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.text = "<html><body>Sign in</body></html>"
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
# ---------------------------------------------------------------------------
# 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

View File

@@ -369,3 +369,66 @@ class TestRefreshRssConcurrency:
await rss_engine.refresh_rss(client)
assert max_active <= 5
# ---------------------------------------------------------------------------
# refresh_rss per-host throttling (#1026)
# ---------------------------------------------------------------------------
class TestRefreshRssPerHostThrottle:
async def test_same_host_requests_never_overlap(self, rss_engine):
"""Feeds on the same host are fetched serially; other hosts stay parallel."""
for i in range(3):
rss_engine.rss.add(
make_rss_item(url=f"https://nyaa.example/rss/{i}", name=f"nyaa{i}")
)
rss_engine.rss.add(make_rss_item(url="https://mikan.example/rss", name="mikan"))
from urllib.parse import urlparse
active: dict[str, int] = {}
max_active: dict[str, int] = {}
async def fake_pull(item):
host = urlparse(item.url).netloc
active[host] = active.get(host, 0) + 1
max_active[host] = max(max_active.get(host, 0), active[host])
# Give concurrently-scheduled pulls a chance to overlap.
await asyncio.sleep(0.01)
active[host] -= 1
return [], None
client = AsyncMock()
with (
patch.object(
RSSEngine,
"_pull_rss_with_status",
new_callable=lambda: AsyncMock(side_effect=fake_pull),
),
patch("module.rss.engine.RSS_PER_HOST_DELAY", 0),
):
await rss_engine.refresh_rss(client)
assert max_active["nyaa.example"] == 1
assert max_active["mikan.example"] == 1
async def test_all_feeds_still_processed(self, rss_engine):
"""Grouping by host must not drop any feed's status update."""
rss_engine.rss.add(make_rss_item(url="https://a.example/rss", name="a"))
rss_engine.rss.add(make_rss_item(url="https://b.example/rss", name="b"))
client = AsyncMock()
with (
patch.object(
RSSEngine,
"_pull_rss_with_status",
new_callable=lambda: AsyncMock(return_value=([], None)),
),
patch("module.rss.engine.RSS_PER_HOST_DELAY", 0),
):
await rss_engine.refresh_rss(client)
for rss_id in (1, 2):
item = rss_engine.rss.search_id(rss_id)
assert item.connection_status == "healthy"

View File

@@ -297,3 +297,95 @@ class TestSentinelPath:
def test_sentinel_path_is_in_config_dir(self):
assert str(SENTINEL_PATH) == "config/.setup_complete"
assert SENTINEL_PATH.parent == Path("config")
class TestTestDownloaderHardening:
"""qB 5.2 login compat (#1044) and SSRF hardening (#1041)."""
@staticmethod
def _mock_client(get_resp=None, login_resp=None, get_exc=None):
mock_instance = AsyncMock()
if get_exc is not None:
mock_instance.get.side_effect = get_exc
else:
mock_instance.get.return_value = get_resp
mock_instance.post.return_value = login_resp
cls_patch = patch("module.api.setup.httpx.AsyncClient")
mock_cls = cls_patch.start()
mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_instance)
mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
return cls_patch
def _post(self, client, host="192.168.1.100:8080"):
return client.post(
"/api/v1/setup/test-downloader",
json={
"type": "qbittorrent",
"host": host,
"username": "admin",
"password": "admin",
"ssl": False,
},
)
def test_login_accepts_204_empty_body(self, client, mock_first_run):
"""qBittorrent >= 5.2 returns 204 + empty body on successful login."""
from unittest.mock import MagicMock
get_resp = MagicMock(text="qBittorrent WebUI")
login_resp = MagicMock(status_code=204, text="")
cls_patch = self._mock_client(get_resp=get_resp, login_resp=login_resp)
try:
response = self._post(client)
finally:
cls_patch.stop()
assert response.status_code == 200
assert response.json()["success"] is True
def test_login_rejects_200_fails_body(self, client, mock_first_run):
"""200 + 'Fails.' (bad credentials) is still a failure."""
from unittest.mock import MagicMock
get_resp = MagicMock(text="qBittorrent WebUI")
login_resp = MagicMock(status_code=200, text="Fails.")
cls_patch = self._mock_client(get_resp=get_resp, login_resp=login_resp)
try:
response = self._post(client)
finally:
cls_patch.stop()
assert response.status_code == 200
assert response.json()["success"] is False
def test_non_http_scheme_rejected(self, client, mock_first_run):
"""Non-http(s) schemes must be rejected before any request is made."""
response = self._post(client, host="ftp://internal-server:21")
assert response.status_code == 400
def test_exception_detail_not_echoed(self, client, mock_first_run):
"""Raw exception text must not leak into the API response (#1041)."""
cls_patch = self._mock_client(get_exc=Exception("secret-detail-xyz"))
try:
response = self._post(client)
finally:
cls_patch.stop()
assert response.status_code == 200
data = response.json()
assert data["success"] is False
assert "secret-detail-xyz" not in data["message_en"]
assert "secret-detail-xyz" not in data["message_zh"]
def test_login_rejects_200_html_body(self, client, mock_first_run):
"""A proxy answering 200 + HTML to the login POST is not a success."""
from unittest.mock import MagicMock
get_resp = MagicMock(text="qBittorrent WebUI")
login_resp = MagicMock(
status_code=200, text="<html><body>portal</body></html>"
)
cls_patch = self._mock_client(get_resp=get_resp, login_resp=login_resp)
try:
response = self._post(client)
finally:
cls_patch.stop()
assert response.status_code == 200
assert response.json()["success"] is False

View File

@@ -4,9 +4,9 @@ from module.parser.title_parser import TitleParser
class TestTitleParser:
def test_parse_without_openai(self):
async def test_parse_without_openai(self):
text = "[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]"
result = TitleParser.raw_parser(text)
result = await TitleParser.raw_parser(text)
assert result.group_name == "梦蓝字幕组"
assert result.title_raw == "New Doraemon"
assert result.dpi == "1080P"
@@ -17,9 +17,9 @@ class TestTitleParser:
not settings.experimental_openai.enable,
reason="OpenAI is not enabled in settings",
)
def test_parse_with_openai(self):
async def test_parse_with_openai(self):
text = "[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]"
result = TitleParser.raw_parser(text)
result = await TitleParser.raw_parser(text)
assert result.group_name == "梦蓝字幕组"
assert result.title_raw == "New Doraemon"
assert result.dpi == "1080P"