feat: add backend i18n response support

This commit is contained in:
jxxghp
2026-07-06 19:13:50 +08:00
parent 76803ae7a3
commit b16c50b03a
14 changed files with 3776 additions and 25 deletions
+35 -5
View File
@@ -34,6 +34,7 @@ from app.db.models.agentchat import AgentChat
from app.db.user_oper import UserOper, get_current_active_user from app.db.user_oper import UserOper, get_current_active_user
from app.helper.agent import attach_web_agent_edit_queue, detach_web_agent_edit_queue from app.helper.agent import attach_web_agent_edit_queue, detach_web_agent_edit_queue
from app.helper.interaction import agent_interaction_manager, media_interaction_manager from app.helper.interaction import agent_interaction_manager, media_interaction_manager
from app.helper.locale import LocaleHelper
from app.log import logger from app.log import logger
from app.schemas.types import EventType, MessageChannel from app.schemas.types import EventType, MessageChannel
@@ -326,15 +327,25 @@ def _save_web_agent_display_snapshot(
logger.debug(f"保存WebAgent展示历史失败: {e}") logger.debug(f"保存WebAgent展示历史失败: {e}")
def _build_web_agent_sse(event_type: str, data: Optional[dict] = None) -> str: def _build_web_agent_sse(
event_type: str,
data: Optional[dict] = None,
locale: Optional[str] = None,
) -> str:
""" """
构建 Web Agent SSE 消息。 构建 Web Agent SSE 消息。
:param event_type: 前端事件类型 :param event_type: 前端事件类型
:param data: 事件数据 :param data: 事件数据
:param locale: 当前请求语言
:return: 符合 SSE 格式的字符串 :return: 符合 SSE 格式的字符串
""" """
payload = {"type": event_type, **(data or {})} payload = {"type": event_type, **(data or {})}
message = payload.get("message")
if event_type == "error" and isinstance(message, str):
payload["message_i18n"] = LocaleHelper.translate_text(
message, locale=locale
)
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
@@ -1597,6 +1608,7 @@ async def web_agent_stream(
:return: SSE 流式响应 :return: SSE 流式响应
""" """
prompt = payload.text.strip() prompt = payload.text.strip()
locale = LocaleHelper.get_locale_from_request(request)
display_prompt = (payload.display_text or payload.text).strip() display_prompt = (payload.display_text or payload.text).strip()
is_traditional_message = ( is_traditional_message = (
_is_web_agent_traditional_message(prompt) _is_web_agent_traditional_message(prompt)
@@ -1610,6 +1622,7 @@ async def web_agent_stream(
_build_web_agent_sse( _build_web_agent_sse(
"error", "error",
{"message": denied_message}, {"message": denied_message},
locale=locale,
) )
]), ]),
media_type="text/event-stream", media_type="text/event-stream",
@@ -1621,6 +1634,7 @@ async def web_agent_stream(
_build_web_agent_sse( _build_web_agent_sse(
"error", "error",
{"message": unknown_command_message}, {"message": unknown_command_message},
locale=locale,
) )
]), ]),
media_type="text/event-stream", media_type="text/event-stream",
@@ -1649,7 +1663,11 @@ async def web_agent_stream(
""" """
生成传统消息链路的 WebAgent SSE 事件。 生成传统消息链路的 WebAgent SSE 事件。
""" """
yield _build_web_agent_sse("start", {"session_id": session_id}) yield _build_web_agent_sse(
"start",
{"session_id": session_id},
locale=locale,
)
events = await _collect_web_agent_traditional_events( events = await _collect_web_agent_traditional_events(
text=prompt, text=prompt,
current_user=current_user, current_user=current_user,
@@ -1660,7 +1678,11 @@ async def web_agent_stream(
display_messages.append(assistant_message) display_messages.append(assistant_message)
for event in events: for event in events:
event_payload = copy.deepcopy(event) event_payload = copy.deepcopy(event)
yield _build_web_agent_sse(event_payload.pop("type"), event_payload) yield _build_web_agent_sse(
event_payload.pop("type"),
event_payload,
locale=locale,
)
if await request.is_disconnected(): if await request.is_disconnected():
break break
await run_in_threadpool( await run_in_threadpool(
@@ -1670,7 +1692,7 @@ async def web_agent_stream(
messages=display_messages, messages=display_messages,
client_session_id=payload.session_id or session_id, client_session_id=payload.session_id or session_id,
) )
yield _build_web_agent_sse("done", {}) yield _build_web_agent_sse("done", {}, locale=locale)
return StreamingResponse( return StreamingResponse(
traditional_event_generator(), traditional_event_generator(),
@@ -1688,6 +1710,7 @@ async def web_agent_stream(
_build_web_agent_sse( _build_web_agent_sse(
"error", "error",
{"message": "智能助手未启用,请先在系统设置中开启。"}, {"message": "智能助手未启用,请先在系统设置中开启。"},
locale=locale,
) )
]), ]),
media_type="text/event-stream", media_type="text/event-stream",
@@ -1703,6 +1726,7 @@ async def web_agent_stream(
_build_web_agent_sse( _build_web_agent_sse(
"error", "error",
{"message": "语音识别失败,请稍后重试。"}, {"message": "语音识别失败,请稍后重试。"},
locale=locale,
) )
]), ]),
media_type="text/event-stream", media_type="text/event-stream",
@@ -1713,6 +1737,7 @@ async def web_agent_stream(
_build_web_agent_sse( _build_web_agent_sse(
"error", "error",
{"message": "请输入要发送给智能助手的内容或选择附件。"}, {"message": "请输入要发送给智能助手的内容或选择附件。"},
locale=locale,
) )
]), ]),
media_type="text/event-stream", media_type="text/event-stream",
@@ -1825,6 +1850,7 @@ async def web_agent_stream(
yield _build_web_agent_sse( yield _build_web_agent_sse(
"start", "start",
{"session_id": session_id}, {"session_id": session_id},
locale=locale,
) )
disconnected = False disconnected = False
while not global_vars.is_system_stopped: while not global_vars.is_system_stopped:
@@ -1832,7 +1858,11 @@ async def web_agent_stream(
disconnected = True disconnected = True
break break
event = await event_queue.get() event = await event_queue.get()
yield _build_web_agent_sse(event.pop("type"), event) yield _build_web_agent_sse(
event.pop("type"),
event,
locale=locale,
)
if task.done() and event_queue.empty(): if task.done() and event_queue.empty():
break break
except asyncio.CancelledError: except asyncio.CancelledError:
+20 -4
View File
@@ -12,6 +12,7 @@ from app.core.config import settings
from app.core.event import eventmanager from app.core.event import eventmanager
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo
from app.core.security import verify_resource_token, verify_token from app.core.security import verify_resource_token, verify_token
from app.helper.locale import LocaleHelper
from app.log import logger from app.log import logger
from app.schemas import MediaRecognizeConvertEventData from app.schemas import MediaRecognizeConvertEventData
from app.schemas.types import MediaType, ChainEventType from app.schemas.types import MediaType, ChainEventType
@@ -39,11 +40,22 @@ def _parse_media_type(mtype: Optional[str]) -> Optional[MediaType]:
return MediaType.from_agent(mtype) or MediaType(mtype) return MediaType.from_agent(mtype) or MediaType(mtype)
def _sse_event(data: dict) -> str: def _sse_event(data: dict, locale: Optional[str] = None) -> str:
""" """
转换为SSE事件 转换为SSE事件
""" """
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n" payload = data
message = payload.get("message")
text = payload.get("text")
if isinstance(message, str) or isinstance(text, str):
payload = data.copy()
if isinstance(message, str):
payload["message_i18n"] = LocaleHelper.translate_text(
message, locale=locale
)
if isinstance(text, str):
payload["text_i18n"] = LocaleHelper.translate_text(text, locale=locale)
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
def _serialize_signed_subtitle_result(subtitle: Any) -> dict: def _serialize_signed_subtitle_result(subtitle: Any) -> dict:
@@ -167,6 +179,7 @@ async def _stream_search_events(request: Request, event_source: AsyncIterator[di
""" """
输出搜索SSE事件 输出搜索SSE事件
""" """
locale = LocaleHelper.get_locale_from_request(request)
try: try:
has_sent_final_replace = False has_sent_final_replace = False
async for event in _iter_batched_search_events(event_source): async for event in _iter_batched_search_events(event_source):
@@ -182,10 +195,13 @@ async def _stream_search_events(request: Request, event_source: AsyncIterator[di
and event.get("items") and event.get("items")
): ):
event = {key: value for key, value in event.items() if key != "items"} event = {key: value for key, value in event.items() if key != "items"}
yield _sse_event(event) yield _sse_event(event, locale=locale)
except Exception as err: except Exception as err:
logger.error(f"渐进式搜索出错:{err}", exc_info=True) logger.error(f"渐进式搜索出错:{err}", exc_info=True)
yield _sse_event({"type": "error", "success": False, "message": str(err)}) yield _sse_event(
{"type": "error", "success": False, "message": str(err)},
locale=locale,
)
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context]) @router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
+18 -9
View File
@@ -35,10 +35,11 @@ from app.db.user_oper import (
get_current_active_user_async, get_current_active_user_async,
) )
from app.helper.image import ImageHelper from app.helper.image import ImageHelper
from app.helper.locale import LocaleHelper
from app.helper.message import MessageHelper from app.helper.message import MessageHelper
from app.helper.server import MoviePilotServerHelper
from app.helper.progress import ProgressHelper from app.helper.progress import ProgressHelper
from app.helper.rule import RuleHelper from app.helper.rule import RuleHelper
from app.helper.server import MoviePilotServerHelper
from app.helper.system import SystemHelper from app.helper.system import SystemHelper
from app.log import logger from app.log import logger
from app.scheduler import Scheduler from app.scheduler import Scheduler
@@ -797,13 +798,14 @@ async def get_progress(
实时获取处理进度,返回格式为SSE 实时获取处理进度,返回格式为SSE
""" """
progress = ProgressHelper(process_type) progress = ProgressHelper(process_type)
locale = LocaleHelper.get_current_locale()
async def event_generator(): async def event_generator():
try: try:
while not global_vars.is_system_stopped: while not global_vars.is_system_stopped:
if await request.is_disconnected(): if await request.is_disconnected():
break break
detail = progress.get() detail = progress.get(locale=locale)
yield f"data: {json.dumps(detail)}\n\n" yield f"data: {json.dumps(detail)}\n\n"
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -1271,13 +1273,20 @@ def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
""" """
查询已加载的模块ID列表 查询已加载的模块ID列表
""" """
modules = [ modules = []
{ for module_id, module in ModuleManager().get_modules().items():
"id": k, name = module.get_name()
"name": v.get_name(), modules.append(
} {
for k, v in ModuleManager().get_modules().items() "id": module_id,
] "name": name,
"name_i18n": LocaleHelper.translate(
f"system.modules.{module_id}.name",
default=name,
),
"name_key": f"system.modules.{module_id}.name",
}
)
return schemas.Response(success=True, data={"modules": modules}) return schemas.Response(success=True, data={"modules": modules})
+44 -1
View File
@@ -1,10 +1,35 @@
from fastapi import FastAPI from typing import Awaitable, Callable
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.core.config import settings from app.core.config import settings
from app.helper.locale import LocaleHelper
from app.startup.lifecycle import lifespan from app.startup.lifecycle import lifespan
async def localized_http_exception_handler(
_request: Request,
exc: HTTPException,
) -> JSONResponse:
"""
为 HTTPException 响应补充多语言错误详情。
:param _request: 当前 HTTP 请求
:param exc: FastAPI HTTP 异常
:return: 带 detail_i18n 的 JSON 错误响应
"""
content = {"detail": exc.detail}
if isinstance(exc.detail, str):
content["detail_i18n"] = LocaleHelper.translate_text(exc.detail)
return JSONResponse(
status_code=exc.status_code,
content=content,
headers=exc.headers,
)
def create_app() -> FastAPI: def create_app() -> FastAPI:
""" """
创建并配置 FastAPI 应用实例。 创建并配置 FastAPI 应用实例。
@@ -15,6 +40,8 @@ def create_app() -> FastAPI:
lifespan=lifespan lifespan=lifespan
) )
_app.add_exception_handler(HTTPException, localized_http_exception_handler)
# 配置 CORS 中间件 # 配置 CORS 中间件
_app.add_middleware( _app.add_middleware(
CORSMiddleware, # noqa CORSMiddleware, # noqa
@@ -24,6 +51,22 @@ def create_app() -> FastAPI:
allow_headers=["*"], allow_headers=["*"],
) )
@_app.middleware("http")
async def locale_context_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""
为每个请求设置后端多语言上下文。
"""
token = LocaleHelper.set_current_locale(
LocaleHelper.get_locale_from_request(request)
)
try:
return await call_next(request)
finally:
LocaleHelper.reset_current_locale(token)
return _app return _app
+280
View File
@@ -0,0 +1,280 @@
import json
import re
from contextvars import ContextVar, Token
from functools import lru_cache
from pathlib import Path
from typing import Any, Optional
class LocaleHelper:
"""
后端多语言文本辅助器。
该类只为需要返回给前端展示的文本生成并行多语言字段,旧有中文字段仍由调用方保留。
"""
DEFAULT_LOCALE = "zh-CN"
SUPPORTED_LOCALES = ("zh-CN", "zh-TW", "en-US")
HEADER_NAMES = ("x-moviepilot-locale", "x-locale")
_PATTERN_FIELD = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
_CURRENT_LOCALE: ContextVar[str] = ContextVar("moviepilot_locale", default=DEFAULT_LOCALE)
_LOCALES_DIR = Path(__file__).resolve().parents[1] / "locales"
_LOCALE_ALIASES = {
"zh": "zh-CN",
"zh-cn": "zh-CN",
"zh-hans": "zh-CN",
"zh-hans-cn": "zh-CN",
"zh-tw": "zh-TW",
"zh-hant": "zh-TW",
"zh-hant-tw": "zh-TW",
"en": "en-US",
"en-us": "en-US",
}
@classmethod
def normalize_locale(cls, locale: Optional[str]) -> str:
"""
规范化语言标识,无法识别时返回默认简体中文。
:param locale: 原始语言标识,如 zh-CN、zh_CN、en-US
:return: 项目支持的语言标识
"""
return cls._match_locale(locale) or cls.DEFAULT_LOCALE
@classmethod
def get_locale_from_request(cls, request: Any) -> str:
"""
从请求参数或请求头解析前端期望语言。
:param request: FastAPI Request 或带 headers 属性的兼容对象
:return: 项目支持的语言标识
"""
query_params = getattr(request, "query_params", {}) or {}
query_locale = query_params.get("locale") if hasattr(query_params, "get") else None
if query_locale:
return cls.normalize_locale(query_locale)
headers = getattr(request, "headers", {}) or {}
for header_name in cls.HEADER_NAMES:
value = headers.get(header_name)
if value:
return cls.normalize_locale(value)
accept_language = headers.get("accept-language")
if not accept_language:
return cls.DEFAULT_LOCALE
choices = []
for index, item in enumerate(accept_language.split(",")):
parts = [part.strip() for part in item.split(";") if part.strip()]
if not parts:
continue
quality = 1.0
for part in parts[1:]:
if part.startswith("q="):
try:
quality = float(part[2:])
except ValueError:
quality = 0.0
choices.append((-quality, index, parts[0]))
for _, _, candidate in sorted(choices):
locale = cls._match_locale(candidate)
if locale:
return locale
return cls.DEFAULT_LOCALE
@classmethod
def get_current_locale(cls) -> str:
"""
获取当前请求上下文中的语言标识。
:return: 项目支持的语言标识
"""
return cls._CURRENT_LOCALE.get()
@classmethod
def set_current_locale(cls, locale: Optional[str]) -> Token[str]:
"""
设置当前请求上下文中的语言标识。
:param locale: 原始语言标识
:return: 用于恢复上下文的令牌
"""
return cls._CURRENT_LOCALE.set(cls.normalize_locale(locale))
@classmethod
def reset_current_locale(cls, token: Token[str]) -> None:
"""
恢复当前请求上下文中的语言标识。
:param token: set_current_locale 返回的上下文令牌
"""
cls._CURRENT_LOCALE.reset(token)
@classmethod
def translate(
cls,
key: str,
locale: Optional[str] = None,
default: Optional[str] = None,
**kwargs: Any,
) -> str:
"""
根据翻译键获取多语言文本。
:param key: 点分隔翻译键
:param locale: 目标语言,未传入或无法识别时使用默认语言
:param default: 翻译缺失时返回的默认文本
:param kwargs: 字符串格式化参数
:return: 翻译后的文本
"""
normalized_locale = cls.normalize_locale(locale) if locale else cls.get_current_locale()
template = cls._lookup(cls._load_catalog(normalized_locale), key)
if template is None and normalized_locale != cls.DEFAULT_LOCALE:
template = cls._lookup(cls._load_catalog(cls.DEFAULT_LOCALE), key)
if template is None:
template = default or key
return cls._format(template, kwargs)
@classmethod
def translate_text(cls, text: Optional[str], locale: Optional[str] = None) -> str:
"""
翻译存量接口返回的中文文本。
:param text: 原始中文文本
:param locale: 目标语言,未传入或无法识别时使用默认语言
:return: 翻译后的文本,缺失翻译时返回原文
"""
if not text:
return ""
normalized_locale = cls.normalize_locale(locale) if locale else cls.get_current_locale()
translated = cls._lookup_message(cls._load_catalog(normalized_locale), text)
if translated is None and cls._contains_chinese(text):
translated = cls._lookup_pattern(normalized_locale, text)
if translated is None and normalized_locale != cls.DEFAULT_LOCALE:
translated = cls._lookup_message(cls._load_catalog(cls.DEFAULT_LOCALE), text)
if (
translated is None
and normalized_locale != cls.DEFAULT_LOCALE
and cls._contains_chinese(text)
):
translated = cls._lookup_pattern(cls.DEFAULT_LOCALE, text)
return translated or text
@classmethod
def _match_locale(cls, locale: Optional[str]) -> Optional[str]:
"""
将原始语言标识匹配为项目支持的语言。
"""
if not locale:
return None
normalized = locale.strip().replace("_", "-").lower()
if not normalized:
return None
return cls._LOCALE_ALIASES.get(normalized)
@staticmethod
@lru_cache(maxsize=16)
def _load_catalog(locale: str) -> dict[str, Any]:
"""
加载指定语言的翻译表。
"""
catalog_path = LocaleHelper._LOCALES_DIR / f"{locale}.json"
try:
with catalog_path.open("r", encoding="utf-8") as file:
return json.load(file)
except (FileNotFoundError, json.JSONDecodeError):
return {}
@staticmethod
def _lookup(catalog: dict[str, Any], key: str) -> Optional[str]:
"""
按点分隔键从结构化翻译表中查找文本。
"""
current: Any = catalog
for part in key.split("."):
if not isinstance(current, dict) or part not in current:
return None
current = current[part]
return current if isinstance(current, str) else None
@staticmethod
def _lookup_message(catalog: dict[str, Any], text: str) -> Optional[str]:
"""
从精确消息表中查找存量中文文本。
"""
messages = catalog.get("messages")
if not isinstance(messages, dict):
return None
translated = messages.get(text)
return translated if isinstance(translated, str) else None
@classmethod
def _lookup_pattern(cls, locale: str, text: str) -> Optional[str]:
"""
使用动态模板匹配存量中文文本。
"""
for pattern, target in cls._load_pattern_matchers(locale):
matched = pattern.fullmatch(text)
if matched:
return cls._format(target, matched.groupdict())
return None
@staticmethod
@lru_cache(maxsize=16)
def _load_pattern_matchers(locale: str) -> list[tuple[re.Pattern[str], str]]:
"""
加载并缓存指定语言的动态文本匹配器。
"""
catalog = LocaleHelper._load_catalog(locale)
patterns = catalog.get("message_patterns")
if not isinstance(patterns, list):
return []
matchers = []
for item in patterns:
if not isinstance(item, dict):
continue
source = item.get("source")
target = item.get("target")
if not isinstance(source, str) or not isinstance(target, str):
continue
pattern = LocaleHelper._compile_pattern(source)
if pattern is None:
continue
matchers.append((pattern, target))
return matchers
@classmethod
def _compile_pattern(cls, source: str) -> Optional[re.Pattern[str]]:
"""
将带命名占位符的中文模板编译为正则。
"""
field_names = cls._PATTERN_FIELD.findall(source)
if not field_names:
return None
pattern = cls._PATTERN_FIELD.sub(
lambda match: f"(?P<{match.group(1)}>.+?)",
re.escape(source).replace(r"\{", "{").replace(r"\}", "}"),
)
return re.compile(pattern)
@staticmethod
def _contains_chinese(text: str) -> bool:
"""
判断文本是否包含中文字符。
"""
return any("\u4e00" <= char <= "\u9fff" for char in text)
@staticmethod
def _format(template: str, kwargs: dict[str, Any]) -> str:
"""
格式化翻译模板,参数缺失时保留模板原文。
"""
if not kwargs:
return template
try:
return template.format(**kwargs)
except (KeyError, AttributeError, IndexError):
return template
+31 -4
View File
@@ -1,7 +1,8 @@
from enum import Enum from enum import Enum
from typing import Union, Optional from typing import Optional, Union
from app.core.cache import TTLCache from app.core.cache import TTLCache
from app.helper.locale import LocaleHelper
from app.schemas.types import ProgressKey from app.schemas.types import ProgressKey
@@ -82,8 +83,34 @@ class ProgressHelper:
current['data'].update(data) current['data'].update(data)
self._progress[self._key] = current self._progress[self._key] = current
def get(self) -> Optional[dict]: def get(self, locale: Optional[str] = None) -> Optional[dict]:
""" """
获取当前进度 获取当前进度,并按语言补充前端展示字段。
:param locale: 目标语言,未传入时使用当前请求上下文语言
:return: 当前进度字典
""" """
return self._progress.get(self._key) current = self._progress.get(self._key)
if not current:
return current
detail = current.copy()
text = detail.get("text")
if isinstance(text, str):
detail["text_i18n"] = LocaleHelper.translate_text(text, locale=locale)
data = detail.get("data")
if isinstance(data, dict):
localized_data = data.copy()
error = localized_data.get("error")
message = localized_data.get("message")
if isinstance(error, str):
localized_data["error_i18n"] = LocaleHelper.translate_text(
error, locale=locale
)
if isinstance(message, str):
localized_data["message_i18n"] = LocaleHelper.translate_text(
message, locale=locale
)
detail["data"] = localized_data
return detail
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
{
"system": {
"modules": {
"BangumiModule": {
"name": "Bangumi"
},
"DiscordModule": {
"name": "Discord"
},
"DoubanModule": {
"name": "豆瓣"
},
"EmbyModule": {
"name": "Emby"
},
"FanartModule": {
"name": "Fanart"
},
"FeishuModule": {
"name": "飞书"
},
"FileManagerModule": {
"name": "文件整理"
},
"FilterModule": {
"name": "过滤器"
},
"IndexerModule": {
"name": "站点索引"
},
"JellyfinModule": {
"name": "Jellyfin"
},
"PlexModule": {
"name": "Plex"
},
"PostgreSQLModule": {
"name": "PostgreSQL"
},
"QbittorrentModule": {
"name": "Qbittorrent"
},
"QQBotModule": {
"name": "QQ"
},
"RedisModule": {
"name": "Redis缓存"
},
"RtorrentModule": {
"name": "Rtorrent"
},
"SlackModule": {
"name": "Slack"
},
"SubtitleModule": {
"name": "站点字幕"
},
"SynologyChatModule": {
"name": "Synology Chat"
},
"TelegramModule": {
"name": "Telegram"
},
"TheMovieDbModule": {
"name": "TheMovieDb"
},
"TheTvDbModule": {
"name": "TheTvDb"
},
"TransmissionModule": {
"name": "Transmission"
},
"TrimeMediaModule": {
"name": "飞牛影视"
},
"UgreenModule": {
"name": "绿联影视"
},
"VoceChatModule": {
"name": "VoceChat"
},
"WebPushModule": {
"name": "WebPush"
},
"WechatModule": {
"name": "企业微信"
},
"WechatClawBotModule": {
"name": "微信 ClawBot"
},
"ZSpaceModule": {
"name": "极影视"
}
},
"module_test": {
"unsupported": "模块不支持测试"
}
},
"messages": {
"模块不支持测试": "模块不支持测试",
"网络请求失败": "网络请求失败",
"豆瓣网络连接失败": "豆瓣网络连接失败",
"Bangumi网络连接失败": "Bangumi网络连接失败",
"fanart网络连接失败": "fanart网络连接失败",
"未配置站点或未通过用户认证": "未配置站点或未通过用户认证",
"Redis连接失败,请检查配置": "Redis连接失败,请检查配置"
},
"message_patterns": [
{
"source": "无法连接Qbittorrent下载器:{name}",
"target": "无法连接Qbittorrent下载器:{name}"
},
{
"source": "无法连接Transmission下载器:{name}",
"target": "无法连接Transmission下载器:{name}"
},
{
"source": "无法连接rTorrent下载器:{name}",
"target": "无法连接rTorrent下载器:{name}"
},
{
"source": "无法连接Emby服务器:{name}",
"target": "无法连接Emby服务器:{name}"
},
{
"source": "无法连接Jellyfin服务器:{name}",
"target": "无法连接Jellyfin服务器:{name}"
},
{
"source": "无法连接Plex服务器:{name}",
"target": "无法连接Plex服务器:{name}"
},
{
"source": "飞牛影视配置不完整:{name}",
"target": "飞牛影视配置不完整:{name}"
},
{
"source": "无法连接飞牛影视:{name}",
"target": "无法连接飞牛影视:{name}"
},
{
"source": "绿联影视配置不完整:{name}",
"target": "绿联影视配置不完整:{name}"
},
{
"source": "无法连接绿联影视:{name}",
"target": "无法连接绿联影视:{name}"
},
{
"source": "无法连接极影视服务器:{name}",
"target": "无法连接极影视服务器:{name}"
},
{
"source": "Telegram {name} 未就绪",
"target": "Telegram {name} 未就绪"
},
{
"source": "飞书 {name} 未就绪",
"target": "飞书 {name} 未就绪"
},
{
"source": "Discord {name} Bot 未就绪",
"target": "Discord {name} Bot 未就绪"
},
{
"source": "Slack {name} 未就绪",
"target": "Slack {name} 未就绪"
},
{
"source": "无法连接Bangumi,错误码:{code}",
"target": "无法连接Bangumi,错误码:{code}"
},
{
"source": "无法连接fanart,错误码:{code}",
"target": "无法连接fanart,错误码:{code}"
},
{
"source": "无法连接 {domain},错误码:{code}",
"target": "无法连接 {domain},错误码:{code}"
},
{
"source": "{domain} 网络连接失败",
"target": "{domain} 网络连接失败"
}
]
}
File diff suppressed because it is too large Load Diff
+55 -1
View File
@@ -1,6 +1,8 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
from app.helper.locale import LocaleHelper
class Statistic(BaseModel): class Statistic(BaseModel):
@@ -87,14 +89,20 @@ class ScheduleProgress(BaseModel):
id: Optional[str] = None id: Optional[str] = None
# 名称 # 名称
name: Optional[str] = None name: Optional[str] = None
# 多语言名称
name_i18n: Optional[str] = None
# 提供者 # 提供者
provider: Optional[str] = None provider: Optional[str] = None
# 多语言提供者
provider_i18n: Optional[str] = None
# 是否正在执行 # 是否正在执行
enable: Optional[bool] = False enable: Optional[bool] = False
# 当前完成百分比 # 当前完成百分比
value: Optional[float] = 0.0 value: Optional[float] = 0.0
# 当前进度文本 # 当前进度文本
text: Optional[str] = None text: Optional[str] = None
# 多语言进度文本
text_i18n: Optional[str] = None
# 执行状态 waiting/running/success/failed # 执行状态 waiting/running/success/failed
status: Optional[str] = None status: Optional[str] = None
# 最近一次执行是否成功 # 最近一次执行是否成功
@@ -105,9 +113,27 @@ class ScheduleProgress(BaseModel):
finished_at: Optional[str] = None finished_at: Optional[str] = None
# 最近一次错误信息 # 最近一次错误信息
error: Optional[str] = None error: Optional[str] = None
# 多语言错误信息
error_i18n: Optional[str] = None
# 扩展数据 # 扩展数据
data: Optional[dict] = Field(default_factory=dict) data: Optional[dict] = Field(default_factory=dict)
@model_validator(mode="after")
def fill_i18n_fields(self) -> "ScheduleProgress":
"""
自动补充后台服务进度的多语言展示字段。
"""
locale = LocaleHelper.get_current_locale()
if self.name and self.name_i18n is None:
self.name_i18n = LocaleHelper.translate_text(self.name, locale=locale)
if self.provider and self.provider_i18n is None:
self.provider_i18n = LocaleHelper.translate_text(self.provider, locale=locale)
if self.text and self.text_i18n is None:
self.text_i18n = LocaleHelper.translate_text(self.text, locale=locale)
if self.error and self.error_i18n is None:
self.error_i18n = LocaleHelper.translate_text(self.error, locale=locale)
return self
class ScheduleInfo(BaseModel): class ScheduleInfo(BaseModel):
"""仪表板后台服务信息。""" """仪表板后台服务信息。"""
@@ -116,21 +142,49 @@ class ScheduleInfo(BaseModel):
id: Optional[str] = None id: Optional[str] = None
# 名称 # 名称
name: Optional[str] = None name: Optional[str] = None
# 多语言名称
name_i18n: Optional[str] = None
# 提供者 # 提供者
provider: Optional[str] = None provider: Optional[str] = None
# 多语言提供者
provider_i18n: Optional[str] = None
# 状态 # 状态
status: Optional[str] = None status: Optional[str] = None
# 多语言状态
status_i18n: Optional[str] = None
# 下次执行时间 # 下次执行时间
next_run: Optional[str] = None next_run: Optional[str] = None
# 多语言下次执行时间
next_run_i18n: Optional[str] = None
# 当前完成百分比 # 当前完成百分比
progress: Optional[float] = 0.0 progress: Optional[float] = 0.0
# 进度文本 # 进度文本
progress_text: Optional[str] = None progress_text: Optional[str] = None
# 多语言进度文本
progress_text_i18n: Optional[str] = None
# 是否正在更新进度 # 是否正在更新进度
progress_enable: Optional[bool] = False progress_enable: Optional[bool] = False
# 进度详情 # 进度详情
progress_detail: Optional[ScheduleProgress] = None progress_detail: Optional[ScheduleProgress] = None
@model_validator(mode="after")
def fill_i18n_fields(self) -> "ScheduleInfo":
"""
自动补充后台服务列表的多语言展示字段。
"""
locale = LocaleHelper.get_current_locale()
if self.name and self.name_i18n is None:
self.name_i18n = LocaleHelper.translate_text(self.name, locale=locale)
if self.provider and self.provider_i18n is None:
self.provider_i18n = LocaleHelper.translate_text(self.provider, locale=locale)
if self.status and self.status_i18n is None:
self.status_i18n = LocaleHelper.translate_text(self.status, locale=locale)
if self.next_run and self.next_run_i18n is None:
self.next_run_i18n = LocaleHelper.translate_text(self.next_run, locale=locale)
if self.progress_text and self.progress_text_i18n is None:
self.progress_text_i18n = LocaleHelper.translate_text(self.progress_text, locale=locale)
return self
class DashboardSystemInfo(BaseModel): class DashboardSystemInfo(BaseModel):
"""仪表板系统摘要信息。""" """仪表板系统摘要信息。"""
+18 -1
View File
@@ -1,12 +1,29 @@
from typing import Optional, Union from typing import Optional, Union
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
from app.helper.locale import LocaleHelper
class Response(BaseModel): class Response(BaseModel):
"""通用接口响应结构"""
# 状态 # 状态
success: bool success: bool
# 消息文本 # 消息文本
message: Optional[str] = None message: Optional[str] = None
# 多语言消息文本
message_i18n: Optional[str] = None
# 数据 # 数据
data: Optional[Union[dict, list]] = Field(default_factory=dict) data: Optional[Union[dict, list]] = Field(default_factory=dict)
@model_validator(mode="after")
def fill_message_i18n(self) -> "Response":
"""
自动补充响应消息的多语言文本。
"""
if self.message and self.message_i18n is None:
self.message_i18n = LocaleHelper.translate_text(
self.message, locale=LocaleHelper.get_current_locale()
)
return self
+6
View File
@@ -86,6 +86,10 @@ MCP 使用系统配置中的 `API_TOKEN` 作为认证密钥,文档中的 API K
MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所有接口同样需要 API KEY 认证,在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>` MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所有接口同样需要 API KEY 认证,在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>`
标准 REST 响应包含 `success``message``message_i18n``data` 字段。为兼容 App 和第三方客户端,`message` 继续保留原中文或原始后端文本;新版前端可发送 `X-MoviePilot-Locale: zh-CN|zh-TW|en-US``Accept-Language`,并优先展示 `message_i18n`。未提供语言头或翻译缺失时,`message_i18n` 会回退为原文本。
FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返回 `detail_i18n`;新版前端优先展示 `detail_i18n`,缺失时回退 `detail`
#### 搜索 / 种子 / 字幕 #### 搜索 / 种子 / 字幕
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
@@ -127,6 +131,8 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所
| GET | `/api/v1/dashboard/schedule2/{job_id}/progress` | 使用 API_TOKEN 查询指定后台定时服务的实时进度详情 | | GET | `/api/v1/dashboard/schedule2/{job_id}/progress` | 使用 API_TOKEN 查询指定后台定时服务的实时进度详情 |
| GET | `/api/v1/system/setting/public/{key}` | 登录用户读取白名单内非敏感系统设置,仅支持目录、存储、站点范围、默认订阅规则、Follow 订阅者和插件市场地址等前端必需配置 | | GET | `/api/v1/system/setting/public/{key}` | 登录用户读取白名单内非敏感系统设置,仅支持目录、存储、站点范围、默认订阅规则、Follow 订阅者和插件市场地址等前端必需配置 |
| POST | `/api/v1/system/setting/PLUGIN_MARKET/sync-wiki` | 管理员从 MoviePilot Wiki 的插件文档同步公开插件仓库清单,和本地 `PLUGIN_MARKET` 合并去重后写入配置 | | POST | `/api/v1/system/setting/PLUGIN_MARKET/sync-wiki` | 管理员从 MoviePilot Wiki 的插件文档同步公开插件仓库清单,和本地 `PLUGIN_MARKET` 合并去重后写入配置 |
| GET | `/api/v1/system/modulelist` | 查询已加载模块,保留 `name` 原始中文字段,并提供 `name_i18n``name_key` 给多语言前端展示 |
| GET | `/api/v1/system/moduletest/{moduleid}` | 测试指定模块可用性,保留原 `message`,并在标准响应顶层返回 `message_i18n` |
### 插件补充接口 ### 插件补充接口
+366
View File
@@ -0,0 +1,366 @@
import ast
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
from fastapi import HTTPException
from app.factory import localized_http_exception_handler
from app.helper.locale import LocaleHelper
from app.helper.progress import ProgressHelper
from app.schemas.dashboard import ScheduleInfo, ScheduleProgress
from app.schemas.response import Response
def _has_chinese(text: str) -> bool:
"""判断文本是否包含中文字符。"""
return any("\u4e00" <= char <= "\u9fff" for char in text)
def _sample_message_expression(value: ast.AST) -> list[str]:
"""从接口 message 表达式中生成用于翻译校验的样本文本。"""
if isinstance(value, ast.Constant) and isinstance(value.value, str):
return [value.value]
if isinstance(value, ast.JoinedStr):
parts = []
has_chinese = False
index = 0
for item in value.values:
if isinstance(item, ast.Constant) and isinstance(item.value, str):
parts.append(item.value)
has_chinese = has_chinese or _has_chinese(item.value)
else:
index += 1
parts.append(f"样例{index}")
return ["".join(parts)] if has_chinese else []
if isinstance(value, ast.IfExp):
return _sample_message_expression(value.body) + _sample_message_expression(value.orelse)
if isinstance(value, ast.BoolOp):
samples = []
for item in value.values:
samples.extend(_sample_message_expression(item))
return samples
if isinstance(value, ast.BinOp) and isinstance(value.op, ast.Add):
left_samples = _sample_message_expression(value.left)
right_samples = _sample_message_expression(value.right)
if left_samples and right_samples:
return [left + right for left in left_samples for right in right_samples]
if left_samples:
return [f"{left}样例" for left in left_samples]
if right_samples:
return [f"样例{right}" for right in right_samples]
return []
def test_locale_helper_normalizes_supported_aliases():
"""语言别名应规范化为项目支持的语言标识。"""
assert LocaleHelper.normalize_locale("en") == "en-US"
assert LocaleHelper.normalize_locale("zh_Hant") == "zh-TW"
assert LocaleHelper.normalize_locale("unknown") == "zh-CN"
def test_locale_helper_reads_request_headers_by_priority():
"""请求语言应优先使用显式前端语言头,再回退 Accept-Language。"""
request = SimpleNamespace(
headers={
"x-moviepilot-locale": "en-US",
"accept-language": "zh-TW,zh;q=0.9",
}
)
assert LocaleHelper.get_locale_from_request(request) == "en-US"
def test_locale_helper_reads_query_locale_before_headers():
"""SSE 请求可通过查询参数显式指定前端语言。"""
request = SimpleNamespace(
query_params={"locale": "zh-TW"},
headers={
"x-moviepilot-locale": "en-US",
"accept-language": "en-US",
},
)
assert LocaleHelper.get_locale_from_request(request) == "zh-TW"
def test_locale_helper_parses_accept_language_quality():
"""Accept-Language 应按 q 权重选择支持的语言。"""
request = SimpleNamespace(
headers={
"accept-language": "fr-FR, en-US;q=0.8, zh-TW;q=0.9",
}
)
assert LocaleHelper.get_locale_from_request(request) == "zh-TW"
def test_locale_helper_translates_module_name_and_keeps_missing_text():
"""翻译存在时返回目标语言,缺失时返回默认文本。"""
assert LocaleHelper.translate(
"system.modules.DoubanModule.name",
locale="en-US",
default="豆瓣",
) == "Douban"
assert LocaleHelper.translate_text("模块不支持测试", locale="en-US") == "Module does not support testing"
assert LocaleHelper.translate_text(
"requirements.txt 文件下载失败", locale="en-US"
) == "Failed to download requirements.txt"
assert LocaleHelper.translate_text("未收录的中文错误", locale="en-US") == "未收录的中文错误"
def test_locale_helper_translates_dynamic_message_patterns():
"""动态消息应保留变量并翻译模板文本。"""
assert LocaleHelper.translate_text(
"无法连接Qbittorrent下载器:默认",
locale="en-US",
) == "Unable to connect to qBittorrent downloader: 默认"
assert LocaleHelper.translate_text(
"无法连接 api.themoviedb.org,错误码:403",
locale="en-US",
) == "Unable to connect to api.themoviedb.org, error code: 403"
assert LocaleHelper.translate_text(
"飞书 工作 未就绪",
locale="zh-TW",
) == "飛書 工作 尚未就緒"
def test_locale_helper_translates_common_backend_response_messages():
"""常见后端链路消息应能生成英文展示文本。"""
samples = {
"QQ Bot 默认 未就绪": "QQ Bot 默认 is not ready",
"微信 ClawBot 工作 未就绪:未登录": "WeChat ClawBot 工作 is not ready: 未登录",
"无法打开网站!": "Unable to open the site!",
"错误:403 Forbidden": "Error: 403 Forbidden",
"站点【https://example.com】不存在": "Site [https://example.com] does not exist",
"下载字幕文件失败,状态码:404 Not Found": "Failed to download subtitle file, status code: 404 Not Found",
"未获取到第 2 季的总集数": "Unable to get the total episode count for season 2",
"电影.mkv 已在整理队列中": "电影.mkv is already in the organization queue",
"未识别到媒体信息,类型:电视剧,id:123": "Unable to recognize media information, type: 电视剧, id: 123",
"默认 的下载目录 /downloads 不存在": "Download directory for 默认 does not exist: /downloads",
"不支持 Local 到 Alist 的文件整理": "File organization from Local to Alist is not supported",
"文件 /tmp/a.mkv 不存在": "File /tmp/a.mkv does not exist",
"添加种子任务失败:种子无效": "Failed to add torrent task: 种子无效",
"检查授权状态失败: timeout": "Failed to check authorization status: timeout",
"未找到名为 工作 的微信 ClawBot 通知配置": (
"No WeChat ClawBot notification configuration named 工作 was found"
),
"整理记录不存在: 1, 2": "Organization record does not exist: 1, 2",
"插件要求 MoviePilot 版本 >=2.14.0,当前版本 2.13.0 不满足,已拒绝安装": (
"The plugin requires MoviePilot version >=2.14.0, but current version 2.13.0 "
"does not satisfy it. Installation was rejected"
),
"已安排一次性 release 升级并重启": "Scheduled one-shot release upgrade and restart",
"已将微信 ClawBot 登录缓存从 old 迁移到 new": (
"Migrated WeChat ClawBot login cache from old to new"
),
"搜索完成,共 3 个资源": "Search completed, 3 resources",
"正在搜索关键字,已完成 1 / 6 个请求 ...": (
"Searching 关键字, completed 1/6 requests ..."
),
"正在搜索字幕关键字,已完成 1 / 6 个请求 ...": (
"Searching subtitles 关键字, completed 1/6 requests ..."
),
"未开启任何支持字幕搜索的有效站点,无法搜索字幕": (
"No valid subtitle-search site is enabled, unable to search subtitles"
),
"用户名或密码错误": "Incorrect username or password",
"工具 'query_schedulers' 未找到": "Tool 'query_schedulers' was not found",
"插件 test 不存在或未加载": "Plugin test does not exist or is not loaded",
"站点 1 不存在": "Site 1 does not exist",
"智能助手未启用,请先在系统设置中开启。": (
"The assistant is not enabled. Enable it in system settings first."
),
"智能助手执行失败: timeout": "Assistant execution failed: timeout",
}
for message, expected in samples.items():
assert LocaleHelper.translate_text(message, locale="en-US") == expected
def test_response_auto_fills_message_i18n_from_locale_context():
"""通用 Response 应根据请求语言上下文自动补充多语言消息。"""
token = LocaleHelper.set_current_locale("en-US")
try:
response = Response(success=False, message="模块不支持测试")
finally:
LocaleHelper.reset_current_locale(token)
assert response.message == "模块不支持测试"
assert response.message_i18n == "Module does not support testing"
def test_http_exception_handler_adds_detail_i18n_from_locale_context():
"""HTTPException 响应应补充多语言 detail 字段。"""
token = LocaleHelper.set_current_locale("en-US")
try:
response = asyncio.run(
localized_http_exception_handler(
None,
HTTPException(status_code=401, detail="用户名或密码错误"),
)
)
finally:
LocaleHelper.reset_current_locale(token)
payload = json.loads(response.body)
assert payload["detail"] == "用户名或密码错误"
assert payload["detail_i18n"] == "Incorrect username or password"
def test_progress_helper_get_adds_i18n_fields_without_mutating_cache():
"""通用进度字典应返回展示字段翻译,同时保留缓存中的原始中文。"""
progress = ProgressHelper("__test_i18n_progress")
progress.start()
progress.update(
text="开始同步媒体服务器,共 2 个 ...",
data={"error": "后台服务不存在"},
)
detail = progress.get(locale="en-US")
assert detail is not None
assert detail["text"] == "开始同步媒体服务器,共 2 个 ..."
assert detail["text_i18n"] == "Starting media server sync, 2 servers ..."
assert detail["data"]["error"] == "后台服务不存在"
assert detail["data"]["error_i18n"] == "Background service does not exist"
detail["data"]["error_i18n"] = "mutated"
second_detail = progress.get(locale="en-US")
assert second_detail is not None
assert second_detail["data"]["error_i18n"] == "Background service does not exist"
def test_schedule_info_auto_fills_i18n_display_fields():
"""后台服务数据应补充前端展示所需的多语言字段。"""
token = LocaleHelper.set_current_locale("en-US")
try:
schedule = ScheduleInfo(
id="mediaserver_sync",
name="同步媒体服务器",
provider="[系统]",
status="等待",
next_run="2小时3分钟",
progress_text="开始同步媒体服务器,共 2 个 ...",
progress_detail=ScheduleProgress(
id="mediaserver_sync",
name="同步媒体服务器",
provider="[系统]",
text="媒体服务器 Emby 无可同步媒体库",
error="后台服务不存在",
),
)
finally:
LocaleHelper.reset_current_locale(token)
assert schedule.name_i18n == "Sync Media Servers"
assert schedule.provider_i18n == "[System]"
assert schedule.status_i18n == "Waiting"
assert schedule.next_run_i18n == "2h 3m"
assert schedule.progress_text_i18n == "Starting media server sync, 2 servers ..."
assert schedule.progress_detail.text_i18n == "Media server Emby has no libraries to sync"
assert schedule.progress_detail.error_i18n == "Background service does not exist"
def test_api_endpoint_literal_messages_have_english_translations():
"""接口直接返回的中文 message 应有英文翻译,避免前端切英文后回退中文。"""
untranslated = []
for path in Path("app/api/endpoints").glob("*.py"):
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
for keyword in node.keywords:
if keyword.arg != "message":
continue
value = keyword.value
if not isinstance(value, ast.Constant) or not isinstance(value.value, str):
continue
message = value.value
if not any("\u4e00" <= char <= "\u9fff" for char in message):
continue
translated = LocaleHelper.translate_text(message, locale="en-US")
if translated == message:
untranslated.append(f"{path}:{value.lineno}:{message}")
assert untranslated == []
def test_api_endpoint_dynamic_messages_have_english_translations():
"""接口动态拼接的中文 message 应由模板翻译覆盖。"""
untranslated = []
for path in Path("app/api/endpoints").glob("*.py"):
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
for keyword in node.keywords:
if keyword.arg != "message":
continue
value = keyword.value
if not isinstance(value, ast.JoinedStr):
continue
parts = []
has_chinese = False
index = 0
for item in value.values:
if isinstance(item, ast.Constant) and isinstance(item.value, str):
parts.append(item.value)
has_chinese = has_chinese or _has_chinese(item.value)
else:
index += 1
parts.append(f"样例{index}")
if not has_chinese:
continue
message = "".join(parts)
translated = LocaleHelper.translate_text(message, locale="en-US")
if translated == message:
untranslated.append(f"{path}:{value.lineno}:{message}")
assert untranslated == []
def test_api_endpoint_message_expressions_have_english_translations():
"""接口 message 表达式中的中文分支应有英文翻译。"""
untranslated = []
for path in Path("app/api/endpoints").glob("*.py"):
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if ast.unparse(node.func) not in {"schemas.Response", "Response"}:
continue
for keyword in node.keywords:
if keyword.arg != "message":
continue
for message in _sample_message_expression(keyword.value):
if not _has_chinese(message):
continue
translated = LocaleHelper.translate_text(message, locale="en-US")
if translated == message:
untranslated.append(f"{path}:{keyword.value.lineno}:{message}")
assert untranslated == []
def test_api_endpoint_http_exception_details_have_english_translations():
"""HTTPException 的中文 detail 应有英文翻译。"""
untranslated = []
for path in Path("app/api/endpoints").glob("*.py"):
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if not ast.unparse(node.func).endswith("HTTPException"):
continue
for keyword in node.keywords:
if keyword.arg != "detail":
continue
for message in _sample_message_expression(keyword.value):
if not _has_chinese(message):
continue
translated = LocaleHelper.translate_text(message, locale="en-US")
if translated == message:
untranslated.append(f"{path}:{keyword.value.lineno}:{message}")
assert untranslated == []
+111
View File
@@ -0,0 +1,111 @@
from types import ModuleType
from unittest.mock import patch
from app.helper.locale import LocaleHelper
from app.testing import stub_modules
def _stub(name: str, **attrs) -> tuple:
"""构造带指定属性的占位模块,返回给 stub_modules 使用。"""
module = ModuleType(name)
for key, value in attrs.items():
setattr(module, key, value)
return name, module
class _Dummy:
"""隔离 system endpoint 导入期重依赖的占位对象。"""
def __init__(self, *args, **kwargs):
pass
def __getattr__(self, _name):
return lambda *args, **kwargs: None
class _FakeDoubanModule:
"""构造带中文名称的模块类,模拟真实 DoubanModule。"""
@staticmethod
def get_name() -> str:
"""获取模块中文名称"""
return "豆瓣"
class _FakeModuleManager:
"""提供 system 模块接口测试所需的最小模块管理器。"""
def get_modules(self) -> dict:
"""返回模块字典"""
return {"DoubanModule": _FakeDoubanModule}
def test(self, moduleid: str) -> tuple[bool, str]:
"""返回模块测试结果"""
return False, "模块不支持测试"
_STUB_MODULES = dict([
_stub("pillow_avif"),
_stub("aiofiles"),
_stub("psutil"),
_stub("app.helper.sites", SitesHelper=_Dummy),
_stub("app.chain.media", MediaChain=_Dummy),
_stub("app.chain.mediaserver", MediaServerChain=_Dummy),
_stub("app.chain.search", SearchChain=_Dummy),
_stub("app.chain.system", SystemChain=_Dummy),
_stub("app.core.event", eventmanager=_Dummy(), Event=_Dummy, EventManager=_Dummy),
_stub("app.core.metainfo", MetaInfo=_Dummy),
_stub("app.core.module", ModuleManager=_Dummy),
_stub("app.core.security", verify_apitoken=_Dummy, verify_resource_token=_Dummy, verify_token=_Dummy),
_stub("app.db.models", User=_Dummy),
_stub("app.db.systemconfig_oper", SystemConfigOper=_Dummy),
_stub("app.db.user_oper", get_current_active_superuser=_Dummy,
get_current_active_superuser_async=_Dummy, get_current_active_user_async=_Dummy),
_stub("app.helper.image", ImageHelper=_Dummy),
_stub("app.helper.mediaserver", MediaServerHelper=_Dummy),
_stub("app.helper.message", MessageHelper=_Dummy),
_stub("app.helper.progress", ProgressHelper=_Dummy),
_stub("app.helper.rule", RuleHelper=_Dummy),
_stub("app.helper.server", MoviePilotServerHelper=_Dummy),
_stub("app.helper.system", SystemHelper=_Dummy),
_stub("app.log", logger=_Dummy(), log_settings=_Dummy(),
LogConfigModel=type("LogConfigModel", (), {})),
_stub("app.scheduler", Scheduler=_Dummy),
_stub("app.utils.crypto", HashUtils=_Dummy),
_stub("app.utils.http", RequestUtils=_Dummy, AsyncRequestUtils=_Dummy),
_stub("version", APP_VERSION="test"),
])
with stub_modules(_STUB_MODULES):
from app.api.endpoints import system as system_endpoint
def test_system_modulelist_keeps_chinese_name_and_adds_i18n_name():
"""模块列表接口应保留旧中文字段,并提供前端可用的多语言字段。"""
token = LocaleHelper.set_current_locale("en-US")
with patch.object(system_endpoint, "ModuleManager", return_value=_FakeModuleManager()):
try:
response = system_endpoint.modulelist(_="token")
finally:
LocaleHelper.reset_current_locale(token)
module = response.data["modules"][0]
assert module["id"] == "DoubanModule"
assert module["name"] == "豆瓣"
assert module["name_i18n"] == "Douban"
assert module["name_key"] == "system.modules.DoubanModule.name"
def test_system_moduletest_keeps_chinese_message_and_adds_i18n_message():
"""模块测试接口应保留旧中文 message,并在顶层提供多语言 message_i18n。"""
token = LocaleHelper.set_current_locale("en-US")
with patch.object(system_endpoint, "ModuleManager", return_value=_FakeModuleManager()):
try:
response = system_endpoint.moduletest("DoubanModule", _="token")
finally:
LocaleHelper.reset_current_locale(token)
assert response.success is False
assert response.message == "模块不支持测试"
assert response.message_i18n == "Module does not support testing"