1681 lines
59 KiB
Python
1681 lines
59 KiB
Python
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*.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
|
||
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")
|