From 735a1ebf275854d75c2d98f5428fa002df41b091 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 12 Jun 2026 15:55:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20doctor=20=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E8=87=AA=E6=95=91=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/cli.py | 59 +- app/core/config.py | 2 + app/doctor/__init__.py | 17 + app/doctor/checks.py | 749 ++++++++++++++++++ app/doctor/formatters.py | 56 ++ app/doctor/models.py | 151 ++++ app/doctor/runner.py | 103 +++ app/helper/system.py | 22 + app/startup/lifecycle.py | 58 +- docker/Dockerfile | 7 +- docker/entrypoint.sh | 53 +- docs/cli.md | 20 + docs/doctor.md | 88 ++ docs/rules/03-commands.md | 16 + moviepilot | 27 + skills/feedback-issue/SKILL.md | 4 + .../scripts/collect_feedback_diagnostics.py | 66 ++ .../scripts/feedback_issue_common.py | 48 ++ .../scripts/prepare_feedback_issue.py | 12 +- .../scripts/submit_feedback_issue.py | 21 +- tests/test_doctor.py | 62 ++ tests/test_feedback_issue_scripts.py | 18 + tests/test_system_utils.py | 32 + 23 files changed, 1635 insertions(+), 56 deletions(-) create mode 100644 app/doctor/__init__.py create mode 100644 app/doctor/checks.py create mode 100644 app/doctor/formatters.py create mode 100644 app/doctor/models.py create mode 100644 app/doctor/runner.py create mode 100644 docs/doctor.md create mode 100644 tests/test_doctor.py diff --git a/app/cli.py b/app/cli.py index c5b4857d..fd31ffbb 100644 --- a/app/cli.py +++ b/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: """查看或修改本地配置""" diff --git a/app/core/config.py b/app/core/config.py index 35c02be7..dfaced77 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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 # 是否开发模式 diff --git a/app/doctor/__init__.py b/app/doctor/__init__.py new file mode 100644 index 00000000..05ae4e56 --- /dev/null +++ b/app/doctor/__init__.py @@ -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", +] diff --git a/app/doctor/checks.py b/app/doctor/checks.py new file mode 100644 index 00000000..7b63dcfa --- /dev/null +++ b/app/doctor/checks.py @@ -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", masked) + else: + masked = pattern.sub("", 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 ` 手动设置。" + ), + 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 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。", + ) diff --git a/app/doctor/formatters.py b/app/doctor/formatters.py new file mode 100644 index 00000000..88af8f6d --- /dev/null +++ b/app/doctor/formatters.py @@ -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 diff --git a/app/doctor/models.py b/app/doctor/models.py new file mode 100644 index 00000000..7bc189de --- /dev/null +++ b/app/doctor/models.py @@ -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], + } diff --git a/app/doctor/runner.py b/app/doctor/runner.py new file mode 100644 index 00000000..74b38cc2 --- /dev/null +++ b/app/doctor/runner.py @@ -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(), + } diff --git a/app/helper/system.py b/app/helper/system.py index 17a5967e..30c08634 100644 --- a/app/helper/system.py +++ b/app/helper/system.py @@ -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() diff --git a/app/startup/lifecycle.py b/app/startup/lifecycle.py index cc6706fe..0ac7f7d1 100644 --- a/app/startup/lifecycle.py +++ b/app/startup/lifecycle.py @@ -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 连接池,释放底层连接资源 diff --git a/docker/Dockerfile b/docker/Dockerfile index 51d079b3..d890c44c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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" ] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 7ee406dd..1daf2929 100644 --- a/docker/entrypoint.sh +++ b/docker/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 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" diff --git a/docs/cli.md b/docs/cli.md index 51609347..9a9a88d2 100644 --- a/docs/cli.md +++ b/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 moviepilot doctor`;如果容器已退出,也可用镜像挂载同一配置目录运行 `python -m app.cli doctor` + 日志: ```shell diff --git a/docs/doctor.md b/docs/doctor.md new file mode 100644 index 00000000..54bd4608 --- /dev/null +++ b/docs/doctor.md @@ -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 moviepilot doctor +docker exec -it moviepilot doctor --json +``` + +容器已经退出时,可用同一镜像挂载配置目录运行: + +```shell +docker run --rm --entrypoint python -v :/config -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 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,避免泄露本机路径和过长输出。 diff --git a/docs/rules/03-commands.md b/docs/rules/03-commands.md index 7f188a55..fd0380f4 100644 --- a/docs/rules/03-commands.md +++ b/docs/rules/03-commands.md @@ -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 moviepilot doctor +docker exec -it moviepilot doctor --json +docker run --rm --entrypoint python -v :/config -m app.cli doctor +``` + +--- + ## Local CLI — Help Discovery ```bash diff --git a/moviepilot b/moviepilot index 508ca926..27aad313 100755 --- a/moviepilot +++ b/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 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 diff --git a/skills/feedback-issue/SKILL.md b/skills/feedback-issue/SKILL.md index 831b2524..c99b6d5f 100644 --- a/skills/feedback-issue/SKILL.md +++ b/skills/feedback-issue/SKILL.md @@ -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. diff --git a/skills/feedback-issue/scripts/collect_feedback_diagnostics.py b/skills/feedback-issue/scripts/collect_feedback_diagnostics.py index 0358db4c..69eeb0f2 100644 --- a/skills/feedback-issue/scripts/collect_feedback_diagnostics.py +++ b/skills/feedback-issue/scripts/collect_feedback_diagnostics.py @@ -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 diff --git a/skills/feedback-issue/scripts/feedback_issue_common.py b/skills/feedback-issue/scripts/feedback_issue_common.py index b9867305..d4fcbee7 100644 --- a/skills/feedback-issue/scripts/feedback_issue_common.py +++ b/skills/feedback-issue/scripts/feedback_issue_common.py @@ -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 {} diff --git a/skills/feedback-issue/scripts/prepare_feedback_issue.py b/skills/feedback-issue/scripts/prepare_feedback_issue.py index 5326c058..57cd0bc6 100644 --- a/skills/feedback-issue/scripts/prepare_feedback_issue.py +++ b/skills/feedback-issue/scripts/prepare_feedback_issue.py @@ -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, diff --git a/skills/feedback-issue/scripts/submit_feedback_issue.py b/skills/feedback-issue/scripts/submit_feedback_issue.py index af403e90..5bb02b73 100644 --- a/skills/feedback-issue/scripts/submit_feedback_issue.py +++ b/skills/feedback-issue/scripts/submit_feedback_issue.py @@ -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, ) diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 00000000..a2246454 --- /dev/null +++ b/tests/test_doctor.py @@ -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 diff --git a/tests/test_feedback_issue_scripts.py b/tests/test_feedback_issue_scripts.py index 52e6ff7d..acb209ec 100644 --- a/tests/test_feedback_issue_scripts.py +++ b/tests/test_feedback_issue_scripts.py @@ -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: ", 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: ", preview) self.assertNotIn("secret", preview) diff --git a/tests/test_system_utils.py b/tests/test_system_utils.py index 1e0b82ad..72bcd645 100644 --- a/tests/test_system_utils.py +++ b/tests/test_system_utils.py @@ -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