's'
This commit is contained in:
86
web.py
86
web.py
@@ -9,15 +9,15 @@ app = FastAPI(title="多进程录制控制台")
|
|||||||
|
|
||||||
# ---------------- 配置(根目录结构) ----------------
|
# ---------------- 配置(根目录结构) ----------------
|
||||||
BASE_DIR = Path(__file__).parent
|
BASE_DIR = Path(__file__).parent
|
||||||
CONFIG_DIR = BASE_DIR / "config" # config 文件夹在项目根目录
|
CONFIG_DIR = BASE_DIR / "config"
|
||||||
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs" # .pm2/logs 在项目根目录
|
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs"
|
||||||
|
|
||||||
MAX_LOG_LINES = 300
|
MAX_LOG_LINES = 300
|
||||||
|
|
||||||
# 支持的进程列表
|
# 支持的进程列表
|
||||||
VALID_PROCESSES = {"tiktok", "youtube", "obs"}
|
VALID_PROCESSES = {"tiktok", "youtube", "obs"}
|
||||||
|
|
||||||
# CORS(支持 GET 和 POST)
|
# CORS
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@@ -25,22 +25,45 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------- 工具 ----------------
|
# ---------------- 工具函数 ----------------
|
||||||
def strip_ansi_codes(text: str) -> str:
|
def strip_ansi_codes(text: str) -> str:
|
||||||
return re.sub(r'\x1b\[[0-9;]*m', '', text)
|
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(
|
process = await asyncio.create_subprocess_shell(
|
||||||
f"pm2 {cmd}",
|
f"pm2 {cmd}",
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15.0)
|
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||||
return (stdout or stderr).decode(errors="ignore").strip()
|
result = (stdout or stderr).decode(errors="ignore").strip()
|
||||||
|
return result if result else "命令执行完成(无输出)"
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
process.kill()
|
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:
|
async def read_log_path(path: str, lines: int) -> str:
|
||||||
if not path:
|
if not path:
|
||||||
@@ -57,21 +80,22 @@ async def read_log_path(path: str, lines: int) -> str:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"读取日志失败 ({path}): {str(e)}\n"
|
return f"读取日志失败 ({path}): {str(e)}\n"
|
||||||
|
|
||||||
|
|
||||||
async def get_process_info(process: str) -> dict:
|
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)
|
describe_clean = strip_ansi_codes(describe_raw)
|
||||||
if ("No such process" in describe_clean or
|
|
||||||
"Unknown process" in describe_clean or
|
if any(x in describe_clean.lower() for x in ["no such process", "unknown process", "not found"]) or not describe_clean.strip():
|
||||||
"not found" in describe_clean.lower() or
|
|
||||||
not describe_clean.strip()):
|
|
||||||
return {
|
return {
|
||||||
"process_status": "not_found",
|
"process_status": "not_found",
|
||||||
"out_path": None,
|
"out_path": None,
|
||||||
"err_path": None,
|
"err_path": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
status = "unknown"
|
status = "unknown"
|
||||||
out_path = None
|
out_path = None
|
||||||
err_path = None
|
err_path = None
|
||||||
|
|
||||||
for line in describe_raw.splitlines():
|
for line in describe_raw.splitlines():
|
||||||
line_clean = strip_ansi_codes(line)
|
line_clean = strip_ansi_codes(line)
|
||||||
if line_clean.count('│') < 2:
|
if line_clean.count('│') < 2:
|
||||||
@@ -86,11 +110,12 @@ async def get_process_info(process: str) -> dict:
|
|||||||
out_path = value
|
out_path = value
|
||||||
elif key == "err_log_path":
|
elif key == "err_log_path":
|
||||||
err_path = value
|
err_path = value
|
||||||
# 备用默认路径
|
|
||||||
if not out_path:
|
if not out_path:
|
||||||
out_path = str(DEFAULT_LOG_DIR / f"{process}-out.log")
|
out_path = str(DEFAULT_LOG_DIR / f"{process}-out.log")
|
||||||
if not err_path:
|
if not err_path:
|
||||||
err_path = str(DEFAULT_LOG_DIR / f"{process}-error.log")
|
err_path = str(DEFAULT_LOG_DIR / f"{process}-error.log")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"process_status": status,
|
"process_status": status,
|
||||||
"out_path": out_path,
|
"out_path": out_path,
|
||||||
@@ -173,20 +198,41 @@ async def start(process: str = Query(..., description="进程名")):
|
|||||||
output = await run_pm2(f"start {process}")
|
output = await run_pm2(f"start {process}")
|
||||||
return JSONResponse({"output": output})
|
return JSONResponse({"output": output})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/stop")
|
@app.get("/stop")
|
||||||
async def stop(process: str = Query(..., description="进程名")):
|
async def stop(process: str = Query(..., description="进程名")):
|
||||||
if process not in VALID_PROCESSES:
|
if process not in VALID_PROCESSES:
|
||||||
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
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")
|
@app.get("/restart")
|
||||||
async def restart(process: str = Query(..., description="进程名")):
|
async def restart(process: str = Query(..., description="进程名")):
|
||||||
if process not in VALID_PROCESSES:
|
if process not in VALID_PROCESSES:
|
||||||
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
||||||
output = await run_pm2(f"restart {process}")
|
output = await run_pm2(f"restart {process}")
|
||||||
|
# 重启后也建议清理一次残留(视情况可选)
|
||||||
|
await force_kill_ffmpeg(process)
|
||||||
return JSONResponse({"output": output})
|
return JSONResponse({"output": output})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/status")
|
@app.get("/status")
|
||||||
async def status(process: str = Query(..., description="进程名")):
|
async def status(process: str = Query(..., description="进程名")):
|
||||||
if process not in VALID_PROCESSES:
|
if process not in VALID_PROCESSES:
|
||||||
@@ -196,14 +242,17 @@ async def status(process: str = Query(..., description="进程名")):
|
|||||||
"recent_log": f"无效进程名: {process}",
|
"recent_log": f"无效进程名: {process}",
|
||||||
"recent_error": ""
|
"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)
|
full_status = strip_ansi_codes(full_status_raw)
|
||||||
info = await get_process_info(process)
|
info = await get_process_info(process)
|
||||||
recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
|
recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
|
||||||
recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
|
recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
|
||||||
|
|
||||||
if info["process_status"] == "not_found":
|
if info["process_status"] == "not_found":
|
||||||
recent_log = "进程未注册到 PM2(可能被 pm2 delete 删除或从未保存)\n"
|
recent_log = "进程未注册到 PM2(可能被 pm2 delete 删除或从未保存)\n"
|
||||||
recent_error = ""
|
recent_error = ""
|
||||||
|
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"raw_status": full_status,
|
"raw_status": full_status,
|
||||||
"process_status": info["process_status"],
|
"process_status": info["process_status"],
|
||||||
@@ -211,7 +260,8 @@ async def status(process: str = Query(..., description="进程名")):
|
|||||||
"recent_error": recent_error,
|
"recent_error": recent_error,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 首页 ----------------
|
# ---------------- 首页 ----------------
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def index():
|
async def index():
|
||||||
return FileResponse(BASE_DIR / "index.html")
|
return FileResponse(BASE_DIR / "index.html")
|
||||||
Reference in New Issue
Block a user