mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
feat: add backend health check for unmanaged MoviePilot processes
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
import warnings
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_third_party_startup_warnings() -> None:
|
||||||
|
"""
|
||||||
|
过滤第三方库在新版 Python 下产生的已知无害启动警告。
|
||||||
|
"""
|
||||||
|
warnings.filterwarnings(
|
||||||
|
"ignore",
|
||||||
|
message=r"invalid escape sequence '\\&'",
|
||||||
|
category=SyntaxWarning,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_filter_third_party_startup_warnings()
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import sys
|
|||||||
from collections import deque
|
from collections import deque
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
@@ -33,6 +36,9 @@ CORE_DEPENDENCIES = (
|
|||||||
"uvicorn",
|
"uvicorn",
|
||||||
)
|
)
|
||||||
LOCAL_HOSTS = {"", "0.0.0.0", "::", "::1", "localhost"}
|
LOCAL_HOSTS = {"", "0.0.0.0", "::", "::1", "localhost"}
|
||||||
|
BACKEND_HEALTH_PATH = "/api/v1/system/global"
|
||||||
|
BACKEND_HEALTH_TOKEN = "moviepilot"
|
||||||
|
BACKEND_HEALTH_TIMEOUT = 0.5
|
||||||
LOG_ERROR_PATTERNS = (
|
LOG_ERROR_PATTERNS = (
|
||||||
re.compile(r"\btraceback\b", re.IGNORECASE),
|
re.compile(r"\btraceback\b", re.IGNORECASE),
|
||||||
re.compile(r"\b(error|critical|exception)\b", re.IGNORECASE),
|
re.compile(r"\b(error|critical|exception)\b", re.IGNORECASE),
|
||||||
@@ -220,6 +226,42 @@ def _can_connect(host: str, port: int, timeout: float = 1.0) -> tuple[bool, str]
|
|||||||
return False, str(err)
|
return False, str(err)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_moviepilot_backend_payload(payload: Any) -> bool:
|
||||||
|
"""
|
||||||
|
判断本地健康接口响应是否来自 MoviePilot 后端。
|
||||||
|
"""
|
||||||
|
if not isinstance(payload, dict) or payload.get("success") is False:
|
||||||
|
return False
|
||||||
|
data = payload.get("data")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return False
|
||||||
|
return bool(data.get("BACKEND_VERSION"))
|
||||||
|
|
||||||
|
|
||||||
|
def _backend_health_payload(port: int, timeout: float = BACKEND_HEALTH_TIMEOUT) -> Optional[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
读取本机后端健康接口响应,用于识别非 CLI 管理的 MoviePilot 进程。
|
||||||
|
"""
|
||||||
|
query = urlencode({"token": BACKEND_HEALTH_TOKEN})
|
||||||
|
url = f"http://{_client_host(settings.HOST)}:{port}{BACKEND_HEALTH_PATH}?{query}"
|
||||||
|
request = Request(url=url, headers={"Accept": "application/json"}, method="GET")
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=timeout) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
return None
|
||||||
|
raw = response.read().decode("utf-8", errors="ignore")
|
||||||
|
except (HTTPError, URLError):
|
||||||
|
return None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw) if raw else None
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return payload if _is_moviepilot_backend_payload(payload) else None
|
||||||
|
|
||||||
|
|
||||||
def _tail_lines(path: Path, max_lines: int = 120, max_bytes: int = 256 * 1024) -> list[str]:
|
def _tail_lines(path: Path, max_lines: int = 120, max_bytes: int = 256 * 1024) -> list[str]:
|
||||||
try:
|
try:
|
||||||
size = path.stat().st_size
|
size = path.stat().st_size
|
||||||
@@ -425,6 +467,24 @@ def _check_port(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if name == "backend":
|
||||||
|
health_payload = _backend_health_payload(port)
|
||||||
|
if health_payload:
|
||||||
|
runner.add(
|
||||||
|
finding_id=f"port.{name}_listening_unmanaged",
|
||||||
|
severity=DoctorSeverity.Info,
|
||||||
|
status=DoctorFindingStatus.Ok,
|
||||||
|
title=f"{name} 端口由 MoviePilot 后端监听",
|
||||||
|
detail=f"端口 {port} 健康接口响应正常;监听进程:{'; '.join(descriptions)}",
|
||||||
|
recommendation="Docker 或非 CLI 管理启动方式下,后端端口被当前服务监听属于正常状态。",
|
||||||
|
context={
|
||||||
|
"port": port,
|
||||||
|
"pids": [process.pid for process in occupants],
|
||||||
|
"backend_version": health_payload.get("data", {}).get("BACKEND_VERSION"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
runner.add(
|
runner.add(
|
||||||
finding_id=f"port.{name}_occupied",
|
finding_id=f"port.{name}_occupied",
|
||||||
severity=DoctorSeverity.Error,
|
severity=DoctorSeverity.Error,
|
||||||
|
|||||||
+27
-1
@@ -1,9 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.doctor import run_doctor
|
from app.doctor import checks, run_doctor
|
||||||
from app.doctor.formatters import format_json_report, format_text_report
|
from app.doctor.formatters import format_json_report, format_text_report
|
||||||
from app.doctor.models import DoctorFinding, DoctorFindingStatus, DoctorSeverity
|
from app.doctor.models import DoctorFinding, DoctorFindingStatus, DoctorSeverity
|
||||||
|
from app.doctor.runner import DoctorRunner
|
||||||
|
|
||||||
|
|
||||||
def test_doctor_report_has_stable_json_shape(tmp_path, monkeypatch):
|
def test_doctor_report_has_stable_json_shape(tmp_path, monkeypatch):
|
||||||
@@ -60,3 +63,26 @@ def test_doctor_fix_removes_stale_runtime(tmp_path, monkeypatch):
|
|||||||
finding = report.find("runtime.backend_stale")
|
finding = report.find("runtime.backend_stale")
|
||||||
assert finding is not None
|
assert finding is not None
|
||||||
assert finding.fixed
|
assert finding.fixed
|
||||||
|
|
||||||
|
|
||||||
|
def test_doctor_accepts_healthy_unmanaged_backend_port(monkeypatch):
|
||||||
|
"""doctor 在容器中应把健康的非 CLI 管理后端端口识别为正常。"""
|
||||||
|
occupant = SimpleNamespace(pid=12345)
|
||||||
|
monkeypatch.setattr(checks, "_port_occupants", lambda port: [occupant])
|
||||||
|
monkeypatch.setattr(checks, "_process_description", lambda process: f"PID {process.pid} (python)")
|
||||||
|
monkeypatch.setattr(checks, "_is_expected_port_process", lambda name, process: False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
checks,
|
||||||
|
"_backend_health_payload",
|
||||||
|
lambda port: {"success": True, "data": {"BACKEND_VERSION": "v2-test"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = DoctorRunner()
|
||||||
|
checks._check_port(runner, name="backend", port=3001, managed_process=None)
|
||||||
|
|
||||||
|
finding = runner.report.find("port.backend_listening_unmanaged")
|
||||||
|
assert finding is not None
|
||||||
|
assert finding.status == DoctorFindingStatus.Ok
|
||||||
|
assert finding.severity == DoctorSeverity.Info
|
||||||
|
assert finding.context["backend_version"] == "v2-test"
|
||||||
|
assert runner.report.find("port.backend_occupied") is None
|
||||||
|
|||||||
Reference in New Issue
Block a user