mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-17 05:30:43 +08:00
新增 doctor 诊断自救功能
This commit is contained in:
59
app/cli.py
59
app/cli.py
@@ -625,23 +625,27 @@ def _spawn_process(
|
||||
return subprocess.Popen(command, **kwargs)
|
||||
|
||||
|
||||
def _spawn_backend_process() -> subprocess.Popen:
|
||||
def _spawn_backend_process(*, safe: bool = False) -> subprocess.Popen:
|
||||
backend_env = {
|
||||
**os.environ,
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"MOVIEPILOT_DISABLE_CONSOLE_LOG": "1",
|
||||
"MOVIEPILOT_STDIO_LOG_FILE": str(BACKEND_STDIO_LOG_FILE),
|
||||
"MOVIEPILOT_STDIO_LOG_MAX_BYTES": str(
|
||||
max(int(settings.LOG_MAX_FILE_SIZE or 0), 1) * 1024 * 1024
|
||||
),
|
||||
"MOVIEPILOT_STDIO_LOG_BACKUP_COUNT": str(
|
||||
max(int(settings.LOG_BACKUP_COUNT or 0), 0)
|
||||
),
|
||||
}
|
||||
if safe:
|
||||
backend_env["MOVIEPILOT_SAFE_MODE"] = "true"
|
||||
|
||||
return _spawn_process(
|
||||
[sys.executable, "-m", "app.main"],
|
||||
cwd=_repo_root(),
|
||||
log_file=None,
|
||||
env={
|
||||
**os.environ,
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"MOVIEPILOT_DISABLE_CONSOLE_LOG": "1",
|
||||
"MOVIEPILOT_STDIO_LOG_FILE": str(BACKEND_STDIO_LOG_FILE),
|
||||
"MOVIEPILOT_STDIO_LOG_MAX_BYTES": str(
|
||||
max(int(settings.LOG_MAX_FILE_SIZE or 0), 1) * 1024 * 1024
|
||||
),
|
||||
"MOVIEPILOT_STDIO_LOG_BACKUP_COUNT": str(
|
||||
max(int(settings.LOG_BACKUP_COUNT or 0), 0)
|
||||
),
|
||||
},
|
||||
env=backend_env,
|
||||
)
|
||||
|
||||
|
||||
@@ -719,7 +723,7 @@ def _wait_until_frontend_ready(runtime: Dict[str, Any], timeout: int) -> Dict[st
|
||||
raise click.ClickException(f"前端进程已启动,但在 {timeout} 秒内未通过健康检查,请执行 `moviepilot logs --frontend` 查看前端日志")
|
||||
|
||||
|
||||
def _start_backend_service(timeout: int) -> Dict[str, Any]:
|
||||
def _start_backend_service(timeout: int, safe: bool = False) -> Dict[str, Any]:
|
||||
state, runtime, process, health_payload = _managed_backend_status()
|
||||
if state in {"running", "starting"} and runtime and process:
|
||||
return {"status": state, "runtime": runtime, "process": process, "health": health_payload, "started": False}
|
||||
@@ -728,7 +732,7 @@ def _start_backend_service(timeout: int) -> Dict[str, Any]:
|
||||
|
||||
_ensure_local_api_token()
|
||||
_clear_json_file(BACKEND_RUNTIME_FILE)
|
||||
process = _spawn_backend_process()
|
||||
process = _spawn_backend_process(safe=safe)
|
||||
ps_process = psutil.Process(process.pid)
|
||||
runtime = {
|
||||
"pid": process.pid,
|
||||
@@ -739,6 +743,7 @@ def _start_backend_service(timeout: int) -> Dict[str, Any]:
|
||||
"started_at": int(time.time()),
|
||||
"python": sys.executable,
|
||||
"stdio_log": str(BACKEND_STDIO_LOG_FILE),
|
||||
"safe_mode": safe,
|
||||
}
|
||||
_write_json_file(BACKEND_RUNTIME_FILE, runtime)
|
||||
health_payload = _wait_until_backend_ready(runtime, timeout)
|
||||
@@ -833,7 +838,8 @@ def cli() -> None:
|
||||
|
||||
@cli.command(context_settings=CONTEXT_SETTINGS)
|
||||
@click.option("--timeout", default=60, show_default=True, help="等待后端与前端就绪的秒数")
|
||||
def start(timeout: int) -> None:
|
||||
@click.option("--safe", is_flag=True, help="安全模式启动,仅保留核心 API,跳过插件和后台任务")
|
||||
def start(timeout: int, safe: bool) -> None:
|
||||
"""后台启动本地 MoviePilot 前后端服务"""
|
||||
_ensure_frontend_not_running_alone(timeout=min(timeout, 15))
|
||||
backend_state, _, _, _ = _managed_backend_status()
|
||||
@@ -841,7 +847,7 @@ def start(timeout: int) -> None:
|
||||
if backend_state == "stopped" and frontend_state == "stopped":
|
||||
_best_effort_auto_update()
|
||||
|
||||
backend_result = _start_backend_service(timeout=timeout)
|
||||
backend_result = _start_backend_service(timeout=timeout, safe=safe)
|
||||
backend_runtime = backend_result["runtime"]
|
||||
try:
|
||||
frontend_result = _start_frontend_service(timeout=timeout, backend_port=int(backend_runtime["port"]))
|
||||
@@ -864,6 +870,8 @@ def start(timeout: int) -> None:
|
||||
click.echo(f"Frontend URL: {_frontend_base_url(frontend_result['runtime'])}")
|
||||
click.echo(f"Backend Version: {backend_version}")
|
||||
click.echo(f"Frontend Version: {frontend_version}")
|
||||
if safe or backend_runtime.get("safe_mode"):
|
||||
click.echo("Safe Mode: enabled")
|
||||
|
||||
|
||||
@cli.command(context_settings=CONTEXT_SETTINGS)
|
||||
@@ -972,6 +980,23 @@ def logs(lines: int, follow: bool, stdio: bool, frontend_log: bool) -> None:
|
||||
_follow_file(log_file)
|
||||
|
||||
|
||||
@cli.command(context_settings=CONTEXT_SETTINGS)
|
||||
@click.option("--json", "json_output", is_flag=True, help="输出 JSON 报告")
|
||||
@click.option("--fix", is_flag=True, help="执行白名单安全修复")
|
||||
@click.option("--deep", is_flag=True, help="执行可能较慢的深度检查")
|
||||
def doctor(json_output: bool, fix: bool, deep: bool) -> None:
|
||||
"""离线诊断本地 MoviePilot 运行环境"""
|
||||
from app.doctor import run_doctor
|
||||
from app.doctor.formatters import format_json_report, format_text_report
|
||||
|
||||
report = run_doctor(fix=fix, deep=deep)
|
||||
if json_output:
|
||||
click.echo(format_json_report(report))
|
||||
else:
|
||||
click.echo(format_text_report(report))
|
||||
raise click.exceptions.Exit(report.exit_code())
|
||||
|
||||
|
||||
@cli.group(context_settings=CONTEXT_SETTINGS)
|
||||
def config() -> None:
|
||||
"""查看或修改本地配置"""
|
||||
|
||||
@@ -74,6 +74,8 @@ class ConfigModel(BaseModel):
|
||||
NGINX_PORT: int = 3000
|
||||
# 配置文件目录
|
||||
CONFIG_DIR: Optional[str] = None
|
||||
# 安全模式,仅保留核心 API,跳过插件、调度器、监控、命令和工作流等扩展启动项
|
||||
MOVIEPILOT_SAFE_MODE: bool = False
|
||||
# 是否调试模式
|
||||
DEBUG: bool = False
|
||||
# 是否开发模式
|
||||
|
||||
17
app/doctor/__init__.py
Normal file
17
app/doctor/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from app.doctor.models import DoctorFinding, DoctorReport
|
||||
from app.doctor.runner import DoctorRunner
|
||||
|
||||
|
||||
def run_doctor(*, fix: bool = False, deep: bool = False) -> DoctorReport:
|
||||
"""
|
||||
运行 MoviePilot 离线诊断并返回报告。
|
||||
"""
|
||||
return DoctorRunner(fix=fix, deep=deep).run()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DoctorFinding",
|
||||
"DoctorReport",
|
||||
"DoctorRunner",
|
||||
"run_doctor",
|
||||
]
|
||||
749
app/doctor/checks.py
Normal file
749
app/doctor/checks.py
Normal file
@@ -0,0 +1,749 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import socket
|
||||
import sqlite3
|
||||
import sys
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import psutil
|
||||
|
||||
from app.core.config import settings
|
||||
from app.doctor.models import DoctorFinding, DoctorFindingStatus, DoctorReport, DoctorSeverity
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
CheckFunc = Callable[["DoctorRunnerProtocol"], None]
|
||||
|
||||
CORE_DEPENDENCIES = (
|
||||
"alembic",
|
||||
"cloakbrowser",
|
||||
"fastapi",
|
||||
"pydantic",
|
||||
"pydantic_core",
|
||||
"pydantic_settings",
|
||||
"sqlalchemy",
|
||||
"starlette",
|
||||
"uvicorn",
|
||||
)
|
||||
LOCAL_HOSTS = {"", "0.0.0.0", "::", "::1", "localhost"}
|
||||
LOG_ERROR_PATTERNS = (
|
||||
re.compile(r"\btraceback\b", re.IGNORECASE),
|
||||
re.compile(r"\b(error|critical|exception)\b", re.IGNORECASE),
|
||||
re.compile(r"加载插件.+出错"),
|
||||
re.compile(r"数据库更新失败"),
|
||||
)
|
||||
SENSITIVE_PATTERNS = (
|
||||
re.compile(r"(?i)(api[_-]?token|token|password|secret|cookie)(\s*[:=]\s*)[^\s&]+"),
|
||||
re.compile(r"\bghp_[A-Za-z0-9]{20,}\b"),
|
||||
re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"),
|
||||
)
|
||||
|
||||
|
||||
def _backend_runtime_file() -> Path:
|
||||
return settings.TEMP_PATH / "moviepilot.runtime.json"
|
||||
|
||||
|
||||
def _frontend_runtime_file() -> Path:
|
||||
return settings.TEMP_PATH / "moviepilot.frontend.runtime.json"
|
||||
|
||||
|
||||
def _backend_stdio_log_file() -> Path:
|
||||
return settings.LOG_PATH / "moviepilot.stdout.log"
|
||||
|
||||
|
||||
def _backend_app_log_file() -> Path:
|
||||
return settings.LOG_PATH / "moviepilot.log"
|
||||
|
||||
|
||||
def _frontend_stdio_log_file() -> Path:
|
||||
return settings.LOG_PATH / "moviepilot.frontend.stdout.log"
|
||||
|
||||
|
||||
class DoctorRunnerProtocol:
|
||||
"""
|
||||
诊断检查使用的 Runner 最小协议,避免检查项反向依赖具体实现细节。
|
||||
"""
|
||||
|
||||
fix: bool
|
||||
deep: bool
|
||||
report: DoctorReport
|
||||
|
||||
def add(
|
||||
self,
|
||||
*,
|
||||
finding_id: str,
|
||||
severity: DoctorSeverity,
|
||||
status: DoctorFindingStatus,
|
||||
title: str,
|
||||
detail: str,
|
||||
recommendation: str,
|
||||
fixable: bool = False,
|
||||
fixed: bool = False,
|
||||
context: Optional[dict[str, Any]] = None,
|
||||
) -> DoctorFinding:
|
||||
"""
|
||||
添加诊断发现。
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def default_checks() -> list[CheckFunc]:
|
||||
"""
|
||||
返回默认离线诊断检查项列表。
|
||||
"""
|
||||
return [
|
||||
_check_runtime_paths,
|
||||
_check_config,
|
||||
_check_processes_and_ports,
|
||||
_check_dependencies,
|
||||
_check_database,
|
||||
_check_frontend_assets,
|
||||
_check_logs,
|
||||
_check_docker,
|
||||
_check_safe_mode,
|
||||
]
|
||||
|
||||
|
||||
def _mask_text(text: str) -> str:
|
||||
masked = text
|
||||
for pattern in SENSITIVE_PATTERNS:
|
||||
if pattern.groups >= 2:
|
||||
masked = pattern.sub(r"\1\2<REDACTED>", masked)
|
||||
else:
|
||||
masked = pattern.sub("<REDACTED>", masked)
|
||||
return masked
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Optional[dict[str, Any]]:
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _runtime_process(runtime: Optional[dict[str, Any]]) -> Optional[psutil.Process]:
|
||||
runtime = runtime or {}
|
||||
pid = runtime.get("pid")
|
||||
create_time = runtime.get("create_time")
|
||||
if not pid or create_time is None:
|
||||
return None
|
||||
try:
|
||||
process = psutil.Process(int(pid))
|
||||
if abs(process.create_time() - float(create_time)) > 2:
|
||||
return None
|
||||
if not process.is_running() or process.status() == psutil.STATUS_ZOMBIE:
|
||||
return None
|
||||
return process
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _process_description(process: psutil.Process) -> str:
|
||||
try:
|
||||
name = process.name()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
name = "unknown"
|
||||
try:
|
||||
command = " ".join(process.cmdline()[:4])
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
command = ""
|
||||
suffix = f" {command}" if command else ""
|
||||
return f"PID {process.pid} ({name}){suffix}"
|
||||
|
||||
|
||||
def _process_name_and_command(process: psutil.Process) -> tuple[str, str]:
|
||||
try:
|
||||
name = process.name().lower()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
name = ""
|
||||
try:
|
||||
command = " ".join(process.cmdline()).lower()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
command = ""
|
||||
return name, command
|
||||
|
||||
|
||||
def _is_expected_port_process(name: str, process: psutil.Process) -> bool:
|
||||
process_name, command = _process_name_and_command(process)
|
||||
if name == "backend":
|
||||
return "app/main.py" in command or "-m app.main" in command or "uvicorn" in command
|
||||
if name == "frontend":
|
||||
return (
|
||||
"nginx" in process_name
|
||||
or "service.js" in command
|
||||
or "node" in process_name
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _port_occupants(port: int) -> list[psutil.Process]:
|
||||
occupants: dict[int, psutil.Process] = {}
|
||||
try:
|
||||
connections = psutil.net_connections(kind="inet")
|
||||
except (psutil.AccessDenied, OSError):
|
||||
return []
|
||||
for conn in connections:
|
||||
local = conn.laddr
|
||||
if not local or getattr(local, "port", None) != port:
|
||||
continue
|
||||
if conn.status != psutil.CONN_LISTEN:
|
||||
continue
|
||||
if not conn.pid:
|
||||
continue
|
||||
try:
|
||||
occupants[conn.pid] = psutil.Process(conn.pid)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
return list(occupants.values())
|
||||
|
||||
|
||||
def _client_host(host: Optional[str]) -> str:
|
||||
host = (host or "").strip()
|
||||
return "127.0.0.1" if host in LOCAL_HOSTS else host
|
||||
|
||||
|
||||
def _can_connect(host: str, port: int, timeout: float = 1.0) -> tuple[bool, str]:
|
||||
try:
|
||||
with socket.create_connection((_client_host(host), int(port)), timeout=timeout):
|
||||
return True, ""
|
||||
except OSError as err:
|
||||
return False, str(err)
|
||||
|
||||
|
||||
def _tail_lines(path: Path, max_lines: int = 120, max_bytes: int = 256 * 1024) -> list[str]:
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
with path.open("rb") as file_obj:
|
||||
if size > max_bytes:
|
||||
file_obj.seek(size - max_bytes)
|
||||
text = file_obj.read().decode("utf-8", errors="replace")
|
||||
except OSError:
|
||||
return []
|
||||
return list(deque((_mask_text(line) for line in text.splitlines()), maxlen=max_lines))
|
||||
|
||||
|
||||
def _find_error_lines(lines: list[str], max_matches: int = 12) -> list[str]:
|
||||
matches: list[str] = []
|
||||
for line in lines:
|
||||
if any(pattern.search(line) for pattern in LOG_ERROR_PATTERNS):
|
||||
matches.append(line)
|
||||
return matches[-max_matches:]
|
||||
|
||||
|
||||
def _frontend_dir() -> Path:
|
||||
root_public = settings.ROOT_PATH / "public"
|
||||
configured = Path(settings.FRONTEND_PATH)
|
||||
if root_public.exists():
|
||||
return root_public
|
||||
if configured.is_absolute():
|
||||
return configured
|
||||
return settings.ROOT_PATH / configured
|
||||
|
||||
|
||||
def _unlink_if_requested(runner: DoctorRunnerProtocol, path: Path) -> bool:
|
||||
if not runner.fix:
|
||||
return False
|
||||
try:
|
||||
path.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _check_runtime_paths(runner: DoctorRunnerProtocol) -> None:
|
||||
runner.add(
|
||||
finding_id="runtime.paths",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Ok,
|
||||
title="运行路径已识别",
|
||||
detail=(
|
||||
f"程序目录:{settings.ROOT_PATH};配置目录:{settings.CONFIG_PATH};"
|
||||
f"日志目录:{settings.LOG_PATH};Python:{sys.executable}"
|
||||
),
|
||||
recommendation="如需切换配置目录,请使用 CONFIG_DIR 或本地 CLI 的 --config-dir 参数。",
|
||||
context={
|
||||
"root_path": str(settings.ROOT_PATH),
|
||||
"config_path": str(settings.CONFIG_PATH),
|
||||
"log_path": str(settings.LOG_PATH),
|
||||
"python": sys.executable,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _check_config(runner: DoctorRunnerProtocol) -> None:
|
||||
token = (settings.API_TOKEN or "").strip()
|
||||
if len(token) < 16:
|
||||
fixed = False
|
||||
detail = "API_TOKEN 未设置或长度小于 16 个字符,后端鉴权和本地工具调用可能不可用。"
|
||||
if runner.fix and "API_TOKEN" not in os.environ:
|
||||
result, message = settings.update_setting("API_TOKEN", token)
|
||||
fixed = result is True
|
||||
if message:
|
||||
detail = f"{detail} {message}"
|
||||
runner.add(
|
||||
finding_id="config.api_token_invalid",
|
||||
severity=DoctorSeverity.Error if not fixed else DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Fixed if fixed else DoctorFindingStatus.Failed,
|
||||
title="API_TOKEN 不可用",
|
||||
detail=detail,
|
||||
recommendation=(
|
||||
"执行 `moviepilot doctor --fix` 自动生成安全 token,或使用 "
|
||||
"`moviepilot config set API_TOKEN <token>` 手动设置。"
|
||||
),
|
||||
fixable="API_TOKEN" not in os.environ,
|
||||
fixed=fixed,
|
||||
)
|
||||
else:
|
||||
runner.add(
|
||||
finding_id="config.api_token",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Ok,
|
||||
title="API_TOKEN 已配置",
|
||||
detail="API_TOKEN 长度满足本地工具和后端鉴权要求,报告不会输出 token 原文。",
|
||||
recommendation="无需处理。",
|
||||
)
|
||||
|
||||
if settings.PORT == settings.NGINX_PORT:
|
||||
runner.add(
|
||||
finding_id="config.port_same",
|
||||
severity=DoctorSeverity.Error,
|
||||
status=DoctorFindingStatus.Failed,
|
||||
title="前后端端口冲突",
|
||||
detail=f"PORT 与 NGINX_PORT 都设置为 {settings.PORT}。",
|
||||
recommendation="将 PORT 或 NGINX_PORT 调整为不同端口后重启服务。",
|
||||
)
|
||||
else:
|
||||
runner.add(
|
||||
finding_id="config.ports",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Ok,
|
||||
title="前后端端口配置不同",
|
||||
detail=f"后端端口 PORT={settings.PORT};前端端口 NGINX_PORT={settings.NGINX_PORT}。",
|
||||
recommendation="无需处理。",
|
||||
)
|
||||
|
||||
proxy_host = (settings.PROXY_HOST or "").strip()
|
||||
if proxy_host and not re.match(r"^https?://", proxy_host, re.IGNORECASE):
|
||||
runner.add(
|
||||
finding_id="config.proxy_format",
|
||||
severity=DoctorSeverity.Warn,
|
||||
status=DoctorFindingStatus.Degraded,
|
||||
title="代理地址格式可能不完整",
|
||||
detail=f"PROXY_HOST={proxy_host} 未包含 http:// 或 https:// 前缀。",
|
||||
recommendation="如果外部访问异常,请把 PROXY_HOST 调整为完整 URL。",
|
||||
)
|
||||
|
||||
|
||||
def _check_runtime_file(
|
||||
runner: DoctorRunnerProtocol,
|
||||
*,
|
||||
name: str,
|
||||
path: Path,
|
||||
port: int,
|
||||
) -> Optional[psutil.Process]:
|
||||
runtime = _read_json(path)
|
||||
process = _runtime_process(runtime)
|
||||
if runtime and not process:
|
||||
fixed = _unlink_if_requested(runner, path)
|
||||
runner.add(
|
||||
finding_id=f"runtime.{name}_stale",
|
||||
severity=DoctorSeverity.Warn if not fixed else DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Fixed if fixed else DoctorFindingStatus.Degraded,
|
||||
title=f"{name} 运行时文件已过期",
|
||||
detail=f"{path} 指向的进程不存在或已不是原进程。",
|
||||
recommendation="执行 `moviepilot doctor --fix` 清理过期运行时文件后再重试启动。",
|
||||
fixable=True,
|
||||
fixed=fixed,
|
||||
context={"runtime_file": str(path)},
|
||||
)
|
||||
if process:
|
||||
runner.add(
|
||||
finding_id=f"process.{name}_managed",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Ok,
|
||||
title=f"{name} 进程正在运行",
|
||||
detail=_process_description(process),
|
||||
recommendation="如服务不可访问,请继续查看端口和日志诊断项。",
|
||||
context={"pid": process.pid, "port": port},
|
||||
)
|
||||
return process
|
||||
|
||||
|
||||
def _check_port(
|
||||
runner: DoctorRunnerProtocol,
|
||||
*,
|
||||
name: str,
|
||||
port: int,
|
||||
managed_process: Optional[psutil.Process],
|
||||
) -> None:
|
||||
occupants = _port_occupants(port)
|
||||
if not occupants:
|
||||
runner.add(
|
||||
finding_id=f"port.{name}_not_listening",
|
||||
severity=DoctorSeverity.Warn,
|
||||
status=DoctorFindingStatus.Degraded,
|
||||
title=f"{name} 端口未监听",
|
||||
detail=f"本机未检测到进程监听端口 {port}。",
|
||||
recommendation="如果服务应当正在运行,请查看启动日志;如果尚未启动,可忽略。",
|
||||
context={"port": port},
|
||||
)
|
||||
return
|
||||
|
||||
descriptions = [_process_description(process) for process in occupants]
|
||||
if managed_process and any(process.pid == managed_process.pid for process in occupants):
|
||||
runner.add(
|
||||
finding_id=f"port.{name}_listening",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Ok,
|
||||
title=f"{name} 端口监听正常",
|
||||
detail=f"端口 {port} 由 MoviePilot 管理进程监听:{'; '.join(descriptions)}",
|
||||
recommendation="无需处理。",
|
||||
context={"port": port, "pids": [process.pid for process in occupants]},
|
||||
)
|
||||
return
|
||||
|
||||
expected_processes = [process for process in occupants if _is_expected_port_process(name, process)]
|
||||
if expected_processes:
|
||||
runner.add(
|
||||
finding_id=f"port.{name}_listening_unmanaged",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Ok,
|
||||
title=f"{name} 端口由 MoviePilot 相关进程监听",
|
||||
detail=f"端口 {port} 监听进程:{'; '.join(_process_description(process) for process in expected_processes)}",
|
||||
recommendation="如果这是 Docker 或非 CLI 管理启动方式,可忽略 runtime 文件缺失。",
|
||||
context={"port": port, "pids": [process.pid for process in expected_processes]},
|
||||
)
|
||||
return
|
||||
|
||||
runner.add(
|
||||
finding_id=f"port.{name}_occupied",
|
||||
severity=DoctorSeverity.Error,
|
||||
status=DoctorFindingStatus.Failed,
|
||||
title=f"{name} 端口被其他进程占用",
|
||||
detail=f"端口 {port} 当前监听进程:{'; '.join(descriptions)}",
|
||||
recommendation="停止占用进程,或修改 MoviePilot 的端口配置后重启。",
|
||||
context={"port": port, "pids": [process.pid for process in occupants]},
|
||||
)
|
||||
|
||||
|
||||
def _check_processes_and_ports(runner: DoctorRunnerProtocol) -> None:
|
||||
backend_process = _check_runtime_file(
|
||||
runner,
|
||||
name="backend",
|
||||
path=_backend_runtime_file(),
|
||||
port=int(settings.PORT),
|
||||
)
|
||||
frontend_process = _check_runtime_file(
|
||||
runner,
|
||||
name="frontend",
|
||||
path=_frontend_runtime_file(),
|
||||
port=int(settings.NGINX_PORT),
|
||||
)
|
||||
_check_port(runner, name="backend", port=int(settings.PORT), managed_process=backend_process)
|
||||
_check_port(runner, name="frontend", port=int(settings.NGINX_PORT), managed_process=frontend_process)
|
||||
|
||||
|
||||
def _check_dependencies(runner: DoctorRunnerProtocol) -> None:
|
||||
missing = [name for name in CORE_DEPENDENCIES if importlib.util.find_spec(name) is None]
|
||||
if missing:
|
||||
runner.add(
|
||||
finding_id="dependencies.core_missing",
|
||||
severity=DoctorSeverity.Error,
|
||||
status=DoctorFindingStatus.Failed,
|
||||
title="核心 Python 依赖缺失",
|
||||
detail=f"无法导入:{', '.join(missing)}。",
|
||||
recommendation="本地环境执行 `moviepilot install deps`;Docker 环境建议重新拉取或重建镜像。",
|
||||
context={"missing": missing},
|
||||
)
|
||||
return
|
||||
runner.add(
|
||||
finding_id="dependencies.core",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Ok,
|
||||
title="核心 Python 依赖可导入",
|
||||
detail=f"已检查:{', '.join(CORE_DEPENDENCIES)}。",
|
||||
recommendation="无需处理。",
|
||||
)
|
||||
|
||||
|
||||
def _check_sqlite_database(runner: DoctorRunnerProtocol) -> None:
|
||||
db_file = settings.CONFIG_PATH / "user.db"
|
||||
if not db_file.exists():
|
||||
runner.add(
|
||||
finding_id="database.sqlite_missing",
|
||||
severity=DoctorSeverity.Warn,
|
||||
status=DoctorFindingStatus.Degraded,
|
||||
title="SQLite 数据库文件不存在",
|
||||
detail=f"未找到 {db_file}。",
|
||||
recommendation="首次启动会自动初始化数据库;如不是首次安装,请确认 CONFIG_DIR 是否指向正确目录。",
|
||||
context={"database": str(db_file)},
|
||||
)
|
||||
return
|
||||
try:
|
||||
connection = sqlite3.connect(f"file:{db_file}?mode=ro", uri=True, timeout=3)
|
||||
try:
|
||||
result = connection.execute("PRAGMA integrity_check").fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
except sqlite3.Error as err:
|
||||
runner.add(
|
||||
finding_id="database.sqlite_open_failed",
|
||||
severity=DoctorSeverity.Error,
|
||||
status=DoctorFindingStatus.Failed,
|
||||
title="SQLite 数据库无法打开",
|
||||
detail=str(err),
|
||||
recommendation="确认配置目录权限和磁盘状态;不要直接删除数据库,必要时先备份再处理。",
|
||||
context={"database": str(db_file)},
|
||||
)
|
||||
return
|
||||
|
||||
if not result or result[0] != "ok":
|
||||
runner.add(
|
||||
finding_id="database.sqlite_integrity_failed",
|
||||
severity=DoctorSeverity.Error,
|
||||
status=DoctorFindingStatus.Failed,
|
||||
title="SQLite 完整性检查失败",
|
||||
detail=str(result[0] if result else "无检查结果"),
|
||||
recommendation="先备份 user.db,再根据 SQLite integrity_check 输出处理或恢复备份。",
|
||||
context={"database": str(db_file)},
|
||||
)
|
||||
return
|
||||
|
||||
runner.add(
|
||||
finding_id="database.sqlite",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Ok,
|
||||
title="SQLite 数据库可读",
|
||||
detail=f"{db_file} 可打开且 integrity_check 返回 ok。",
|
||||
recommendation="无需处理。",
|
||||
context={"database": str(db_file)},
|
||||
)
|
||||
|
||||
|
||||
def _check_postgresql_database(runner: DoctorRunnerProtocol) -> None:
|
||||
missing = []
|
||||
for key in ("DB_POSTGRESQL_HOST", "DB_POSTGRESQL_DATABASE", "DB_POSTGRESQL_USERNAME"):
|
||||
if not str(getattr(settings, key, "") or "").strip():
|
||||
missing.append(key)
|
||||
if missing:
|
||||
runner.add(
|
||||
finding_id="database.postgresql_config_incomplete",
|
||||
severity=DoctorSeverity.Error,
|
||||
status=DoctorFindingStatus.Failed,
|
||||
title="PostgreSQL 配置不完整",
|
||||
detail=f"缺少配置:{', '.join(missing)}。",
|
||||
recommendation="补齐 PostgreSQL 主机、库名和用户名后重启。",
|
||||
context={"missing": missing},
|
||||
)
|
||||
return
|
||||
|
||||
if not runner.deep:
|
||||
runner.add(
|
||||
finding_id="database.postgresql_config",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Skipped,
|
||||
title="PostgreSQL 配置已具备基本字段",
|
||||
detail="默认离线模式不主动连接 PostgreSQL;可使用 `moviepilot doctor --deep` 做 TCP 连通性探测。",
|
||||
recommendation="如启动日志提示数据库连接失败,请检查 PostgreSQL 服务、网络和账号权限。",
|
||||
)
|
||||
return
|
||||
|
||||
host = settings.DB_POSTGRESQL_HOST
|
||||
port = settings.DB_POSTGRESQL_PORT
|
||||
if settings.DB_POSTGRESQL_SOCKET_MODE or not port:
|
||||
runner.add(
|
||||
finding_id="database.postgresql_deep_skipped",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Skipped,
|
||||
title="PostgreSQL 深度探测已跳过",
|
||||
detail="当前使用 Unix Socket 或未配置 TCP 端口,doctor 不直接打开数据库连接。",
|
||||
recommendation="如需验证账号权限,请使用 PostgreSQL 客户端在宿主环境单独测试。",
|
||||
)
|
||||
return
|
||||
ok, detail = _can_connect(host, int(port), timeout=2.0)
|
||||
runner.add(
|
||||
finding_id="database.postgresql_tcp",
|
||||
severity=DoctorSeverity.Info if ok else DoctorSeverity.Error,
|
||||
status=DoctorFindingStatus.Ok if ok else DoctorFindingStatus.Failed,
|
||||
title="PostgreSQL TCP 端口可连接" if ok else "PostgreSQL TCP 端口不可连接",
|
||||
detail=f"{settings.DB_POSTGRESQL_TARGET} {detail}".strip(),
|
||||
recommendation="不可连接时请检查数据库服务、容器网络、端口映射和防火墙。",
|
||||
)
|
||||
|
||||
|
||||
def _check_database(runner: DoctorRunnerProtocol) -> None:
|
||||
if settings.DB_TYPE.lower() == "postgresql":
|
||||
_check_postgresql_database(runner)
|
||||
else:
|
||||
_check_sqlite_database(runner)
|
||||
|
||||
|
||||
def _check_frontend_assets(runner: DoctorRunnerProtocol) -> None:
|
||||
frontend_dir = _frontend_dir()
|
||||
required = [frontend_dir / "version.txt"]
|
||||
service_file = frontend_dir / "service.js"
|
||||
index_file = frontend_dir / "index.html"
|
||||
if service_file.exists():
|
||||
required.append(service_file)
|
||||
else:
|
||||
required.append(index_file)
|
||||
missing = [path for path in required if not path.exists()]
|
||||
if missing:
|
||||
runner.add(
|
||||
finding_id="frontend.assets_missing",
|
||||
severity=DoctorSeverity.Error,
|
||||
status=DoctorFindingStatus.Failed,
|
||||
title="前端资源缺失",
|
||||
detail=f"缺少文件:{', '.join(str(path) for path in missing)}。",
|
||||
recommendation="本地环境执行 `moviepilot install frontend`;Docker 环境建议重新拉取或重建镜像。",
|
||||
context={"frontend_dir": str(frontend_dir), "missing": [str(path) for path in missing]},
|
||||
)
|
||||
return
|
||||
|
||||
version = ""
|
||||
try:
|
||||
version = (frontend_dir / "version.txt").read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
version = "unknown"
|
||||
runner.add(
|
||||
finding_id="frontend.assets",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Ok,
|
||||
title="前端资源存在",
|
||||
detail=f"前端目录:{frontend_dir};版本:{version or 'unknown'}。",
|
||||
recommendation="无需处理。",
|
||||
context={"frontend_dir": str(frontend_dir), "version": version},
|
||||
)
|
||||
|
||||
|
||||
def _check_logs(runner: DoctorRunnerProtocol) -> None:
|
||||
log_files = [
|
||||
_backend_app_log_file(),
|
||||
_backend_stdio_log_file(),
|
||||
_frontend_stdio_log_file(),
|
||||
]
|
||||
plugin_log_dir = settings.LOG_PATH / "plugins"
|
||||
if plugin_log_dir.exists():
|
||||
log_files.extend(sorted(plugin_log_dir.rglob("*.log"))[:20])
|
||||
|
||||
found_any = False
|
||||
for path in log_files:
|
||||
if not path.exists() or not path.is_file():
|
||||
continue
|
||||
found_any = True
|
||||
lines = _tail_lines(path)
|
||||
errors = _find_error_lines(lines)
|
||||
if not errors:
|
||||
continue
|
||||
is_plugin = plugin_log_dir in path.parents
|
||||
runner.add(
|
||||
finding_id=f"logs.{path.stem}.recent_errors",
|
||||
severity=DoctorSeverity.Warn,
|
||||
status=DoctorFindingStatus.Degraded,
|
||||
title="最近日志存在插件异常" if is_plugin else "最近日志存在错误线索",
|
||||
detail="\n".join(errors),
|
||||
recommendation=(
|
||||
"可使用安全模式启动后检查插件配置。"
|
||||
if is_plugin
|
||||
else "结合前后的启动日志定位异常;必要时执行 `moviepilot doctor --json` 交给 Agent 或 Issue 流程。"
|
||||
),
|
||||
context={"log_file": str(path), "matches": len(errors)},
|
||||
)
|
||||
|
||||
if not found_any:
|
||||
runner.add(
|
||||
finding_id="logs.none",
|
||||
severity=DoctorSeverity.Warn,
|
||||
status=DoctorFindingStatus.Degraded,
|
||||
title="未找到运行日志",
|
||||
detail=f"{settings.LOG_PATH} 下没有可读取的 MoviePilot 日志。",
|
||||
recommendation="如果服务尚未启动过可忽略;否则请确认 CONFIG_DIR 和日志目录权限。",
|
||||
)
|
||||
return
|
||||
|
||||
if not any(finding.id.startswith("logs.") and finding.id.endswith("recent_errors") for finding in runner.report.findings):
|
||||
runner.add(
|
||||
finding_id="logs.recent",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Ok,
|
||||
title="最近日志未发现明显错误关键词",
|
||||
detail=f"已扫描 {settings.LOG_PATH} 下的主日志、启动日志和插件日志。",
|
||||
recommendation="如果问题仍存在,请结合具体操作时间扩大日志范围排查。",
|
||||
)
|
||||
|
||||
|
||||
def _check_docker(runner: DoctorRunnerProtocol) -> None:
|
||||
if not SystemUtils.is_docker():
|
||||
runner.add(
|
||||
finding_id="docker.environment",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Skipped,
|
||||
title="当前不是 Docker 环境",
|
||||
detail=f"平台:{platform.system()} {platform.release()}。",
|
||||
recommendation="本地源码模式可直接使用 `moviepilot doctor`。",
|
||||
)
|
||||
return
|
||||
|
||||
issues = []
|
||||
if not Path("/config").exists():
|
||||
issues.append("/config 不存在")
|
||||
venv_path = Path(os.getenv("VENV_PATH", "/opt/venv")) / "bin" / "python3"
|
||||
if not venv_path.exists():
|
||||
issues.append(f"{venv_path} 不存在")
|
||||
command_path = Path("/usr/local/bin/moviepilot")
|
||||
if not command_path.exists():
|
||||
issues.append("/usr/local/bin/moviepilot 不存在")
|
||||
|
||||
if issues:
|
||||
runner.add(
|
||||
finding_id="docker.runtime_incomplete",
|
||||
severity=DoctorSeverity.Error,
|
||||
status=DoctorFindingStatus.Failed,
|
||||
title="Docker 诊断入口不完整",
|
||||
detail=";".join(issues),
|
||||
recommendation="重新构建镜像,或使用 `python -m app.cli doctor` 作为临时入口。",
|
||||
)
|
||||
return
|
||||
|
||||
runner.add(
|
||||
finding_id="docker.runtime",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Ok,
|
||||
title="Docker 诊断入口可用",
|
||||
detail=(
|
||||
f"CONFIG_DIR={settings.CONFIG_PATH};VENV_PATH={os.getenv('VENV_PATH', '/opt/venv')};"
|
||||
f"MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE={os.getenv('MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE', 'true')}"
|
||||
),
|
||||
recommendation="主进程异常退出后容器会保活,仍可通过 `docker exec <container> moviepilot doctor` 诊断。",
|
||||
)
|
||||
|
||||
|
||||
def _check_safe_mode(runner: DoctorRunnerProtocol) -> None:
|
||||
if settings.MOVIEPILOT_SAFE_MODE:
|
||||
runner.add(
|
||||
finding_id="startup.safe_mode",
|
||||
severity=DoctorSeverity.Warn,
|
||||
status=DoctorFindingStatus.Degraded,
|
||||
title="当前处于安全模式",
|
||||
detail="本次启动会跳过插件、调度器、监控、命令和工作流等后台扩展能力。",
|
||||
recommendation="修复异常插件或配置后,移除 MOVIEPILOT_SAFE_MODE 或改用普通 `moviepilot start`。",
|
||||
)
|
||||
else:
|
||||
runner.add(
|
||||
finding_id="startup.safe_mode_off",
|
||||
severity=DoctorSeverity.Info,
|
||||
status=DoctorFindingStatus.Ok,
|
||||
title="安全模式未启用",
|
||||
detail="本次运行会按正常流程加载插件和后台任务。",
|
||||
recommendation="若插件或后台任务导致无法启动,可使用 `moviepilot start --safe` 或设置 MOVIEPILOT_SAFE_MODE=true。",
|
||||
)
|
||||
56
app/doctor/formatters.py
Normal file
56
app/doctor/formatters.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.doctor.models import DoctorFinding, DoctorReport
|
||||
|
||||
|
||||
STATUS_LABELS = {
|
||||
"healthy": "healthy",
|
||||
"degraded": "degraded",
|
||||
"failed": "failed",
|
||||
}
|
||||
|
||||
|
||||
def format_json_report(report: DoctorReport) -> str:
|
||||
"""
|
||||
将诊断报告格式化为 JSON 文本。
|
||||
"""
|
||||
return json.dumps(report.to_dict(), ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def format_text_report(report: DoctorReport) -> str:
|
||||
"""
|
||||
将诊断报告格式化为面向用户阅读的文本。
|
||||
"""
|
||||
lines = [
|
||||
"MoviePilot Doctor",
|
||||
"",
|
||||
f"状态: {STATUS_LABELS.get(report.status.value, report.status.value)}",
|
||||
f"版本: {report.version}",
|
||||
f"生成时间: {report.generated_at.isoformat(timespec='seconds')}",
|
||||
f"运行环境: {report.environment.get('runtime', 'unknown')}",
|
||||
f"配置目录: {report.environment.get('config_path', '')}",
|
||||
"",
|
||||
]
|
||||
for finding in report.findings:
|
||||
lines.extend(_format_finding(finding))
|
||||
summary = report.summary
|
||||
lines.extend([
|
||||
"",
|
||||
f"汇总: total={summary['total']} error={summary['error']} warn={summary['warn']} fixed={summary['fixed']}",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_finding(finding: DoctorFinding) -> list[str]:
|
||||
marker = finding.severity.value.upper()
|
||||
if finding.fixed:
|
||||
marker = "FIXED"
|
||||
lines = [f"[{marker}] {finding.title}", f"ID: {finding.id}"]
|
||||
if finding.detail:
|
||||
lines.append(f"原因: {finding.detail}")
|
||||
if finding.recommendation:
|
||||
lines.append(f"建议: {finding.recommendation}")
|
||||
lines.append("")
|
||||
return lines
|
||||
151
app/doctor/models.py
Normal file
151
app/doctor/models.py
Normal file
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class DoctorSeverity(StrEnum):
|
||||
"""
|
||||
诊断结果严重级别。
|
||||
"""
|
||||
|
||||
Info = "info"
|
||||
Warn = "warn"
|
||||
Error = "error"
|
||||
|
||||
|
||||
class DoctorFindingStatus(StrEnum):
|
||||
"""
|
||||
单项诊断状态。
|
||||
"""
|
||||
|
||||
Ok = "ok"
|
||||
Skipped = "skipped"
|
||||
Degraded = "degraded"
|
||||
Failed = "failed"
|
||||
Fixed = "fixed"
|
||||
|
||||
|
||||
class DoctorReportStatus(StrEnum):
|
||||
"""
|
||||
整体诊断报告状态。
|
||||
"""
|
||||
|
||||
Healthy = "healthy"
|
||||
Degraded = "degraded"
|
||||
Failed = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DoctorFinding:
|
||||
"""
|
||||
单条诊断发现,描述问题、原因、建议和可选修复状态。
|
||||
"""
|
||||
|
||||
id: str
|
||||
severity: DoctorSeverity
|
||||
status: DoctorFindingStatus
|
||||
title: str
|
||||
detail: str
|
||||
recommendation: str
|
||||
fixable: bool = False
|
||||
fixed: bool = False
|
||||
context: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
转换为稳定的 JSON 字典结构。
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"severity": self.severity.value,
|
||||
"status": self.status.value,
|
||||
"title": self.title,
|
||||
"detail": self.detail,
|
||||
"recommendation": self.recommendation,
|
||||
"fixable": self.fixable,
|
||||
"fixed": self.fixed,
|
||||
}
|
||||
if self.context:
|
||||
payload["context"] = self.context
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass
|
||||
class DoctorReport:
|
||||
"""
|
||||
MoviePilot 离线诊断报告。
|
||||
"""
|
||||
|
||||
generated_at: datetime
|
||||
version: str
|
||||
environment: dict[str, Any]
|
||||
findings: list[DoctorFinding] = field(default_factory=list)
|
||||
schema_version: int = 1
|
||||
|
||||
@property
|
||||
def status(self) -> DoctorReportStatus:
|
||||
"""
|
||||
根据诊断发现计算整体状态。
|
||||
"""
|
||||
unresolved = [finding for finding in self.findings if not finding.fixed]
|
||||
if any(finding.severity == DoctorSeverity.Error for finding in unresolved):
|
||||
return DoctorReportStatus.Failed
|
||||
if any(finding.severity == DoctorSeverity.Warn for finding in unresolved):
|
||||
return DoctorReportStatus.Degraded
|
||||
return DoctorReportStatus.Healthy
|
||||
|
||||
@property
|
||||
def summary(self) -> dict[str, int]:
|
||||
"""
|
||||
统计不同严重级别的诊断发现数量。
|
||||
"""
|
||||
counts = {
|
||||
"total": len(self.findings),
|
||||
"info": 0,
|
||||
"warn": 0,
|
||||
"error": 0,
|
||||
"fixed": 0,
|
||||
}
|
||||
for finding in self.findings:
|
||||
counts[finding.severity.value] += 1
|
||||
if finding.fixed:
|
||||
counts["fixed"] += 1
|
||||
return counts
|
||||
|
||||
def exit_code(self) -> int:
|
||||
"""
|
||||
返回适合 CLI 和自动化脚本使用的退出码。
|
||||
"""
|
||||
return 2 if self.status == DoctorReportStatus.Failed else 0
|
||||
|
||||
def add_finding(self, finding: DoctorFinding) -> None:
|
||||
"""
|
||||
添加一条诊断发现。
|
||||
"""
|
||||
self.findings.append(finding)
|
||||
|
||||
def find(self, finding_id: str) -> Optional[DoctorFinding]:
|
||||
"""
|
||||
按诊断项 ID 查找发现。
|
||||
"""
|
||||
for finding in self.findings:
|
||||
if finding.id == finding_id:
|
||||
return finding
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
转换为稳定的 JSON 字典结构。
|
||||
"""
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"status": self.status.value,
|
||||
"generated_at": self.generated_at.isoformat(timespec="seconds"),
|
||||
"version": self.version,
|
||||
"environment": self.environment,
|
||||
"summary": self.summary,
|
||||
"findings": [finding.to_dict() for finding in self.findings],
|
||||
}
|
||||
103
app/doctor/runner.py
Normal file
103
app/doctor/runner.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.core.config import settings
|
||||
from app.doctor.checks import default_checks
|
||||
from app.doctor.models import (
|
||||
DoctorFinding,
|
||||
DoctorFindingStatus,
|
||||
DoctorReport,
|
||||
DoctorSeverity,
|
||||
)
|
||||
from app.utils.system import SystemUtils
|
||||
from version import APP_VERSION
|
||||
|
||||
|
||||
class DoctorRunner:
|
||||
"""
|
||||
MoviePilot 离线诊断运行器,负责组合检查项并生成报告。
|
||||
"""
|
||||
|
||||
def __init__(self, *, fix: bool = False, deep: bool = False):
|
||||
"""
|
||||
初始化诊断运行器。
|
||||
|
||||
:param fix: 是否执行白名单安全修复
|
||||
:param deep: 是否执行可能较慢的深度检查
|
||||
"""
|
||||
self.fix = fix
|
||||
self.deep = deep
|
||||
self.report = DoctorReport(
|
||||
generated_at=datetime.now(),
|
||||
version=APP_VERSION,
|
||||
environment=self._environment(),
|
||||
)
|
||||
|
||||
def run(self) -> DoctorReport:
|
||||
"""
|
||||
执行所有默认诊断检查并返回报告。
|
||||
"""
|
||||
for check in default_checks():
|
||||
try:
|
||||
check(self)
|
||||
except Exception as err:
|
||||
self.add(
|
||||
finding_id=f"doctor.check_failed.{check.__name__.lstrip('_')}",
|
||||
severity=DoctorSeverity.Error,
|
||||
status=DoctorFindingStatus.Failed,
|
||||
title="诊断检查自身执行失败",
|
||||
detail=f"{check.__name__}: {str(err)}",
|
||||
recommendation="请把该 doctor 报告附加到反馈 Issue,便于修复诊断器本身。",
|
||||
)
|
||||
return self.report
|
||||
|
||||
def add(
|
||||
self,
|
||||
*,
|
||||
finding_id: str,
|
||||
severity: DoctorSeverity,
|
||||
status: DoctorFindingStatus,
|
||||
title: str,
|
||||
detail: str,
|
||||
recommendation: str,
|
||||
fixable: bool = False,
|
||||
fixed: bool = False,
|
||||
context: Optional[dict[str, Any]] = None,
|
||||
) -> DoctorFinding:
|
||||
"""
|
||||
添加诊断发现并返回该对象。
|
||||
"""
|
||||
finding = DoctorFinding(
|
||||
id=finding_id,
|
||||
severity=severity,
|
||||
status=status,
|
||||
title=title,
|
||||
detail=detail,
|
||||
recommendation=recommendation,
|
||||
fixable=fixable,
|
||||
fixed=fixed,
|
||||
context=context or {},
|
||||
)
|
||||
self.report.add_finding(finding)
|
||||
return finding
|
||||
|
||||
@staticmethod
|
||||
def _environment() -> dict[str, Any]:
|
||||
return {
|
||||
"runtime": "Docker" if SystemUtils.is_docker() else platform.system(),
|
||||
"platform": platform.platform(),
|
||||
"python": sys.executable,
|
||||
"python_version": platform.python_version(),
|
||||
"root_path": str(settings.ROOT_PATH),
|
||||
"config_path": str(settings.CONFIG_PATH),
|
||||
"log_path": str(settings.LOG_PATH),
|
||||
"temp_path": str(settings.TEMP_PATH),
|
||||
"is_docker": SystemUtils.is_docker(),
|
||||
"safe_mode": settings.MOVIEPILOT_SAFE_MODE,
|
||||
"pid": os.getpid(),
|
||||
}
|
||||
@@ -35,6 +35,7 @@ class SystemHelper(ConfigReloadMixin):
|
||||
__local_backend_runtime_file = settings.TEMP_PATH / "moviepilot.runtime.json"
|
||||
__local_restart_log_file = settings.LOG_PATH / "moviepilot.restart.stdout.log"
|
||||
__one_shot_update_flag_file = settings.TEMP_PATH / "moviepilot.pending_update"
|
||||
__docker_restart_intent_file = settings.TEMP_PATH / "moviepilot.intentional_restart"
|
||||
|
||||
def on_config_changed(self):
|
||||
logger.update_loggers()
|
||||
@@ -260,6 +261,25 @@ class SystemHelper(ConfigReloadMixin):
|
||||
logger.warning(f"检查重启策略失败: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _mark_docker_intentional_restart() -> None:
|
||||
try:
|
||||
SystemHelper.__docker_restart_intent_file.parent.mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
SystemHelper.__docker_restart_intent_file.write_text(
|
||||
str(os.getpid()), encoding="utf-8"
|
||||
)
|
||||
except OSError as err:
|
||||
logger.warning(f"写入内置重启标记失败: {err}")
|
||||
|
||||
@staticmethod
|
||||
def _clear_docker_intentional_restart() -> None:
|
||||
try:
|
||||
SystemHelper.__docker_restart_intent_file.unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
logger.warning(f"清理内置重启标记失败: {err}")
|
||||
|
||||
@staticmethod
|
||||
def restart() -> Tuple[bool, str]:
|
||||
"""
|
||||
@@ -283,6 +303,7 @@ class SystemHelper(ConfigReloadMixin):
|
||||
if has_restart_policy:
|
||||
# 有重启策略,使用优雅退出方式
|
||||
logger.info("检测到容器配置了自动重启策略,使用优雅重启方式...")
|
||||
SystemHelper._mark_docker_intentional_restart()
|
||||
# 启动优雅退出超时监控
|
||||
SystemHelper._start_graceful_shutdown_monitor()
|
||||
# 发送SIGTERM信号给当前进程,触发优雅停止
|
||||
@@ -294,6 +315,7 @@ class SystemHelper(ConfigReloadMixin):
|
||||
return SystemHelper._docker_api_restart()
|
||||
except Exception as err:
|
||||
logger.error(f"重启失败: {str(err)}")
|
||||
SystemHelper._clear_docker_intentional_restart()
|
||||
# 降级为Docker API重启
|
||||
logger.warning("降级为Docker API重启...")
|
||||
return SystemHelper._docker_api_restart()
|
||||
|
||||
@@ -17,7 +17,7 @@ except Exception:
|
||||
pass
|
||||
|
||||
from app.chain.system import SystemChain
|
||||
from app.core.config import global_vars
|
||||
from app.core.config import global_vars, settings
|
||||
from app.helper.server import MoviePilotServerHelper
|
||||
from app.helper.system import SystemHelper
|
||||
from app.startup.command_initializer import init_command, stop_command, restart_command
|
||||
@@ -38,6 +38,10 @@ async def init_extra():
|
||||
"""
|
||||
同步插件及重启相关依赖服务
|
||||
"""
|
||||
if settings.MOVIEPILOT_SAFE_MODE:
|
||||
SystemHelper().set_system_modified()
|
||||
SystemChain().restart_finish()
|
||||
return
|
||||
if await sync_plugins():
|
||||
# 重新注册插件定时服务
|
||||
init_plugin_scheduler()
|
||||
@@ -63,18 +67,21 @@ async def lifespan(app: FastAPI):
|
||||
init_routers(app)
|
||||
# 初始化模块
|
||||
init_modules()
|
||||
# 恢复插件备份
|
||||
SystemChain().restore_plugins()
|
||||
# 初始化插件
|
||||
init_plugins()
|
||||
# 初始化定时器
|
||||
init_scheduler()
|
||||
# 初始化监控器
|
||||
init_monitor()
|
||||
# 初始化命令
|
||||
init_command()
|
||||
# 初始化工作流
|
||||
init_workflow()
|
||||
if settings.MOVIEPILOT_SAFE_MODE:
|
||||
print("MoviePilot safe mode enabled: skip plugins, scheduler, monitor, commands and workflow.")
|
||||
else:
|
||||
# 恢复插件备份
|
||||
SystemChain().restore_plugins()
|
||||
# 初始化插件
|
||||
init_plugins()
|
||||
# 初始化定时器
|
||||
init_scheduler()
|
||||
# 初始化监控器
|
||||
init_monitor()
|
||||
# 初始化命令
|
||||
init_command()
|
||||
# 初始化工作流
|
||||
init_workflow()
|
||||
# 插件同步到本地
|
||||
sync_plugins_task = asyncio.create_task(init_extra())
|
||||
try:
|
||||
@@ -90,18 +97,19 @@ async def lifespan(app: FastAPI):
|
||||
pass
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
# 备份插件
|
||||
SystemChain().backup_plugins()
|
||||
# 停止工作流
|
||||
stop_workflow()
|
||||
# 停止命令
|
||||
stop_command()
|
||||
# 停止监控器
|
||||
stop_monitor()
|
||||
# 停止定时器
|
||||
stop_scheduler()
|
||||
# 停止插件
|
||||
stop_plugins()
|
||||
if not settings.MOVIEPILOT_SAFE_MODE:
|
||||
# 备份插件
|
||||
SystemChain().backup_plugins()
|
||||
# 停止工作流
|
||||
stop_workflow()
|
||||
# 停止命令
|
||||
stop_command()
|
||||
# 停止监控器
|
||||
stop_monitor()
|
||||
# 停止定时器
|
||||
stop_scheduler()
|
||||
# 停止插件
|
||||
stop_plugins()
|
||||
# 停止模块
|
||||
await stop_modules()
|
||||
# 关闭共享的异步 HTTP 连接池,释放底层连接资源
|
||||
|
||||
Reference in New Issue
Block a user