mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-15 14:38:46 +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 连接池,释放底层连接资源
|
||||
|
||||
@@ -112,7 +112,8 @@ RUN FRONTEND_VERSION=$(sed -n "s/^FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/p" /app/
|
||||
# final 阶段: 安装运行时依赖和配置最终镜像
|
||||
FROM prepare_package AS final
|
||||
|
||||
ENV LD_PRELOAD="/usr/local/lib/libjemalloc.so"
|
||||
ENV LD_PRELOAD="/usr/local/lib/libjemalloc.so" \
|
||||
MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE="true"
|
||||
|
||||
# 引入支持 amr 编码的静态 ffmpeg
|
||||
COPY --from=mwader/static-ffmpeg:8.1.1 /ffmpeg /usr/local/bin/
|
||||
@@ -143,7 +144,8 @@ RUN cp -f /app/docker/nginx.common.conf /etc/nginx/common.conf \
|
||||
&& cp -f /app/docker/update.sh /usr/local/bin/mp_update.sh \
|
||||
&& cp -f /app/docker/entrypoint.sh /entrypoint.sh \
|
||||
&& cp -f /app/docker/docker_http_proxy.conf /etc/nginx/docker_http_proxy.conf \
|
||||
&& chmod +x /entrypoint.sh /usr/local/bin/mp_update.sh \
|
||||
&& printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' 'cd /app' 'exec "${VENV_PATH:-/opt/venv}/bin/python3" -m app.cli "$@"' > /usr/local/bin/moviepilot \
|
||||
&& chmod +x /entrypoint.sh /usr/local/bin/mp_update.sh /usr/local/bin/moviepilot \
|
||||
&& mkdir -p ${HOME} \
|
||||
&& groupadd -r moviepilot -g 918 \
|
||||
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 918 \
|
||||
@@ -156,4 +158,5 @@ RUN cp -f /app/docker/nginx.common.conf /etc/nginx/common.conf \
|
||||
|
||||
EXPOSE 3000
|
||||
VOLUME [ "${CONFIG_DIR}" ]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 CMD curl -fsS "http://127.0.0.1:${PORT:-3001}/api/v1/system/global?token=moviepilot" >/dev/null || exit 1
|
||||
ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/entrypoint.sh" ]
|
||||
|
||||
@@ -43,6 +43,8 @@ function load_config_from_app_env() {
|
||||
["PROXY_HOST"]=""
|
||||
["GITHUB_TOKEN"]=""
|
||||
["MOVIEPILOT_AUTO_UPDATE"]="release"
|
||||
["MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE"]="true"
|
||||
["MOVIEPILOT_SAFE_MODE"]="false"
|
||||
["BROWSER_EMULATION"]="cloakbrowser"
|
||||
|
||||
# cert
|
||||
@@ -197,6 +199,8 @@ function graceful_exit() {
|
||||
|
||||
if [ "$reason" = "signal" ]; then
|
||||
INFO "→ 收到停止信号,执行精准清理程序..."
|
||||
elif [ "$reason" = "intentional_restart" ]; then
|
||||
INFO "→ 检测到内置重启流程,执行清理程序..."
|
||||
else
|
||||
INFO "→ 主进程已退出 (代码: $exit_code),执行清理程序..."
|
||||
fi
|
||||
@@ -224,7 +228,7 @@ function graceful_exit() {
|
||||
# 根据退出码判断最终日志性质
|
||||
# 0: 正常退出
|
||||
# 130/143: 被系统信号终止(通常也视为预期的清理退出)
|
||||
if [ "$exit_code" -eq 0 ] || [ "$exit_code" -eq 130 ] || [ "$exit_code" -eq 143 ]; then
|
||||
if [ "$exit_code" -eq 0 ] || [ "$exit_code" -eq 130 ] || [ "$exit_code" -eq 143 ] || [ "$reason" = "intentional_restart" ]; then
|
||||
INFO "→ 所有服务已按序清理,容器正常退出 (ExitCode: $exit_code)。"
|
||||
else
|
||||
# 非预期退出码,使用 ERROR 级别并加重提示
|
||||
@@ -233,6 +237,32 @@ function graceful_exit() {
|
||||
exit "$exit_code"
|
||||
}
|
||||
|
||||
# 后端异常退出时默认保留容器,避免无法 docker exec 进入容器运行 doctor。
|
||||
function diagnostic_keepalive() {
|
||||
local exit_code=${1:-1}
|
||||
local keepalive="${MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE:-true}"
|
||||
keepalive="${keepalive,,}"
|
||||
|
||||
if [ "${keepalive}" = "false" ] || [ "${keepalive}" = "0" ] || [ "${keepalive}" = "no" ]; then
|
||||
graceful_exit "$exit_code" "python_exit"
|
||||
fi
|
||||
|
||||
ERROR "→ 后端主进程异常退出 (ExitCode: ${exit_code}),容器将保持运行以便执行 moviepilot doctor。"
|
||||
WARN "→ 可运行:docker exec <container> moviepilot doctor"
|
||||
WARN "→ 如需恢复旧行为,可设置 MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE=false。"
|
||||
|
||||
if [ "${START_NOGOSU:-false}" = "true" ]; then
|
||||
"${VENV_PATH}/bin/python3" -m app.cli doctor || true
|
||||
else
|
||||
gosu moviepilot:moviepilot "${VENV_PATH}/bin/python3" -m app.cli doctor || true
|
||||
fi
|
||||
|
||||
while true; do
|
||||
sleep 3600 &
|
||||
wait $! || true
|
||||
done
|
||||
}
|
||||
|
||||
# 启动前先检查后端核心依赖是否仍然可导入。
|
||||
# 插件依赖和主程序共用同一套 venv 时,历史安装记录可能已经污染环境,
|
||||
# 这里优先在真正拉起后端前做一次自愈,避免容器反复起不来。
|
||||
@@ -255,12 +285,12 @@ function ensure_backend_runtime_dependencies() {
|
||||
|
||||
if ! "${pip_cmd[@]}" > /dev/stdout 2> /dev/stderr; then
|
||||
ERROR "→ 自动恢复主程序依赖失败,后端无法启动。"
|
||||
exit 1
|
||||
diagnostic_keepalive 1
|
||||
fi
|
||||
|
||||
if ! "${VENV_PATH}/bin/python3" -c "${probe_code}" >/dev/null 2>&1; then
|
||||
ERROR "→ 主程序依赖恢复后仍然异常,后端无法启动。"
|
||||
exit 1
|
||||
diagnostic_keepalive 1
|
||||
fi
|
||||
|
||||
INFO "→ 已自动恢复主程序依赖,继续启动后端。"
|
||||
@@ -376,4 +406,19 @@ wait "$PYTHON_PID" 2>/dev/null
|
||||
exit_code=$?
|
||||
|
||||
# 如果 Python 自己退出了(非信号触发),执行清理
|
||||
graceful_exit "$exit_code" "python_exit"
|
||||
INTENTIONAL_RESTART_FLAG="${CONFIG_DIR}/temp/moviepilot.intentional_restart"
|
||||
if [ -f "${INTENTIONAL_RESTART_FLAG}" ]; then
|
||||
rm -f "${INTENTIONAL_RESTART_FLAG}"
|
||||
restart_exit_code="$exit_code"
|
||||
if [ "$restart_exit_code" -eq 0 ]; then
|
||||
restart_exit_code=1
|
||||
fi
|
||||
WARN "→ 检测到内置手动重启标记,退出容器并交给 Docker 重启策略处理..."
|
||||
graceful_exit "$restart_exit_code" "intentional_restart"
|
||||
fi
|
||||
|
||||
if [ "$exit_code" -eq 0 ] || [ "$exit_code" -eq 130 ] || [ "$exit_code" -eq 143 ]; then
|
||||
graceful_exit "$exit_code" "python_exit"
|
||||
fi
|
||||
|
||||
diagnostic_keepalive "$exit_code"
|
||||
|
||||
20
docs/cli.md
20
docs/cli.md
@@ -128,6 +128,7 @@ moviepilot stop
|
||||
moviepilot restart
|
||||
moviepilot status
|
||||
moviepilot logs
|
||||
moviepilot doctor
|
||||
moviepilot version
|
||||
moviepilot config path
|
||||
moviepilot config list
|
||||
@@ -356,6 +357,7 @@ moviepilot agent --new-session 帮我总结当前系统配置有什么明显问
|
||||
```shell
|
||||
moviepilot start
|
||||
moviepilot start --timeout 60
|
||||
moviepilot start --safe
|
||||
moviepilot stop
|
||||
moviepilot stop --timeout 30 --force
|
||||
moviepilot restart
|
||||
@@ -367,6 +369,7 @@ moviepilot version
|
||||
说明:
|
||||
|
||||
- `start` 会先启动后端,再启动前端
|
||||
- `start --safe` 会以安全模式启动后端,本次启动跳过插件、调度器、监控、命令和工作流等后台扩展能力,不修改用户配置
|
||||
- 如果开启了 `MOVIEPILOT_AUTO_UPDATE=release|true|dev`,`start/restart` 会在启动前尽力执行一次本地自动更新;更新失败只告警,不阻断当前启动
|
||||
- 通过系统内置的重启入口触发重启时,本地 CLI 安装模式也会复用同一套前后端进程管理完成重启
|
||||
- 前端默认监听 `NGINX_PORT`,默认值 `3000`
|
||||
@@ -374,6 +377,23 @@ moviepilot version
|
||||
- 前端通过 `service.js` 代理 `/api` 与 `/cookiecloud` 到后端
|
||||
- 本地前端代理在启动时会先确认后端可用;如果后端长时间不可用,前端也会自动退出,避免只剩半套服务
|
||||
|
||||
离线诊断:
|
||||
|
||||
```shell
|
||||
moviepilot doctor
|
||||
moviepilot doctor --json
|
||||
moviepilot doctor --fix
|
||||
moviepilot doctor --deep
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `doctor` 不依赖后端服务已经启动,会直接读取配置目录、运行时文件、日志、进程、端口、依赖、数据库和前端资源
|
||||
- `--json` 输出稳定 JSON,可供 Agent、脚本或 Issue 流程收集
|
||||
- `--fix` 只执行白名单安全修复,例如清理过期 runtime 文件或补齐不合法的 `API_TOKEN`
|
||||
- `--deep` 执行可能较慢的深度探测,例如 PostgreSQL TCP 连通性检查
|
||||
- Docker 环境可使用 `docker exec <container> moviepilot doctor`;如果容器已退出,也可用镜像挂载同一配置目录运行 `python -m app.cli doctor`
|
||||
|
||||
日志:
|
||||
|
||||
```shell
|
||||
|
||||
88
docs/doctor.md
Normal file
88
docs/doctor.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# MoviePilot Doctor 诊断与自救
|
||||
|
||||
`moviepilot doctor` 是离线诊断入口,适合 WebUI、后端 API、Agent 或插件都不可用时使用。它不调用 MoviePilot 后端 API,而是直接检查本地配置、运行时文件、进程、端口、日志、依赖、数据库、前端资源和 Docker 环境。
|
||||
|
||||
## 快速使用
|
||||
|
||||
本地源码安装:
|
||||
|
||||
```shell
|
||||
moviepilot doctor
|
||||
moviepilot doctor --json
|
||||
moviepilot doctor --fix
|
||||
```
|
||||
|
||||
安全模式启动:
|
||||
|
||||
```shell
|
||||
moviepilot start --safe
|
||||
```
|
||||
|
||||
Docker 容器仍在运行或处于诊断保活状态:
|
||||
|
||||
```shell
|
||||
docker exec -it <container> moviepilot doctor
|
||||
docker exec -it <container> moviepilot doctor --json
|
||||
```
|
||||
|
||||
容器已经退出时,可用同一镜像挂载配置目录运行:
|
||||
|
||||
```shell
|
||||
docker run --rm --entrypoint python -v <config-dir>:/config <image> -m app.cli doctor
|
||||
```
|
||||
|
||||
## 诊断内容
|
||||
|
||||
Doctor 默认执行只读检查:
|
||||
|
||||
- 运行路径:程序目录、配置目录、日志目录、Python 解释器
|
||||
- 关键配置:`API_TOKEN`、`PORT`、`NGINX_PORT`、代理格式、安全模式
|
||||
- 进程与端口:后端、前端端口监听状态,runtime 文件是否过期
|
||||
- 日志线索:后端日志、启动日志、前端日志和插件日志中的近期错误
|
||||
- 核心依赖:FastAPI、Pydantic、SQLAlchemy、Uvicorn、CloakBrowser 等是否可导入
|
||||
- 数据库:SQLite 只读打开和完整性检查;PostgreSQL 默认做配置检查
|
||||
- 前端资源:`version.txt`、`service.js` 或核心静态文件是否存在
|
||||
- Docker:`/config`、虚拟环境和容器内 `moviepilot` 命令是否可用
|
||||
|
||||
`--deep` 会启用可能较慢或更依赖环境的检查,例如 PostgreSQL TCP 连通性。
|
||||
|
||||
## 自救能力
|
||||
|
||||
`moviepilot doctor --fix` 只做白名单安全修复:
|
||||
|
||||
- 清理指向已退出进程的 runtime 文件
|
||||
- 在未被系统环境变量锁定时,为缺失或过短的 `API_TOKEN` 生成合规值
|
||||
|
||||
Doctor 不会自动删除数据库、修改 Docker Compose、回滚迁移、禁用多个插件或删除用户数据。
|
||||
|
||||
## 安全模式
|
||||
|
||||
`moviepilot start --safe` 或 `MOVIEPILOT_SAFE_MODE=true` 会在本次启动中跳过:
|
||||
|
||||
- 第三方插件加载与插件同步
|
||||
- 调度器和 Agent 定时任务
|
||||
- 目录监控
|
||||
- 命令注册
|
||||
- 工作流后台服务
|
||||
|
||||
安全模式不修改用户配置,适合插件、调度任务或 Agent 导致后端无法启动时先恢复后台入口。修复问题后移除环境变量或使用普通 `moviepilot start` 重启即可恢复完整能力。
|
||||
|
||||
## Docker 诊断保活
|
||||
|
||||
Docker 镜像默认设置 `MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE=true`。当后端主进程非正常退出时,entrypoint 不会立刻退出容器,而是打印一次 doctor 报告并保持容器运行,方便执行:
|
||||
|
||||
```shell
|
||||
docker exec -it <container> moviepilot doctor
|
||||
```
|
||||
|
||||
如果需要恢复旧行为,可设置:
|
||||
|
||||
```env
|
||||
MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE=false
|
||||
```
|
||||
|
||||
Dockerfile 同时提供 `HEALTHCHECK`,用于标记容器健康状态。是否自动重启仍由 Docker Compose、NAS 平台或 Docker restart policy 决定。
|
||||
|
||||
## Issue 反馈集成
|
||||
|
||||
`feedback-issue` skill 的诊断收集脚本会自动调用 `moviepilot doctor --json`,并把 doctor 摘要写入预览和最终 Issue 正文。完整 doctor JSON 存在运行时 diagnostics 文件中,默认不会直接贴入 Issue,避免泄露本机路径和过长输出。
|
||||
@@ -107,6 +107,12 @@ moviepilot restart
|
||||
moviepilot restart --start-timeout 60 --stop-timeout 30
|
||||
moviepilot status
|
||||
moviepilot version
|
||||
moviepilot doctor
|
||||
moviepilot doctor --json
|
||||
moviepilot doctor --fix
|
||||
moviepilot doctor --deep
|
||||
moviepilot doctor --json --fix
|
||||
moviepilot start --safe
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -240,6 +246,16 @@ moviepilot agent --new-session "Summarize any obvious problems with the current
|
||||
|
||||
---
|
||||
|
||||
## Docker CLI — Doctor
|
||||
|
||||
```bash
|
||||
docker exec -it <container> moviepilot doctor
|
||||
docker exec -it <container> moviepilot doctor --json
|
||||
docker run --rm --entrypoint python -v <config-dir>:/config <image> -m app.cli doctor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Local CLI — Help Discovery
|
||||
|
||||
```bash
|
||||
|
||||
27
moviepilot
27
moviepilot
@@ -21,6 +21,7 @@ Bootstrap Commands:
|
||||
|
||||
Runtime Commands:
|
||||
moviepilot start|stop|restart|status|logs|version
|
||||
moviepilot doctor [--json] [--fix] [--deep]
|
||||
moviepilot config ...
|
||||
moviepilot tool ...
|
||||
moviepilot scheduler ...
|
||||
@@ -46,6 +47,7 @@ Examples:
|
||||
moviepilot help config
|
||||
moviepilot config keys
|
||||
moviepilot start
|
||||
moviepilot doctor
|
||||
moviepilot tool list
|
||||
EOF
|
||||
}
|
||||
@@ -73,6 +75,7 @@ Runtime Commands
|
||||
restart
|
||||
status
|
||||
logs
|
||||
doctor
|
||||
version
|
||||
config path
|
||||
config list
|
||||
@@ -233,6 +236,23 @@ Options:
|
||||
EOF
|
||||
}
|
||||
|
||||
show_doctor_help() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
moviepilot doctor [OPTIONS]
|
||||
|
||||
Options:
|
||||
--json 输出 JSON 报告,便于 Agent、脚本或 Issue 流程收集
|
||||
--fix 执行白名单安全修复,如清理过期 runtime 文件或补齐 API_TOKEN
|
||||
--deep 执行可能较慢的深度检查
|
||||
-h, --help 显示帮助
|
||||
|
||||
说明:
|
||||
- doctor 是离线诊断入口,不依赖后端服务已经启动
|
||||
- Docker 环境可执行 docker exec <container> moviepilot doctor
|
||||
EOF
|
||||
}
|
||||
|
||||
python_version_ok() {
|
||||
local python_bin="$1"
|
||||
"$python_bin" - <<'PY' >/dev/null 2>&1
|
||||
@@ -358,6 +378,10 @@ show_command_help() {
|
||||
show_agent_help
|
||||
exit 0
|
||||
;;
|
||||
doctor)
|
||||
show_doctor_help
|
||||
exit 0
|
||||
;;
|
||||
update)
|
||||
show_update_help
|
||||
exit 0
|
||||
@@ -480,6 +504,9 @@ case "${1:-}" in
|
||||
require_bootstrap_python
|
||||
exec "$BOOTSTRAP_PYTHON" "$SETUP_SCRIPT" agent "$@"
|
||||
;;
|
||||
doctor)
|
||||
run_runtime_cli "$@"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -x "$VENV_PYTHON" ]; then
|
||||
|
||||
@@ -93,6 +93,10 @@ The script outputs JSON. Keep `diagnostics_file` and `runtime_dir`.
|
||||
The raw logs are written into `diagnostics_file`, already redacted and
|
||||
capped; do not paste the full file back into the model context unless
|
||||
you need to show the preview generated in the next step.
|
||||
The collect script also runs `moviepilot doctor --json` or falls back to
|
||||
`python -m app.cli doctor --json`, stores the structured doctor report
|
||||
inside `diagnostics_file`, and later preview/submit steps include a
|
||||
short doctor summary automatically.
|
||||
|
||||
If `success=false` with `no_explicit_feedback_intent`, stop this skill
|
||||
and return to local diagnosis.
|
||||
|
||||
@@ -4,6 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -109,6 +112,67 @@ def candidate_log_files() -> list[Path]:
|
||||
return [path for path in files if path.exists() and path.is_file()]
|
||||
|
||||
|
||||
def collect_doctor_report() -> dict:
|
||||
"""调用离线 doctor 命令收集结构化诊断报告。"""
|
||||
commands = []
|
||||
moviepilot_bin = shutil.which("moviepilot")
|
||||
if moviepilot_bin:
|
||||
commands.append([moviepilot_bin, "doctor", "--json"])
|
||||
commands.append([sys.executable, "-m", "app.cli", "doctor", "--json"])
|
||||
|
||||
for command in commands:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=str(settings.ROOT_PATH),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as err:
|
||||
last_error = str(err)
|
||||
continue
|
||||
|
||||
output = (result.stdout or "").strip()
|
||||
if not output:
|
||||
last_error = f"{' '.join(command)} 没有输出"
|
||||
continue
|
||||
try:
|
||||
payload = json_loads_from_output(output)
|
||||
except ValueError as err:
|
||||
last_error = str(err)
|
||||
continue
|
||||
payload["_command"] = " ".join(command)
|
||||
payload["_returncode"] = result.returncode
|
||||
return {
|
||||
"success": True,
|
||||
"report": payload,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error": last_error if "last_error" in locals() else "doctor 命令不可用",
|
||||
}
|
||||
|
||||
|
||||
def json_loads_from_output(output: str) -> dict:
|
||||
"""从命令输出中解析 doctor JSON 对象。"""
|
||||
import json
|
||||
|
||||
start = output.find("{")
|
||||
end = output.rfind("}")
|
||||
if start == -1 or end == -1 or end < start:
|
||||
raise ValueError("doctor 输出中未找到 JSON 对象")
|
||||
payload = json.loads(output[start:end + 1])
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("doctor JSON 顶层不是对象")
|
||||
return payload
|
||||
|
||||
|
||||
def normalize_keywords(keywords: Optional[list[str]]) -> list[str]:
|
||||
"""过滤掉过短或过于宽泛的日志关键词。"""
|
||||
normalized: list[str] = []
|
||||
@@ -256,6 +320,7 @@ def collect_diagnostics(
|
||||
"keywords": normalized_keywords,
|
||||
"found": bool(logs.strip()),
|
||||
"logs": logs,
|
||||
"doctor": collect_doctor_report(),
|
||||
"source_files": source_files,
|
||||
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||
}
|
||||
@@ -268,6 +333,7 @@ def collect_diagnostics(
|
||||
"source_files": source_files,
|
||||
"log_bytes": len(logs.encode("utf-8", errors="replace")),
|
||||
"log_lines": len(logs.splitlines()) if logs else 0,
|
||||
"doctor_collected": bool(diagnostics["doctor"].get("success")),
|
||||
"message": (
|
||||
"已收集并写入反馈诊断日志文件。"
|
||||
if logs
|
||||
|
||||
@@ -47,6 +47,7 @@ MAX_BODY_CHARS = 60 * 1024
|
||||
MAX_LOGS_CHARS = 8 * 1024
|
||||
MAX_URL_LOGS_CHARS = 3 * 1024
|
||||
MAX_PREVIEW_LOGS_CHARS = 3 * 1024
|
||||
MAX_DOCTOR_SUMMARY_CHARS = 2 * 1024
|
||||
|
||||
DEDUP_TTL_SECONDS = 60
|
||||
USER_COOLDOWN_SECONDS = 30 * 60
|
||||
@@ -275,6 +276,53 @@ def build_prefill_url(
|
||||
return f"{FEEDBACK_ISSUE_NEW_URL}?{encoded}"
|
||||
|
||||
|
||||
def format_doctor_summary(doctor: Optional[dict[str, Any]]) -> str:
|
||||
"""把 doctor JSON 报告压缩成适合 Issue 和预览展示的摘要。"""
|
||||
if not isinstance(doctor, dict):
|
||||
return "未收集到 doctor 报告。"
|
||||
if not doctor.get("success"):
|
||||
return f"doctor 收集失败:{doctor.get('error') or '未知错误'}"
|
||||
|
||||
report = doctor.get("report") or {}
|
||||
if not isinstance(report, dict):
|
||||
return "doctor 报告格式异常。"
|
||||
|
||||
lines = [
|
||||
f"状态:{report.get('status') or 'unknown'}",
|
||||
]
|
||||
environment = report.get("environment") or {}
|
||||
if isinstance(environment, dict):
|
||||
runtime = environment.get("runtime")
|
||||
if runtime:
|
||||
lines.append(f"运行环境:{runtime}")
|
||||
summary = report.get("summary") or {}
|
||||
if isinstance(summary, dict):
|
||||
lines.append(
|
||||
"汇总:"
|
||||
f"total={summary.get('total', 0)} "
|
||||
f"error={summary.get('error', 0)} "
|
||||
f"warn={summary.get('warn', 0)} "
|
||||
f"fixed={summary.get('fixed', 0)}"
|
||||
)
|
||||
|
||||
findings = report.get("findings") or []
|
||||
if isinstance(findings, list):
|
||||
important = [
|
||||
item for item in findings
|
||||
if isinstance(item, dict) and item.get("severity") in {"error", "warn"}
|
||||
][:8]
|
||||
if important:
|
||||
lines.append("关键发现:")
|
||||
for item in important:
|
||||
title = str(item.get("title") or item.get("id") or "未知诊断项")
|
||||
recommendation = str(item.get("recommendation") or "").strip()
|
||||
line = f"- [{item.get('severity')}] {title}"
|
||||
if recommendation:
|
||||
line = f"{line};建议:{recommendation}"
|
||||
lines.append(line)
|
||||
return truncate("\n".join(lines), MAX_DOCTOR_SUMMARY_CHARS)
|
||||
|
||||
|
||||
def classify_failure(status_code: Optional[int], headers: Optional[dict] = None) -> str:
|
||||
"""把 GitHub API HTTP 状态码映射成脚本输出的稳定失败原因。"""
|
||||
headers = headers or {}
|
||||
|
||||
@@ -13,6 +13,7 @@ from feedback_issue_common import (
|
||||
MAX_TITLE_CHARS,
|
||||
build_issue_body,
|
||||
check_content_quality,
|
||||
format_doctor_summary,
|
||||
load_diagnostics_logs,
|
||||
read_json_file,
|
||||
result_payload,
|
||||
@@ -63,6 +64,7 @@ def validate_draft(draft: dict[str, Any], logs: str) -> Optional[str]:
|
||||
def build_preview_text(draft: dict[str, Any], logs: str, diagnostics: dict[str, Any]) -> str:
|
||||
"""构造给用户确认的 Markdown 预览文本。"""
|
||||
preview_logs = sanitize_logs(logs, MAX_PREVIEW_LOGS_CHARS) or "会话中未捕获到相关后端日志。"
|
||||
doctor_summary = format_doctor_summary(diagnostics.get("doctor"))
|
||||
source_files = diagnostics.get("source_files") or []
|
||||
sources = "\n".join(f"- {item}" for item in source_files) or "- 未命中具体日志文件"
|
||||
return (
|
||||
@@ -73,6 +75,8 @@ def build_preview_text(draft: dict[str, Any], logs: str, diagnostics: dict[str,
|
||||
f"类型:{draft['issue_type']}\n\n"
|
||||
"诊断来源:\n"
|
||||
f"{sources}\n\n"
|
||||
"Doctor 摘要:\n"
|
||||
f"```text\n{doctor_summary}\n```\n\n"
|
||||
"问题描述:\n"
|
||||
f"{draft['description'].strip()}\n\n"
|
||||
"日志预览(已脱敏):\n"
|
||||
@@ -119,12 +123,18 @@ def prepare_issue(draft_file: str | Path) -> dict[str, Any]:
|
||||
preview_text = build_preview_text(draft, logs, diagnostics)
|
||||
preview_file.write_text(preview_text, encoding="utf-8")
|
||||
|
||||
combined_logs = "\n\n".join(
|
||||
part for part in (
|
||||
f"### Doctor 摘要\n{format_doctor_summary(diagnostics.get('doctor'))}",
|
||||
logs,
|
||||
) if part
|
||||
)
|
||||
body_preview = build_issue_body(
|
||||
version=draft["version"],
|
||||
environment=draft["environment"],
|
||||
issue_type=draft["issue_type"],
|
||||
description=draft["description"],
|
||||
logs=logs,
|
||||
logs=combined_logs,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
@@ -19,6 +19,7 @@ from feedback_issue_common import (
|
||||
check_recent_duplicate,
|
||||
check_user_rate_limit,
|
||||
classify_failure,
|
||||
format_doctor_summary,
|
||||
load_diagnostics_logs,
|
||||
load_submission_state,
|
||||
read_json_file,
|
||||
@@ -150,7 +151,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
try:
|
||||
logs, _ = load_diagnostics_logs(payload["diagnostics_file"])
|
||||
logs, diagnostics = load_diagnostics_logs(payload["diagnostics_file"])
|
||||
except Exception as err:
|
||||
return {
|
||||
"success": False,
|
||||
@@ -166,12 +167,18 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
|
||||
"message": error,
|
||||
}
|
||||
|
||||
combined_logs = "\n\n".join(
|
||||
part for part in (
|
||||
f"### Doctor 摘要\n{format_doctor_summary(diagnostics.get('doctor'))}",
|
||||
logs,
|
||||
) if part
|
||||
)
|
||||
body = build_issue_body(
|
||||
version=payload["version"],
|
||||
environment=payload["environment"],
|
||||
issue_type=payload["issue_type"],
|
||||
description=payload["description"],
|
||||
logs=logs,
|
||||
logs=combined_logs,
|
||||
)
|
||||
state = load_submission_state()
|
||||
if check_recent_duplicate(payload["title"], body, state):
|
||||
@@ -186,7 +193,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
|
||||
result = build_api_failure_result(
|
||||
reason="rate_limited_user",
|
||||
payload=payload,
|
||||
logs=logs,
|
||||
logs=combined_logs,
|
||||
)
|
||||
result["message"] = rate_error + " 如确实是另一个真实问题,请使用 prefill_url 手动提交。"
|
||||
save_submission_state(state)
|
||||
@@ -195,7 +202,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
|
||||
record_user_submission(username, state)
|
||||
if not settings.GITHUB_TOKEN:
|
||||
save_submission_state(state)
|
||||
return build_no_token_result(payload, logs)
|
||||
return build_no_token_result(payload, combined_logs)
|
||||
|
||||
record_submission(payload["title"], body, state)
|
||||
save_submission_state(state)
|
||||
@@ -205,7 +212,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
|
||||
return build_api_failure_result(
|
||||
reason="network_error",
|
||||
payload=payload,
|
||||
logs=logs,
|
||||
logs=combined_logs,
|
||||
github_message=str(err),
|
||||
)
|
||||
|
||||
@@ -213,7 +220,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
|
||||
return build_api_failure_result(
|
||||
reason="network_error",
|
||||
payload=payload,
|
||||
logs=logs,
|
||||
logs=combined_logs,
|
||||
)
|
||||
|
||||
if response.status_code == 201:
|
||||
@@ -234,7 +241,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
|
||||
return build_api_failure_result(
|
||||
reason=reason,
|
||||
payload=payload,
|
||||
logs=logs,
|
||||
logs=combined_logs,
|
||||
github_message=api_message,
|
||||
)
|
||||
|
||||
|
||||
62
tests/test_doctor.py
Normal file
62
tests/test_doctor.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.config import settings
|
||||
from app.doctor import run_doctor
|
||||
from app.doctor.formatters import format_json_report, format_text_report
|
||||
from app.doctor.models import DoctorFinding, DoctorFindingStatus, DoctorSeverity
|
||||
|
||||
|
||||
def test_doctor_report_has_stable_json_shape(tmp_path, monkeypatch):
|
||||
"""doctor JSON 报告应包含稳定状态、环境、汇总和发现列表。"""
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||
(settings.LOG_PATH).mkdir(parents=True, exist_ok=True)
|
||||
(settings.ROOT_PATH / "public").mkdir(exist_ok=True)
|
||||
|
||||
report = run_doctor()
|
||||
payload = report.to_dict()
|
||||
|
||||
assert payload["schema_version"] == 1
|
||||
assert payload["status"] in {"healthy", "degraded", "failed"}
|
||||
assert payload["environment"]["config_path"] == str(tmp_path)
|
||||
assert isinstance(payload["summary"]["total"], int)
|
||||
assert isinstance(payload["findings"], list)
|
||||
assert any(item["id"] == "runtime.paths" for item in payload["findings"])
|
||||
|
||||
|
||||
def test_doctor_formatters_include_status_and_finding(tmp_path, monkeypatch):
|
||||
"""doctor 文本和 JSON 格式化应展示状态与诊断项。"""
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||
report = run_doctor()
|
||||
report.add_finding(
|
||||
DoctorFinding(
|
||||
id="test.demo",
|
||||
severity=DoctorSeverity.Warn,
|
||||
status=DoctorFindingStatus.Degraded,
|
||||
title="测试诊断项",
|
||||
detail="测试原因",
|
||||
recommendation="测试建议",
|
||||
)
|
||||
)
|
||||
|
||||
text = format_text_report(report)
|
||||
json_text = format_json_report(report)
|
||||
|
||||
assert "MoviePilot Doctor" in text
|
||||
assert "测试诊断项" in text
|
||||
assert '"schema_version": 1' in json_text
|
||||
assert '"test.demo"' in json_text
|
||||
|
||||
|
||||
def test_doctor_fix_removes_stale_runtime(tmp_path, monkeypatch):
|
||||
"""doctor --fix 应清理指向失效进程的 runtime 文件。"""
|
||||
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
|
||||
settings.TEMP_PATH.mkdir(parents=True, exist_ok=True)
|
||||
runtime_file = settings.TEMP_PATH / "moviepilot.runtime.json"
|
||||
runtime_file.write_text('{"pid": 999999, "create_time": 1}', encoding="utf-8")
|
||||
|
||||
report = run_doctor(fix=True)
|
||||
|
||||
assert not runtime_file.exists()
|
||||
finding = report.find("runtime.backend_stale")
|
||||
assert finding is not None
|
||||
assert finding.fixed
|
||||
@@ -101,6 +101,21 @@ class FeedbackIssueScriptTestCase(unittest.TestCase):
|
||||
"original_user_request": "订阅刷新接口返回 500,帮我提交上游 Issue",
|
||||
"found": bool(logs),
|
||||
"logs": logs,
|
||||
"doctor": {
|
||||
"success": True,
|
||||
"report": {
|
||||
"status": "degraded",
|
||||
"summary": {"total": 2, "error": 1, "warn": 1, "fixed": 0},
|
||||
"environment": {"runtime": "Docker"},
|
||||
"findings": [
|
||||
{
|
||||
"severity": "error",
|
||||
"title": "后端端口被占用",
|
||||
"recommendation": "修改 PORT 或停止占用进程",
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
"source_files": [str(settings.LOG_PATH / "moviepilot.log")],
|
||||
},
|
||||
)
|
||||
@@ -225,6 +240,7 @@ class TestCollectFeedbackDiagnosticsScript(FeedbackIssueScriptTestCase):
|
||||
self.assertIn("TMDB lookup failed", diagnostics["logs"])
|
||||
self.assertIn("Cookie: <REDACTED>", diagnostics["logs"])
|
||||
self.assertNotIn("secret", diagnostics["logs"])
|
||||
self.assertIn("doctor", diagnostics)
|
||||
|
||||
|
||||
class TestPrepareAndSubmitScripts(FeedbackIssueScriptTestCase):
|
||||
@@ -242,6 +258,8 @@ class TestPrepareAndSubmitScripts(FeedbackIssueScriptTestCase):
|
||||
self.assertTrue(Path(result["payload_file"]).exists())
|
||||
preview = Path(result["preview_file"]).read_text(encoding="utf-8")
|
||||
self.assertIn("请确认是否提交以下问题反馈", preview)
|
||||
self.assertIn("Doctor 摘要", preview)
|
||||
self.assertIn("后端端口被占用", preview)
|
||||
self.assertIn("Cookie: <REDACTED>", preview)
|
||||
self.assertNotIn("secret", preview)
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.helper.system import SystemHelper
|
||||
from app.core.config import settings
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
@@ -42,3 +45,32 @@ class SystemUtilsTest(TestCase):
|
||||
self.assertFalse(success)
|
||||
self.assertIn("返回码:2", message)
|
||||
self.assertIn("无标准输出或错误输出", message)
|
||||
|
||||
|
||||
class SystemHelperRestartTest(TestCase):
|
||||
|
||||
def test_docker_restart_policy_marks_intent_before_sigterm(self):
|
||||
"""
|
||||
Docker 内置重启走优雅退出时,应写入意图标记,避免 entrypoint 误进入 doctor 保活。
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
original_config_dir = settings.CONFIG_DIR
|
||||
original_intent_file = SystemHelper._SystemHelper__docker_restart_intent_file
|
||||
settings.CONFIG_DIR = temp_dir
|
||||
SystemHelper._SystemHelper__docker_restart_intent_file = (
|
||||
settings.TEMP_PATH / "moviepilot.intentional_restart"
|
||||
)
|
||||
try:
|
||||
with patch("app.helper.system.SystemUtils.is_docker", return_value=True), \
|
||||
patch.object(SystemHelper, "_check_restart_policy", return_value=True), \
|
||||
patch.object(SystemHelper, "_start_graceful_shutdown_monitor"), \
|
||||
patch("app.helper.system.os.kill") as kill_mock:
|
||||
ret, msg = SystemHelper.restart()
|
||||
|
||||
self.assertTrue(ret)
|
||||
self.assertEqual(msg, "")
|
||||
self.assertTrue((settings.TEMP_PATH / "moviepilot.intentional_restart").exists())
|
||||
kill_mock.assert_called_once()
|
||||
finally:
|
||||
SystemHelper._SystemHelper__docker_restart_intent_file = original_intent_file
|
||||
settings.CONFIG_DIR = original_config_dir
|
||||
|
||||
Reference in New Issue
Block a user