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.scrcpy_vendor import ensure_scrcpy_jar 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 # scrcpy-server 下载可走外网重试,勿阻塞启动;否则 systemd 默认 TimeoutStartSec≈90s 会杀进程,:8001 永不起 async def _bootstrap_scrcpy() -> None: try: ok, msg = await asyncio.to_thread(ensure_scrcpy_jar, BASE_DIR) if not ok: print(f"[web] scrcpy-server: {msg}", file=sys.stderr) except Exception as exc: print(f"[web] scrcpy-server bootstrap: {exc}", file=sys.stderr) asyncio.create_task(_bootstrap_scrcpy()) 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")