Files
gitlab-instance-0a899031_sh/d2ypp2/web.py
2026-05-28 15:59:03 -05:00

1681 lines
59 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import asyncio
import configparser
import json
import os
import re
import signal
import sqlite3
import sys
import time
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from typing import Any, 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_diagnostics,
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 (
SHELLCRASH_DEFAULT_NODE,
SHELLCRASH_DEFAULT_PROFILE,
apply_shellcrash_profile,
deployment_report,
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,
load_stack_env,
)
from src.redroid_control import redroid_action, redroid_runtime_summary
from src.hdmi_control import hdmi_capture_summary, hdmi_ffmpeg_probe
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.neko_capacity import ensure_neko_capacity_for_youtube_channels
from src.process_runtime_status import summarize_process_activity, tail_log_text
from src.process_runtime_status import coerce_declared_process_status, process_family
from src.runtime_bootstrap import raise_nofile_limit
from src.web_process_backend import WebProcessBackend, force_kill_ffmpeg, strip_ansi_codes
from src.youtube_pro_runtime import (
channel_pm2_name,
ensure_youtube_pro_channel_runtime_files,
managed_url_config_relpath_for_pm2,
managed_youtube_ini_relpath_for_pm2,
validate_youtube_runtime_files,
)
# ---------------- 配置(根目录结构) ----------------
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)
raise_nofile_limit()
def _runtime_layout_warnings() -> list[str]:
from src.runtime_bootstrap import ensure_runtime_layout
return ensure_runtime_layout(BASE_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*.pyOBS 仅 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
try:
root_pid = await process_backend.process_pid(str(name))
except Exception:
root_pid = 0
try:
root_pgid = os.getpgid(root_pid) if root_pid > 0 else None
except OSError:
root_pgid = None
await process_backend.stop(name)
if not str(name).startswith("web"):
await force_kill_ffmpeg(str(name), BASE_DIR, root_pid=root_pid, root_pgid=root_pgid)
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
)
STATIC_PROCESS_MONITOR = _build_process_monitor()
def _youtube_script_for_dynamic_channels() -> Optional[str]:
matches = [p["script"] for p in STATIC_PROCESS_MONITOR if is_youtube_script(str(p.get("script", "")))]
if not matches:
return None
matches.sort(key=lambda s: (len(Path(s).stem), s))
return matches[0]
def _youtube_pro_label(name: str) -> str:
return f"YouTube Pro \u00b7 {name}"
def youtube_pro_process_entries() -> tuple[dict[str, str], ...]:
youtube_script = _youtube_script_for_dynamic_channels()
if not youtube_script:
return ()
extras: list[dict[str, str]] = []
static_pm2 = {str(row.get("pm2", "")).strip() for row in STATIC_PROCESS_MONITOR}
seen_pm2: set[str] = set()
for channel in get_youtube_pro_channels():
if not isinstance(channel, dict):
continue
cid = str(channel.get("id", "")).strip()
if not cid:
continue
pm2 = channel_pm2_name(channel)
if not pm2 or pm2 in static_pm2 or pm2 in seen_pm2:
continue
seen_pm2.add(pm2)
name = str(channel.get("name", "")).strip() or cid
extras.append({"pm2": pm2, "script": youtube_script, "label": _youtube_pro_label(name)})
return tuple(extras)
def process_monitor_entries() -> tuple[dict[str, str], ...]:
extras = list(youtube_pro_process_entries())
merged = [dict(row) for row in STATIC_PROCESS_MONITOR]
index = {str(row["pm2"]).strip(): pos for pos, row in enumerate(merged)}
for row in extras:
pm2 = str(row.get("pm2", "")).strip()
if not pm2:
continue
index[pm2] = len(merged)
merged.append(dict(row))
return tuple(merged)
def _sync_youtube_pro_runtime_files(process: str) -> list[str]:
_, errors = ensure_youtube_pro_channel_runtime_files(CONFIG_DIR, process, get_youtube_pro_channels())
return errors
def _normalize_textarea_content(text: str) -> str:
return (text or "").replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff")
def _extract_stream_key_from_youtube_ini(text: str) -> str:
parser = configparser.ConfigParser()
try:
parser.read_string(_normalize_textarea_content(text) or "[youtube]\n")
except (configparser.Error, TypeError, ValueError):
return ""
if not parser.has_section("youtube"):
return ""
for key_name in ("key", "stream_key"):
value = str(parser.get("youtube", key_name, fallback="") or "").strip()
if value and not value.startswith("#"):
return value
return ""
def _persist_youtube_pro_channel_edits(
process: str,
*,
url_content: str | None = None,
youtube_ini_content: str | None = None,
) -> bool:
channels = get_youtube_pro_channels()
if not channels:
return False
target = (process or "").strip()
if not target:
return False
updated = False
next_channels: list[dict[str, Any]] = []
for channel in channels:
row = dict(channel) if isinstance(channel, dict) else {}
if channel_pm2_name(row) != target:
next_channels.append(row)
continue
if url_content is not None:
row["urlLines"] = _normalize_textarea_content(url_content)
updated = True
if youtube_ini_content is not None:
row["streamKey"] = _extract_stream_key_from_youtube_ini(youtube_ini_content)
updated = True
next_channels.append(row)
if updated:
set_youtube_pro_channels(next_channels)
return updated
def _youtube_pro_store_error(exc: sqlite3.Error) -> str:
return f"多频道状态数据库写入失败: {exc}; 请确认 config 目录归 live 用户可写"
def _validate_youtube_runtime_files(process: str) -> list[str]:
return validate_youtube_runtime_files(BASE_DIR, CONFIG_DIR, process, get_youtube_pro_channels())
async def _process_runtime_identity(process: str) -> tuple[int, int | None]:
try:
pid = await process_backend.process_pid(process)
except Exception:
pid = 0
if pid <= 0:
return 0, None
try:
return pid, os.getpgid(pid)
except OSError:
return pid, None
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 STATIC_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 _resolve_dynamic_channel_script(pm2: str) -> Optional[str]:
target = (pm2 or "").strip()
if not target:
return None
for row in youtube_pro_process_entries():
if row.get("pm2") == target:
return row.get("script")
return None
def script_for_pm2_or_pro(pm2: str) -> Optional[str]:
s = script_for_pm2(pm2)
if s:
return s
dynamic = _resolve_dynamic_channel_script(pm2)
if dynamic:
return dynamic
head_l = _pro_script_head(pm2)
if head_l:
resolved = _resolve_pro_style_script(head_l)
if resolved:
return resolved
for p in STATIC_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 managed_url_config_relpath_for_pm2(pm2)
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 managed_youtube_ini_relpath_for_pm2(pm2)
def script_for_pm2(pm2: str) -> Optional[str]:
for p in process_monitor_entries():
if p["pm2"] == pm2:
return p["script"]
return None
@asynccontextmanager
async def lifespan(app: FastAPI):
runtime_issues = _runtime_layout_warnings()
if runtime_issues:
for issue in runtime_issues:
print(f"[web] runtime-dir warning: {issue}", file=sys.stderr)
try:
await process_backend.ensure_mode()
except Exception:
# PM2/Shell 异常时不阻塞整站启动(否则反代持续 502
pass
async def _bootstrap_pm2() -> None:
for _ in range(4):
try:
await process_backend.ensure_mode()
except Exception as exc:
print(f"[web] pm2 bootstrap: {exc}", file=sys.stderr)
await asyncio.sleep(2)
# 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_pm2())
asyncio.create_task(_bootstrap_scrcpy())
yield
app = FastAPI(title="多进程录制控制台", lifespan=lifespan)
configure_hub(process_backend, process_monitor_entries, 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)
except sqlite3.Error as exc:
return JSONResponse({"error": _youtube_pro_store_error(exc)}, status_code=500)
capacity = await asyncio.to_thread(ensure_neko_capacity_for_youtube_channels, BASE_DIR, body.channels)
return {"message": "ok", "neko": capacity}
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
)
_NO_STORE_HEADERS = {
"Cache-Control": "no-store, max-age=0, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
}
_READ_CONFIG_MIN_INTERVAL_SECONDS = 0.45
_READ_CONFIG_LAST_SEEN: dict[tuple[str, str, str], float] = {}
def _client_host(request: Request) -> str:
forwarded = (request.headers.get("x-forwarded-for") or "").split(",", 1)[0].strip()
if forwarded:
return forwarded
return request.client.host if request.client else "unknown"
def _reject_config_read_storm(request: Request, endpoint: str, process: str) -> JSONResponse | None:
now = time.monotonic()
if len(_READ_CONFIG_LAST_SEEN) > 2048:
cutoff = now - 30.0
for key, seen_at in list(_READ_CONFIG_LAST_SEEN.items()):
if seen_at < cutoff:
_READ_CONFIG_LAST_SEEN.pop(key, None)
key = (_client_host(request), endpoint, (process or "").strip())
last = _READ_CONFIG_LAST_SEEN.get(key, 0.0)
_READ_CONFIG_LAST_SEEN[key] = now
if now - last >= _READ_CONFIG_MIN_INTERVAL_SECONDS:
return None
return JSONResponse(
{"error": "刷新过快,请稍后重试"},
status_code=429,
headers={**_NO_STORE_HEADERS, "Retry-After": "1"},
)
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="^(install|start|stop|restart|status|uninstall|upgrade)$")
class ShellCrashApplyProfileBody(BaseModel):
path: str = Field(default=SHELLCRASH_DEFAULT_PROFILE, min_length=1, max_length=240)
target_node: str = Field(default=SHELLCRASH_DEFAULT_NODE, min_length=1, max_length=200)
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 RedroidActionBody(BaseModel):
action: str = Field(..., min_length=1, max_length=64)
name: str = Field(default="", max_length=120)
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"
def _read_log_tail_bytes(log_file: Path, max_bytes: int = 512 * 1024) -> str:
size = log_file.stat().st_size
with log_file.open("rb") as f:
if size > max_bytes:
f.seek(-max_bytes, os.SEEK_END)
return f.read().decode("utf-8", errors="ignore")
def _newest_log_age_seconds(*paths: Optional[str]) -> float | None:
newest: float | None = None
for raw in paths:
if not raw:
continue
norm = raw.replace("\\", "/").lower()
if "/dev/null" in norm or norm.endswith("/nul") or norm == "nul":
continue
try:
ts = Path(raw).stat().st_mtime
except OSError:
continue
newest = ts if newest is None else max(newest, ts)
if newest is None:
return None
import time
return max(0.0, round(time.time() - newest, 1))
async def read_log_path_normalized(path: Optional[str], lines: int) -> str:
if not path:
return "无日志路径\n"
norm = path.replace("\\", "/").lower()
if "/dev/null" in norm or norm.endswith("/nul") or norm == "nul":
return "日志输出被禁用PM2 配置为无日志)\n"
log_file = Path(path)
if not log_file.exists():
return f"日志文件不存在: {path}\n"
try:
text = await asyncio.to_thread(_read_log_tail_bytes, log_file)
tailed = tail_log_text(text, lines)
return tailed if tailed 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():
await process_backend.ensure_mode()
stack = stack_summary()
env = load_stack_env()
return {
"status": "ok",
"process_backend": process_backend.mode,
"console_built": (CONSOLE_OUT / "index.html").is_file(),
"stack_env_present": (CONFIG_DIR / "system-stack.env").is_file(),
"port": env.get("PORT", os.environ.get("PORT", "8001")),
"mdns_host": stack.get("mdns_host"),
"control_url": stack.get("control_url"),
}
@app.get("/deploy/check")
async def deploy_check():
return await asyncio.to_thread(deployment_report)
@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/diagnostics")
async def get_android_diagnostics(serial: str = Query(..., description="Android device serial")):
try:
return await android_device_diagnostics(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("/redroid/summary")
async def get_redroid_summary():
try:
return await redroid_runtime_summary()
except Exception as exc: # noqa: BLE001
return JSONResponse({"error": str(exc)}, status_code=500)
@app.post("/redroid/action")
async def post_redroid_action(body: RedroidActionBody):
try:
return await redroid_action(body.action.strip(), (body.name or "").strip() or None)
except (RuntimeError, ValueError) as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
@app.get("/hdmi/status")
async def get_hdmi_status():
try:
return await hdmi_capture_summary()
except Exception as exc: # noqa: BLE001
return JSONResponse({"error": str(exc)}, status_code=500)
@app.get("/hdmi/ffmpeg_probe")
async def get_hdmi_ffmpeg_probe(device: str = Query(..., description="Linux video device path")):
try:
return await hdmi_ffmpeg_probe(device)
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.post("/shellcrash/apply_profile")
async def post_shellcrash_apply_profile(body: ShellCrashApplyProfileBody):
try:
return await apply_shellcrash_profile(body.path.strip(), body.target_node.strip())
except FileNotFoundError:
return JSONResponse({"error": f"ShellCrash YAML not found: {body.path}"}, status_code=404)
except (PermissionError, ValueError) as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except RuntimeError as exc:
return JSONResponse({"error": str(exc)}, status_code=500)
@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(request: Request, process: str = Query(..., description="进程名")):
storm = _reject_config_read_storm(request, "get_config", process)
if storm is not None:
return storm
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():
sync_errors = _sync_youtube_pro_runtime_files(process)
if sync_errors:
return JSONResponse({"error": "; ".join(sync_errors)}, status_code=400)
legacy = CONFIG_DIR / "youtube.ini"
if legacy.is_file():
try:
return JSONResponse({"content": legacy.read_text(encoding="utf-8")}, headers=_NO_STORE_HEADERS)
except OSError as e:
return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500)
return JSONResponse({"content": f"# {rel} 尚未创建,保存后将写入磁盘\n"}, headers=_NO_STORE_HEADERS)
try:
content = config_file.read_text(encoding="utf-8")
return JSONResponse({"content": content}, headers=_NO_STORE_HEADERS)
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)
_persist_youtube_pro_channel_edits(process, youtube_ini_content=content)
return JSONResponse({"message": "配置保存成功"}, headers=_NO_STORE_HEADERS)
except sqlite3.Error as e:
return JSONResponse({"error": _youtube_pro_store_error(e)}, status_code=500)
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(request: Request, process: str = Query(..., description="进程名")):
storm = _reject_config_read_storm(request, "get_url_config", process)
if storm is not None:
return storm
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():
sync_errors = _sync_youtube_pro_runtime_files(process)
if sync_errors:
return JSONResponse({"error": "; ".join(sync_errors)}, status_code=400)
legacy = CONFIG_DIR / "URL_config.ini"
if legacy.is_file():
try:
return JSONResponse({"content": legacy.read_text(encoding="utf-8")}, headers=_NO_STORE_HEADERS)
except OSError as e:
return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500)
return JSONResponse({"content": f"# {rel} 尚未创建,保存后将写入磁盘\n"}, headers=_NO_STORE_HEADERS)
try:
content = config_file.read_text(encoding="utf-8")
return JSONResponse({"content": content}, headers=_NO_STORE_HEADERS)
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)
_persist_youtube_pro_channel_edits(process, url_content=content)
return JSONResponse({"message": "配置保存成功"}, headers=_NO_STORE_HEADERS)
except sqlite3.Error as e:
return JSONResponse({"error": _youtube_pro_store_error(e)}, status_code=500)
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 删除或从未保存)"
def _configured_hint(process: str) -> str:
return f"进程「{process}」已在控制台配置,但当前未启动;点击「启动」即可拉起。"
def _coerce_local_configured_process(process: str, info: dict) -> dict:
next_status = coerce_declared_process_status(
process_backend.mode,
str(info.get("process_status") or ""),
bool(script_for_pm2_or_pro(process)),
)
if next_status == str(info.get("process_status") or "").lower():
return info
coerced = dict(info)
coerced["process_status"] = next_status
return coerced
async def _process_monitor_snapshot(entry: dict[str, str]) -> dict[str, Any]:
name = str(entry.get("pm2") or "").strip()
info = await get_process_info(name)
return await _process_monitor_snapshot_from_info(entry, info)
async def _process_monitor_snapshot_from_info(
entry: dict[str, str],
info: dict[str, Any],
*,
light: bool = False,
) -> dict[str, Any]:
name = str(entry.get("pm2") or "").strip()
info = _coerce_local_configured_process(name, info)
status = str(info.get("process_status") or "").lower()
online = status in {"online", "running", "launching"}
read_logs = online or not light
log_lines = 12 if light else 40
error_lines = 8 if light else 40
if read_logs:
recent_log, recent_error = await asyncio.gather(
read_log_path_normalized(info.get("out_path"), log_lines),
read_log_path_normalized(info.get("err_path"), error_lines),
)
else:
recent_log = ""
recent_error = ""
ffmpeg_pids = await process_backend.live_ffmpeg_pids(name, info) if online else []
activity = summarize_process_activity(
name,
str(info.get("process_status") or ""),
recent_log,
recent_error,
log_age_seconds=_newest_log_age_seconds(info.get("out_path"), info.get("err_path")),
ffmpeg_pids=ffmpeg_pids,
)
return {
"pm2": name,
"script": str(entry.get("script") or ""),
"label": str(entry.get("label") or name),
"process_status": str(info.get("process_status") or ""),
"business_status": str(activity.get("business_status") or ""),
"business_note": str(activity.get("business_note") or ""),
"ffmpeg_active": bool(activity.get("ffmpeg_active")),
"is_pushing": bool(activity.get("is_pushing")),
}
def _process_monitor_family_matches(entry: dict[str, str], family: str | None) -> bool:
target = (family or "").strip().lower()
if not target or target == "all":
return True
script = str(entry.get("script") or "").strip()
if script:
return process_family(Path(script).stem) == target
return process_family(str(entry.get("pm2") or "").strip()) == target
# ---------------- 进程控制 ----------------
@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)
sync_errors = _validate_youtube_runtime_files(process)
if sync_errors:
return JSONResponse({"output": "; ".join(sync_errors)}, status_code=400)
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)
root_pid, root_pgid = await _process_runtime_identity(process)
pm2_output = await process_backend.stop(process)
await asyncio.sleep(0.25)
if not process.startswith("web"):
await force_kill_ffmpeg(process, BASE_DIR, root_pid=root_pid, root_pgid=root_pgid)
if root_pid > 0:
with suppress(ProcessLookupError, PermissionError, OSError):
os.kill(root_pid, signal.SIGTERM)
await asyncio.sleep(0.15)
post_info = await get_process_info(process)
if str(post_info.get("process_status") or "").lower() in {"online", "running"}:
with suppress(ProcessLookupError, PermissionError, OSError):
os.kill(root_pid, signal.SIGKILL)
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)
sync_errors = _validate_youtube_runtime_files(process)
if sync_errors:
return JSONResponse({"output": "; ".join(sync_errors)}, status_code=400)
stopped = await _stop_other_hdmi_sink_processes(process)
root_pid, root_pgid = await _process_runtime_identity(process)
if not process.startswith("web"):
await force_kill_ffmpeg(process, BASE_DIR, root_pid=root_pid, root_pgid=root_pgid)
output = await process_backend.restart(process, sc)
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)
info = _coerce_local_configured_process(process, info)
recent_log = await read_log_path_normalized(info.get("out_path"), MAX_LOG_LINES)
recent_error = await read_log_path_normalized(info.get("err_path"), MAX_LOG_LINES)
activity = summarize_process_activity(
process,
str(info.get("process_status") or ""),
recent_log,
recent_error,
log_age_seconds=_newest_log_age_seconds(info.get("out_path"), info.get("err_path")),
ffmpeg_pids=await process_backend.live_ffmpeg_pids(process),
)
if info["process_status"] == "not_found":
recent_log = _not_found_hint(process) + "\n"
recent_error = ""
elif info["process_status"] == "configured":
configured_hint = _configured_hint(process)
recent_log = configured_hint + "\n"
recent_error = ""
full_status = configured_hint
return JSONResponse(
{
"raw_status": full_status,
"process_status": info["process_status"],
"recent_log": recent_log,
"recent_error": recent_error,
**activity,
}
)
@app.get("/process_monitor")
async def process_monitor(
family: str | None = Query(None, description="按进程族过滤youtube/tiktok/obs/generic"),
light: bool = Query(False, description="轻量模式:减少日志读取,仅返回监控所需状态"),
):
await process_backend.ensure_mode()
family_value = family if isinstance(family, str) else None
light_value = bool(light) if isinstance(light, bool) else False
entries = [dict(p) for p in process_monitor_entries() if _process_monitor_family_matches(p, family_value)]
names = [str(entry.get("pm2") or "").strip() for entry in entries if str(entry.get("pm2") or "").strip()]
infos = await process_backend.process_infos(names)
async def build_snapshot(entry: dict[str, str]) -> dict[str, Any]:
name = str(entry.get("pm2") or "").strip()
return await _process_monitor_snapshot_from_info(entry, infos.get(name) or {}, light=light_value)
results = await asyncio.gather(*(build_snapshot(entry) for entry in entries), return_exceptions=True)
snapshots: list[dict[str, Any]] = []
for entry, result in zip(entries, results):
if not isinstance(result, Exception):
snapshots.append(result)
continue
snapshots.append(
{
"pm2": entry["pm2"],
"script": entry["script"],
"label": entry["label"],
"process_status": "error",
"business_status": "error",
"business_note": "Failed to inspect process state",
"ffmpeg_active": False,
"is_pushing": False,
}
)
return {
"entries": snapshots,
"process_backend": process_backend.mode,
"family": (family_value or "all").strip().lower() or "all",
"light": light_value,
}
@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
ico = CONSOLE_OUT / "favicon.ico"
if ico.is_file():
return FileResponse(ico, media_type="image/x-icon")
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, media_type="text/html; charset=utf-8")
return FileResponse(BASE_DIR / "index.html", media_type="text/html; charset=utf-8")
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, media_type="text/html; charset=utf-8")
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, media_type="text/html; charset=utf-8")
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, media_type="text/html; charset=utf-8")
return await index()
_next_dir = CONSOLE_OUT / "_next"
if _next_dir.is_dir():
app.mount("/_next", StaticFiles(directory=str(_next_dir)), name="next_static")