mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-07-18 20:51:02 +08:00
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:
@@ -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}
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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="设置失败,请查看服务器日志。",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("|"):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user