优化直播控制台:引入进程监控轻量轮询,并拆分 Live UI 组件

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric
2026-07-09 02:47:33 -05:00
parent 5f1b9db2dc
commit a0104f6517
16 changed files with 1969 additions and 461 deletions

158
web2.py
View File

@@ -29,7 +29,8 @@ from web2_youtube_oauth import (
oauth_status as youtube_oauth_status_snapshot,
)
from web2_client_overview import build_client_overview
from web2_process_runtime import summarize_process_activity
from web2_process_runtime import process_family, summarize_process_activity
from web2_douyin_source_display import extract_douyin_source_meta
from web2_config_governance import (
list_config_backups,
restore_config_backup,
@@ -67,6 +68,7 @@ from web2_relay_pro import (
watch_url_for_relay_process,
write_url_config_for_channel,
)
from web2_youtube_preflight import youtube_start_preflight_message
from web2_youtube_ini import parse_youtube_ini, serialize_youtube_ini, validate_youtube_keys
from web2_host_caps import gpu_status_payload, machine_summary_payload
from web2_camera import camera_config_ready, list_capture_devices, load_camera_config, save_camera_config
@@ -555,7 +557,12 @@ def _ffmpeg_pids_for_process(info: dict, process_name: str) -> list[int]:
if "ffmpeg" not in name:
continue
flat = " ".join(str(x) for x in (proc.info.get("cmdline") or [])).lower()
if needle in flat and int(proc.info["pid"]) not in seen:
env_name = ""
try:
env_name = str(proc.environ().get("LIVE_PM2_PROCESS_NAME") or "").strip().lower()
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
env_name = ""
if env_name == needle or (needle in flat and int(proc.info["pid"]) not in seen):
pids.append(int(proc.info["pid"]))
seen.add(int(proc.info["pid"]))
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, ValueError):
@@ -649,26 +656,48 @@ async def _youtube_guard_rows(targets: list[str]) -> tuple[list[dict], dict[str,
return rows, config_by_process
async def _status_payload_for_process(process: str) -> dict:
async def _status_payload_for_process(process: str, *, light: bool = False) -> dict:
info = await control_get_process_info(process)
recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
if info["process_status"] == "not_found":
ps = str(info.get("process_status") or "")
if ps == "not_found":
recent_log = "进程未运行或未由本机管理器启动。\n"
recent_error = ""
ffmpeg_pids = _ffmpeg_pids_for_process(info, process)
out_age = _log_age_seconds(info.get("out_path"))
err_age = _log_age_seconds(info.get("err_path"))
ages = [x for x in (out_age, err_age) if x is not None]
log_age_seconds = min(ages) if ages else None
ffmpeg_pids: list[int] = []
log_age_seconds = None
elif ps in {"stopped", "stopping"}:
recent_log = ""
recent_error = ""
ffmpeg_pids = []
log_age_seconds = None
else:
online = ps in {"online", "running", "launching"}
read_logs = online or not light
if light and process_family(process) == "youtube":
log_lines = 80
err_lines = 24
else:
log_lines = MAX_LOG_LINES
err_lines = MAX_LOG_LINES
if read_logs:
recent_log = await read_log_path(info["out_path"], log_lines)
recent_error = await read_log_path(info["err_path"], err_lines)
else:
recent_log = ""
recent_error = ""
ffmpeg_pids = _ffmpeg_pids_for_process(info, process) if online else []
out_age = _log_age_seconds(info.get("out_path"))
err_age = _log_age_seconds(info.get("err_path"))
ages = [x for x in (out_age, err_age) if x is not None]
log_age_seconds = min(ages) if ages else None
activity = summarize_process_activity(
process,
str(info["process_status"]),
ps,
recent_log,
recent_error,
log_age_seconds=log_age_seconds,
ffmpeg_pids=ffmpeg_pids,
)
source_meta = _youtube_source_meta(process, recent_log)
return {
"process": process,
"process_status": info["process_status"],
@@ -685,9 +714,17 @@ async def _status_payload_for_process(process: str) -> dict:
"ffmpeg_speed": activity.get("ffmpeg_speed"),
"ffmpeg_progress_stalled": activity.get("ffmpeg_progress_stalled"),
"ffmpeg_speed_low": activity.get("ffmpeg_speed_low"),
**source_meta,
}
def _youtube_source_meta(process: str, recent_log: str = "") -> dict:
sc = script_for_pm2(process)
if not sc or not is_youtube_script(sc):
return {}
return extract_douyin_source_meta(_url_lines_for_process(process), recent_log)
# ---------------- 配置路由youtube.ini仅 youtube ----------------
@app.get("/get_config")
async def get_config(process: str = Query(..., description="进程名")):
@@ -984,6 +1021,10 @@ async def start(request: Request, process: str = Query(..., description="进程
cam_msg = _stream_worker_ready_message(process)
if cam_msg:
return JSONResponse({"output": cam_msg}, status_code=400)
if is_youtube_script(sc):
preflight_msg = await youtube_start_preflight_message()
if preflight_msg:
return JSONResponse({"output": preflight_msg}, status_code=400)
output = await control_start(process)
return JSONResponse({"output": output})
@@ -1045,12 +1086,20 @@ async def restart(request: Request, process: str = Query(..., description="进
cam_msg = _stream_worker_ready_message(process)
if cam_msg:
return JSONResponse({"output": f"{stop_output}\n{cam_msg}"}, status_code=400)
sc = script_for_pm2(process)
if is_youtube_script(sc):
preflight_msg = await youtube_start_preflight_message()
if preflight_msg:
return JSONResponse({"output": f"{stop_output}\n{preflight_msg}"}, status_code=400)
start_output = await control_start(process)
return JSONResponse({"output": f"{stop_output}\n{start_output}"})
@app.get("/status")
async def status(process: str = Query(..., description="进程名")):
async def status(
process: str = Query(..., description="进程名"),
light: bool = Query(False, description="轻量模式:减少日志读取,适合轮询"),
):
if not process_valid(process):
return JSONResponse({
"raw_status": "",
@@ -1059,9 +1108,11 @@ async def status(process: str = Query(..., description="进程名")):
"recent_error": ""
})
full_status_raw = await control_full_status()
full_status = strip_ansi_codes(full_status_raw)
payload = await _status_payload_for_process(process)
full_status = ""
if not light:
full_status_raw = await control_full_status()
full_status = strip_ansi_codes(full_status_raw)
payload = await _status_payload_for_process(process, light=light)
ps = str(payload["process_status"])
prev = _PROCESS_STATUS_CACHE.get(process)
@@ -1277,8 +1328,50 @@ async def config_restore(request: Request):
return {"message": msg}
def _process_monitor_family_matches(entry: dict, family: str | None) -> bool:
target = (family or "").strip().lower()
if not target or target == "all":
return True
script = str(entry.get("script") or "")
pm2 = str(entry.get("pm2") or "")
if target == "youtube":
return is_youtube_script(script) or pm2.startswith("youtube2__")
if target == "tiktok":
return is_tiktok_script(script)
return True
async def _process_monitor_snapshot(entry: dict, *, light: bool = True) -> dict:
pm = str(entry.get("pm2") or "").strip()
snap = await _status_payload_for_process(pm, light=light)
return {
"pm2": pm,
"script": str(entry.get("script") or ""),
"label": str(entry.get("label") or pm),
"process_status": snap.get("process_status", ""),
"business_status": snap.get("business_status", ""),
"business_note": snap.get("business_note", ""),
"is_pushing": bool(snap.get("is_pushing")),
"ffmpeg_active": snap.get("ffmpeg_active"),
"ffmpeg_speed": snap.get("ffmpeg_speed"),
"ffmpeg_frame": snap.get("ffmpeg_frame"),
"ffmpeg_time_seconds": snap.get("ffmpeg_time_seconds"),
"ffmpeg_progress_stalled": snap.get("ffmpeg_progress_stalled"),
"ffmpeg_speed_low": snap.get("ffmpeg_speed_low"),
"source_display": snap.get("source_display", ""),
"source_title": snap.get("source_title", ""),
"source_room_id": snap.get("source_room_id", ""),
"source_url": snap.get("source_url", ""),
"source_ambiguous": snap.get("source_ambiguous", False),
"source_current": snap.get("source_current", False),
}
@app.get("/process_monitor")
async def process_monitor():
async def process_monitor(
family: str | None = Query(None, description="按进程族过滤youtube/tiktok"),
light: bool = Query(False, description="轻量快照:含 business_status / is_pushing"),
):
ensure_default_relay_config(CONFIG_DIR)
entries = [
{"pm2": p["pm2"], "script": p["script"], "label": p["label"]}
@@ -1297,7 +1390,36 @@ async def process_monitor():
"label": f"YouTube {ch.get('name', cid)}",
}
)
return {"entries": entries}
family_value = (family or "").strip().lower() or None
if family_value:
entries = [e for e in entries if _process_monitor_family_matches(e, family_value)]
if not light and not family_value:
return {"entries": entries}
results = await asyncio.gather(
*(_process_monitor_snapshot(e, light=bool(light)) for e in entries),
return_exceptions=True,
)
snapshots: list[dict] = []
for entry, result in zip(entries, results):
if isinstance(result, Exception):
snapshots.append(
{
"pm2": entry["pm2"],
"script": entry.get("script", ""),
"label": entry.get("label", entry["pm2"]),
"process_status": "error",
"business_status": "error",
"business_note": "Failed to inspect process state",
"is_pushing": False,
}
)
else:
snapshots.append(result)
return {
"entries": snapshots,
"family": family_value or "all",
"light": bool(light),
}
# ---------------- 首页 ----------------