mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-28 11:12:00 +08:00
新增 doctor 诊断自救功能
This commit is contained in:
@@ -4,6 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -109,6 +112,67 @@ def candidate_log_files() -> list[Path]:
|
||||
return [path for path in files if path.exists() and path.is_file()]
|
||||
|
||||
|
||||
def collect_doctor_report() -> dict:
|
||||
"""调用离线 doctor 命令收集结构化诊断报告。"""
|
||||
commands = []
|
||||
moviepilot_bin = shutil.which("moviepilot")
|
||||
if moviepilot_bin:
|
||||
commands.append([moviepilot_bin, "doctor", "--json"])
|
||||
commands.append([sys.executable, "-m", "app.cli", "doctor", "--json"])
|
||||
|
||||
for command in commands:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=str(settings.ROOT_PATH),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as err:
|
||||
last_error = str(err)
|
||||
continue
|
||||
|
||||
output = (result.stdout or "").strip()
|
||||
if not output:
|
||||
last_error = f"{' '.join(command)} 没有输出"
|
||||
continue
|
||||
try:
|
||||
payload = json_loads_from_output(output)
|
||||
except ValueError as err:
|
||||
last_error = str(err)
|
||||
continue
|
||||
payload["_command"] = " ".join(command)
|
||||
payload["_returncode"] = result.returncode
|
||||
return {
|
||||
"success": True,
|
||||
"report": payload,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error": last_error if "last_error" in locals() else "doctor 命令不可用",
|
||||
}
|
||||
|
||||
|
||||
def json_loads_from_output(output: str) -> dict:
|
||||
"""从命令输出中解析 doctor JSON 对象。"""
|
||||
import json
|
||||
|
||||
start = output.find("{")
|
||||
end = output.rfind("}")
|
||||
if start == -1 or end == -1 or end < start:
|
||||
raise ValueError("doctor 输出中未找到 JSON 对象")
|
||||
payload = json.loads(output[start:end + 1])
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("doctor JSON 顶层不是对象")
|
||||
return payload
|
||||
|
||||
|
||||
def normalize_keywords(keywords: Optional[list[str]]) -> list[str]:
|
||||
"""过滤掉过短或过于宽泛的日志关键词。"""
|
||||
normalized: list[str] = []
|
||||
@@ -256,6 +320,7 @@ def collect_diagnostics(
|
||||
"keywords": normalized_keywords,
|
||||
"found": bool(logs.strip()),
|
||||
"logs": logs,
|
||||
"doctor": collect_doctor_report(),
|
||||
"source_files": source_files,
|
||||
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||
}
|
||||
@@ -268,6 +333,7 @@ def collect_diagnostics(
|
||||
"source_files": source_files,
|
||||
"log_bytes": len(logs.encode("utf-8", errors="replace")),
|
||||
"log_lines": len(logs.splitlines()) if logs else 0,
|
||||
"doctor_collected": bool(diagnostics["doctor"].get("success")),
|
||||
"message": (
|
||||
"已收集并写入反馈诊断日志文件。"
|
||||
if logs
|
||||
|
||||
@@ -47,6 +47,7 @@ MAX_BODY_CHARS = 60 * 1024
|
||||
MAX_LOGS_CHARS = 8 * 1024
|
||||
MAX_URL_LOGS_CHARS = 3 * 1024
|
||||
MAX_PREVIEW_LOGS_CHARS = 3 * 1024
|
||||
MAX_DOCTOR_SUMMARY_CHARS = 2 * 1024
|
||||
|
||||
DEDUP_TTL_SECONDS = 60
|
||||
USER_COOLDOWN_SECONDS = 30 * 60
|
||||
@@ -275,6 +276,53 @@ def build_prefill_url(
|
||||
return f"{FEEDBACK_ISSUE_NEW_URL}?{encoded}"
|
||||
|
||||
|
||||
def format_doctor_summary(doctor: Optional[dict[str, Any]]) -> str:
|
||||
"""把 doctor JSON 报告压缩成适合 Issue 和预览展示的摘要。"""
|
||||
if not isinstance(doctor, dict):
|
||||
return "未收集到 doctor 报告。"
|
||||
if not doctor.get("success"):
|
||||
return f"doctor 收集失败:{doctor.get('error') or '未知错误'}"
|
||||
|
||||
report = doctor.get("report") or {}
|
||||
if not isinstance(report, dict):
|
||||
return "doctor 报告格式异常。"
|
||||
|
||||
lines = [
|
||||
f"状态:{report.get('status') or 'unknown'}",
|
||||
]
|
||||
environment = report.get("environment") or {}
|
||||
if isinstance(environment, dict):
|
||||
runtime = environment.get("runtime")
|
||||
if runtime:
|
||||
lines.append(f"运行环境:{runtime}")
|
||||
summary = report.get("summary") or {}
|
||||
if isinstance(summary, dict):
|
||||
lines.append(
|
||||
"汇总:"
|
||||
f"total={summary.get('total', 0)} "
|
||||
f"error={summary.get('error', 0)} "
|
||||
f"warn={summary.get('warn', 0)} "
|
||||
f"fixed={summary.get('fixed', 0)}"
|
||||
)
|
||||
|
||||
findings = report.get("findings") or []
|
||||
if isinstance(findings, list):
|
||||
important = [
|
||||
item for item in findings
|
||||
if isinstance(item, dict) and item.get("severity") in {"error", "warn"}
|
||||
][:8]
|
||||
if important:
|
||||
lines.append("关键发现:")
|
||||
for item in important:
|
||||
title = str(item.get("title") or item.get("id") or "未知诊断项")
|
||||
recommendation = str(item.get("recommendation") or "").strip()
|
||||
line = f"- [{item.get('severity')}] {title}"
|
||||
if recommendation:
|
||||
line = f"{line};建议:{recommendation}"
|
||||
lines.append(line)
|
||||
return truncate("\n".join(lines), MAX_DOCTOR_SUMMARY_CHARS)
|
||||
|
||||
|
||||
def classify_failure(status_code: Optional[int], headers: Optional[dict] = None) -> str:
|
||||
"""把 GitHub API HTTP 状态码映射成脚本输出的稳定失败原因。"""
|
||||
headers = headers or {}
|
||||
|
||||
@@ -13,6 +13,7 @@ from feedback_issue_common import (
|
||||
MAX_TITLE_CHARS,
|
||||
build_issue_body,
|
||||
check_content_quality,
|
||||
format_doctor_summary,
|
||||
load_diagnostics_logs,
|
||||
read_json_file,
|
||||
result_payload,
|
||||
@@ -63,6 +64,7 @@ def validate_draft(draft: dict[str, Any], logs: str) -> Optional[str]:
|
||||
def build_preview_text(draft: dict[str, Any], logs: str, diagnostics: dict[str, Any]) -> str:
|
||||
"""构造给用户确认的 Markdown 预览文本。"""
|
||||
preview_logs = sanitize_logs(logs, MAX_PREVIEW_LOGS_CHARS) or "会话中未捕获到相关后端日志。"
|
||||
doctor_summary = format_doctor_summary(diagnostics.get("doctor"))
|
||||
source_files = diagnostics.get("source_files") or []
|
||||
sources = "\n".join(f"- {item}" for item in source_files) or "- 未命中具体日志文件"
|
||||
return (
|
||||
@@ -73,6 +75,8 @@ def build_preview_text(draft: dict[str, Any], logs: str, diagnostics: dict[str,
|
||||
f"类型:{draft['issue_type']}\n\n"
|
||||
"诊断来源:\n"
|
||||
f"{sources}\n\n"
|
||||
"Doctor 摘要:\n"
|
||||
f"```text\n{doctor_summary}\n```\n\n"
|
||||
"问题描述:\n"
|
||||
f"{draft['description'].strip()}\n\n"
|
||||
"日志预览(已脱敏):\n"
|
||||
@@ -119,12 +123,18 @@ def prepare_issue(draft_file: str | Path) -> dict[str, Any]:
|
||||
preview_text = build_preview_text(draft, logs, diagnostics)
|
||||
preview_file.write_text(preview_text, encoding="utf-8")
|
||||
|
||||
combined_logs = "\n\n".join(
|
||||
part for part in (
|
||||
f"### Doctor 摘要\n{format_doctor_summary(diagnostics.get('doctor'))}",
|
||||
logs,
|
||||
) if part
|
||||
)
|
||||
body_preview = build_issue_body(
|
||||
version=draft["version"],
|
||||
environment=draft["environment"],
|
||||
issue_type=draft["issue_type"],
|
||||
description=draft["description"],
|
||||
logs=logs,
|
||||
logs=combined_logs,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
@@ -19,6 +19,7 @@ from feedback_issue_common import (
|
||||
check_recent_duplicate,
|
||||
check_user_rate_limit,
|
||||
classify_failure,
|
||||
format_doctor_summary,
|
||||
load_diagnostics_logs,
|
||||
load_submission_state,
|
||||
read_json_file,
|
||||
@@ -150,7 +151,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
try:
|
||||
logs, _ = load_diagnostics_logs(payload["diagnostics_file"])
|
||||
logs, diagnostics = load_diagnostics_logs(payload["diagnostics_file"])
|
||||
except Exception as err:
|
||||
return {
|
||||
"success": False,
|
||||
@@ -166,12 +167,18 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
|
||||
"message": error,
|
||||
}
|
||||
|
||||
combined_logs = "\n\n".join(
|
||||
part for part in (
|
||||
f"### Doctor 摘要\n{format_doctor_summary(diagnostics.get('doctor'))}",
|
||||
logs,
|
||||
) if part
|
||||
)
|
||||
body = build_issue_body(
|
||||
version=payload["version"],
|
||||
environment=payload["environment"],
|
||||
issue_type=payload["issue_type"],
|
||||
description=payload["description"],
|
||||
logs=logs,
|
||||
logs=combined_logs,
|
||||
)
|
||||
state = load_submission_state()
|
||||
if check_recent_duplicate(payload["title"], body, state):
|
||||
@@ -186,7 +193,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
|
||||
result = build_api_failure_result(
|
||||
reason="rate_limited_user",
|
||||
payload=payload,
|
||||
logs=logs,
|
||||
logs=combined_logs,
|
||||
)
|
||||
result["message"] = rate_error + " 如确实是另一个真实问题,请使用 prefill_url 手动提交。"
|
||||
save_submission_state(state)
|
||||
@@ -195,7 +202,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
|
||||
record_user_submission(username, state)
|
||||
if not settings.GITHUB_TOKEN:
|
||||
save_submission_state(state)
|
||||
return build_no_token_result(payload, logs)
|
||||
return build_no_token_result(payload, combined_logs)
|
||||
|
||||
record_submission(payload["title"], body, state)
|
||||
save_submission_state(state)
|
||||
@@ -205,7 +212,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
|
||||
return build_api_failure_result(
|
||||
reason="network_error",
|
||||
payload=payload,
|
||||
logs=logs,
|
||||
logs=combined_logs,
|
||||
github_message=str(err),
|
||||
)
|
||||
|
||||
@@ -213,7 +220,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
|
||||
return build_api_failure_result(
|
||||
reason="network_error",
|
||||
payload=payload,
|
||||
logs=logs,
|
||||
logs=combined_logs,
|
||||
)
|
||||
|
||||
if response.status_code == 201:
|
||||
@@ -234,7 +241,7 @@ def submit_issue(payload_file: str | Path, username: str) -> dict[str, Any]:
|
||||
return build_api_failure_result(
|
||||
reason=reason,
|
||||
payload=payload,
|
||||
logs=logs,
|
||||
logs=combined_logs,
|
||||
github_message=api_message,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user