mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-19 11:42:04 +08:00
303 lines
10 KiB
Python
303 lines
10 KiB
Python
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,
|
|
cls._build_pattern_values(locale, matched.groupdict()),
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _build_pattern_values(cls, locale: str, values: dict[str, str]) -> dict[str, str]:
|
|
"""
|
|
为动态模板补充可选的占位值翻译。
|
|
"""
|
|
pattern_values = dict(values)
|
|
catalog = cls._load_catalog(locale)
|
|
default_catalog = (
|
|
cls._load_catalog(cls.DEFAULT_LOCALE)
|
|
if locale != cls.DEFAULT_LOCALE
|
|
else catalog
|
|
)
|
|
for name, value in values.items():
|
|
translated = cls._lookup_message(catalog, value)
|
|
if translated is None and locale != cls.DEFAULT_LOCALE:
|
|
translated = cls._lookup_message(default_catalog, value)
|
|
pattern_values[f"{name}_i18n"] = translated or value
|
|
return pattern_values
|
|
|
|
@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
|