新增 doctor 诊断自救功能

This commit is contained in:
jxxghp
2026-06-12 15:55:24 +08:00
parent 10dcb3727e
commit 735a1ebf27
23 changed files with 1635 additions and 56 deletions

View File

@@ -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:
"""查看或修改本地配置"""

View File

@@ -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
View 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
View 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
View 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
View 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
View 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(),
}

View File

@@ -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()

View File

@@ -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 连接池,释放底层连接资源

View File

@@ -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" ]

View File

@@ -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"

View File

@@ -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
View 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避免泄露本机路径和过长输出。

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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 {}

View File

@@ -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,

View File

@@ -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
View 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

View File

@@ -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)

View File

@@ -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