1164 lines
39 KiB
Python
1164 lines
39 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import re
|
||
import sys
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
from fastapi import FastAPI, File, Query, Request, UploadFile, WebSocket
|
||
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,
|
||
adb_connect_address,
|
||
adb_disconnect_address,
|
||
adb_pair_code,
|
||
android_action,
|
||
android_device_overview,
|
||
android_display_metrics,
|
||
android_logcat_recent,
|
||
android_packages,
|
||
android_screenshot,
|
||
android_ui_nodes,
|
||
list_android_devices,
|
||
)
|
||
from src.scrcpy_stream import cleanup_scrcpy_session, open_scrcpy_h264_stream, scrcpy_stream_info
|
||
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.config_governance import (
|
||
delete_managed_file as governed_delete_managed_file,
|
||
inspect_file,
|
||
list_backups,
|
||
optimize_content,
|
||
restore_backup,
|
||
save_managed_file as governed_save_managed_file,
|
||
)
|
||
from src.hub_kv_store import get_youtube_pro_channels, set_youtube_pro_channels
|
||
from src.hub_routes import YoutubeProChannelsBody, configure_hub, router as hub_router
|
||
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
|
||
|
||
MAX_TIKTOK_REPLAY_UPLOAD = 2 * 1024 * 1024 * 1024
|
||
_TIKTOK_REPLAY_EXT = frozenset({".mp4", ".mkv", ".ts", ".flv", ".mov", ".m4v", ".webm"})
|
||
|
||
process_backend = WebProcessBackend(BASE_DIR, DEFAULT_LOG_DIR)
|
||
|
||
|
||
def _sanitize_pm2_dir_fragment(name: str) -> str:
|
||
s = re.sub(r"[^a-zA-Z0-9._-]+", "_", (name or "").strip())
|
||
return (s or "default")[:120]
|
||
|
||
|
||
def tiktok_replay_dir(pm2: str) -> Path:
|
||
d = CONFIG_DIR / "tiktok_replay" / _sanitize_pm2_dir_fragment(pm2)
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
return d
|
||
|
||
|
||
def allows_tiktok_replay_upload(pm2: str) -> bool:
|
||
sc = script_for_pm2_or_pro(pm2)
|
||
return bool(sc and Path(sc).stem.lower().startswith("tiktok"))
|
||
|
||
|
||
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 仅 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")
|
||
|
||
# douyin_youtube_ffplay.py 为推流实现库,由 youtube.py 动态 import;单独执行会立即退出,勿当作 PM2/控制台进程。
|
||
_douyin_youtube_skip = frozenset({"douyin_youtube_ffplay.py"})
|
||
for path in sorted(base.glob("douyin_youtube*.py")):
|
||
if path.is_file() and path.resolve() != web_path and path.name not in _douyin_youtube_skip:
|
||
add(path.name, "douyin_youtube")
|
||
|
||
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 _script_targets_hdmi_sink(script: str) -> bool:
|
||
"""与采集卡/板端 HDMI 输出争用同一物理链路的进程(互斥;与 YouTube RTMP 推流无关)。"""
|
||
if is_tiktok_script(script):
|
||
return True
|
||
s = _stem(script).lower()
|
||
return script.endswith(".sh") and s.startswith("obs")
|
||
|
||
|
||
async def _stop_other_hdmi_sink_processes(keep_pm2: str) -> list[str]:
|
||
sc_keep = script_for_pm2_or_pro(keep_pm2)
|
||
if not sc_keep or not _script_targets_hdmi_sink(sc_keep):
|
||
return []
|
||
await process_backend.ensure_mode()
|
||
stopped: list[str] = []
|
||
try:
|
||
online = await process_backend.online_process_names()
|
||
except Exception:
|
||
online = []
|
||
for name in online:
|
||
if name == keep_pm2:
|
||
continue
|
||
sc = script_for_pm2_or_pro(name)
|
||
if not sc or not _script_targets_hdmi_sink(sc):
|
||
continue
|
||
info = await get_process_info(name)
|
||
st = (info.get("process_status") or "").lower()
|
||
if st not in ("online", "running"):
|
||
continue
|
||
await process_backend.stop(name)
|
||
if not str(name).startswith("web"):
|
||
await force_kill_ffmpeg(str(name), BASE_DIR)
|
||
stopped.append(str(name))
|
||
if stopped:
|
||
await asyncio.sleep(0.85)
|
||
return stopped
|
||
|
||
|
||
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()
|
||
|
||
|
||
def _pm2_safe_segment(pm2: str) -> str:
|
||
return re.sub(r"[^a-zA-Z0-9_.-]", "_", pm2)
|
||
|
||
|
||
def _pro_script_head(pm2: str) -> Optional[str]:
|
||
if "__" not in pm2:
|
||
return None
|
||
head = pm2.split("__", 1)[0].strip()
|
||
return head.lower() if head else None
|
||
|
||
|
||
def _resolve_pro_style_script(head_l: str) -> Optional[str]:
|
||
"""多频道 / Electron 风格 PM2 名前缀(如 youtube2__、tiktok_live__)映射到仓库内脚本。
|
||
|
||
不能仅用 stem 相等判断:youtube2 != youtube.py 的 stem「youtube」。
|
||
"""
|
||
if not head_l:
|
||
return None
|
||
h = head_l.lower()
|
||
py_scripts = [p["script"] for p in PROCESS_MONITOR if str(p.get("script", "")).endswith(".py")]
|
||
matches: list[str] = []
|
||
if h.startswith("douyin_youtube"):
|
||
matches = [s for s in py_scripts if Path(s).stem.lower().startswith("douyin_youtube")]
|
||
elif h.startswith("youtube"):
|
||
matches = [s for s in py_scripts if Path(s).stem.lower().startswith("youtube")]
|
||
elif h.startswith("tiktok"):
|
||
matches = [s for s in py_scripts if Path(s).stem.lower().startswith("tiktok")]
|
||
if not matches:
|
||
return None
|
||
matches.sort(key=lambda s: (len(Path(s).stem), s))
|
||
return matches[0]
|
||
|
||
|
||
def _guess_script_from_pm2_alias(pm2: str) -> Optional[str]:
|
||
"""PM2 名与 ecosystem 脚本 stem 不一致时(如自定义 name),按关键词匹配仓库内脚本。"""
|
||
low = (pm2 or "").strip().lower()
|
||
if not low:
|
||
return None
|
||
base = BASE_DIR
|
||
web_path = Path(__file__).resolve()
|
||
|
||
def pick(glob_pat: str, skip: frozenset[str]) -> Optional[str]:
|
||
for p in sorted(base.glob(glob_pat)):
|
||
if not p.is_file() or p.resolve() == web_path:
|
||
continue
|
||
if p.name in skip:
|
||
continue
|
||
return p.name
|
||
return None
|
||
|
||
if "douyin_youtube" in low or low.startswith("d2y"):
|
||
return pick("douyin_youtube*.py", frozenset({"douyin_youtube_ffplay.py"}))
|
||
if "tiktok" in low:
|
||
return pick("tiktok*.py", frozenset())
|
||
if "youtube" in low:
|
||
return pick("youtube*.py", frozenset())
|
||
return None
|
||
|
||
|
||
def script_for_pm2_or_pro(pm2: str) -> Optional[str]:
|
||
s = script_for_pm2(pm2)
|
||
if s:
|
||
return s
|
||
head_l = _pro_script_head(pm2)
|
||
if head_l:
|
||
resolved = _resolve_pro_style_script(head_l)
|
||
if resolved:
|
||
return resolved
|
||
for p in PROCESS_MONITOR:
|
||
if Path(p["script"]).stem.lower() == head_l:
|
||
return p["script"]
|
||
guess = _guess_script_from_pm2_alias(pm2)
|
||
if guess:
|
||
return guess
|
||
return None
|
||
|
||
|
||
def is_valid_control_process(pm2: str) -> bool:
|
||
return script_for_pm2_or_pro(pm2) is not None
|
||
|
||
|
||
def allows_url_config(pm2: str) -> bool:
|
||
sc = script_for_pm2_or_pro(pm2)
|
||
return sc is not None and (is_youtube_script(sc) or is_tiktok_script(sc))
|
||
|
||
|
||
def managed_url_config_relpath(pm2: str) -> str:
|
||
sc = script_for_pm2_or_pro(pm2)
|
||
if not sc:
|
||
return "URL_config.ini"
|
||
if is_youtube_script(sc) or is_tiktok_script(sc):
|
||
return f"URL_config.{_pm2_safe_segment(pm2)}.ini"
|
||
return "URL_config.ini"
|
||
|
||
|
||
def managed_youtube_ini_relpath(pm2: str) -> Optional[str]:
|
||
sc = script_for_pm2_or_pro(pm2)
|
||
if not sc or not is_youtube_script(sc):
|
||
return None
|
||
return f"youtube.{_pm2_safe_segment(pm2)}.ini"
|
||
|
||
|
||
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):
|
||
try:
|
||
await process_backend.refresh_mode()
|
||
except Exception:
|
||
# PM2/Shell 异常时不阻塞整站启动(否则反代持续 502)
|
||
pass
|
||
yield
|
||
|
||
|
||
app = FastAPI(title="多进程录制控制台", lifespan=lifespan)
|
||
|
||
configure_hub(process_backend, list(PROCESS_MONITOR), BASE_DIR)
|
||
app.include_router(hub_router)
|
||
|
||
|
||
@app.get("/youtube_pro_channels", include_in_schema=False)
|
||
async def alias_youtube_pro_channels_get() -> dict:
|
||
"""与 /hub/youtube_pro_channels 相同;便于反代只转发根路径时仍能同步多频道列表。"""
|
||
return {"channels": get_youtube_pro_channels()}
|
||
|
||
|
||
@app.post("/youtube_pro_channels", include_in_schema=False)
|
||
async def alias_youtube_pro_channels_post(body: YoutubeProChannelsBody):
|
||
try:
|
||
set_youtube_pro_channels(body.channels)
|
||
except ValueError as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
return {"message": "ok"}
|
||
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"],
|
||
allow_methods=["GET", "POST", "OPTIONS"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
|
||
class ProcessNameBody(BaseModel):
|
||
process: str = Field(..., min_length=1, max_length=160)
|
||
|
||
|
||
class ServiceActionPostBody(BaseModel):
|
||
service: str = Field(..., min_length=1, max_length=64)
|
||
action: str = Field(..., pattern="^(start|stop|restart)$")
|
||
|
||
|
||
class ManagedFileBody(BaseModel):
|
||
root: str = Field(..., min_length=1, max_length=64)
|
||
path: str = Field(..., min_length=1, max_length=240)
|
||
content: str = ""
|
||
|
||
|
||
class ConfigAnalyzeBody(ManagedFileBody):
|
||
pass
|
||
|
||
|
||
class ConfigOptimizeBody(ManagedFileBody):
|
||
action: str = Field(..., min_length=1, max_length=64)
|
||
|
||
|
||
class ConfigRestoreBody(BaseModel):
|
||
root: str = Field(..., min_length=1, max_length=64)
|
||
path: str = Field(..., min_length=1, max_length=240)
|
||
backup_id: str = Field(..., min_length=1, max_length=320)
|
||
|
||
|
||
class AndroidConnectBody(BaseModel):
|
||
address: str = Field(..., min_length=1, max_length=200)
|
||
|
||
|
||
class AndroidDisconnectBody(BaseModel):
|
||
address: str = Field(default="", max_length=200)
|
||
|
||
|
||
class AndroidPairBody(BaseModel):
|
||
host: str = Field(..., min_length=1, max_length=160)
|
||
port: int = Field(default=37_777, ge=1, le=65535)
|
||
code: str = Field(..., min_length=4, max_length=16)
|
||
|
||
|
||
class TiktokReplayDeleteBody(BaseModel):
|
||
process: str = Field(..., min_length=1)
|
||
filename: str = Field(..., min_length=1, max_length=512)
|
||
|
||
|
||
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.post("/android/connect")
|
||
async def post_android_connect(body: AndroidConnectBody):
|
||
try:
|
||
return await adb_connect_address(body.address.strip())
|
||
except (RuntimeError, ValueError) as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
|
||
|
||
@app.post("/android/disconnect")
|
||
async def post_android_disconnect(body: AndroidDisconnectBody):
|
||
try:
|
||
return await adb_disconnect_address((body.address or "").strip())
|
||
except (RuntimeError, ValueError) as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
|
||
|
||
@app.post("/android/pair")
|
||
async def post_android_pair(body: AndroidPairBody):
|
||
try:
|
||
return await adb_pair_code(body.host.strip(), body.port, body.code.strip())
|
||
except (RuntimeError, ValueError) as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
|
||
|
||
@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/logcat")
|
||
async def get_android_logcat(
|
||
serial: str = Query(..., description="Android device serial"),
|
||
lines: int = Query(200, ge=1, le=3000, description="Number of recent log lines"),
|
||
):
|
||
try:
|
||
text = await android_logcat_recent(serial, lines=lines)
|
||
return {"text": text}
|
||
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"),
|
||
fast: bool = Query(False, description="更短超时,适合 Live 轮询(无线/Redroid 仍可能回落慢路径)"),
|
||
):
|
||
try:
|
||
content = await android_screenshot(serial, exec_timeout=8.5 if fast else 14.0)
|
||
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.get("/android/display_metrics")
|
||
async def get_android_display_metrics(serial: str = Query(..., description="Android device serial")):
|
||
try:
|
||
return await android_display_metrics(serial)
|
||
except (RuntimeError, ValueError) as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
|
||
|
||
@app.get("/android/scrcpy/info")
|
||
async def get_android_scrcpy_info():
|
||
"""是否具备 scrcpy-server 与版本提示(真 · WebSocket 流依赖此 jar)。"""
|
||
return scrcpy_stream_info()
|
||
|
||
|
||
@app.websocket("/android/scrcpy/ws")
|
||
async def android_scrcpy_ws(websocket: WebSocket):
|
||
"""浏览器 WebSocket:推送设备端 scrcpy-server raw_stream H.264 字节流(与官方 scrcpy 同源编码)。"""
|
||
await websocket.accept()
|
||
serial = (websocket.query_params.get("serial") or "").strip()
|
||
ms_raw = (websocket.query_params.get("max_size") or "1920").strip()
|
||
try:
|
||
max_size = max(0, min(int(ms_raw), 8192))
|
||
except ValueError:
|
||
max_size = 1920
|
||
|
||
writer: asyncio.StreamWriter | None = None
|
||
port: int | None = None
|
||
try:
|
||
port, _scid, reader, writer = await open_scrcpy_h264_stream(serial, max_size=max_size)
|
||
except ValueError as exc:
|
||
await websocket.send_text(json.dumps({"error": str(exc), "phase": "serial"}))
|
||
await websocket.close(code=4400)
|
||
return
|
||
except Exception as exc:
|
||
await websocket.send_text(json.dumps({"error": str(exc), "phase": "setup"}))
|
||
await websocket.close(code=4500)
|
||
return
|
||
|
||
async def pump() -> None:
|
||
try:
|
||
while True:
|
||
chunk = await reader.read(64 * 1024)
|
||
if not chunk:
|
||
break
|
||
await websocket.send_bytes(chunk)
|
||
except Exception:
|
||
pass
|
||
|
||
async def wait_client_disconnect() -> None:
|
||
try:
|
||
while True:
|
||
msg = await websocket.receive()
|
||
if msg.get("type") == "websocket.disconnect":
|
||
return
|
||
except Exception:
|
||
return
|
||
|
||
pump_task = asyncio.create_task(pump())
|
||
disc_task = asyncio.create_task(wait_client_disconnect())
|
||
try:
|
||
_done, pending = await asyncio.wait(
|
||
{pump_task, disc_task},
|
||
return_when=asyncio.FIRST_COMPLETED,
|
||
)
|
||
for t in pending:
|
||
t.cancel()
|
||
await asyncio.gather(*pending, return_exceptions=True)
|
||
finally:
|
||
if not pump_task.done():
|
||
pump_task.cancel()
|
||
await asyncio.gather(pump_task, return_exceptions=True)
|
||
if not disc_task.done():
|
||
disc_task.cancel()
|
||
await asyncio.gather(disc_task, return_exceptions=True)
|
||
if port is not None:
|
||
await cleanup_scrcpy_session(port, writer)
|
||
try:
|
||
await websocket.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
@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.post("/service_action")
|
||
async def run_service_action_post(body: ServiceActionPostBody):
|
||
try:
|
||
return await service_action(body.service, body.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 = ManagedFileBody(**(await request.json()))
|
||
return governed_save_managed_file(data.root, data.path, data.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 governed_delete_managed_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("/config_insights")
|
||
async def get_config_insights(
|
||
root: str = Query(..., description="管理目录 id"),
|
||
path: str = Query(..., description="相对路径"),
|
||
):
|
||
try:
|
||
return inspect_file(root, path)
|
||
except KeyError as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=404)
|
||
except (PermissionError, ValueError) as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
|
||
|
||
@app.post("/config_insights")
|
||
async def post_config_insights(body: ConfigAnalyzeBody):
|
||
try:
|
||
return inspect_file(body.root, body.path, draft_content=body.content)
|
||
except KeyError as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=404)
|
||
except (PermissionError, ValueError) as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
|
||
|
||
@app.get("/config_backups")
|
||
async def get_config_backups(
|
||
root: str = Query(..., description="管理目录 id"),
|
||
path: str = Query(..., description="相对路径"),
|
||
limit: int = Query(12, ge=1, le=50),
|
||
):
|
||
try:
|
||
return {"backups": list_backups(root, path, limit=limit)}
|
||
except KeyError as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=404)
|
||
except (PermissionError, ValueError) as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
|
||
|
||
@app.post("/config_optimize")
|
||
async def post_config_optimize(body: ConfigOptimizeBody):
|
||
try:
|
||
return optimize_content(body.root, body.path, body.action, body.content)
|
||
except KeyError as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=404)
|
||
except (PermissionError, ValueError) as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||
|
||
|
||
@app.post("/config_restore")
|
||
async def post_config_restore(body: ConfigRestoreBody):
|
||
try:
|
||
return restore_backup(body.root, body.path, body.backup_id)
|
||
except KeyError as exc:
|
||
return JSONResponse({"error": str(exc)}, status_code=404)
|
||
except FileNotFoundError:
|
||
return JSONResponse({"error": f"Backup not found: {body.backup_id}"}, 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="进程名")):
|
||
rel = managed_youtube_ini_relpath(process)
|
||
if not rel:
|
||
return JSONResponse(
|
||
{"error": "仅 YouTube / 抖音→YouTube 类脚本支持 youtube.ini 编辑"}, status_code=400
|
||
)
|
||
|
||
config_file = CONFIG_DIR / rel
|
||
if not config_file.is_file():
|
||
legacy = CONFIG_DIR / "youtube.ini"
|
||
if legacy.is_file():
|
||
try:
|
||
return {"content": legacy.read_text(encoding="utf-8")}
|
||
except OSError as e:
|
||
return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500)
|
||
return {"content": f"# {rel} 尚未创建,保存后将写入磁盘\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="进程名")):
|
||
rel = managed_youtube_ini_relpath(process)
|
||
if not rel:
|
||
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)
|
||
|
||
try:
|
||
governed_save_managed_file("app-config", rel, content)
|
||
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 not allows_url_config(process):
|
||
return JSONResponse(
|
||
{"error": "仅 tiktok*.py / youtube*.py / douyin_youtube*.py 对应进程支持 URL 配置编辑"},
|
||
status_code=400,
|
||
)
|
||
|
||
rel = managed_url_config_relpath(process)
|
||
config_file = CONFIG_DIR / rel
|
||
if not config_file.is_file():
|
||
legacy = CONFIG_DIR / "URL_config.ini"
|
||
if legacy.is_file():
|
||
try:
|
||
return {"content": legacy.read_text(encoding="utf-8")}
|
||
except OSError as e:
|
||
return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500)
|
||
return {"content": f"# {rel} 尚未创建,保存后将写入磁盘\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 not allows_url_config(process):
|
||
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)
|
||
|
||
try:
|
||
rel = managed_url_config_relpath(process)
|
||
governed_save_managed_file("app-config", rel, content)
|
||
return {"message": "配置保存成功"}
|
||
except OSError as e:
|
||
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
|
||
|
||
|
||
@app.get("/tiktok_replay/list")
|
||
async def tiktok_replay_list(process: str = Query(..., min_length=1)):
|
||
if not allows_tiktok_replay_upload(process):
|
||
return JSONResponse({"error": "仅 TikTok 类进程支持录播素材目录"}, status_code=400)
|
||
root = tiktok_replay_dir(process)
|
||
files: list[dict[str, str | int]] = []
|
||
try:
|
||
for p in sorted(root.iterdir(), key=lambda x: x.name.lower()):
|
||
if not p.is_file():
|
||
continue
|
||
if p.suffix.lower() not in _TIKTOK_REPLAY_EXT:
|
||
continue
|
||
st = p.stat()
|
||
abs_posix = str(p.resolve()).replace("\\", "/")
|
||
files.append(
|
||
{
|
||
"name": p.name,
|
||
"size": int(st.st_size),
|
||
"mtime": int(st.st_mtime),
|
||
"replay_url": f"replayfile:{abs_posix}",
|
||
}
|
||
)
|
||
except OSError as e:
|
||
return JSONResponse({"error": f"列出文件失败: {e}"}, status_code=500)
|
||
return {"files": files}
|
||
|
||
|
||
@app.post("/tiktok_replay/upload")
|
||
async def tiktok_replay_upload(
|
||
process: str = Query(..., min_length=1),
|
||
file: UploadFile = File(...),
|
||
):
|
||
if not allows_tiktok_replay_upload(process):
|
||
return JSONResponse({"error": "仅 TikTok 类进程支持录播素材目录"}, status_code=400)
|
||
raw_name = (file.filename or "").strip()
|
||
if not raw_name:
|
||
return JSONResponse({"error": "缺少文件名"}, status_code=400)
|
||
base = Path(raw_name).name
|
||
if base in {".", ".."} or "/" in raw_name or "\\" in raw_name:
|
||
return JSONResponse({"error": "非法文件名"}, status_code=400)
|
||
ext = Path(base).suffix.lower()
|
||
if ext not in _TIKTOK_REPLAY_EXT:
|
||
return JSONResponse({"error": f"不支持的扩展名(允许 {', '.join(sorted(_TIKTOK_REPLAY_EXT))})"}, status_code=400)
|
||
dest = tiktok_replay_dir(process) / base
|
||
try:
|
||
data = await file.read(MAX_TIKTOK_REPLAY_UPLOAD + 1)
|
||
except OSError as e:
|
||
return JSONResponse({"error": f"读取上传失败: {e}"}, status_code=500)
|
||
if len(data) > MAX_TIKTOK_REPLAY_UPLOAD:
|
||
return JSONResponse({"error": "文件过大(上限 2GB)"}, status_code=413)
|
||
try:
|
||
dest.write_bytes(data)
|
||
except OSError as e:
|
||
return JSONResponse({"error": f"写入失败: {e}"}, status_code=500)
|
||
abs_posix = str(dest.resolve()).replace("\\", "/")
|
||
return {"message": "上传成功", "name": base, "replay_url": f"replayfile:{abs_posix}"}
|
||
|
||
|
||
@app.post("/tiktok_replay/delete")
|
||
async def tiktok_replay_delete(body: TiktokReplayDeleteBody):
|
||
if not allows_tiktok_replay_upload(body.process):
|
||
return JSONResponse({"error": "仅 TikTok 类进程支持录播素材目录"}, status_code=400)
|
||
base = Path(body.filename).name
|
||
if not base or base in {".", ".."}:
|
||
return JSONResponse({"error": "非法文件名"}, status_code=400)
|
||
root = tiktok_replay_dir(body.process)
|
||
target = root / base
|
||
try:
|
||
resolved = target.resolve()
|
||
if resolved.parent != root.resolve():
|
||
return JSONResponse({"error": "路径越界"}, status_code=400)
|
||
except OSError:
|
||
return JSONResponse({"error": "路径无效"}, status_code=400)
|
||
if not target.is_file():
|
||
return JSONResponse({"error": "文件不存在"}, status_code=404)
|
||
try:
|
||
target.unlink()
|
||
except OSError as e:
|
||
return JSONResponse({"error": f"删除失败: {e}"}, status_code=500)
|
||
return {"message": "已删除"}
|
||
|
||
|
||
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 not is_valid_control_process(process):
|
||
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
||
sc = script_for_pm2_or_pro(process)
|
||
if not sc:
|
||
return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500)
|
||
stopped = await _stop_other_hdmi_sink_processes(process)
|
||
output = await process_backend.start(process, sc)
|
||
prefix = ""
|
||
if stopped:
|
||
prefix = "已停止其它占用 HDMI 链路的进程: " + "、".join(stopped) + "\n"
|
||
return JSONResponse({"output": prefix + output})
|
||
|
||
|
||
@app.post("/start")
|
||
async def start_post(body: ProcessNameBody):
|
||
return await start(process=body.process)
|
||
|
||
|
||
@app.get("/stop")
|
||
async def stop(process: str = Query(..., description="进程名")):
|
||
if not is_valid_control_process(process):
|
||
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, BASE_DIR)
|
||
|
||
await asyncio.sleep(1.0)
|
||
|
||
return JSONResponse(
|
||
{
|
||
"message": "已执行 stop" + ("" if process.startswith("web") else " + 强杀 ffmpeg"),
|
||
"pm2_output": pm2_output,
|
||
"note": "如果仍有残留,可多次点击停止或检查日志目录 .pm2/logs",
|
||
}
|
||
)
|
||
|
||
|
||
@app.post("/stop")
|
||
async def stop_post(body: ProcessNameBody):
|
||
return await stop(process=body.process)
|
||
|
||
|
||
@app.get("/restart")
|
||
async def restart(process: str = Query(..., description="进程名")):
|
||
if not is_valid_control_process(process):
|
||
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
||
sc = script_for_pm2_or_pro(process)
|
||
if not sc:
|
||
return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500)
|
||
stopped = await _stop_other_hdmi_sink_processes(process)
|
||
output = await process_backend.restart(process, sc)
|
||
if not process.startswith("web"):
|
||
await force_kill_ffmpeg(process, BASE_DIR)
|
||
prefix = ""
|
||
if stopped:
|
||
prefix = "已停止其它占用 HDMI 链路的进程: " + "、".join(stopped) + "\n"
|
||
return JSONResponse({"output": prefix + output})
|
||
|
||
|
||
@app.post("/restart")
|
||
async def restart_post(body: ProcessNameBody):
|
||
return await restart(process=body.process)
|
||
|
||
|
||
@app.get("/status")
|
||
async def status(process: str = Query(..., description="进程名")):
|
||
if not is_valid_control_process(process):
|
||
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("/console", include_in_schema=False)
|
||
async def console_page():
|
||
route_file = _console_route_html("console")
|
||
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")
|