mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-11 08:10:39 +08:00
361 lines
14 KiB
Python
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,
|
|
)
|