mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-07-18 20:51:02 +08:00
11
.github/workflows/build.yml
vendored
11
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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="设置失败,请查看服务器日志。",
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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("|"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fate/strange 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])
|
||||
|
||||
105
backend/src/test/test_request_url.py
Normal file
105
backend/src/test/test_request_url.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
2
backend/uv.lock
generated
2
backend/uv.lock
generated
@@ -61,7 +61,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "auto-bangumi"
|
||||
version = "3.2.4"
|
||||
version = "3.2.8"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user