From bf6b827e1fde792fb4f71d153f6fe65dd7664d75 Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 28 Feb 2026 09:06:48 -0600 Subject: [PATCH] 's' --- web.py | 86 ++++++++++++++++++++++++++++++++++++++++++++++------------ web.sh | 2 +- 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/web.py b/web.py index 000514a..9efc064 100644 --- a/web.py +++ b/web.py @@ -9,15 +9,15 @@ app = FastAPI(title="多进程录制控制台") # ---------------- 配置(根目录结构) ---------------- BASE_DIR = Path(__file__).parent -CONFIG_DIR = BASE_DIR / "config" # config 文件夹在项目根目录 -DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs" # .pm2/logs 在项目根目录 +CONFIG_DIR = BASE_DIR / "config" +DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs" MAX_LOG_LINES = 300 # 支持的进程列表 VALID_PROCESSES = {"tiktok", "youtube", "obs"} -# CORS(支持 GET 和 POST) +# CORS app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -25,22 +25,45 @@ app.add_middleware( allow_headers=["*"], ) -# ---------------- 工具 ---------------- +# ---------------- 工具函数 ---------------- def strip_ansi_codes(text: str) -> str: return re.sub(r'\x1b\[[0-9;]*m', '', text) -async def run_pm2(cmd: str) -> str: + +async def run_pm2(cmd: str, timeout: float = 15.0) -> str: process = await asyncio.create_subprocess_shell( f"pm2 {cmd}", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) try: - stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15.0) - return (stdout or stderr).decode(errors="ignore").strip() + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout) + result = (stdout or stderr).decode(errors="ignore").strip() + return result if result else "命令执行完成(无输出)" except asyncio.TimeoutError: process.kill() - return "ERROR: PM2 命令超时" + await process.wait() + return "ERROR: PM2 命令超时,已强制终止" + + +async def force_kill_ffmpeg(process_name: str): + """尝试强杀与该进程相关的 ffmpeg""" + cmds = [ + # 优先杀带有进程名的 ffmpeg(最精准) + f"pkill -f 'ffmpeg.*{process_name}' || true", + # 再杀所有 ffmpeg(兜底,比较暴力) + "killall -9 ffmpeg 2>/dev/null || true", + ] + + for cmd in cmds: + proc = await asyncio.create_subprocess_shell( + cmd, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + await asyncio.sleep(0.4) # 稍微间隔,避免竞争 + async def read_log_path(path: str, lines: int) -> str: if not path: @@ -57,21 +80,22 @@ async def read_log_path(path: str, lines: int) -> str: except Exception as e: return f"读取日志失败 ({path}): {str(e)}\n" + async def get_process_info(process: str) -> dict: - describe_raw = await run_pm2(f"describe {process}") + describe_raw = await run_pm2(f"describe {process}", timeout=10.0) describe_clean = strip_ansi_codes(describe_raw) - if ("No such process" in describe_clean or - "Unknown process" in describe_clean or - "not found" in describe_clean.lower() or - not describe_clean.strip()): + + if any(x in describe_clean.lower() for x in ["no such process", "unknown process", "not found"]) or not describe_clean.strip(): return { "process_status": "not_found", "out_path": None, "err_path": None, } + status = "unknown" out_path = None err_path = None + for line in describe_raw.splitlines(): line_clean = strip_ansi_codes(line) if line_clean.count('│') < 2: @@ -86,11 +110,12 @@ async def get_process_info(process: str) -> dict: out_path = value elif key == "err_log_path": err_path = value - # 备用默认路径 + if not out_path: out_path = str(DEFAULT_LOG_DIR / f"{process}-out.log") if not err_path: err_path = str(DEFAULT_LOG_DIR / f"{process}-error.log") + return { "process_status": status, "out_path": out_path, @@ -173,20 +198,41 @@ async def start(process: str = Query(..., description="进程名")): output = await run_pm2(f"start {process}") return JSONResponse({"output": output}) + @app.get("/stop") async def stop(process: str = Query(..., description="进程名")): if process not in VALID_PROCESSES: return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400) - output = await run_pm2(f"stop {process}") - return JSONResponse({"output": output}) + + # 第一步:尝试优雅停止 + pm2_output = await run_pm2(f"stop {process}") + + # 第二步:等待 PM2 信号传播(通常 1~3 秒) + await asyncio.sleep(2.0) + + # 第三步:主动查找并杀死 ffmpeg + await force_kill_ffmpeg(process) + + # 第四步:再等一小会儿,确认 + await asyncio.sleep(1.0) + + return JSONResponse({ + "message": "已执行 stop + 强杀 ffmpeg", + "pm2_output": pm2_output, + "note": "如果仍有残留,可多次点击停止或检查 pm2 logs" + }) + @app.get("/restart") async def restart(process: str = Query(..., description="进程名")): if process not in VALID_PROCESSES: return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400) output = await run_pm2(f"restart {process}") + # 重启后也建议清理一次残留(视情况可选) + await force_kill_ffmpeg(process) return JSONResponse({"output": output}) + @app.get("/status") async def status(process: str = Query(..., description="进程名")): if process not in VALID_PROCESSES: @@ -196,14 +242,17 @@ async def status(process: str = Query(..., description="进程名")): "recent_log": f"无效进程名: {process}", "recent_error": "" }) - full_status_raw = await run_pm2("status") + + full_status_raw = await run_pm2("status", timeout=8.0) full_status = strip_ansi_codes(full_status_raw) info = await 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": recent_log = "进程未注册到 PM2(可能被 pm2 delete 删除或从未保存)\n" recent_error = "" + return JSONResponse({ "raw_status": full_status, "process_status": info["process_status"], @@ -211,7 +260,8 @@ async def status(process: str = Query(..., description="进程名")): "recent_error": recent_error, }) + # ---------------- 首页 ---------------- @app.get("/") async def index(): - return FileResponse(BASE_DIR / "index.html") + return FileResponse(BASE_DIR / "index.html") \ No newline at end of file diff --git a/web.sh b/web.sh index 758f1de..584810b 100644 --- a/web.sh +++ b/web.sh @@ -1 +1 @@ -uvicorn web:app --host 0.0.0.0 --port 8200 --reload +uvicorn web:app --host 0.0.0.0 --port 8000 --reload