Skip to content

Commit ba61194

Browse files
authored
Merge pull request #972 from EstrellaXD/3.2-dev
3.2.3
2 parents f4a83d1 + 326d31d commit ba61194

115 files changed

Lines changed: 7280 additions & 733 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,57 @@
1+
# [3.2.3] - 2026-02-23
2+
3+
## Backend
4+
5+
### Added
6+
7+
- 新增 MCP (Model Context Protocol) 服务器,支持通过 Claude Desktop 等 LLM 工具管理番剧订阅
8+
- SSE 传输层挂载在 `/mcp/sse`,支持 MCP 客户端连接
9+
- 10 个工具:list_anime、get_anime、search_anime、subscribe_anime、unsubscribe_anime、list_downloads、list_rss_feeds、get_program_status、refresh_feeds、update_anime
10+
- 4 个资源:anime/list、anime/{id}、status、rss/feeds
11+
- 本地网络 IP 白名单安全中间件(RFC 1918 + 回环地址),无需 JWT 认证
12+
- 新增通知系统重构,支持多通知渠道同时启用
13+
- 支持 Telegram、Bark、Server 酱、企业微信、Discord、Gotify、Pushover、Webhook 八种渠道
14+
- 新增通知管理 API:`GET/PUT /api/notification/providers`
15+
- 新增 E2E 集成测试套件,覆盖 RSS→下载→重命名全流程
16+
17+
### Fixes
18+
19+
- 修复第 0 集(SP/OVA)被错误重命名为第 1 集的问题 (#977)
20+
- Episode 0 现在免受集数偏移影响,不再覆盖正常集数文件
21+
- 修复 RSS 过滤器包含特殊字符(如 `[字幕组`)时导致程序崩溃的问题 (#974)
22+
- 无效正则表达式自动降级为字面量匹配
23+
- 修复聚合 RSS 解析时 `title_raw` 为空导致 `TypeError` 崩溃的问题 (#976)
24+
- 修复解析器处理无括号种子名称时 `IndexError` 崩溃的问题 (#973)
25+
- 修复删除番剧时未清理关联种子记录的问题
26+
- 修复认证路由、JWT 刷新和 WebAuthn 注册流程的多个安全问题
27+
- 修复程序生命周期管理和后台任务取消逻辑
28+
- 修复数据库迁移在部分场景下未正确执行的问题
29+
30+
### Performance
31+
32+
- 优化日志系统:`RotatingFileHandler` 轮转(5 MB × 3)、`QueueHandler` 异步写入、`GET /api/log` 限读 512 KB
33+
- 优化重命名器:批量数据库查询,并发获取种子文件列表
34+
- 所有 `logger.debug(f"...")` 转为惰性 `%s` 格式化(~80 处)
35+
36+
### Tests
37+
38+
- 新增 26 个回归测试覆盖 #974#976#977#986
39+
- 扩展 raw_parser、torrent_parser、path_parser 测试覆盖率
40+
41+
## Frontend
42+
43+
### Fixes
44+
45+
- 修复认证路由守卫和 i18n 初始化顺序问题
46+
- 修复通知设置组件与项目设计系统的对齐问题
47+
- 修复组件生命周期管理问题
48+
49+
## Docs
50+
51+
- README 移除未实现的 Aria2 和 Transmission 下载器 (#987)
52+
53+
---
54+
155
# [3.2.0-beta.13] - 2026-01-26
256

357
## Frontend

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,6 @@
8080
***已支持的下载器:***
8181

8282
- qBittorrent
83-
- Aria2
84-
- Transmission
8583

8684
## Star History
8785

backend/pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "auto-bangumi"
3-
version = "3.2.3-beta.1"
3+
version = "3.2.3"
44
description = "AutoBangumi - Automated anime download manager"
55
requires-python = ">=3.13"
66
dependencies = [
@@ -24,6 +24,7 @@ dependencies = [
2424
"sse-starlette>=1.6.5",
2525
"webauthn>=2.0.0",
2626
"urllib3>=2.0.3",
27+
"mcp[cli]>=1.8.0",
2728
]
2829

2930
[dependency-groups]
@@ -40,6 +41,9 @@ dev = [
4041
testpaths = ["src/test"]
4142
pythonpath = ["src"]
4243
asyncio_mode = "auto"
44+
markers = [
45+
"e2e: End-to-end integration tests (require Docker)",
46+
]
4347

4448
[tool.ruff]
4549
line-length = 88

backend/src/main.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import logging
22
import os
33
from contextlib import asynccontextmanager
4+
from pathlib import Path
45

56
import uvicorn
67
from fastapi import FastAPI, Request
8+
from fastapi.middleware.cors import CORSMiddleware
79
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
810
from fastapi.staticfiles import StaticFiles
911
from fastapi.templating import Jinja2Templates
12+
1013
from module.api import v1
1114
from module.api.program import program
1215
from module.conf import VERSION, settings, setup_logger
16+
from module.mcp import create_mcp_app
1317

1418
setup_logger(reset=True)
1519
logger = logging.getLogger(__name__)
@@ -42,21 +46,35 @@ async def lifespan(app: FastAPI):
4246
def create_app() -> FastAPI:
4347
app = FastAPI(lifespan=lifespan)
4448

49+
app.add_middleware(
50+
CORSMiddleware,
51+
allow_origins=[],
52+
allow_credentials=True,
53+
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
54+
allow_headers=["*"],
55+
)
56+
4557
# mount routers
4658
app.include_router(v1, prefix="/api")
4759

60+
# mount MCP server (SSE transport for LLM tool integration)
61+
app.mount("/mcp", create_mcp_app())
62+
4863
return app
4964

5065

5166
app = create_app()
5267

5368

69+
_POSTERS_BASE = Path("data/posters").resolve()
70+
71+
5472
@app.get("/posters/{path:path}", tags=["posters"])
5573
def posters(path: str):
56-
# prevent path traversal
57-
if ".." in path:
74+
resolved = (_POSTERS_BASE / path).resolve()
75+
if not str(resolved).startswith(str(_POSTERS_BASE)):
5876
return HTMLResponse(status_code=403)
59-
return FileResponse(f"data/posters/{path}")
77+
return FileResponse(str(resolved))
6078

6179

6280
if VERSION != "DEV_VERSION":
@@ -73,6 +91,7 @@ def html(request: Request, path: str):
7391
else:
7492
context = {"request": request}
7593
return templates.TemplateResponse("index.html", context)
94+
7695
else:
7796

7897
@app.get("/", status_code=302, tags=["html"])

backend/src/module/ab_decorator/__init__.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22
import functools
33
import logging
44

5+
import httpx
6+
57
from .timeout import timeout
68

79
logger = logging.getLogger(__name__)
810
_lock = asyncio.Lock()
911

12+
_RETRY_DELAYS = [5, 15, 45, 120, 300]
13+
1014

1115
def qb_connect_failed_wait(func):
1216
@functools.wraps(func)
@@ -15,11 +19,21 @@ async def wrapper(*args, **kwargs):
1519
while times < 5:
1620
try:
1721
return await func(*args, **kwargs)
18-
except Exception as e:
19-
logger.debug(f"URL: {args[0]}")
22+
except (
23+
ConnectionError,
24+
TimeoutError,
25+
OSError,
26+
httpx.ConnectError,
27+
httpx.TimeoutException,
28+
httpx.RequestError,
29+
) as e:
30+
delay = _RETRY_DELAYS[times]
31+
logger.debug("URL: %s", args[0])
2032
logger.warning(e)
21-
logger.warning("Cannot connect to qBittorrent. Wait 5 min and retry...")
22-
await asyncio.sleep(300)
33+
logger.warning(
34+
"Cannot connect to qBittorrent. Wait %ds and retry...", delay
35+
)
36+
await asyncio.sleep(delay)
2337
times += 1
2438

2539
return wrapper
@@ -31,7 +45,7 @@ async def wrapper(*args, **kwargs):
3145
try:
3246
return await func(*args, **kwargs)
3347
except Exception as e:
34-
logger.debug(f"URL: {args[0]}")
48+
logger.debug("URL: %s", args[0])
3549
logger.warning("Wrong API response.")
3650
logger.debug(e)
3751

backend/src/module/api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .rss import router as rss_router
1111
from .search import router as search_router
1212
from .setup import router as setup_router
13+
from .notification import router as notification_router
1314

1415
__all__ = "v1"
1516

@@ -25,3 +26,4 @@
2526
v1.include_router(rss_router)
2627
v1.include_router(search_router)
2728
v1.include_router(setup_router)
29+
v1.include_router(notification_router)

backend/src/module/api/auth.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
from datetime import timedelta
1+
from datetime import datetime, timedelta
22

3-
from fastapi import APIRouter, Depends, HTTPException, status
3+
from fastapi import APIRouter, Cookie, Depends, HTTPException, status
44
from fastapi.responses import JSONResponse, Response
55
from fastapi.security import OAuth2PasswordRequestForm
66

@@ -12,7 +12,7 @@
1212
get_current_user,
1313
update_user_info,
1414
)
15-
from module.security.jwt import create_access_token
15+
from module.security.jwt import create_access_token, decode_token
1616

1717
from .response import u_response
1818

@@ -35,19 +35,29 @@ async def login(response: Response, form_data=Depends(OAuth2PasswordRequestForm)
3535
@router.get(
3636
"/refresh_token", response_model=dict, dependencies=[Depends(get_current_user)]
3737
)
38-
async def refresh(response: Response):
39-
token = create_access_token(
40-
data={"sub": active_user[0]}, expires_delta=timedelta(days=1)
38+
async def refresh(response: Response, token: str = Cookie(None)):
39+
payload = decode_token(token)
40+
username = payload.get("sub") if payload else None
41+
if not username:
42+
raise HTTPException(
43+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized"
44+
)
45+
active_user[username] = datetime.now()
46+
new_token = create_access_token(
47+
data={"sub": username}, expires_delta=timedelta(days=1)
4148
)
42-
response.set_cookie(key="token", value=token, httponly=True, max_age=86400)
43-
return {"access_token": token, "token_type": "bearer"}
49+
response.set_cookie(key="token", value=new_token, httponly=True, max_age=86400)
50+
return {"access_token": new_token, "token_type": "bearer"}
4451

4552

4653
@router.get(
4754
"/logout", response_model=APIResponse, dependencies=[Depends(get_current_user)]
4855
)
49-
async def logout(response: Response):
50-
active_user.clear()
56+
async def logout(response: Response, token: str = Cookie(None)):
57+
payload = decode_token(token)
58+
username = payload.get("sub") if payload else None
59+
if username:
60+
active_user.pop(username, None)
5161
response.delete_cookie(key="token")
5262
return JSONResponse(
5363
status_code=200,
@@ -56,8 +66,15 @@ async def logout(response: Response):
5666

5767

5868
@router.post("/update", response_model=dict, dependencies=[Depends(get_current_user)])
59-
async def update_user(user_data: UserUpdate, response: Response):
60-
old_user = active_user[0]
69+
async def update_user(
70+
user_data: UserUpdate, response: Response, token: str = Cookie(None)
71+
):
72+
payload = decode_token(token)
73+
old_user = payload.get("sub") if payload else None
74+
if not old_user:
75+
raise HTTPException(
76+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized"
77+
)
6178
if update_user_info(user_data, old_user):
6279
token = create_access_token(
6380
data={"sub": old_user}, expires_delta=timedelta(days=1)

backend/src/module/api/config.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,24 @@
1010
router = APIRouter(prefix="/config", tags=["config"])
1111
logger = logging.getLogger(__name__)
1212

13+
_SENSITIVE_KEYS = ("password", "api_key", "token", "secret")
1314

14-
@router.get("/get", response_model=Config, dependencies=[Depends(get_current_user)])
15+
16+
def _sanitize_dict(d: dict) -> dict:
17+
result = {}
18+
for k, v in d.items():
19+
if isinstance(v, dict):
20+
result[k] = _sanitize_dict(v)
21+
elif any(s in k.lower() for s in _SENSITIVE_KEYS):
22+
result[k] = "********"
23+
else:
24+
result[k] = v
25+
return result
26+
27+
28+
@router.get("/get", dependencies=[Depends(get_current_user)])
1529
async def get_config():
16-
return settings
30+
return _sanitize_dict(settings.dict())
1731

1832

1933
@router.patch(
@@ -27,7 +41,10 @@ async def update_config(config: Config):
2741
logger.info("Config updated")
2842
return JSONResponse(
2943
status_code=200,
30-
content={"msg_en": "Update config successfully.", "msg_zh": "更新配置成功。"},
44+
content={
45+
"msg_en": "Update config successfully.",
46+
"msg_zh": "更新配置成功。",
47+
},
3148
)
3249
except Exception as e:
3350
logger.warning(e)

0 commit comments

Comments
 (0)