614 lines
20 KiB
Python
614 lines
20 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import sys
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from fastapi import FastAPI, Query, Request
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||
from fastapi.staticfiles import StaticFiles
|
||
|
||
from src.android_control import (
|
||
adb_available,
|
||
android_action,
|
||
android_device_overview,
|
||
android_packages,
|
||
android_screenshot,
|
||
android_ui_nodes,
|
||
list_android_devices,
|
||
)
|
||
from src.control_plane import (
|
||
delete_root_file,
|
||
list_root_files,
|
||
list_root_summaries,
|
||
load_hardware_profile,
|
||
query_services,
|
||
read_root_file,
|
||
service_action,
|
||
stack_summary,
|
||
system_snapshot,
|
||
write_root_file,
|
||
)
|
||
from src.web_process_backend import WebProcessBackend, force_kill_ffmpeg, strip_ansi_codes
|
||
|
||
# ---------------- 配置(根目录结构) ----------------
|
||
BASE_DIR = Path(__file__).parent.resolve()
|
||
CONFIG_DIR = BASE_DIR / "config"
|
||
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs"
|
||
CONSOLE_OUT = BASE_DIR / "web-console" / "out"
|
||
|
||
MAX_LOG_LINES = 300
|
||
|
||
process_backend = WebProcessBackend(BASE_DIR, DEFAULT_LOG_DIR)
|
||
|
||
|
||
def _label_short(prefix: str) -> str:
|
||
return {
|
||
"tiktok": "TikTok",
|
||
"youtube": "YouTube",
|
||
"douyin_youtube": "抖音→YouTube",
|
||
"obs": "OBS",
|
||
"web": "Web 控制台",
|
||
}.get(prefix, prefix)
|
||
|
||
|
||
def _sort_script_entries(entries: list[dict], web_script_name: str) -> list[dict]:
|
||
"""下拉列表顺序:抖音→YouTube → YouTube → TikTok → OBS → Web 控制台(默认首选 YouTube 业务)。"""
|
||
|
||
def sort_key(e: dict) -> tuple:
|
||
script = e["script"]
|
||
st = Path(script).stem.lower()
|
||
if st.startswith("douyin_youtube"):
|
||
return (0, script)
|
||
if script.endswith(".py") and st.startswith("youtube"):
|
||
return (1, script)
|
||
if st.startswith("tiktok"):
|
||
return (2, script)
|
||
if st.startswith("obs"):
|
||
return (3, script)
|
||
if script == web_script_name or st == "web":
|
||
return (99, script)
|
||
return (50, script)
|
||
|
||
return sorted(entries, key=sort_key)
|
||
|
||
|
||
def discover_script_entries() -> tuple:
|
||
"""扫描根目录:tiktok*.py、youtube*.py、douyin_youtube*.py;OBS 在 Windows 用 obs*.bat/.cmd,在类 Unix 用 obs*.sh。"""
|
||
base = BASE_DIR
|
||
web_path = Path(__file__).resolve()
|
||
entries: list[dict] = []
|
||
seen_stems: set[str] = set()
|
||
|
||
def add(script_name: str, label_key: str) -> None:
|
||
stem = Path(script_name).stem
|
||
if stem in seen_stems:
|
||
return
|
||
seen_stems.add(stem)
|
||
entries.append({"script": script_name, "label": _label_short(label_key)})
|
||
|
||
for path in sorted(base.glob("tiktok*.py")):
|
||
if path.is_file() and path.resolve() != web_path:
|
||
add(path.name, "tiktok")
|
||
|
||
for path in sorted(base.glob("youtube*.py")):
|
||
if path.is_file() and path.resolve() != web_path:
|
||
add(path.name, "youtube")
|
||
|
||
for path in sorted(base.glob("douyin_youtube*.py")):
|
||
if path.is_file() and path.resolve() != web_path:
|
||
add(path.name, "douyin_youtube")
|
||
|
||
if sys.platform == "win32":
|
||
for path in sorted(list(base.glob("obs*.bat")) + list(base.glob("obs*.cmd"))):
|
||
if path.is_file():
|
||
add(path.name, "obs")
|
||
else:
|
||
for path in sorted(base.glob("obs*.sh")):
|
||
if path.is_file():
|
||
add(path.name, "obs")
|
||
|
||
entries.append({"script": web_path.name, "label": _label_short("web")})
|
||
entries = _sort_script_entries(entries, web_path.name)
|
||
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:
|
||
if not script.endswith(".py"):
|
||
return False
|
||
s = _stem(script).lower()
|
||
return s.startswith("youtube") or s.startswith("douyin_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
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
await process_backend.refresh_mode()
|
||
yield
|
||
|
||
|
||
app = FastAPI(title="多进程录制控制台", lifespan=lifespan)
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"],
|
||
allow_methods=["GET", "POST"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
|
||
async def read_log_path(path: Optional[str], lines: int) -> str:
|
||
if not path:
|
||
return "无日志路径\n"
|
||
norm = path.replace("\\", "/").lower()
|
||
if "/dev/null" in path or norm.endswith("/nul") or norm == "nul":
|
||
return "日志输出被禁用(PM2 配置为无日志)\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 OSError as e:
|
||
return f"读取日志失败 ({path}): {str(e)}\n"
|
||
|
||
|
||
async def get_process_info(process: str) -> dict:
|
||
return await process_backend.parse_pm2_describe(process)
|
||
|
||
|
||
# ---------------- 服务信息(前端可展示 PM2 / 本地后端) ----------------
|
||
@app.get("/health")
|
||
async def health():
|
||
return {"status": "ok"}
|
||
|
||
|
||
@app.get("/server_info")
|
||
async def server_info(refresh: bool = Query(False, description="为 true 时重新检测 PM2 是否可用")):
|
||
if refresh:
|
||
await process_backend.refresh_mode()
|
||
else:
|
||
await process_backend.ensure_mode()
|
||
return {
|
||
"platform": sys.platform,
|
||
"process_backend": process_backend.mode,
|
||
"console_built": (CONSOLE_OUT / "index.html").is_file(),
|
||
"message": "PM2 可用时使用 PM2;否则使用本地 PID 表(无需 npm i -g pm2)。",
|
||
"deployment": {
|
||
"runtime_env_example": "config/runtime.env.example",
|
||
"runtime_env_active": (CONFIG_DIR / "runtime.env").is_file(),
|
||
"docker_compose_default_port": 8101,
|
||
"process_dropdown_order": "douyin_youtube → youtube → tiktok → obs → web",
|
||
},
|
||
"stack": stack_summary(),
|
||
}
|
||
|
||
|
||
@app.get("/stack/summary")
|
||
async def get_stack_summary():
|
||
return stack_summary()
|
||
|
||
|
||
@app.get("/stack/services")
|
||
async def get_stack_services():
|
||
return {"services": await query_services()}
|
||
|
||
|
||
@app.get("/android/devices")
|
||
async def get_android_devices():
|
||
return {
|
||
"available": adb_available(),
|
||
"devices": await list_android_devices(),
|
||
}
|
||
|
||
|
||
@app.get("/android/packages")
|
||
async def get_android_packages(
|
||
serial: str = Query(..., description="Android device serial"),
|
||
query: str = Query("", description="Package name filter"),
|
||
limit: int = Query(60, ge=1, le=200, description="Maximum number of packages"),
|
||
):
|
||
try:
|
||
return {"packages": await android_packages(serial, query=query, limit=limit)}
|
||
except (RuntimeError, ValueError) as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
|
||
|
||
@app.get("/android/ui")
|
||
async def get_android_ui(
|
||
serial: str = Query(..., description="Android device serial"),
|
||
limit: int = Query(80, ge=1, le=200, description="Maximum number of nodes"),
|
||
):
|
||
try:
|
||
return {"nodes": await android_ui_nodes(serial, limit=limit)}
|
||
except (RuntimeError, ValueError) as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
|
||
|
||
@app.get("/android/overview")
|
||
async def get_android_overview(serial: str = Query(..., description="Android device serial")):
|
||
try:
|
||
return await android_device_overview(serial)
|
||
except (RuntimeError, ValueError) as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
|
||
|
||
@app.get("/android/screenshot")
|
||
async def get_android_screenshot(serial: str = Query(..., description="Android device serial")):
|
||
try:
|
||
content = await android_screenshot(serial)
|
||
except (RuntimeError, ValueError) as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
return Response(
|
||
content=content,
|
||
media_type="image/png",
|
||
headers={"Cache-Control": "no-store, max-age=0"},
|
||
)
|
||
|
||
|
||
@app.post("/android/action")
|
||
async def post_android_action(request: Request):
|
||
try:
|
||
data = await request.json()
|
||
action = str(data.get("action", "")).strip()
|
||
serial = str(data.get("serial", "")).strip()
|
||
payload = data.get("payload", {})
|
||
return await android_action(action, serial, payload)
|
||
except (RuntimeError, ValueError) as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
|
||
|
||
@app.get("/service_action")
|
||
async def run_service_action(
|
||
service: str = Query(..., description="服务 id"),
|
||
action: str = Query(..., description="start / stop / restart"),
|
||
):
|
||
try:
|
||
return await service_action(service, action)
|
||
except (KeyError, ValueError) as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
|
||
|
||
@app.get("/managed_roots")
|
||
async def get_managed_roots():
|
||
return {"roots": list_root_summaries()}
|
||
|
||
|
||
@app.get("/managed_files")
|
||
async def get_managed_files(root: str = Query(..., description="管理目录 id")):
|
||
try:
|
||
return {"files": list_root_files(root)}
|
||
except KeyError as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=404)
|
||
|
||
|
||
@app.get("/managed_file")
|
||
async def get_managed_file(
|
||
root: str = Query(..., description="管理目录 id"),
|
||
path: str = Query(..., description="相对路径"),
|
||
):
|
||
try:
|
||
return read_root_file(root, path)
|
||
except KeyError as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=404)
|
||
except FileNotFoundError:
|
||
return JSONResponse({"error": f"File not found: {path}"}, status_code=404)
|
||
except (PermissionError, ValueError) as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
|
||
|
||
@app.post("/managed_file")
|
||
async def save_managed_file(request: Request):
|
||
try:
|
||
data = await request.json()
|
||
return write_root_file(data["root"], data["path"], data.get("content", ""))
|
||
except KeyError as exc:
|
||
return JSONResponse({"error": f"Missing field: {exc}"}, status_code=400)
|
||
except (PermissionError, ValueError) as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
|
||
|
||
@app.delete("/managed_file")
|
||
async def delete_managed_file(
|
||
root: str = Query(..., description="管理目录 id"),
|
||
path: str = Query(..., description="相对路径"),
|
||
):
|
||
try:
|
||
return delete_root_file(root, path)
|
||
except KeyError as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=404)
|
||
except FileNotFoundError:
|
||
return JSONResponse({"error": f"File not found: {path}"}, status_code=404)
|
||
except (PermissionError, ValueError) as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
|
||
|
||
@app.get("/hardware_profile")
|
||
async def hardware_profile(refresh: bool = Query(False, description="重新探测硬件")):
|
||
return await load_hardware_profile(force_refresh=refresh)
|
||
|
||
|
||
@app.get("/system_snapshot")
|
||
async def get_system_snapshot():
|
||
return system_snapshot()
|
||
|
||
|
||
# ---------------- 配置路由(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 / 抖音→YouTube 类脚本支持 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 OSError 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 / 抖音→YouTube 类脚本支持 youtube.ini 编辑"}, status_code=400
|
||
)
|
||
|
||
try:
|
||
data = await request.json()
|
||
content = data.get("content", "")
|
||
except Exception:
|
||
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 OSError as e:
|
||
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
|
||
|
||
|
||
# ---------------- 配置路由(URL_config.ini) ----------------
|
||
@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 / douyin_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 OSError 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 / douyin_youtube*.py 对应进程支持 URL 配置编辑",
|
||
},
|
||
status_code=400,
|
||
)
|
||
|
||
try:
|
||
data = await request.json()
|
||
content = data.get("content", "")
|
||
except Exception:
|
||
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 OSError as e:
|
||
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
|
||
|
||
|
||
def _not_found_hint(process: str) -> str:
|
||
if process_backend.mode == "local":
|
||
return (
|
||
f"进程「{process}」未在本地注册表中;请点击「启动」由控制台直接拉起脚本,"
|
||
"或安装 PM2 后先用 pm2 start 注册同名进程。"
|
||
)
|
||
return "进程未注册到 PM2(可能被 pm2 delete 删除或从未保存)"
|
||
|
||
|
||
# ---------------- 进程控制 ----------------
|
||
@app.get("/start")
|
||
async def start(process: str = Query(..., description="进程名")):
|
||
if process not in VALID_PROCESSES:
|
||
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
||
sc = script_for_pm2(process)
|
||
if not sc:
|
||
return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500)
|
||
output = await process_backend.start(process, sc)
|
||
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)
|
||
|
||
pm2_output = await process_backend.stop(process)
|
||
|
||
await asyncio.sleep(2.0)
|
||
|
||
if not process.startswith("web"):
|
||
await force_kill_ffmpeg(process)
|
||
|
||
await asyncio.sleep(1.0)
|
||
|
||
return JSONResponse(
|
||
{
|
||
"message": "已执行 stop" + ("" if process.startswith("web") else " + 强杀 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)
|
||
sc = script_for_pm2(process)
|
||
if not sc:
|
||
return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500)
|
||
output = await process_backend.restart(process, sc)
|
||
if not process.startswith("web"):
|
||
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:
|
||
return JSONResponse(
|
||
{
|
||
"raw_status": "",
|
||
"process_status": "invalid",
|
||
"recent_log": f"无效进程名: {process}",
|
||
"recent_error": "",
|
||
}
|
||
)
|
||
|
||
full_status_raw = await process_backend.full_status_raw()
|
||
full_status = strip_ansi_codes(full_status_raw)
|
||
info = await get_process_info(process)
|
||
recent_log = await read_log_path(info.get("out_path"), MAX_LOG_LINES)
|
||
recent_error = await read_log_path(info.get("err_path"), MAX_LOG_LINES)
|
||
|
||
if info["process_status"] == "not_found":
|
||
recent_log = _not_found_hint(process) + "\n"
|
||
recent_error = ""
|
||
|
||
return JSONResponse(
|
||
{
|
||
"raw_status": full_status,
|
||
"process_status": info["process_status"],
|
||
"recent_log": recent_log,
|
||
"recent_error": recent_error,
|
||
}
|
||
)
|
||
|
||
|
||
@app.get("/process_monitor")
|
||
async def process_monitor():
|
||
await process_backend.ensure_mode()
|
||
return {
|
||
"entries": [
|
||
{"pm2": p["pm2"], "script": p["script"], "label": p["label"]}
|
||
for p in PROCESS_MONITOR
|
||
],
|
||
"process_backend": process_backend.mode,
|
||
}
|
||
|
||
|
||
@app.get("/favicon.ico", include_in_schema=False)
|
||
async def favicon():
|
||
ico = CONSOLE_OUT / "favicon.ico"
|
||
if ico.is_file():
|
||
return FileResponse(ico)
|
||
return JSONResponse({}, status_code=404)
|
||
|
||
|
||
@app.get("/")
|
||
async def index():
|
||
next_index = CONSOLE_OUT / "index.html"
|
||
if next_index.is_file():
|
||
return FileResponse(next_index)
|
||
return FileResponse(BASE_DIR / "index.html")
|
||
|
||
|
||
def _console_route_html(name: str) -> Optional[Path]:
|
||
direct = CONSOLE_OUT / f"{name}.html"
|
||
if direct.is_file():
|
||
return direct
|
||
nested = CONSOLE_OUT / name / "index.html"
|
||
if nested.is_file():
|
||
return nested
|
||
return None
|
||
|
||
|
||
@app.get("/shellcrash", include_in_schema=False)
|
||
async def shellcrash_page():
|
||
route_file = _console_route_html("shellcrash")
|
||
if route_file:
|
||
return FileResponse(route_file)
|
||
return await index()
|
||
|
||
|
||
@app.get("/android", include_in_schema=False)
|
||
async def android_page():
|
||
route_file = _console_route_html("android")
|
||
if route_file:
|
||
return FileResponse(route_file)
|
||
return await index()
|
||
|
||
|
||
_next_dir = CONSOLE_OUT / "_next"
|
||
if _next_dir.is_dir():
|
||
app.mount("/_next", StaticFiles(directory=str(_next_dir)), name="next_static")
|