Files
MoviePilot/app/helper/locale.py
2026-07-07 06:04:45 +08:00

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