Merge pull request #1047 from EstrellaXD/3.2-dev

3.2.8
This commit is contained in:
Estrella Pan
2026-07-02 12:14:07 +02:00
committed by GitHub
36 changed files with 1083 additions and 152 deletions

View File

@@ -314,9 +314,18 @@ jobs:
echo ${{ needs.version-info.outputs.version }}
echo "VERSION='${{ needs.version-info.outputs.version }}'" >> module/__version__.py
- uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Generate requirements.txt for non-uv consumers (#994)
run: |
cd backend && uv export --format requirements-txt --no-hashes --no-dev -o requirements.txt
- name: Zip app
run: |
cd backend && zip -r app-v${{ needs.version-info.outputs.version }}.zip src
cd backend && zip -r app-v${{ needs.version-info.outputs.version }}.zip \
src pyproject.toml uv.lock requirements.txt
- name: Generate Release info
id: release-info

View File

@@ -1,5 +1,32 @@
# [Unreleased]
# [3.2.8] - 2026-07-02
## Backend
### Fixed
- 修复 qBittorrent ≥ 5.2 登录失败:新版登录成功返回 HTTP 204 空响应体,旧代码要求 200 + "Ok." 导致认证失败 (#1044, #1034, #1043)
- 修复删除番剧/种子时文件不被删除hashes 列表未按 qB API 要求以 `|` 拼接且响应状态未校验,删除失败现在会正确上报到 WebUI (#1046)
- 修复下载器认证失败时 httpx 连接池泄漏:连接失败后正确关闭客户端 (#1043)
- 加固初始设置接口:`/setup/test-downloader` 增加 URL 协议校验,所有 setup 接口不再回显原始错误详情 (#1041)
- 修复同一站点多个 RSS 并发请求触发 HTTP 429按主机分组串行抓取并加入间隔不同主机仍并行 (#1026)
- 修复 OpenAI 解析阻塞事件循环:切换到 AsyncOpenAI 异步客户端
- 修复番剧缓存的失效竞态:加锁并引入代数计数,防止过期快照覆盖较新的失效
- 修复无 `[字幕组]` 前缀的标题被解析器破坏 (#1025)
- 共享 httpx 客户端跟随 302 重定向mikanime.tv 镜像域名) (#983)
- 修复 Linux 主机解析 Windows qBittorrent 保存路径导致季度总被置为 01 (#1016)
- 加固共享 httpx 客户端:缩短 keepalive 防止陈旧连接风暴 (#1018, #1028, #984)
- 删除 RSS 订阅时级联删除其种子记录,侧边栏不再残留条目 (#1019)
- 探测 qBittorrent 时遵循 SSL 设置 (#1014)
### Maintenance
- 发布包纳入 `pyproject.toml` / `uv.lock` / `requirements.txt` (#994, #1015)
- 容器启动 chown 跳过 `.venv`,加快启动 (#1011)
# [3.2.6] - 2026-03-01
## Backend
### Added

View File

@@ -1,6 +1,6 @@
[project]
name = "auto-bangumi"
version = "3.2.4"
version = "3.2.8"
description = "AutoBangumi - Automated anime download manager"
requires-python = ">=3.13"
dependencies = [

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

@@ -66,8 +66,9 @@ class Checker:
return True
try:
prefix = "https" if settings.downloader.ssl else "http"
url = (
f"http://{settings.downloader.host}"
f"{prefix}://{settings.downloader.host}"
if "://" not in settings.downloader.host
else f"{settings.downloader.host}"
)

View File

@@ -31,7 +31,14 @@ class RSSThread(ProgramStatus):
# Analyse RSS
rss_list = engine.rss.search_aggregate()
for rss in rss_list:
await self.analyser.rss_to_data(rss, engine)
try:
await self.analyser.rss_to_data(rss, engine)
except Exception:
# RSS 可能在遍历期间被 API 删除,跳过即可
logger.debug(
"[RSSThread] Skipping RSS id=%s, likely deleted",
rss.id if hasattr(rss, "id") else "?",
)
# Run RSS Engine
await engine.refresh_rss(client)
if settings.bangumi_manage.eps_complete:

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

@@ -2,7 +2,7 @@ import logging
from sqlmodel import Session, and_, delete, select
from module.models import RSSItem, RSSUpdate
from module.models import RSSItem, RSSUpdate, Torrent
logger = logging.getLogger(__name__)
@@ -107,16 +107,23 @@ class RSSDatabase:
return list(result.scalars().all())
def delete(self, _id: int) -> bool:
condition = delete(RSSItem).where(RSSItem.id == _id)
try:
self.session.execute(condition)
# 先删除引用该 RSS 的 torrent避免外键约束报错
self.session.execute(delete(Torrent).where(Torrent.rss_id == _id))
self.session.execute(delete(RSSItem).where(RSSItem.id == _id))
self.session.commit()
return True
except Exception as e:
self.session.rollback()
logger.error(f"Delete RSS Item failed. Because: {e}")
return False
def delete_all(self):
condition = delete(RSSItem)
self.session.execute(condition)
self.session.commit()
try:
# 先删除所有引用 RSS 的 torrent避免外键约束报错
self.session.execute(delete(Torrent).where(Torrent.rss_id != None)) # noqa: E711
self.session.execute(delete(RSSItem))
self.session.commit()
except Exception as e:
self.session.rollback()
logger.error(f"Delete all RSS Items failed. Because: {e}")

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

@@ -28,16 +28,33 @@ class QbDownloader:
times = 0
use_https = self.host.startswith("https://")
timeout = httpx.Timeout(connect=5.0, read=10.0, write=10.0, pool=10.0)
# Keepalive_expiry keeps idle TCP sockets short-lived so they can't
# outlive a proxy / NAS idle-reap, which would otherwise surface as
# "Server disconnected without sending a response" when the next
# renamer cycle reuses the pool (#984). max_connections caps parallel
# load on the downloader and anything fronting it.
limits = httpx.Limits(
max_keepalive_connections=5,
max_connections=10,
keepalive_expiry=30.0,
)
# Never verify certificates - self-signed certs are the norm for
# home-server / NAS / Docker qBittorrent setups.
self._client = httpx.AsyncClient(timeout=timeout, verify=False)
self._client = httpx.AsyncClient(timeout=timeout, limits=limits, verify=False)
while times < retry:
try:
resp = await self._client.post(
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")
@@ -191,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(
@@ -242,7 +270,8 @@ class QbDownloader:
break
# Final attempt failed
logger.debug(
"[Downloader] Rename API returned 200 but file unchanged: %s", old_path
"[Downloader] Rename API returned 200 but file unchanged: %s",
old_path,
)
return False
# new_path found or old_path not found

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

@@ -1,6 +1,7 @@
import logging
import re
from os import PathLike
from pathlib import PureWindowsPath
from module.conf import PLATFORM, settings
from module.models import Bangumi, BangumiUpdate
@@ -36,9 +37,11 @@ class TorrentPath:
@staticmethod
def _path_to_bangumi(save_path: PathLike[str] | str, torrent_name: str = ""):
# Split save path and download path
save_parts = Path(save_path).parts
download_parts = Path(settings.downloader.path).parts
# Use PureWindowsPath regardless of the host AB runs on: it accepts
# both "\" and "/" separators, so a qBittorrent-on-Windows save_path
# reaching a Linux AB still splits into segments correctly (#1016).
save_parts = PureWindowsPath(save_path).parts
download_parts = PureWindowsPath(settings.downloader.path).parts
# Get bangumi name and season
bangumi_name = ""
season = 1

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

@@ -12,6 +12,15 @@ logger = logging.getLogger(__name__)
_shared_client: httpx.AsyncClient | None = None
_shared_client_proxy_key: str | None = None
# RSS 循环间隔 900s 远超服务端 keep-alive 超时60-120s
# keepalive_expiry=60 让空闲连接在过期前主动丢弃,避免复用过期连接
# max_connections=20 足够覆盖典型订阅数量
_CONNECTION_LIMITS = httpx.Limits(
max_keepalive_connections=5,
max_connections=20,
keepalive_expiry=60.0,
)
def _proxy_config_key() -> str:
if settings.proxy.enable:
@@ -27,28 +36,45 @@ async def get_shared_client() -> httpx.AsyncClient:
if _shared_client is not None:
await _shared_client.aclose()
timeout = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0)
# follow_redirects=True: Mikan mirrors and some CDNs respond with 302 to the
# canonical host; without this, raise_for_status treats the redirect as an
# error and the RSS pull fails (#983).
common_kwargs = {
"timeout": timeout,
"limits": _CONNECTION_LIMITS,
"follow_redirects": True,
}
if settings.proxy.enable:
if "http" in settings.proxy.type:
if settings.proxy.username:
proxy_url = f"http://{settings.proxy.username}:{settings.proxy.password}@{settings.proxy.host}:{settings.proxy.port}"
else:
proxy_url = f"http://{settings.proxy.host}:{settings.proxy.port}"
_shared_client = httpx.AsyncClient(proxy=proxy_url, timeout=timeout)
_shared_client = httpx.AsyncClient(proxy=proxy_url, **common_kwargs)
elif settings.proxy.type == "socks5":
if settings.proxy.username:
socks_url = f"socks5://{settings.proxy.username}:{settings.proxy.password}@{settings.proxy.host}:{settings.proxy.port}"
else:
socks_url = f"socks5://{settings.proxy.host}:{settings.proxy.port}"
transport = AsyncProxyTransport.from_url(socks_url, rdns=True)
_shared_client = httpx.AsyncClient(transport=transport, timeout=timeout)
_shared_client = httpx.AsyncClient(transport=transport, **common_kwargs)
else:
_shared_client = httpx.AsyncClient(timeout=timeout)
_shared_client = httpx.AsyncClient(**common_kwargs)
else:
_shared_client = httpx.AsyncClient(timeout=timeout)
_shared_client = httpx.AsyncClient(**common_kwargs)
_shared_client_proxy_key = current_key
return _shared_client
async def reset_shared_client():
"""关闭并清除共享客户端,下次请求时自动创建新连接池。"""
global _shared_client, _shared_client_proxy_key
if _shared_client is not None:
await _shared_client.aclose()
_shared_client = None
_shared_client_proxy_key = None
class RequestURL:
# More complete User-Agent to avoid Cloudflare blocking
DEFAULT_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
@@ -67,7 +93,9 @@ class RequestURL:
}
# For torrent files, use different Accept header
if url.endswith(".torrent") or "/download/" in url:
base_headers["Accept"] = "application/x-bittorrent, application/octet-stream, */*"
base_headers["Accept"] = (
"application/x-bittorrent, application/octet-stream, */*"
)
else:
base_headers["Accept"] = "application/xml, text/xml, */*"
return base_headers
@@ -78,7 +106,11 @@ class RequestURL:
while True:
try:
req = await self._client.get(url=url, headers=headers)
logger.debug("[Network] Successfully connected to %s. Status: %s", url, req.status_code)
logger.debug(
"[Network] Successfully connected to %s. Status: %s",
url,
req.status_code,
)
req.raise_for_status()
return req
except httpx.HTTPStatusError as e:
@@ -91,20 +123,23 @@ class RequestURL:
try_time += 1
if try_time >= retry:
break
# 连接错误时重建客户端以清除过期连接
await reset_shared_client()
self._client = await get_shared_client()
await asyncio.sleep(5)
except Exception as e:
logger.warning(f"[Network] Unexpected error for {url}: {e}")
break
logger.error(f"[Network] Unable to connect to {url}, Please check your network settings")
logger.error(
f"[Network] Unable to connect to {url}, Please check your network settings"
)
return None
async def post_url(self, url: str, data: dict, retry=3):
try_time = 0
while True:
try:
req = await self._client.post(
url=url, headers=self.header, data=data
)
req = await self._client.post(url=url, headers=self.header, data=data)
req.raise_for_status()
return req
except httpx.RequestError:
@@ -114,6 +149,9 @@ class RequestURL:
try_time += 1
if try_time >= retry:
break
# 连接错误时重建客户端,清除过期连接
await reset_shared_client()
self._client = await get_shared_client()
await asyncio.sleep(5)
except Exception as e:
logger.debug(e)

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

@@ -59,7 +59,11 @@ def pre_process(raw_name: str) -> str:
def prefix_process(raw: str, group: str) -> str:
raw = re.sub(f".{re.escape(group)}.", "", raw)
# Guard against empty group: without this, the pattern degenerates to ".."
# and every pair of characters gets deleted, destroying titles that lack a
# [group] prefix (#1025).
if group:
raw = re.sub(f".{re.escape(group)}.", "", raw)
raw_process = PREFIX_RE.sub("/", raw)
arg_group = raw_process.split("/")
while "" in arg_group:

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,14 +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 concurrently
# 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))
results = await asyncio.gather(
*[self._pull_rss_with_status(rss_item) for rss_item in rss_items]
semaphore = asyncio.Semaphore(5)
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
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

@@ -1,6 +1,7 @@
import json
import pytest
from sqlalchemy import event
from sqlmodel import Session, SQLModel, create_engine
from module.database.bangumi import BangumiDatabase
@@ -12,6 +13,30 @@ from module.models import Bangumi, RSSItem, Torrent
engine = create_engine("sqlite://", echo=False)
@event.listens_for(engine, "connect")
def _enable_foreign_keys(dbapi_conn, connection_record):
"""匹配生产环境行为:启用 SQLite 外键约束。"""
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
def _ensure_bangumi(session, bangumi_id: int):
"""确保 bangumi 表中存在指定 id 的记录,满足外键约束。"""
if session.get(Bangumi, bangumi_id) is None:
session.add(Bangumi(
id=bangumi_id,
official_title=f"Stub Anime {bangumi_id}",
title_raw=f"Stub {bangumi_id}",
group_name="TestGroup",
dpi="1080p",
source="Web",
subtitle="CHT",
rss_link=f"stub_{bangumi_id}",
))
session.commit()
@pytest.fixture
def db_session():
SQLModel.metadata.create_all(engine)
@@ -189,6 +214,9 @@ def test_torrent_with_bangumi_id(db_session):
"""Test torrent with bangumi_id for offset lookup."""
db = TorrentDatabase(db_session)
# 父记录满足外键约束
_ensure_bangumi(db_session, 42)
# Create torrent linked to a bangumi
torrent = Torrent(
name="[SubGroup] Test Anime - 04 [1080p].mkv",
@@ -445,6 +473,7 @@ class TestDeleteByBangumiId:
def test_deletes_matching_torrents(self, db_session):
db = TorrentDatabase(db_session)
_ensure_bangumi(db_session, 10)
for i in range(3):
db.add(Torrent(name=f"torrent_{i}", url=f"https://example.com/{i}", bangumi_id=10))
assert len(db.search_all()) == 3
@@ -455,6 +484,8 @@ class TestDeleteByBangumiId:
def test_leaves_other_bangumi_torrents(self, db_session):
db = TorrentDatabase(db_session)
_ensure_bangumi(db_session, 20)
_ensure_bangumi(db_session, 30)
db.add(Torrent(name="keep", url="https://example.com/keep", bangumi_id=20))
db.add(Torrent(name="delete", url="https://example.com/delete", bangumi_id=30))
@@ -466,6 +497,7 @@ class TestDeleteByBangumiId:
def test_no_match_returns_zero(self, db_session):
db = TorrentDatabase(db_session)
_ensure_bangumi(db_session, 5)
db.add(Torrent(name="unrelated", url="https://example.com/1", bangumi_id=5))
count = db.delete_by_bangumi_id(999)
@@ -474,6 +506,7 @@ class TestDeleteByBangumiId:
def test_skips_null_bangumi_id(self, db_session):
db = TorrentDatabase(db_session)
_ensure_bangumi(db_session, 7)
db.add(Torrent(name="orphan", url="https://example.com/orphan", bangumi_id=None))
db.add(Torrent(name="target", url="https://example.com/target", bangumi_id=7))
@@ -486,6 +519,7 @@ class TestDeleteByBangumiId:
def test_check_new_finds_urls_after_cleanup(self, db_session):
"""Core scenario: after deleting torrent records, check_new should treat those URLs as new."""
db = TorrentDatabase(db_session)
_ensure_bangumi(db_session, 42)
db.add(Torrent(name="ep01", url="https://mikan.me/t/001", bangumi_id=42))
db.add(Torrent(name="ep02", url="https://mikan.me/t/002", bangumi_id=42))
@@ -579,3 +613,99 @@ def test_match_list_with_aliases(db_session):
unmatched = db.match_list(torrents, "rss2")
assert len(unmatched) == 1
assert unmatched[0].name == "[OtherGroup] Different Anime - 01.mkv"
# ============================================================
# RSS Foreign Key Constraint Tests
# ============================================================
class TestRSSDeleteWithTorrents:
"""Regression tests: deleting RSSItem must cascade-delete referencing torrents."""
def test_delete_rss_with_torrents(self, db_session):
"""删除 RSSItem 时应自动清除引用它的 torrent 记录。"""
rss_db = RSSDatabase(db_session)
torrent_db = TorrentDatabase(db_session)
# 创建 RSS 和关联的 torrent
rss = RSSItem(url="https://mikanani.me/RSS/test", name="Test RSS")
rss_db.add(rss)
torrent_db.add(Torrent(name="ep01", url="https://example.com/1", rss_id=rss.id))
torrent_db.add(Torrent(name="ep02", url="https://example.com/2", rss_id=rss.id))
# 不关联此 RSS 的 torrent
torrent_db.add(Torrent(name="other", url="https://example.com/3", rss_id=None))
assert len(torrent_db.search_rss(rss.id)) == 2
# 删除 RSS不应报外键错误
result = rss_db.delete(rss.id)
assert result is True
# RSS 和关联 torrent 都应被删除
assert rss_db.search_id(rss.id) is None
assert len(torrent_db.search_rss(rss.id)) == 0
# 无关 torrent 不受影响
assert len(torrent_db.search_all()) == 1
def test_delete_rss_without_torrents(self, db_session):
"""删除没有关联 torrent 的 RSSItem 应正常工作。"""
rss_db = RSSDatabase(db_session)
rss = RSSItem(url="https://mikanani.me/RSS/empty", name="Empty RSS")
rss_db.add(rss)
result = rss_db.delete(rss.id)
assert result is True
assert rss_db.search_id(rss.id) is None
def test_delete_rss_cascades_in_transaction(self, db_session):
"""验证删除操作在同一事务中完成,要么全成功要么全回滚。"""
rss_db = RSSDatabase(db_session)
torrent_db = TorrentDatabase(db_session)
rss = RSSItem(url="https://mikanani.me/RSS/tx", name="TX Test")
rss_db.add(rss)
for i in range(5):
torrent_db.add(
Torrent(name=f"ep{i:02d}", url=f"https://example.com/{i}", rss_id=rss.id)
)
assert len(torrent_db.search_rss(rss.id)) == 5
rss_db.delete(rss.id)
# 全部清理干净
assert rss_db.search_id(rss.id) is None
assert len(torrent_db.search_rss(rss.id)) == 0
def test_delete_all_rss_with_torrents(self, db_session):
"""delete_all 应删除所有 RSS 及其关联 torrent。"""
rss_db = RSSDatabase(db_session)
torrent_db = TorrentDatabase(db_session)
rss1 = RSSItem(url="https://mikanani.me/RSS/a", name="RSS A")
rss2 = RSSItem(url="https://mikanani.me/RSS/b", name="RSS B")
rss_db.add(rss1)
rss_db.add(rss2)
torrent_db.add(Torrent(name="t1", url="https://example.com/1", rss_id=rss1.id))
torrent_db.add(Torrent(name="t2", url="https://example.com/2", rss_id=rss2.id))
# rss_id 为 None 的 torrent 应不受影响
torrent_db.add(Torrent(name="orphan", url="https://example.com/3", rss_id=None))
rss_db.delete_all()
assert len(rss_db.search_all()) == 0
# 只剩 rss_id 为 None 的
remaining = torrent_db.search_all()
assert len(remaining) == 1
assert remaining[0].name == "orphan"
def test_delete_nonexistent_rss(self, db_session):
"""删除不存在的 RSS 不应报错。"""
rss_db = RSSDatabase(db_session)
result = rss_db.delete(999)
assert result is True

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

@@ -13,6 +13,34 @@ def test_path_to_bangumi():
assert season == 2
def test_path_to_bangumi_windows_style_save_path():
"""Regression for #1016: when qBittorrent runs on Windows and AB runs on
Linux, qB returns backslash paths. PurePosixPath treats the whole string
as one segment, leaving season stuck at 1."""
from module.downloader.path import TorrentPath
with patch("module.downloader.path.settings") as mock_settings:
mock_settings.downloader.path = r"D:\video\Bangumis"
path = r"D:\video\Bangumis\小书痴的下克上\Season 4"
bangumi_name, season = TorrentPath._path_to_bangumi(path)
assert bangumi_name == "小书痴的下克上"
assert season == 4
def test_path_to_bangumi_posix_path_on_linux_ab():
"""Regression guard: POSIX paths still parse correctly after the fix."""
from module.downloader.path import TorrentPath
with patch("module.downloader.path.settings") as mock_settings:
mock_settings.downloader.path = "/downloads/Bangumi"
path = "/downloads/Bangumi/葬送的芙莉莲/Season 2"
bangumi_name, season = TorrentPath._path_to_bangumi(path)
assert bangumi_name == "葬送的芙莉莲"
assert season == 2
class TestGenSavePath:
"""Tests for TorrentPath._gen_save_path with season_offset."""

View File

@@ -24,12 +24,16 @@ class TestQbDownloaderConstructor:
def test_ssl_true_no_scheme_uses_https(self):
"""ssl=True with bare host prepends https://."""
qb = QbDownloader(host="192.168.1.10:8080", username="admin", password="pass", ssl=True)
qb = QbDownloader(
host="192.168.1.10:8080", username="admin", password="pass", ssl=True
)
assert qb.host == "https://192.168.1.10:8080"
def test_ssl_false_no_scheme_uses_http(self):
"""ssl=False with bare host prepends http://."""
qb = QbDownloader(host="192.168.1.10:8080", username="admin", password="pass", ssl=False)
qb = QbDownloader(
host="192.168.1.10:8080", username="admin", password="pass", ssl=False
)
assert qb.host == "http://192.168.1.10:8080"
def test_explicit_http_scheme_preserved_when_ssl_true(self):
@@ -42,18 +46,25 @@ class TestQbDownloaderConstructor:
def test_explicit_https_scheme_preserved_when_ssl_false(self):
"""Explicit https:// scheme is kept even if ssl=False."""
qb = QbDownloader(
host="https://192.168.1.10:8080", username="admin", password="pass", ssl=False
host="https://192.168.1.10:8080",
username="admin",
password="pass",
ssl=False,
)
assert qb.host == "https://192.168.1.10:8080"
def test_explicit_http_scheme_preserved_ssl_false(self):
"""Explicit http:// URL with ssl=False stays as http://."""
qb = QbDownloader(host="http://nas.local:8080", username="u", password="p", ssl=False)
qb = QbDownloader(
host="http://nas.local:8080", username="u", password="p", ssl=False
)
assert qb.host == "http://nas.local:8080"
def test_explicit_https_scheme_preserved_ssl_true(self):
"""Explicit https:// URL with ssl=True stays as https://."""
qb = QbDownloader(host="https://nas.local:8080", username="u", password="p", ssl=True)
qb = QbDownloader(
host="https://nas.local:8080", username="u", password="p", ssl=True
)
assert qb.host == "https://nas.local:8080"
def test_credentials_stored(self):
@@ -67,7 +78,9 @@ class TestQbDownloaderConstructor:
def test_client_initially_none(self):
"""_client starts as None before any auth call."""
qb = QbDownloader(host="localhost:8080", username="admin", password="pass", ssl=False)
qb = QbDownloader(
host="localhost:8080", username="admin", password="pass", ssl=False
)
assert qb._client is None
@@ -110,7 +123,9 @@ class TestAuthClientCreation:
async def test_auth_creates_client_with_verify_false_when_ssl_true(self):
"""verify=False is used even when ssl=True (self-signed certs are common)."""
qb = QbDownloader(host="192.168.1.10:8080", username="admin", password="pass", ssl=True)
qb = QbDownloader(
host="192.168.1.10:8080", username="admin", password="pass", ssl=True
)
captured: list[dict] = []
@@ -127,7 +142,9 @@ class TestAuthClientCreation:
async def aclose(self):
pass
with patch("module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient):
with patch(
"module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient
):
result = await qb.auth()
assert result is True
@@ -136,7 +153,9 @@ class TestAuthClientCreation:
async def test_auth_creates_client_with_verify_false_when_ssl_false(self):
"""verify=False is used even when ssl=False."""
qb = QbDownloader(host="192.168.1.10:8080", username="admin", password="pass", ssl=False)
qb = QbDownloader(
host="192.168.1.10:8080", username="admin", password="pass", ssl=False
)
captured: list[dict] = []
@@ -153,7 +172,9 @@ class TestAuthClientCreation:
async def aclose(self):
pass
with patch("module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient):
with patch(
"module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient
):
result = await qb.auth()
assert result is True
@@ -178,12 +199,46 @@ class TestAuthClientCreation:
async def aclose(self):
pass
with patch("module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient):
with patch(
"module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient
):
await qb.auth()
assert len(captured_timeouts) == 1
assert captured_timeouts[0].connect == pytest.approx(5.0)
async def test_auth_sets_connection_limits_for_keepalive(self):
"""Regression for #984: qB client must cap keepalive so idle TCP
sockets don't linger past proxy / NAS idle-reap timeouts, otherwise
parallel renamer calls cascade into 'Server disconnected' errors."""
qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False)
captured: list[dict] = []
class _FakeClient:
def __init__(self, **kwargs):
captured.append(kwargs)
async def post(self, url, data=None):
resp = MagicMock()
resp.status_code = 200
resp.text = "Ok."
return resp
async def aclose(self):
pass
with patch(
"module.downloader.client.qb_downloader.httpx.AsyncClient", _FakeClient
):
await qb.auth()
limits = captured[0].get("limits")
assert limits is not None
assert limits.keepalive_expiry is not None
assert limits.keepalive_expiry > 0
assert limits.max_connections is not None
# ---------------------------------------------------------------------------
# auth: success / failure paths
@@ -195,7 +250,9 @@ class TestAuthSuccessFailure:
async def test_auth_returns_true_on_ok_response(self):
"""Returns True when server responds 200 + 'Ok.'."""
qb = QbDownloader(host="localhost:8080", username="admin", password="pass", ssl=False)
qb = QbDownloader(
host="localhost:8080", username="admin", password="pass", ssl=False
)
mock_client = AsyncMock()
mock_resp = MagicMock()
@@ -213,7 +270,9 @@ class TestAuthSuccessFailure:
async def test_auth_returns_false_on_403(self):
"""Returns False and stops retrying immediately on 403 Forbidden."""
qb = QbDownloader(host="localhost:8080", username="admin", password="pass", ssl=False)
qb = QbDownloader(
host="localhost:8080", username="admin", password="pass", ssl=False
)
mock_client = AsyncMock()
mock_resp = MagicMock()
@@ -233,7 +292,9 @@ class TestAuthSuccessFailure:
async def test_auth_retries_up_to_limit_on_server_error(self):
"""Retries up to the retry limit on non-200/non-403 responses."""
qb = QbDownloader(host="localhost:8080", username="admin", password="pass", ssl=False)
qb = QbDownloader(
host="localhost:8080", username="admin", password="pass", ssl=False
)
mock_client = AsyncMock()
mock_resp = MagicMock()
@@ -271,7 +332,9 @@ class TestAuthConnectErrorLogging:
)
mock_client = AsyncMock()
mock_client.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
mock_client.post = AsyncMock(
side_effect=httpx.ConnectError("Connection refused")
)
with patch(
"module.downloader.client.qb_downloader.httpx.AsyncClient",
@@ -287,7 +350,9 @@ class TestAuthConnectErrorLogging:
result = await qb.auth(retry=1)
assert result is False
error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR]
error_messages = [
r.message for r in caplog.records if r.levelno == logging.ERROR
]
assert any("HTTPS" in msg for msg in error_messages)
assert any(
"disable SSL" in msg or "plain HTTP" in msg for msg in error_messages
@@ -296,7 +361,9 @@ class TestAuthConnectErrorLogging:
async def test_https_url_derived_from_ssl_flag_logs_https_guidance(self, caplog):
"""HTTPS guidance also fires when scheme comes from ssl=True (bare host)."""
# Bare host + ssl=True -> self.host becomes https://... -> use_https=True in auth()
qb = QbDownloader(host="192.168.1.10:8080", username="u", password="p", ssl=True)
qb = QbDownloader(
host="192.168.1.10:8080", username="u", password="p", ssl=True
)
assert qb.host.startswith("https://")
mock_client = AsyncMock()
@@ -315,7 +382,9 @@ class TestAuthConnectErrorLogging:
):
await qb.auth(retry=1)
error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR]
error_messages = [
r.message for r in caplog.records if r.levelno == logging.ERROR
]
assert any("HTTPS" in msg for msg in error_messages)
async def test_http_url_logs_generic_message_without_ssl_hint(self, caplog):
@@ -325,7 +394,9 @@ class TestAuthConnectErrorLogging:
)
mock_client = AsyncMock()
mock_client.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
mock_client.post = AsyncMock(
side_effect=httpx.ConnectError("Connection refused")
)
with patch(
"module.downloader.client.qb_downloader.httpx.AsyncClient",
@@ -341,14 +412,20 @@ class TestAuthConnectErrorLogging:
result = await qb.auth(retry=1)
assert result is False
error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR]
assert any("Cannot connect to qBittorrent Server" in msg for msg in error_messages)
error_messages = [
r.message for r in caplog.records if r.levelno == logging.ERROR
]
assert any(
"Cannot connect to qBittorrent Server" in msg for msg in error_messages
)
# SSL-disable hint must NOT appear for plain HTTP connections
assert not any("disable SSL" in msg for msg in error_messages)
async def test_http_url_derived_from_ssl_flag_false_no_ssl_hint(self, caplog):
"""SSL-disable hint is absent when scheme comes from ssl=False (bare host)."""
qb = QbDownloader(host="192.168.1.10:8080", username="u", password="p", ssl=False)
qb = QbDownloader(
host="192.168.1.10:8080", username="u", password="p", ssl=False
)
assert qb.host.startswith("http://")
mock_client = AsyncMock()
@@ -420,7 +497,9 @@ class TestAuthConnectErrorLogging:
):
await qb.auth(retry=1)
error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR]
error_messages = [
r.message for r in caplog.records if r.levelno == logging.ERROR
]
assert not any("disable SSL" in msg for msg in error_messages)
assert not any("HTTPS" in msg for msg in error_messages)
@@ -445,5 +524,124 @@ class TestUrlHelper:
def test_url_with_explicit_http_scheme_overriding_ssl_true(self):
"""_url works correctly when explicit http:// scheme overrides ssl=True."""
qb = QbDownloader(host="http://nas.local:8080", username="u", password="p", ssl=True)
qb = QbDownloader(
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

@@ -56,7 +56,9 @@ def test_raw_parser():
assert info.episode == 9
assert info.season == 1
content = "[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]"
content = (
"[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]"
)
info = raw_parser(content)
assert info.group == "梦蓝字幕组"
assert info.title_zh == "哆啦A梦新番"
@@ -65,7 +67,9 @@ def test_raw_parser():
assert info.episode == 747
assert info.season == 1
content = "[织梦字幕组][尼尔:机械纪元 NieR Automata Ver1.1a][02集][1080P][AVC][简日双语]"
content = (
"[织梦字幕组][尼尔:机械纪元 NieR Automata Ver1.1a][02集][1080P][AVC][简日双语]"
)
info = raw_parser(content)
assert info.group == "织梦字幕组"
assert info.title_zh == "尼尔:机械纪元"
@@ -160,7 +164,9 @@ def test_raw_parser():
assert info.season == 1
# Issue #990: Title starting with number — should not misparse "29" as episode
content = "[ANi] 29 岁单身中坚冒险家的日常 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"
content = (
"[ANi] 29 岁单身中坚冒险家的日常 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"
)
info = raw_parser(content)
assert info.group == "ANi"
assert info.title_zh == "29 岁单身中坚冒险家的日常"
@@ -310,8 +316,9 @@ class TestIssue764WesternFormat:
assert info.resolution == "1080p"
# No brackets → group detection fails
assert info.group == ""
# No CJK chars → no title_zh/jp; EN detection also fails (short segments)
assert info.title_en is None
# After the #1025 fix, prefix_process no longer destroys titles without
# a [group] prefix, so the English title is now extracted correctly.
assert info.title_en == "Girls Band Cry"
assert info.title_zh is None
@@ -323,7 +330,9 @@ class TestIssue986AtlasFormat:
"[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fatestrange Fake][07_神自黄昏归来][简繁日内封PGS][日语配音版_Japanese Dub][Web-DL Remux][1080p AVC AAC]",
]
@pytest.mark.xfail(reason="Atlas bracket-delimited format not supported by TITLE_RE")
@pytest.mark.xfail(
reason="Atlas bracket-delimited format not supported by TITLE_RE"
)
def test_parse_atlas_format(self):
info = raw_parser(self.TITLES[0])
assert info is not None
@@ -362,3 +371,24 @@ class TestIssue805TitleWithCht:
assert info.source == "Baha"
assert info.sub == "CHT"
class TestIssue1025NoGroupPrefix:
"""Issue #1025: Titles without a [group] prefix must still parse.
prefix_process was calling re.sub(f".{group}.", "", raw) even when
group was empty, which reduced the pattern to `..` and deleted every
pair of characters, leaving a stub like `1` that name_process couldn't
split into en/zh/jp.
"""
def test_mixed_cjk_and_en_without_group(self):
content = (
"冰之城墙「氷の城壁」The Ramparts of Ice S01E02 1080p 日英双语-多国字幕"
)
info = raw_parser(content)
assert info is not None
assert info.episode == 2
assert info.season == 1
# Before the fix all three title fields were None and title_parser
# raised "Cannot extract title_raw". At least one must now be set.
assert any([info.title_en, info.title_zh, info.title_jp])

View File

@@ -0,0 +1,105 @@
"""Tests for network request_url: shared client configuration and reset."""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from module.network.request_url import get_shared_client, reset_shared_client
@pytest.fixture(autouse=True)
async def _clean_shared_client():
"""Ensure shared client is reset after each test."""
yield
import module.network.request_url as mod
if mod._shared_client is not None:
await mod._shared_client.aclose()
mod._shared_client = None
mod._shared_client_proxy_key = None
class TestSharedClientLimits:
async def test_client_has_keepalive_expiry(self):
"""Shared client should use a finite keepalive_expiry."""
client = await get_shared_client()
pool = client._transport._pool
assert pool._keepalive_expiry is not None
assert pool._keepalive_expiry > 0
async def test_client_has_max_connections(self):
"""Shared client should have a connection pool limit."""
client = await get_shared_client()
pool = client._transport._pool
assert pool._max_connections is not None
assert pool._max_connections > 0
async def test_client_follows_redirects(self):
"""Regression for #983: mikanime mirror returns 302 to the canonical
URL but httpx refuses to follow by default, so the RSS fetch fails."""
client = await get_shared_client()
assert client.follow_redirects is True
class TestResetSharedClient:
async def test_reset_closes_existing_client(self):
"""reset_shared_client should close and clear the shared client."""
client = await get_shared_client()
assert client is not None
await reset_shared_client()
import module.network.request_url as mod
assert mod._shared_client is None
assert mod._shared_client_proxy_key is None
async def test_reset_idempotent_when_no_client(self):
"""reset_shared_client should be safe when no client exists."""
import module.network.request_url as mod
mod._shared_client = None
mod._shared_client_proxy_key = None
await reset_shared_client()
async def test_new_client_after_reset(self):
"""After reset, get_shared_client returns a fresh client."""
old_client = await get_shared_client()
await reset_shared_client()
new_client = await get_shared_client()
assert new_client is not old_client
class TestRetryWithReset:
async def test_get_url_resets_on_connect_error(self):
"""get_url should call reset_shared_client after ConnectTimeout."""
import httpx
from module.network.request_url import RequestURL
call_count = 0
async def mock_get(**kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise httpx.ConnectTimeout("Connection timed out")
resp = MagicMock()
resp.status_code = 200
resp.raise_for_status = MagicMock()
return resp
with (
patch("module.network.request_url.get_shared_client") as mock_get_client,
patch(
"module.network.request_url.reset_shared_client",
new_callable=AsyncMock,
) as mock_reset,
):
mock_client = AsyncMock()
mock_client.get = mock_get
mock_get_client.return_value = mock_client
async with RequestURL() as req:
result = await req.get_url("https://example.com/test", retry=2)
mock_reset.assert_called()
assert call_count == 2

View File

@@ -1,5 +1,7 @@
"""Tests for RSS engine: pull_rss, match_torrent, refresh_rss, add_rss."""
import asyncio
import pytest
from unittest.mock import AsyncMock, patch
@@ -51,7 +53,9 @@ class TestPullRss:
Torrent(name="new1", url="https://example.com/new1.torrent"),
Torrent(name="new2", url="https://example.com/new2.torrent"),
]
with patch.object(RSSEngine, "_get_torrents", new_callable=AsyncMock) as mock_get:
with patch.object(
RSSEngine, "_get_torrents", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = all_torrents
result = await rss_engine.pull_rss(rss_item)
@@ -67,7 +71,9 @@ class TestPullRss:
existing = make_torrent(url="https://example.com/only.torrent", rss_id=1)
rss_engine.torrent.add(existing)
with patch.object(RSSEngine, "_get_torrents", new_callable=AsyncMock) as mock_get:
with patch.object(
RSSEngine, "_get_torrents", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = [
Torrent(name="only", url="https://example.com/only.torrent")
]
@@ -81,7 +87,9 @@ class TestPullRss:
rss_engine.rss.add(rss_item)
rss_item = rss_engine.rss.search_id(1)
with patch.object(RSSEngine, "_get_torrents", new_callable=AsyncMock) as mock_get:
with patch.object(
RSSEngine, "_get_torrents", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = []
result = await rss_engine.pull_rss(rss_item)
@@ -99,9 +107,7 @@ class TestMatchTorrent:
bangumi = make_bangumi(title_raw="Mushoku Tensei", filter="")
rss_engine.bangumi.add(bangumi)
torrent = make_torrent(
name="[Lilith-Raws] Mushoku Tensei - 11 [1080p].mkv"
)
torrent = make_torrent(name="[Lilith-Raws] Mushoku Tensei - 11 [1080p].mkv")
result = rss_engine.match_torrent(torrent)
assert result is not None
@@ -122,9 +128,7 @@ class TestMatchTorrent:
bangumi = make_bangumi(title_raw="Mushoku Tensei", filter="720")
rss_engine.bangumi.add(bangumi)
torrent = make_torrent(
name="[Sub] Mushoku Tensei - 01 [720p].mkv"
)
torrent = make_torrent(name="[Sub] Mushoku Tensei - 01 [720p].mkv")
result = rss_engine.match_torrent(torrent)
assert result is None
@@ -134,9 +138,7 @@ class TestMatchTorrent:
bangumi = make_bangumi(title_raw="Mushoku Tensei", filter="")
rss_engine.bangumi.add(bangumi)
torrent = make_torrent(
name="[Sub] Mushoku Tensei - 01 [720p].mkv"
)
torrent = make_torrent(name="[Sub] Mushoku Tensei - 01 [720p].mkv")
result = rss_engine.match_torrent(torrent)
assert result is not None
@@ -147,9 +149,7 @@ class TestMatchTorrent:
rss_engine.bangumi.add(bangumi)
# Torrent has "hevc" in lowercase - should still be filtered
torrent = make_torrent(
name="[Sub] Mushoku Tensei - 01 [1080p][hevc].mkv"
)
torrent = make_torrent(name="[Sub] Mushoku Tensei - 01 [1080p][hevc].mkv")
result = rss_engine.match_torrent(torrent)
assert result is None
@@ -201,7 +201,9 @@ class TestRefreshRss:
name="[Sub] Mushoku Tensei - 12 [1080p].mkv",
url="https://example.com/ep12.torrent",
)
with patch.object(RSSEngine, "_get_torrents", new_callable=AsyncMock) as mock_get:
with patch.object(
RSSEngine, "_get_torrents", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = [new_torrent]
# Create a mock client
@@ -227,7 +229,9 @@ class TestRefreshRss:
name="[Sub] Unknown Anime - 01 [1080p].mkv",
url="https://example.com/unknown.torrent",
)
with patch.object(RSSEngine, "_get_torrents", new_callable=AsyncMock) as mock_get:
with patch.object(
RSSEngine, "_get_torrents", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = [unmatched]
client = AsyncMock()
await rss_engine.refresh_rss(client)
@@ -244,7 +248,9 @@ class TestRefreshRss:
rss_engine.rss.add(rss1)
rss_engine.rss.add(rss2)
with patch.object(RSSEngine, "_get_torrents", new_callable=AsyncMock) as mock_get:
with patch.object(
RSSEngine, "_get_torrents", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = []
client = AsyncMock()
await rss_engine.refresh_rss(client, rss_id=2)
@@ -254,7 +260,9 @@ class TestRefreshRss:
async def test_refresh_nonexistent_rss_id(self, rss_engine):
"""refresh_rss with non-existent rss_id does nothing."""
with patch.object(RSSEngine, "_get_torrents", new_callable=AsyncMock) as mock_get:
with patch.object(
RSSEngine, "_get_torrents", new_callable=AsyncMock
) as mock_get:
client = AsyncMock()
await rss_engine.refresh_rss(client, rss_id=999)
@@ -284,9 +292,7 @@ class TestAddRss:
async def test_add_without_name_fetches_title(self, rss_engine):
"""add_rss without name calls get_rss_title to auto-discover title."""
with patch(
"module.rss.engine.RequestContent"
) as MockReq:
with patch("module.rss.engine.RequestContent") as MockReq:
mock_instance = AsyncMock()
mock_instance.get_rss_title = AsyncMock(return_value="Fetched Title")
MockReq.return_value.__aenter__ = AsyncMock(return_value=mock_instance)
@@ -303,9 +309,7 @@ class TestAddRss:
async def test_add_without_name_fetch_fails(self, rss_engine):
"""add_rss returns error when title fetch fails."""
with patch(
"module.rss.engine.RequestContent"
) as MockReq:
with patch("module.rss.engine.RequestContent") as MockReq:
mock_instance = AsyncMock()
mock_instance.get_rss_title = AsyncMock(return_value=None)
MockReq.return_value.__aenter__ = AsyncMock(return_value=mock_instance)
@@ -332,3 +336,99 @@ class TestAddRss:
assert result.status is False
assert result.status_code == 406
class TestRefreshRssConcurrency:
async def test_concurrent_requests_limited(self, rss_engine):
"""refresh_rss should limit concurrent requests via semaphore."""
rss_items = [
make_rss_item(name=f"Feed {i}", url=f"https://feed{i}.com/rss")
for i in range(10)
]
for item in rss_items:
rss_engine.rss.add(item)
active_count = 0
max_active = 0
lock = asyncio.Lock()
async def track_concurrency(rss_item):
nonlocal active_count, max_active
async with lock:
active_count += 1
max_active = max(max_active, active_count)
await asyncio.sleep(0.01)
async with lock:
active_count -= 1
return [], None
with patch.object(
rss_engine, "_pull_rss_with_status", side_effect=track_concurrency
):
client = AsyncMock()
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"

2
backend/uv.lock generated
View File

@@ -61,7 +61,7 @@ wheels = [
[[package]]
name = "auto-bangumi"
version = "3.2.4"
version = "3.2.8"
source = { virtual = "." }
dependencies = [
{ name = "aiosqlite" },

View File

@@ -10,6 +10,6 @@ fi
groupmod -o -g "${PGID}" ab
usermod -o -u "${PUID}" ab
chown ab:ab -R /app /home/ab
chown ab:ab -R /app/data /app/config /home/ab
exec su-exec "${PUID}:${PGID}" python main.py