mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
feat: add backend i18n response support
This commit is contained in:
@@ -34,6 +34,7 @@ from app.db.models.agentchat import AgentChat
|
||||
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.interaction import agent_interaction_manager, media_interaction_manager
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.log import logger
|
||||
from app.schemas.types import EventType, MessageChannel
|
||||
|
||||
@@ -326,15 +327,25 @@ def _save_web_agent_display_snapshot(
|
||||
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 消息。
|
||||
|
||||
:param event_type: 前端事件类型
|
||||
:param data: 事件数据
|
||||
:param locale: 当前请求语言
|
||||
:return: 符合 SSE 格式的字符串
|
||||
"""
|
||||
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"
|
||||
|
||||
|
||||
@@ -1597,6 +1608,7 @@ async def web_agent_stream(
|
||||
:return: SSE 流式响应
|
||||
"""
|
||||
prompt = payload.text.strip()
|
||||
locale = LocaleHelper.get_locale_from_request(request)
|
||||
display_prompt = (payload.display_text or payload.text).strip()
|
||||
is_traditional_message = (
|
||||
_is_web_agent_traditional_message(prompt)
|
||||
@@ -1610,6 +1622,7 @@ async def web_agent_stream(
|
||||
_build_web_agent_sse(
|
||||
"error",
|
||||
{"message": denied_message},
|
||||
locale=locale,
|
||||
)
|
||||
]),
|
||||
media_type="text/event-stream",
|
||||
@@ -1621,6 +1634,7 @@ async def web_agent_stream(
|
||||
_build_web_agent_sse(
|
||||
"error",
|
||||
{"message": unknown_command_message},
|
||||
locale=locale,
|
||||
)
|
||||
]),
|
||||
media_type="text/event-stream",
|
||||
@@ -1649,7 +1663,11 @@ async def web_agent_stream(
|
||||
"""
|
||||
生成传统消息链路的 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(
|
||||
text=prompt,
|
||||
current_user=current_user,
|
||||
@@ -1660,7 +1678,11 @@ async def web_agent_stream(
|
||||
display_messages.append(assistant_message)
|
||||
for event in events:
|
||||
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():
|
||||
break
|
||||
await run_in_threadpool(
|
||||
@@ -1670,7 +1692,7 @@ async def web_agent_stream(
|
||||
messages=display_messages,
|
||||
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(
|
||||
traditional_event_generator(),
|
||||
@@ -1688,6 +1710,7 @@ async def web_agent_stream(
|
||||
_build_web_agent_sse(
|
||||
"error",
|
||||
{"message": "智能助手未启用,请先在系统设置中开启。"},
|
||||
locale=locale,
|
||||
)
|
||||
]),
|
||||
media_type="text/event-stream",
|
||||
@@ -1703,6 +1726,7 @@ async def web_agent_stream(
|
||||
_build_web_agent_sse(
|
||||
"error",
|
||||
{"message": "语音识别失败,请稍后重试。"},
|
||||
locale=locale,
|
||||
)
|
||||
]),
|
||||
media_type="text/event-stream",
|
||||
@@ -1713,6 +1737,7 @@ async def web_agent_stream(
|
||||
_build_web_agent_sse(
|
||||
"error",
|
||||
{"message": "请输入要发送给智能助手的内容或选择附件。"},
|
||||
locale=locale,
|
||||
)
|
||||
]),
|
||||
media_type="text/event-stream",
|
||||
@@ -1825,6 +1850,7 @@ async def web_agent_stream(
|
||||
yield _build_web_agent_sse(
|
||||
"start",
|
||||
{"session_id": session_id},
|
||||
locale=locale,
|
||||
)
|
||||
disconnected = False
|
||||
while not global_vars.is_system_stopped:
|
||||
@@ -1832,7 +1858,11 @@ async def web_agent_stream(
|
||||
disconnected = True
|
||||
break
|
||||
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():
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_resource_token, verify_token
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.log import logger
|
||||
from app.schemas import MediaRecognizeConvertEventData
|
||||
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)
|
||||
|
||||
|
||||
def _sse_event(data: dict) -> str:
|
||||
def _sse_event(data: dict, locale: Optional[str] = None) -> str:
|
||||
"""
|
||||
转换为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:
|
||||
@@ -167,6 +179,7 @@ async def _stream_search_events(request: Request, event_source: AsyncIterator[di
|
||||
"""
|
||||
输出搜索SSE事件
|
||||
"""
|
||||
locale = LocaleHelper.get_locale_from_request(request)
|
||||
try:
|
||||
has_sent_final_replace = False
|
||||
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")
|
||||
):
|
||||
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:
|
||||
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])
|
||||
|
||||
@@ -35,10 +35,11 @@ from app.db.user_oper import (
|
||||
get_current_active_user_async,
|
||||
)
|
||||
from app.helper.image import ImageHelper
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.server import MoviePilotServerHelper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.helper.rule import RuleHelper
|
||||
from app.helper.server import MoviePilotServerHelper
|
||||
from app.helper.system import SystemHelper
|
||||
from app.log import logger
|
||||
from app.scheduler import Scheduler
|
||||
@@ -797,13 +798,14 @@ async def get_progress(
|
||||
实时获取处理进度,返回格式为SSE
|
||||
"""
|
||||
progress = ProgressHelper(process_type)
|
||||
locale = LocaleHelper.get_current_locale()
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
while not global_vars.is_system_stopped:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
detail = progress.get()
|
||||
detail = progress.get(locale=locale)
|
||||
yield f"data: {json.dumps(detail)}\n\n"
|
||||
await asyncio.sleep(0.5)
|
||||
except asyncio.CancelledError:
|
||||
@@ -1271,13 +1273,20 @@ def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
查询已加载的模块ID列表
|
||||
"""
|
||||
modules = [
|
||||
{
|
||||
"id": k,
|
||||
"name": v.get_name(),
|
||||
}
|
||||
for k, v in ModuleManager().get_modules().items()
|
||||
]
|
||||
modules = []
|
||||
for module_id, module in ModuleManager().get_modules().items():
|
||||
name = module.get_name()
|
||||
modules.append(
|
||||
{
|
||||
"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})
|
||||
|
||||
|
||||
|
||||
+44
-1
@@ -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.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.helper.locale import LocaleHelper
|
||||
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:
|
||||
"""
|
||||
创建并配置 FastAPI 应用实例。
|
||||
@@ -15,6 +40,8 @@ def create_app() -> FastAPI:
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
_app.add_exception_handler(HTTPException, localized_http_exception_handler)
|
||||
|
||||
# 配置 CORS 中间件
|
||||
_app.add_middleware(
|
||||
CORSMiddleware, # noqa
|
||||
@@ -24,6 +51,22 @@ def create_app() -> FastAPI:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -1,7 +1,8 @@
|
||||
from enum import Enum
|
||||
from typing import Union, Optional
|
||||
from typing import Optional, Union
|
||||
|
||||
from app.core.cache import TTLCache
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.schemas.types import ProgressKey
|
||||
|
||||
|
||||
@@ -82,8 +83,34 @@ class ProgressHelper:
|
||||
current['data'].update(data)
|
||||
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
@@ -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
@@ -1,6 +1,8 @@
|
||||
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):
|
||||
@@ -87,14 +89,20 @@ class ScheduleProgress(BaseModel):
|
||||
id: Optional[str] = None
|
||||
# 名称
|
||||
name: Optional[str] = None
|
||||
# 多语言名称
|
||||
name_i18n: Optional[str] = None
|
||||
# 提供者
|
||||
provider: Optional[str] = None
|
||||
# 多语言提供者
|
||||
provider_i18n: Optional[str] = None
|
||||
# 是否正在执行
|
||||
enable: Optional[bool] = False
|
||||
# 当前完成百分比
|
||||
value: Optional[float] = 0.0
|
||||
# 当前进度文本
|
||||
text: Optional[str] = None
|
||||
# 多语言进度文本
|
||||
text_i18n: Optional[str] = None
|
||||
# 执行状态 waiting/running/success/failed
|
||||
status: Optional[str] = None
|
||||
# 最近一次执行是否成功
|
||||
@@ -105,9 +113,27 @@ class ScheduleProgress(BaseModel):
|
||||
finished_at: Optional[str] = None
|
||||
# 最近一次错误信息
|
||||
error: Optional[str] = None
|
||||
# 多语言错误信息
|
||||
error_i18n: Optional[str] = None
|
||||
# 扩展数据
|
||||
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):
|
||||
"""仪表板后台服务信息。"""
|
||||
@@ -116,21 +142,49 @@ class ScheduleInfo(BaseModel):
|
||||
id: Optional[str] = None
|
||||
# 名称
|
||||
name: Optional[str] = None
|
||||
# 多语言名称
|
||||
name_i18n: Optional[str] = None
|
||||
# 提供者
|
||||
provider: Optional[str] = None
|
||||
# 多语言提供者
|
||||
provider_i18n: Optional[str] = None
|
||||
# 状态
|
||||
status: Optional[str] = None
|
||||
# 多语言状态
|
||||
status_i18n: Optional[str] = None
|
||||
# 下次执行时间
|
||||
next_run: Optional[str] = None
|
||||
# 多语言下次执行时间
|
||||
next_run_i18n: Optional[str] = None
|
||||
# 当前完成百分比
|
||||
progress: Optional[float] = 0.0
|
||||
# 进度文本
|
||||
progress_text: Optional[str] = None
|
||||
# 多语言进度文本
|
||||
progress_text_i18n: Optional[str] = None
|
||||
# 是否正在更新进度
|
||||
progress_enable: Optional[bool] = False
|
||||
# 进度详情
|
||||
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):
|
||||
"""仪表板系统摘要信息。"""
|
||||
|
||||
+18
-1
@@ -1,12 +1,29 @@
|
||||
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):
|
||||
"""通用接口响应结构"""
|
||||
|
||||
# 状态
|
||||
success: bool
|
||||
# 消息文本
|
||||
message: Optional[str] = None
|
||||
# 多语言消息文本
|
||||
message_i18n: Optional[str] = None
|
||||
# 数据
|
||||
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
|
||||
|
||||
@@ -86,6 +86,10 @@ MCP 使用系统配置中的 `API_TOKEN` 作为认证密钥,文档中的 API K
|
||||
|
||||
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/system/setting/public/{key}` | 登录用户读取白名单内非敏感系统设置,仅支持目录、存储、站点范围、默认订阅规则、Follow 订阅者和插件市场地址等前端必需配置 |
|
||||
| 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` |
|
||||
|
||||
### 插件补充接口
|
||||
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user