from fastapi import FastAPI, Query, Request from fastapi.responses import FileResponse, JSONResponse from pathlib import Path import asyncio from typing import Optional from fastapi.middleware.cors import CORSMiddleware from web2_supervisor import ( BuiltinSupervisor, force_kill_ffmpeg_cross_platform, get_process_info_pm2, run_pm2_shell, strip_ansi_codes, use_builtin, ) from web2_network import run_network_dashboard from web2_proxy_config import read_proxy_config, write_proxy_config from web2_system_metrics import snapshot as system_metrics_snapshot app = FastAPI(title="多进程录制控制台") # ---------------- 配置(根目录结构) ---------------- BASE_DIR = Path(__file__).parent CONFIG_DIR = BASE_DIR / "config" DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs" MAX_LOG_LINES = 300 def _label_short(prefix: str) -> str: return {"tiktok": "TikTok", "youtube": "YouTube", "obs": "OBS", "web": "Web 控制台"}.get(prefix, prefix) def discover_script_entries() -> tuple: """扫描项目根目录自动识别入口:tiktok*.py、youtube*.py、obs*.sh / obs*.bat,以及当前本文件(Web 控制台)。进程名 = 主文件名(无扩展名)。""" base = BASE_DIR web_path = Path(__file__).resolve() entries: list[dict] = [] for path in sorted(base.glob("tiktok*.py")): if path.is_file() and path.resolve() != web_path: entries.append({"script": path.name, "label": _label_short("tiktok")}) for path in sorted(base.glob("youtube*.py")): if path.is_file() and path.resolve() != web_path: entries.append({"script": path.name, "label": _label_short("youtube")}) for path in sorted(base.glob("obs*.sh")): if path.is_file(): entries.append({"script": path.name, "label": _label_short("obs")}) for path in sorted(base.glob("obs*.bat")): if path.is_file(): entries.append({"script": path.name, "label": _label_short("obs")}) entries.append({"script": web_path.name, "label": _label_short("web")}) return tuple(entries) SCRIPT_ENTRIES = discover_script_entries() def pm2_name_from_script(script: str) -> str: return Path(script).stem def _stem(script: str) -> str: return Path(script).stem def is_youtube_script(script: str) -> bool: return script.endswith(".py") and _stem(script).startswith("youtube") def is_tiktok_script(script: str) -> bool: return script.endswith(".py") and _stem(script).startswith("tiktok") def _build_process_monitor(): return tuple( { "pm2": pm2_name_from_script(e["script"]), "script": e["script"], "label": e["label"], } for e in SCRIPT_ENTRIES ) PROCESS_MONITOR = _build_process_monitor() VALID_PROCESSES = frozenset(p["pm2"] for p in PROCESS_MONITOR) URL_CONFIG_PM2 = frozenset( pm2_name_from_script(e["script"]) for e in SCRIPT_ENTRIES if is_tiktok_script(e["script"]) or is_youtube_script(e["script"]) ) def script_for_pm2(pm2: str) -> Optional[str]: for p in PROCESS_MONITOR: if p["pm2"] == pm2: return p["script"] return None WEB_SCRIPT_NAME = Path(__file__).name _builtin_supervisor: Optional[BuiltinSupervisor] = None def get_builtin_supervisor() -> BuiltinSupervisor: global _builtin_supervisor if _builtin_supervisor is None: _builtin_supervisor = BuiltinSupervisor(BASE_DIR, WEB_SCRIPT_NAME, script_for_pm2) return _builtin_supervisor async def control_start(process: str) -> str: if use_builtin(): return await get_builtin_supervisor().start(process) return await run_pm2_shell(f"start {process}") async def control_stop(process: str) -> str: if use_builtin(): return await get_builtin_supervisor().stop(process) return await run_pm2_shell(f"stop {process}") async def control_restart(process: str) -> str: if use_builtin(): return await get_builtin_supervisor().restart(process) return await run_pm2_shell(f"restart {process}") async def control_full_status() -> str: if use_builtin(): return await get_builtin_supervisor().full_status_text() return await run_pm2_shell("status", timeout=8.0) async def control_get_process_info(process: str) -> dict: if use_builtin(): return await get_builtin_supervisor().get_process_info(process) return await get_process_info_pm2(process, DEFAULT_LOG_DIR, strip_ansi_codes) # CORS app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["GET", "POST"], allow_headers=["*"], ) # ---------------- 工具函数 ---------------- async def read_log_path(path: str, lines: int) -> str: if not path: return "无日志路径\n" if "/dev/null" in path: return "日志输出被禁用(无日志文件)\n" log_file = Path(path) if not log_file.exists(): return f"日志文件不存在: {path}\n" try: with open(log_file, "r", encoding="utf-8", errors="ignore") as f: content_lines = f.readlines() return "".join(content_lines[-lines:]) if content_lines else "无日志内容\n" except Exception as e: return f"读取日志失败 ({path}): {str(e)}\n" # ---------------- 配置路由(youtube.ini,仅 youtube) ---------------- @app.get("/get_config") async def get_config(process: str = Query(..., description="进程名")): sc = script_for_pm2(process) if not sc or not is_youtube_script(sc): return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini 编辑"}, status_code=400) config_file = CONFIG_DIR / "youtube.ini" if not config_file.exists(): return {"content": "# youtube.ini 文件不存在,将创建空文件\n"} try: content = config_file.read_text(encoding="utf-8") return {"content": content} except Exception as e: return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500) @app.post("/save_config") async def save_config(request: Request, process: str = Query(..., description="进程名")): sc = script_for_pm2(process) if not sc or not is_youtube_script(sc): return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini 编辑"}, status_code=400) try: data = await request.json() content = data.get("content", "") except: return JSONResponse({"error": "无效的 JSON 数据"}, status_code=400) config_file = CONFIG_DIR / "youtube.ini" try: CONFIG_DIR.mkdir(parents=True, exist_ok=True) config_file.write_text(content, encoding="utf-8") return {"message": "配置保存成功"} except Exception as e: return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500) # ---------------- 配置路由(URL_config.ini,youtube 和 tiktok 共享) ---------------- @app.get("/get_url_config") async def get_url_config(process: str = Query(..., description="进程名")): if process not in URL_CONFIG_PM2: return JSONResponse({"error": "仅 tiktok*.py / youtube*.py 对应进程支持 URL 配置编辑"}, status_code=400) config_file = CONFIG_DIR / "URL_config.ini" if not config_file.exists(): return {"content": "# URL_config.ini 文件不存在,将创建空文件\n"} try: content = config_file.read_text(encoding="utf-8") return {"content": content} except Exception as e: return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500) @app.post("/save_url_config") async def save_url_config(request: Request, process: str = Query(..., description="进程名")): if process not in URL_CONFIG_PM2: return JSONResponse({"error": "仅 tiktok*.py / youtube*.py 对应进程支持 URL 配置编辑"}, status_code=400) try: data = await request.json() content = data.get("content", "") except: return JSONResponse({"error": "无效的 JSON 数据"}, status_code=400) config_file = CONFIG_DIR / "URL_config.ini" try: CONFIG_DIR.mkdir(parents=True, exist_ok=True) config_file.write_text(content, encoding="utf-8") return {"message": "配置保存成功"} except Exception as e: return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500) # ---------------- PM2 控制路由 ---------------- @app.get("/start") async def start(process: str = Query(..., description="进程名")): if process not in VALID_PROCESSES: return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400) output = await control_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) # 第一步:尝试优雅停止 ctrl_output = await control_stop(process) # 第二步:等待信号传播(通常 1~3 秒) await asyncio.sleep(2.0) # 第三步:录制类进程才强杀 ffmpeg(web* 控制台不杀 ffmpeg) if not process.startswith("web"): await force_kill_ffmpeg_cross_platform(process) # 第四步:再等一小会儿,确认 await asyncio.sleep(1.0) return JSONResponse({ "message": "已执行 stop" + ("" if process.startswith("web") else " + 强杀 ffmpeg"), "pm2_output": ctrl_output, "note": "如果仍有残留,可多次点击停止或查看下方日志", }) @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 control_restart(process) if not process.startswith("web"): await force_kill_ffmpeg_cross_platform(process) return JSONResponse({"output": output}) @app.get("/status") async def status(process: str = Query(..., description="进程名")): if process not in VALID_PROCESSES: return JSONResponse({ "raw_status": "", "process_status": "invalid", "recent_log": f"无效进程名: {process}", "recent_error": "" }) full_status_raw = await control_full_status() full_status = strip_ansi_codes(full_status_raw) 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": recent_log = "进程未运行或未由本机管理器启动。\n" recent_error = "" return JSONResponse({ "raw_status": full_status, "process_status": info["process_status"], "recent_log": recent_log, "recent_error": recent_error, }) @app.get("/health") async def health(): return {"ok": True} @app.get("/network_status") async def network_status(): """Google / 抖音 连通与简易网速(供仪表盘)。""" try: return await run_network_dashboard() except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) @app.get("/proxy_config") async def get_proxy_config(): """仅作用于录制子进程的代理(不写 Windows 系统代理)。""" return read_proxy_config() @app.post("/proxy_config") async def post_proxy_config(request: Request): try: data = await request.json() except Exception: return JSONResponse({"error": "无效的 JSON"}, status_code=400) if not isinstance(data, dict): return JSONResponse({"error": "无效数据"}, status_code=400) write_proxy_config( { "enabled": bool(data.get("enabled")), "http": str(data.get("http") or ""), "socks": str(data.get("socks") or ""), } ) return {"ok": True} @app.get("/system_metrics") async def system_metrics(): """CPU/内存/磁盘/网卡速率与 ffmpeg 进程摘要。""" try: return system_metrics_snapshot() except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) # ---------------- 进程列表(供前端同步,由 discover_script_entries 自动扫描) ---------------- @app.get("/process_monitor") async def process_monitor(): return { "entries": [ {"pm2": p["pm2"], "script": p["script"], "label": p["label"]} for p in PROCESS_MONITOR ] } # ---------------- 首页 ---------------- @app.get("/") async def index(): return FileResponse(BASE_DIR / "index.html")