This commit is contained in:
root
2026-05-17 04:31:59 +00:00
parent 75a0ca4e31
commit 07aa02bca6
51 changed files with 1322 additions and 435 deletions

View File

@@ -4,8 +4,9 @@ import asyncio
import json
import os
import re
import signal
import sys
from contextlib import asynccontextmanager
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from typing import Any, Optional
@@ -58,7 +59,7 @@ from src.config_governance import (
from src.hub_kv_store import get_youtube_pro_channels, set_youtube_pro_channels
from src.hub_routes import YoutubeProChannelsBody, configure_hub, router as hub_router
from src.process_runtime_status import summarize_process_activity, tail_log_text
from src.process_runtime_status import coerce_declared_process_status
from src.process_runtime_status import coerce_declared_process_status, process_family
from src.runtime_bootstrap import raise_nofile_limit
from src.web_process_backend import WebProcessBackend, force_kill_ffmpeg, strip_ansi_codes
from src.youtube_pro_runtime import (
@@ -66,6 +67,7 @@ from src.youtube_pro_runtime import (
ensure_youtube_pro_channel_runtime_files,
managed_url_config_relpath_for_pm2,
managed_youtube_ini_relpath_for_pm2,
validate_youtube_runtime_files,
)
# ---------------- 配置(根目录结构) ----------------
@@ -217,9 +219,17 @@ async def _stop_other_hdmi_sink_processes(keep_pm2: str) -> list[str]:
st = (info.get("process_status") or "").lower()
if st not in ("online", "running"):
continue
try:
root_pid = await process_backend.process_pid(str(name))
except Exception:
root_pid = 0
try:
root_pgid = os.getpgid(root_pid) if root_pid > 0 else None
except OSError:
root_pgid = None
await process_backend.stop(name)
if not str(name).startswith("web"):
await force_kill_ffmpeg(str(name), BASE_DIR)
await force_kill_ffmpeg(str(name), BASE_DIR, root_pid=root_pid, root_pgid=root_pgid)
stopped.append(str(name))
if stopped:
await asyncio.sleep(0.85)
@@ -278,19 +288,6 @@ def process_monitor_entries() -> tuple[dict[str, str], ...]:
extras = list(youtube_pro_process_entries())
merged = [dict(row) for row in STATIC_PROCESS_MONITOR]
index = {str(row["pm2"]).strip(): pos for pos, row in enumerate(merged)}
for channel in get_youtube_pro_channels():
if not isinstance(channel, dict):
continue
cid = str(channel.get("id", "")).strip()
if not cid:
continue
pm2 = channel_pm2_name(channel)
if not pm2 or pm2 not in index:
continue
if not is_youtube_script(str(merged[index[pm2]].get("script", ""))):
continue
name = str(channel.get("name", "")).strip() or cid
merged[index[pm2]]["label"] = _youtube_pro_label(name)
for row in extras:
pm2 = str(row.get("pm2", "")).strip()
if not pm2:
@@ -305,6 +302,23 @@ def _sync_youtube_pro_runtime_files(process: str) -> list[str]:
return errors
def _validate_youtube_runtime_files(process: str) -> list[str]:
return validate_youtube_runtime_files(BASE_DIR, CONFIG_DIR, process, get_youtube_pro_channels())
async def _process_runtime_identity(process: str) -> tuple[int, int | None]:
try:
pid = await process_backend.process_pid(process)
except Exception:
pid = 0
if pid <= 0:
return 0, None
try:
return pid, os.getpgid(pid)
except OSError:
return pid, None
def _pm2_safe_segment(pm2: str) -> str:
return re.sub(r"[^a-zA-Z0-9_.-]", "_", pm2)
@@ -586,19 +600,19 @@ def _newest_log_age_seconds(*paths: Optional[str]) -> float | None:
async def read_log_path_normalized(path: Optional[str], lines: int) -> str:
if not path:
return "鏃犳棩蹇楄矾寰刓n"
return "无日志路径\n"
norm = path.replace("\\", "/").lower()
if "/dev/null" in norm or norm.endswith("/nul") or norm == "nul":
return "鏃ュ織杈撳嚭琚鐢紙PM2 閰嶇疆涓烘棤鏃ュ織锛塡n"
return "日志输出被禁用PM2 配置为无日志)\n"
log_file = Path(path)
if not log_file.exists():
return f"鏃ュ織鏂囦欢涓嶅瓨鍦? {path}\n"
return f"日志文件不存在: {path}\n"
try:
text = await asyncio.to_thread(_read_log_tail_bytes, log_file)
tailed = tail_log_text(text, lines)
return tailed if tailed else "鏃犳棩蹇楀唴瀹筡n"
return tailed if tailed else "无日志内容\n"
except OSError as e:
return f"璇诲彇鏃ュ織澶辫触 ({path}): {str(e)}\n"
return f"读取日志失败 ({path}): {str(e)}\n"
async def get_process_info(process: str) -> dict:
@@ -1224,16 +1238,40 @@ def _coerce_local_configured_process(process: str, info: dict) -> dict:
async def _process_monitor_snapshot(entry: dict[str, str]) -> dict[str, Any]:
name = str(entry.get("pm2") or "").strip()
info = await get_process_info(name)
return await _process_monitor_snapshot_from_info(entry, info)
async def _process_monitor_snapshot_from_info(
entry: dict[str, str],
info: dict[str, Any],
*,
light: bool = False,
) -> dict[str, Any]:
name = str(entry.get("pm2") or "").strip()
info = _coerce_local_configured_process(name, info)
recent_log = await read_log_path_normalized(info.get("out_path"), 40)
recent_error = await read_log_path_normalized(info.get("err_path"), 40)
status = str(info.get("process_status") or "").lower()
online = status in {"online", "running", "launching"}
read_logs = online or not light
log_lines = 12 if light else 40
error_lines = 8 if light else 40
if read_logs:
recent_log, recent_error = await asyncio.gather(
read_log_path_normalized(info.get("out_path"), log_lines),
read_log_path_normalized(info.get("err_path"), error_lines),
)
else:
recent_log = ""
recent_error = ""
ffmpeg_pids = await process_backend.live_ffmpeg_pids(name, info) if online else []
activity = summarize_process_activity(
name,
str(info.get("process_status") or ""),
recent_log,
recent_error,
log_age_seconds=_newest_log_age_seconds(info.get("out_path"), info.get("err_path")),
ffmpeg_pids=await process_backend.live_ffmpeg_pids(name),
ffmpeg_pids=ffmpeg_pids,
)
return {
"pm2": name,
@@ -1247,6 +1285,16 @@ async def _process_monitor_snapshot(entry: dict[str, str]) -> dict[str, Any]:
}
def _process_monitor_family_matches(entry: dict[str, str], family: str | None) -> bool:
target = (family or "").strip().lower()
if not target or target == "all":
return True
script = str(entry.get("script") or "").strip()
if script:
return process_family(Path(script).stem) == target
return process_family(str(entry.get("pm2") or "").strip()) == target
# ---------------- 进程控制 ----------------
@app.get("/start")
async def start(process: str = Query(..., description="进程名")):
@@ -1255,7 +1303,7 @@ async def start(process: str = Query(..., description="进程名")):
sc = script_for_pm2_or_pro(process)
if not sc:
return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500)
sync_errors = _sync_youtube_pro_runtime_files(process)
sync_errors = _validate_youtube_runtime_files(process)
if sync_errors:
return JSONResponse({"output": "; ".join(sync_errors)}, status_code=400)
stopped = await _stop_other_hdmi_sink_processes(process)
@@ -1276,14 +1324,22 @@ async def stop(process: str = Query(..., description="进程名")):
if not is_valid_control_process(process):
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
root_pid, root_pgid = await _process_runtime_identity(process)
pm2_output = await process_backend.stop(process)
await asyncio.sleep(2.0)
await asyncio.sleep(0.25)
if not process.startswith("web"):
await force_kill_ffmpeg(process, BASE_DIR)
await force_kill_ffmpeg(process, BASE_DIR, root_pid=root_pid, root_pgid=root_pgid)
await asyncio.sleep(1.0)
if root_pid > 0:
with suppress(ProcessLookupError, PermissionError, OSError):
os.kill(root_pid, signal.SIGTERM)
await asyncio.sleep(0.15)
post_info = await get_process_info(process)
if str(post_info.get("process_status") or "").lower() in {"online", "running"}:
with suppress(ProcessLookupError, PermissionError, OSError):
os.kill(root_pid, signal.SIGKILL)
return JSONResponse(
{
@@ -1306,13 +1362,14 @@ async def restart(process: str = Query(..., description="进程名")):
sc = script_for_pm2_or_pro(process)
if not sc:
return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500)
sync_errors = _sync_youtube_pro_runtime_files(process)
sync_errors = _validate_youtube_runtime_files(process)
if sync_errors:
return JSONResponse({"output": "; ".join(sync_errors)}, status_code=400)
stopped = await _stop_other_hdmi_sink_processes(process)
output = await process_backend.restart(process, sc)
root_pid, root_pgid = await _process_runtime_identity(process)
if not process.startswith("web"):
await force_kill_ffmpeg(process, BASE_DIR)
await force_kill_ffmpeg(process, BASE_DIR, root_pid=root_pid, root_pgid=root_pgid)
output = await process_backend.restart(process, sc)
prefix = ""
if stopped:
prefix = "已停止其它占用 HDMI 链路的进程: " + "".join(stopped) + "\n"
@@ -1373,29 +1430,44 @@ async def status(process: str = Query(..., description="进程名")):
@app.get("/process_monitor")
async def process_monitor():
async def process_monitor(
family: str | None = Query(None, description="按进程族过滤youtube/tiktok/obs/generic"),
light: bool = Query(False, description="轻量模式:减少日志读取,仅返回监控所需状态"),
):
await process_backend.ensure_mode()
entries = [dict(p) for p in process_monitor_entries()]
family_value = family if isinstance(family, str) else None
light_value = bool(light) if isinstance(light, bool) else False
entries = [dict(p) for p in process_monitor_entries() if _process_monitor_family_matches(p, family_value)]
names = [str(entry.get("pm2") or "").strip() for entry in entries if str(entry.get("pm2") or "").strip()]
infos = await process_backend.process_infos(names)
async def build_snapshot(entry: dict[str, str]) -> dict[str, Any]:
name = str(entry.get("pm2") or "").strip()
return await _process_monitor_snapshot_from_info(entry, infos.get(name) or {}, light=light_value)
results = await asyncio.gather(*(build_snapshot(entry) for entry in entries), return_exceptions=True)
snapshots: list[dict[str, Any]] = []
for entry in entries:
try:
snapshots.append(await _process_monitor_snapshot(entry))
except Exception:
snapshots.append(
{
"pm2": entry["pm2"],
"script": entry["script"],
"label": entry["label"],
"process_status": "error",
"business_status": "error",
"business_note": "Failed to inspect process state",
"ffmpeg_active": False,
"is_pushing": False,
}
)
for entry, result in zip(entries, results):
if not isinstance(result, Exception):
snapshots.append(result)
continue
snapshots.append(
{
"pm2": entry["pm2"],
"script": entry["script"],
"label": entry["label"],
"process_status": "error",
"business_status": "error",
"business_note": "Failed to inspect process state",
"ffmpeg_active": False,
"is_pushing": False,
}
)
return {
"entries": snapshots,
"process_backend": process_backend.mode,
"family": (family_value or "all").strip().lower() or "all",
"light": light_value,
}