Files
MoviePilot/app/startup/composition/system.py
2026-09-21 18:30:15 +08:00

361 lines
14 KiB
Python

"""System API 外部能力的唯一启动组合根。"""
from __future__ import annotations
import asyncio
import re
from collections import deque
from collections.abc import AsyncIterator, Awaitable, Callable
from pathlib import Path
from typing import Any, cast
import aiofiles # type: ignore[import-untyped]
from anyio import Path as AsyncPath
from app.adapters.external.github import GITHUB_DEVICE_CLIENT_ID, GithubAuthClient
from app.adapters.external.plugin.client import extract_plugin_market_repos_from_wiki
from app.adapters.external.server import MoviePilotServerHelper
from app.adapters.network.http import AsyncRequestUtils
from app.adapters.system import rust as rust_accel
from app.adapters.system.update import system_update_manager
from app.application.configuration import RuntimeSettingsService, SystemConfigService
from app.application.github_auth import GithubAuthService
from app.application.plugin.runtime import plugin_system_config_mutation
from app.application.security.url import SecurityUtils
from app.application.system import (
ConfigurationEventPort,
LlmCapabilityPort,
LogFileData,
LogNotFoundError,
MarketFetchResult,
PluginMarketPort,
ReleaseCatalogPort,
RuntimeFeaturePort,
ServerInfoPort,
SystemControlPort,
SystemLogPort,
SystemService,
SystemUpdatePort,
)
from app.domain.metainfo import clear_rust_parse_options_cache
from app.runtime.events import eventmanager
from app.runtime.state import SystemHelper
from app.runtime.stop import runtime_stop_state
from app.schemas.event import ConfigChangeEventData
from app.schemas.system import SystemUpdateStatus, SystemUpdateType
from app.schemas.types import EventType, SystemConfigKey
_LOG_DOWNLOAD_LIMIT = 10
_LOG_DOWNLOAD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
_RUST_METAINFO_OPTION_SETTING_KEYS = frozenset({
SystemConfigKey.CustomIdentifiers.value,
SystemConfigKey.CustomReleaseGroups.value,
SystemConfigKey.Customization.value,
"RMT_MEDIAEXT",
"RMT_SUBEXT",
"RMT_AUDIOEXT",
})
class _FileLogAdapter(SystemLogPort):
"""以受限日志根实现文件读取、尾随和归档数据源。"""
def __init__(self, settings: RuntimeSettingsService) -> None:
"""保存日志根设置提供器。"""
self._settings = settings
async def read(self, logfile: str) -> str:
"""校验路径后异步读取完整日志文本。"""
path = await self._resolve_logfile(logfile)
async with aiofiles.open(path, mode="r", encoding="utf-8", errors="replace") as file:
return cast(str, await file.read())
async def follow(
self,
logfile: str,
length: int,
disconnected: Callable[[], Awaitable[bool]],
) -> AsyncIterator[str]:
"""先返回历史尾部,再轮询并返回新增日志行。"""
path = await self._resolve_logfile(logfile)
return self._follow_path(path, length, disconnected)
async def collect(self, name: str) -> list[LogFileData]:
"""在线程中发现并读取允许归档的日志文件。"""
return await asyncio.to_thread(self._collect_sync, name)
async def _resolve_logfile(self, logfile: str) -> Path:
"""把相对日志名解析为日志根内的现存 .log 文件。"""
root = Path(self._settings.get("LOG_PATH"))
path = root / str(logfile or "")
safe = await SecurityUtils.async_is_safe_path(
base_path=AsyncPath(root),
user_path=AsyncPath(path),
allowed_suffixes={".log"},
)
if not safe or not await asyncio.to_thread(path.is_file):
raise LogNotFoundError(logfile)
return path
async def _follow_path(
self,
path: Path,
length: int,
disconnected: Callable[[], Awaitable[bool]],
) -> AsyncIterator[str]:
"""对一个已准入文件执行有界历史读取和新增内容轮询。"""
lines_queue: deque[str] = deque(maxlen=max(length, 50))
file_size = (await asyncio.to_thread(path.stat)).st_size
async with aiofiles.open(path, mode="r", encoding="utf-8", errors="replace") as file:
if file_size > 100 * 1024:
position = file_size - min(file_size, 100 * 1024)
await file.seek(position)
content = await file.read()
first_newline = content.find("\n")
if first_newline != -1:
content = content[first_newline + 1 :]
else:
content = await file.read()
for line in (item.strip() for item in content.splitlines() if item.strip()):
lines_queue.append(line)
for line in lines_queue:
yield line
async with aiofiles.open(path, mode="r", encoding="utf-8", errors="replace") as file:
await file.seek(0, 2)
previous_size = (await asyncio.to_thread(path.stat)).st_size
pending = ""
while not runtime_stop_state.is_system_stopped:
if await disconnected():
break
current_size = (await asyncio.to_thread(path.stat)).st_size
if current_size < previous_size:
await file.seek(0)
previous_size = 0
pending = ""
if current_size > previous_size:
pending += await file.read()
previous_size = current_size
fragments = pending.splitlines(keepends=True)
if fragments and not fragments[-1].endswith(("\n", "\r")):
pending = fragments.pop()
else:
pending = ""
for fragment in fragments:
line = fragment.strip()
if line:
yield line
else:
await asyncio.sleep(0.5)
def _collect_sync(self, name: str) -> list[LogFileData]:
"""同步发现并读取最多十个主程序或指定插件日志。"""
normalized = str(name or "").strip().lower()
if not normalized or not _LOG_DOWNLOAD_NAME_PATTERN.fullmatch(normalized):
raise LogNotFoundError(name)
root = Path(self._settings.get("LOG_PATH"))
directory = root if normalized == "moviepilot" else root / "plugins"
prefix = "moviepilot.log" if normalized == "moviepilot" else f"{normalized}.log"
if not directory.is_dir():
raise LogNotFoundError(name)
current = directory / prefix
backups = [
item
for item in directory.iterdir()
if item.is_file() and item.name.startswith(f"{prefix}.")
]
backups.sort(key=lambda item: item.stat().st_mtime, reverse=True)
paths = ([current] if current.is_file() else []) + backups
entries: list[LogFileData] = []
for path in paths[:_LOG_DOWNLOAD_LIMIT]:
if not SecurityUtils.is_safe_path(base_path=root, user_path=path):
raise LogNotFoundError(name)
entries.append(LogFileData(name=path.name, content=path.read_bytes()))
if not entries:
raise LogNotFoundError(name)
return entries
class _PluginMarketAdapter(PluginMarketPort):
"""通过统一异步 HTTP 客户端读取并解析 Wiki 市场清单。"""
def __init__(self, settings: RuntimeSettingsService) -> None:
"""保存代理与 User-Agent 设置提供器。"""
self._settings = settings
async def fetch(self, url: str) -> MarketFetchResult:
"""读取 Wiki 文本并把解析异常收敛为空仓库列表。"""
response = await AsyncRequestUtils(
ua=self._settings.get("USER_AGENT"),
proxies=self._settings.get("PROXY"),
timeout=30,
accept_type="text/plain,*/*",
).get_res(url)
if response is None:
return MarketFetchResult(status_code=None, repos=[])
repos = (
extract_plugin_market_repos_from_wiki(response.text)
if response.status_code == 200
else []
)
return MarketFetchResult(status_code=response.status_code, repos=repos)
class _ConfigurationEventAdapter(ConfigurationEventPort):
"""把已提交设置变更发布到宿主事件总线。"""
async def publish(self, key: Any, value: Any = None) -> None:
"""同步失效派生缓存并等待全部配置重载处理器完成。"""
payload = ConfigChangeEventData(key=key, value=value, change_type="update")
if payload.key.intersection(_RUST_METAINFO_OPTION_SETTING_KEYS):
# 必须在写入入口返回前失效缓存,避免下一次识别读取旧配置。
clear_rust_parse_options_cache()
await eventmanager.async_send_event_strict(
etype=EventType.ConfigChanged,
data=payload,
)
class _ServerInfoAdapter(ServerInfoPort):
"""适配 MoviePilot Server 的用户权限与统计接口。"""
async def user_global(self) -> dict[str, Any]:
"""合并用户 UUID 与两项共享管理权限。"""
is_admin = await MoviePilotServerHelper.async_is_admin_user()
return {
"USER_UNIQUE_ID": MoviePilotServerHelper.get_user_uuid(),
"SUBSCRIBE_SHARE_MANAGE": is_admin,
"WORKFLOW_SHARE_MANAGE": is_admin,
}
async def usage(self) -> dict[str, Any]:
"""读取安装版本统计。"""
return await MoviePilotServerHelper.async_get_usage_statistic()
class _ReleaseCatalogAdapter(ReleaseCatalogPort):
"""通过 GitHub API 读取主程序 Release 列表。"""
def __init__(self, settings: RuntimeSettingsService) -> None:
"""保存 GitHub 代理与请求头设置提供器。"""
self._settings = settings
async def list(self) -> list[dict[str, Any]] | None:
"""读取有效 JSON 列表,失败时返回空值。"""
response = await AsyncRequestUtils(
proxies=self._settings.get("PROXY"),
headers=self._settings.get("GITHUB_HEADERS"),
).get_res("https://api.github.com/repos/jxxghp/MoviePilot/releases")
if response is None or response.status_code != 200:
return None
payload = response.json()
return payload if isinstance(payload, list) else None
class _RuntimeFeatureAdapter(RuntimeFeaturePort):
"""适配本机 Rust 加速能力探测。"""
def snapshot(self) -> dict[str, bool]:
"""返回现有环境接口使用的四项 Rust 状态。"""
return {
"RUST_ACCEL": rust_accel.is_config_enabled(),
"RUST_ACCEL_AVAILABLE": rust_accel.is_available(),
"RUST_ACCEL_ENABLED": rust_accel.is_enabled(),
"RUST_ACCEL_REQUIRED": rust_accel.is_required(),
}
class _SystemControlAdapter(SystemControlPort):
"""适配宿主进程的重启与开发分支更新控制。"""
def can_restart(self) -> bool:
"""查询部署环境重启能力。"""
return SystemHelper.can_restart()
def restart(self) -> tuple[bool, str]:
"""请求宿主重启。"""
return SystemHelper.restart()
def upgrade_dev(self) -> tuple[bool, str]:
"""执行开发分支更新。"""
return SystemHelper.upgrade_dev()
class _SystemUpdateAdapter(SystemUpdatePort):
"""适配后台 Release 更新状态机。"""
def status(self) -> SystemUpdateStatus:
"""读取当前更新状态。"""
return system_update_manager.get_status()
def check(self) -> SystemUpdateStatus:
"""立即检查更新。"""
return system_update_manager.check()
def download(self, target: SystemUpdateType = "application") -> SystemUpdateStatus:
"""启动指定升级类型的后台下载。"""
return system_update_manager.start_download(target)
def prepare_install(self, target: SystemUpdateType = "application") -> tuple[bool, str]:
"""确认指定升级类型的安装准备。"""
return system_update_manager.request_install(target)
def cancel_install(self, message: str) -> None:
"""撤销失败的安装请求。"""
system_update_manager.cancel_install(message)
class _LlmCapabilityAdapter(LlmCapabilityPort):
"""适配 Agent 目录中的服务端工具能力注册表。"""
def validate(self, env: dict[str, Any], settings: RuntimeSettingsService) -> str | None:
"""拒绝所选模型不支持的强制内建联网搜索。"""
from app.agent.llm.tools import ServerToolRegistry, ServerToolUnavailableError
mode = ServerToolRegistry.normalize_web_search_mode(
env.get("LLM_WEB_SEARCH_MODE", settings.get("LLM_WEB_SEARCH_MODE", "local"))
)
if mode != "builtin":
return None
provider = str(env.get("LLM_PROVIDER", settings.get("LLM_PROVIDER", "")) or "").strip()
model = str(env.get("LLM_MODEL", settings.get("LLM_MODEL", "")) or "").strip()
base_url = env.get("LLM_BASE_URL", settings.get("LLM_BASE_URL"))
if ServerToolRegistry.get_capability(
provider=provider,
model=model,
base_url=str(base_url or "").strip() or None,
tool_id="web_search",
):
return None
return str(ServerToolUnavailableError(provider=provider, model=model, tool_id="web_search"))
def compose_system_service(
*,
settings: RuntimeSettingsService,
system_config: SystemConfigService,
rule_group_mutation: Callable[[], Any],
) -> SystemService:
"""构造 System API 应用服务并注入全部具体外部能力。"""
github_auth = GithubAuthService(
settings=settings,
system_config=system_config,
transport=GithubAuthClient(settings),
client_id=GITHUB_DEVICE_CLIENT_ID,
)
return SystemService(
settings=settings,
system_config=system_config,
logs=_FileLogAdapter(settings),
market=_PluginMarketAdapter(settings),
events=_ConfigurationEventAdapter(),
server=_ServerInfoAdapter(),
releases=_ReleaseCatalogAdapter(settings),
features=_RuntimeFeatureAdapter(),
control=_SystemControlAdapter(),
updates=_SystemUpdateAdapter(),
llm=_LlmCapabilityAdapter(),
plugin_mutation=plugin_system_config_mutation,
rule_group_mutation=rule_group_mutation,
github_auth=github_auth,
)