Files
2026-05-20 23:50:43 -05:00

1110 lines
41 KiB
Python
Raw Permalink 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 fastapi import FastAPI, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from pathlib import Path
import asyncio
import html
import os
import time
from contextlib import asynccontextmanager
from typing import Optional
import psutil
from fastapi.middleware.cors import CORSMiddleware
from web2_supervisor import (
BuiltinSupervisor,
force_kill_ffmpeg_cross_platform,
get_process_info_pm2,
run_pm2_shell,
strip_ansi_codes,
use_builtin,
)
from web2_network import run_network_dashboard
from web2_proxy_config import read_proxy_config, write_proxy_config
from web2_system_metrics import snapshot as system_metrics_snapshot
from web2_youtube_oauth import (
fetch_stream_key_and_write_ini,
oauth_authorization_url,
oauth_exchange_code,
oauth_status as youtube_oauth_status_snapshot,
)
from web2_wechat_chatbot import register_wechat_chatbot_routes
from web2_client_overview import build_client_overview
from web2_relay_pro import (
add_relay_channel,
build_relay_live_status,
ensure_default_relay_config,
extract_url_lines_from_ini,
first_relay_channel_id,
first_youtube_pm2_name,
get_channel_by_id,
get_channel_detail,
list_channels_for_pro,
materialize_relay_channel_youtube_ini,
read_url_config_for_channel,
relay_pm2_name_for_channel,
remove_relay_channel,
sync_channel_to_legacy_files,
sync_legacy_files_to_first_channel,
update_channel_key,
update_channel_keys_list,
write_youtube_ini_single_key,
write_url_config_for_channel,
)
from web2_youtube_ini import parse_youtube_ini, serialize_youtube_ini, validate_youtube_keys
from web2_host_caps import gpu_status_payload, machine_summary_payload
from web2_douyin_preview import fetch_douyin_anchor_name
from web2_pocketbase import REQUIRE_PB, pb_auth_refresh_and_validate
@asynccontextmanager
async def lifespan(app: FastAPI):
ensure_default_relay_config(CONFIG_DIR)
yield
app = FastAPI(title="多进程录制控制台", lifespan=lifespan)
# ---------------- 配置(根目录结构) ----------------
BASE_DIR = Path(__file__).parent
CONFIG_DIR = BASE_DIR / "config"
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs"
MAX_LOG_LINES = 300
def _label_short(prefix: str) -> str:
return {"tiktok": "TikTok", "youtube": "YouTube", "obs": "OBS", "web": "Web 控制台"}.get(prefix, prefix)
def discover_script_entries() -> tuple:
"""扫描项目根目录自动识别入口tiktok*.py、youtube*.py、obs*.sh / obs*.bat以及当前本文件Web 控制台)。进程名 = 主文件名(无扩展名)。"""
base = BASE_DIR
web_path = Path(__file__).resolve()
entries: list[dict] = []
for path in sorted(base.glob("tiktok*.py")):
if path.is_file() and path.resolve() != web_path:
entries.append({"script": path.name, "label": _label_short("tiktok")})
for path in sorted(base.glob("youtube*.py")):
if path.is_file() and path.resolve() != web_path:
entries.append({"script": path.name, "label": _label_short("youtube")})
for path in sorted(base.glob("obs*.sh")):
if path.is_file():
entries.append({"script": path.name, "label": _label_short("obs")})
for path in sorted(base.glob("obs*.bat")):
if path.is_file():
entries.append({"script": path.name, "label": _label_short("obs")})
entries.append({"script": web_path.name, "label": _label_short("web")})
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:
return script.endswith(".py") and _stem(script).startswith("youtube")
def is_tiktok_script(script: str) -> bool:
return script.endswith(".py") and _stem(script).startswith("tiktok")
def _build_process_monitor():
return tuple(
{
"pm2": pm2_name_from_script(e["script"]),
"script": e["script"],
"label": e["label"],
}
for e in SCRIPT_ENTRIES
)
PROCESS_MONITOR = _build_process_monitor()
VALID_PROCESSES = frozenset(p["pm2"] for p in PROCESS_MONITOR)
def relay_channel_id_from_pm2_name(name: str) -> Optional[str]:
prefix = "youtube2__"
if not name.startswith(prefix):
return None
return name[len(prefix) :]
def process_valid(name: str) -> bool:
if name in VALID_PROCESSES:
return True
cid = relay_channel_id_from_pm2_name(name)
if not cid:
return False
return get_channel_by_id(CONFIG_DIR, cid) is not None
URL_CONFIG_PM2 = frozenset(
pm2_name_from_script(e["script"])
for e in SCRIPT_ENTRIES
if is_tiktok_script(e["script"]) or is_youtube_script(e["script"])
)
def supports_legacy_url_config(process: str) -> bool:
if process in URL_CONFIG_PM2:
return True
sc = script_for_pm2(process)
return bool(sc and is_youtube_script(sc) and relay_channel_id_from_pm2_name(process))
def script_for_pm2(pm2: str) -> Optional[str]:
if relay_channel_id_from_pm2_name(pm2):
return "youtube2.py"
for p in PROCESS_MONITOR:
if p["pm2"] == pm2:
return p["script"]
return None
WEB_SCRIPT_NAME = Path(__file__).name
_builtin_supervisor: Optional[BuiltinSupervisor] = None
def get_builtin_supervisor() -> BuiltinSupervisor:
global _builtin_supervisor
if _builtin_supervisor is None:
_builtin_supervisor = BuiltinSupervisor(BASE_DIR, WEB_SCRIPT_NAME, script_for_pm2)
return _builtin_supervisor
async def control_start(process: str) -> str:
if relay_channel_id_from_pm2_name(process) and not use_builtin():
return "多频道独立进程仅支持内置进程管理(请勿使用 WEB2_SUPERVISOR=pm2"
if use_builtin():
return await get_builtin_supervisor().start(process)
return await run_pm2_shell(f"start {process}")
async def control_stop(process: str) -> str:
if relay_channel_id_from_pm2_name(process) and not use_builtin():
return "多频道独立进程仅支持内置进程管理(请勿使用 WEB2_SUPERVISOR=pm2"
if use_builtin():
return await get_builtin_supervisor().stop(process)
return await run_pm2_shell(f"stop {process}")
async def control_restart(process: str) -> str:
if relay_channel_id_from_pm2_name(process) and not use_builtin():
return "多频道独立进程仅支持内置进程管理(请勿使用 WEB2_SUPERVISOR=pm2"
if use_builtin():
return await get_builtin_supervisor().restart(process)
return await run_pm2_shell(f"restart {process}")
async def control_full_status() -> str:
if use_builtin():
return await get_builtin_supervisor().full_status_text()
return await run_pm2_shell("status", timeout=8.0)
async def control_get_process_info(process: str) -> dict:
if use_builtin():
return await get_builtin_supervisor().get_process_info(process)
return await get_process_info_pm2(process, DEFAULT_LOG_DIR, strip_ansi_codes)
async def control_get_process_info_with_first_pro_alias(process: str) -> tuple[dict, str | None]:
"""Pro 第一线路未单独启动时,沿用仍在运行的单频道 youtube2 状态与日志。"""
info = await control_get_process_info(process)
cid = relay_channel_id_from_pm2_name(process)
if not cid or cid != first_relay_channel_id(CONFIG_DIR):
return info, None
if str(info.get("process_status") or "") == "online":
return info, None
legacy = first_youtube_pm2_name(_script_entries_payload())
if not legacy or legacy == process:
return info, None
legacy_info = await control_get_process_info(legacy)
if str(legacy_info.get("process_status") or "") != "online":
return info, None
aliased = dict(legacy_info)
aliased["aliased_from_process"] = legacy
return aliased, legacy
async def resolve_effective_stop_process(process: str) -> tuple[str, str | None]:
"""停止按钮应作用在真实状态来源上Pro 第一线路代理单频道时停止 youtube2 本体。"""
_, source = await control_get_process_info_with_first_pro_alias(process)
return (source or process), source
@app.middleware("http")
async def web2_token_middleware(request: Request, call_next):
"""若设置环境变量 WEB2_API_TOKEN则除 /health 与 OpenAPI 外需携带 Bearer 或 X-API-Token。"""
# CORS 预检不带 Authorization若在外层先于 CORSMiddleware 拦截会缺少 Access-Control-* 头
if request.method == "OPTIONS":
return await call_next(request)
token = os.environ.get("WEB2_API_TOKEN", "").strip()
if not token:
return await call_next(request)
path = request.url.path
if path == "/health" or path.startswith("/docs") or path in ("/openapi.json", "/redoc"):
return await call_next(request)
auth = request.headers.get("authorization") or ""
xtok = (request.headers.get("x-api-token") or "").strip()
if auth.startswith("Bearer ") and auth[7:].strip() == token:
return await call_next(request)
if xtok == token:
return await call_next(request)
return JSONResponse({"error": "unauthorized"}, status_code=401)
# 最后注册,保证 CORSMiddleware 在最外层401 等响应仍带 CORS 头(供 Vite 等跨源调试)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
)
register_wechat_chatbot_routes(app, BASE_DIR)
@app.get("/client/overview")
async def client_overview():
"""Electron 工作台:频道列表、微信摘要。"""
return build_client_overview(CONFIG_DIR, BASE_DIR)
# ---------------- Pro 多路转播(每频道独立 URL 文件 + 去重 + 同步 legacy ----------------
@app.get("/relay_pro/channels")
async def relay_pro_channels():
return list_channels_for_pro(CONFIG_DIR)
@app.post("/relay_pro/channel_add")
async def relay_pro_channel_add(request: Request):
try:
data = await request.json()
except Exception:
return JSONResponse({"error": "无效的 JSON"}, status_code=400)
if not isinstance(data, dict):
return JSONResponse({"error": "无效数据"}, status_code=400)
name = str(data.get("name") or "")
key = str(data.get("youtubeKey") or data.get("key") or "")
ok, msg, new_id = add_relay_channel(CONFIG_DIR, name, key)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"message": msg, "channelId": new_id}
@app.post("/relay_pro/channel_delete")
async def relay_pro_channel_delete(request: Request):
try:
data = await request.json()
except Exception:
return JSONResponse({"error": "无效的 JSON"}, status_code=400)
if not isinstance(data, dict):
return JSONResponse({"error": "无效数据"}, status_code=400)
cid = str(data.get("channelId") or data.get("channel_id") or "").strip()
if not cid:
return JSONResponse({"error": "缺少 channelId"}, status_code=400)
ok, msg = remove_relay_channel(CONFIG_DIR, cid)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"message": msg}
@app.get("/relay_pro/channel_detail")
async def relay_pro_channel_detail(channel_id: str = Query(..., description="频道 id如 ch_0")):
d = get_channel_detail(CONFIG_DIR, channel_id)
if not d:
return JSONResponse({"error": "未找到频道"}, status_code=404)
return d
@app.get("/relay_pro/url_config")
async def relay_pro_get_url_config(channel_id: str = Query(...)):
if channel_id == first_relay_channel_id(CONFIG_DIR):
legacy = CONFIG_DIR / "URL_config.ini"
if legacy.exists():
try:
return {"content": legacy.read_text(encoding="utf-8-sig")}
except Exception as e:
return JSONResponse({"error": f"读取单频道地址失败: {e}"}, status_code=500)
return {"content": read_url_config_for_channel(CONFIG_DIR, channel_id)}
@app.post("/relay_pro/url_config")
async def relay_pro_post_url_config(request: Request, channel_id: str = Query(...)):
try:
data = await request.json()
except Exception:
return JSONResponse({"error": "无效的 JSON"}, status_code=400)
content = data.get("content", "") if isinstance(data, dict) else ""
ok, msg = write_url_config_for_channel(CONFIG_DIR, channel_id, content)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
if channel_id == first_relay_channel_id(CONFIG_DIR):
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
(CONFIG_DIR / "URL_config.ini").write_text(str(content), encoding="utf-8")
except Exception as e:
return JSONResponse({"error": f"同步到单频道地址失败: {e}"}, status_code=500)
return {"message": msg}
@app.post("/relay_pro/channel_key")
async def relay_pro_save_channel_key(request: Request):
try:
data = await request.json()
except Exception:
return JSONResponse({"error": "无效的 JSON"}, status_code=400)
if not isinstance(data, dict):
return JSONResponse({"error": "无效数据"}, status_code=400)
cid = str(data.get("channelId") or data.get("channel_id") or "").strip()
key = str(data.get("youtubeKey") or data.get("key") or "")
if not cid:
return JSONResponse({"error": "缺少 channelId"}, status_code=400)
ok, msg = update_channel_key(CONFIG_DIR, cid, key)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
if cid == first_relay_channel_id(CONFIG_DIR):
write_youtube_ini_single_key(CONFIG_DIR, key)
return {"message": msg}
@app.post("/relay_pro/channel_keys")
async def relay_pro_save_channel_keys(request: Request):
try:
data = await request.json()
except Exception:
return JSONResponse({"error": "无效的 JSON"}, status_code=400)
if not isinstance(data, dict):
return JSONResponse({"error": "无效数据"}, status_code=400)
cid = str(data.get("channelId") or data.get("channel_id") or "").strip()
keys = data.get("keys")
if not cid:
return JSONResponse({"error": "缺少 channelId"}, status_code=400)
if not isinstance(keys, list):
return JSONResponse({"error": "keys 须为数组"}, status_code=400)
try:
ai = int(data.get("activeIndex", 0))
except Exception:
ai = 0
ok, msg = update_channel_keys_list(CONFIG_DIR, cid, [str(x) for x in keys], ai)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
if cid == first_relay_channel_id(CONFIG_DIR):
cleaned = [str(x).strip() for x in keys if str(x).strip()]
if cleaned:
safe_ai = max(0, min(ai, len(cleaned) - 1))
write_youtube_ini_single_key(CONFIG_DIR, cleaned[safe_ai])
return {"message": msg}
@app.post("/relay_pro/sync_active")
async def relay_pro_sync_active(channel_id: str = Query(...)):
ok, msg = sync_channel_to_legacy_files(CONFIG_DIR, channel_id)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"message": msg}
@app.post("/relay_pro/sync_legacy_first")
async def relay_pro_sync_legacy_first():
ok, msg, cid = sync_legacy_files_to_first_channel(CONFIG_DIR)
if not ok:
return JSONResponse({"error": msg, "channelId": cid}, status_code=400)
return {"message": msg, "channelId": cid}
@app.get("/relay_pro/live_status")
async def relay_pro_live_status():
yt = first_youtube_pm2_name([{"script": e["script"]} for e in SCRIPT_ENTRIES])
async def read_tail(path: str, lines: int) -> str:
return await read_log_path(path, lines)
async def get_info(process: str) -> dict:
info, _ = await control_get_process_info_with_first_pro_alias(process)
return info
return await build_relay_live_status(
CONFIG_DIR,
BASE_DIR,
get_info,
read_tail,
yt,
)
@app.get("/host/gpu_status")
async def host_gpu_status():
return gpu_status_payload()
@app.get("/host/machine_summary")
async def host_machine_summary():
"""本机 hostname / OS / 架构等,供会员遥测写入 PocketBase。"""
return machine_summary_payload()
@app.post("/douyin/room_previews")
async def douyin_room_previews(request: Request):
try:
data = await request.json()
except Exception:
return JSONResponse({"error": "无效的 JSON"}, status_code=400)
if not isinstance(data, dict):
return JSONResponse({"error": "无效数据"}, status_code=400)
urls = data.get("urls")
if not isinstance(urls, list):
return JSONResponse({"error": "urls 须为数组"}, status_code=400)
out = []
for u in urls[:16]:
if not isinstance(u, str) or not u.strip():
continue
out.append(fetch_douyin_anchor_name(u.strip()))
return {"previews": out}
# ---------------- 工具函数 ----------------
async def read_log_path(path: str, lines: int) -> str:
if not path:
return "无日志路径\n"
if "/dev/null" in path:
return "日志输出被禁用(无日志文件)\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 Exception as e:
return f"读取日志失败 ({path}): {str(e)}\n"
def clear_log_path(path: str | None) -> bool:
if not path:
return False
p = Path(path)
try:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("", encoding="utf-8")
return True
except Exception:
return False
def _log_age_seconds(path: str | None) -> float | None:
if not path:
return None
try:
p = Path(path)
if not p.exists():
return None
return max(0.0, time.time() - p.stat().st_mtime)
except OSError:
return None
def _ffmpeg_pids_for_process(info: dict, process_name: str) -> list[int]:
"""优先按内置 supervisor 的父子进程关系识别;兼容 PM2 时回退命令行关键字匹配。"""
pids: list[int] = []
seen: set[int] = set()
pid = info.get("pid")
if isinstance(pid, int) and pid > 0:
try:
parent = psutil.Process(pid)
for child in parent.children(recursive=True):
try:
name = (child.name() or "").lower()
if "ffmpeg" in name and child.pid not in seen:
pids.append(child.pid)
seen.add(child.pid)
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
pass
if pids:
return pids
needle = (process_name or "").strip().lower()
if not needle:
return pids
for proc in psutil.process_iter(["pid", "name", "cmdline"]):
try:
name = (proc.info.get("name") or "").lower()
if "ffmpeg" not in name:
continue
flat = " ".join(str(x) for x in (proc.info.get("cmdline") or [])).lower()
if needle in flat and int(proc.info["pid"]) not in seen:
pids.append(int(proc.info["pid"]))
seen.add(int(proc.info["pid"]))
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, ValueError):
continue
return pids
def _business_status_from_runtime(
*,
process_status: str,
recent_log: str,
recent_error: str,
ffmpeg_pids: list[int],
log_age_seconds: float | None,
) -> tuple[str, str]:
log = (recent_log or "").lower()
err = (recent_error or "").lower()
if process_status in {"invalid", "not_found", "stopped"}:
return "stopped", "进程未运行"
if process_status not in {"online", "running"}:
return "unknown", "等待进程状态稳定"
if "至少需要一个有效" in recent_log or "串流密钥" in recent_log or "youtube key missing" in log:
return "misconfigured", "缺少或无法使用 YouTube 串流密钥"
if any(x in err for x in ("traceback", "error", "exception", "failed", "errno")):
return "error", "近期错误日志中出现异常"
if ffmpeg_pids:
return "streaming", f"检测到 {len(ffmpeg_pids)} 个 FFmpeg 推流进程"
if "[relay_meta]" in log or "正在直播中" in recent_log:
return "source_live", "源站已开播,但暂未检测到 FFmpeg worker"
if log_age_seconds is not None and log_age_seconds > 180:
return "stale", f"日志 {int(log_age_seconds)} 秒未更新"
if "等待直播" in recent_log or "检测直播间中" in recent_log:
return "waiting_source", "正在等待源站开播"
return "monitoring", "进程在线,正在监测源站"
# ---------------- 配置路由youtube.ini仅 youtube ----------------
def mirror_youtube_key_to_first_channel(keys: list[str], active_index: int) -> tuple[bool, str]:
cid = first_relay_channel_id(CONFIG_DIR)
if not cid or not keys:
return True, ""
idx = max(0, min(active_index, len(keys) - 1))
key = str(keys[idx] or "").strip()
if not key:
return True, ""
return update_channel_keys_list(CONFIG_DIR, cid, [key], 0)
def mirror_url_config_to_first_channel(content: str) -> tuple[bool, str]:
cid = first_relay_channel_id(CONFIG_DIR)
if not cid:
return True, ""
return write_url_config_for_channel(CONFIG_DIR, cid, content)
@app.get("/get_config")
async def get_config(process: str = Query(..., description="进程名")):
sc = script_for_pm2(process)
if not sc or not is_youtube_script(sc):
return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini 编辑"}, status_code=400)
config_file = CONFIG_DIR / "youtube.ini"
if not config_file.exists():
return {"content": "# youtube.ini 文件不存在,将创建空文件\n"}
try:
content = config_file.read_text(encoding="utf-8")
return {"content": content}
except Exception as e:
return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500)
@app.post("/save_config")
async def save_config(request: Request, process: str = Query(..., description="进程名")):
sc = script_for_pm2(process)
if not sc or not is_youtube_script(sc):
return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini 编辑"}, status_code=400)
try:
data = await request.json()
content = data.get("content", "")
except:
return JSONResponse({"error": "无效的 JSON 数据"}, status_code=400)
config_file = CONFIG_DIR / "youtube.ini"
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
config_file.write_text(content, encoding="utf-8")
try:
parsed = parse_youtube_ini(str(content))
keys = parsed.get("keys") if isinstance(parsed, dict) else []
active_index = int(parsed.get("activeIndex", 0) if isinstance(parsed, dict) else 0)
if isinstance(keys, list):
ok, msg = mirror_youtube_key_to_first_channel([str(x) for x in keys], active_index)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
except Exception:
pass
return {"message": "配置保存成功"}
except Exception as e:
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
@app.get("/youtube/ini_model")
async def youtube_ini_model_get(process: str = Query(..., description="进程名")):
sc = script_for_pm2(process)
if not sc or not is_youtube_script(sc):
return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini"}, status_code=400)
config_file = CONFIG_DIR / "youtube.ini"
if not config_file.exists():
return {"keys": [""], "activeIndex": 0, "options": {}, "header": ""}
try:
content = config_file.read_text(encoding="utf-8")
except Exception as e:
return JSONResponse({"error": f"读取失败: {e}"}, status_code=500)
return parse_youtube_ini(content)
@app.post("/youtube/ini_model")
async def youtube_ini_model_post(request: Request, process: str = Query(..., description="进程名")):
sc = script_for_pm2(process)
if not sc or not is_youtube_script(sc):
return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini"}, status_code=400)
try:
data = await request.json()
except Exception:
return JSONResponse({"error": "无效的 JSON 数据"}, status_code=400)
if not isinstance(data, dict):
return JSONResponse({"error": "无效数据"}, status_code=400)
keys = data.get("keys")
if not isinstance(keys, list):
keys = [""]
keys = [str(x) for x in keys]
ok_keys, key_msg = validate_youtube_keys(keys)
if not ok_keys:
return JSONResponse({"error": key_msg}, status_code=400)
try:
active_index = int(data.get("activeIndex", 0))
except Exception:
active_index = 0
options = data.get("options")
if not isinstance(options, dict):
options = {}
else:
options = {str(k): str(v) for k, v in options.items()}
header = data.get("header")
if not isinstance(header, str):
header = ""
content = serialize_youtube_ini(keys, active_index, options, header)
config_file = CONFIG_DIR / "youtube.ini"
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
config_file.write_text(content, encoding="utf-8")
ok, msg = mirror_youtube_key_to_first_channel(keys, active_index)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"message": "配置保存成功"}
except Exception as e:
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
# ---------------- 配置路由URL_config.iniyoutube 和 tiktok 共享) ----------------
@app.get("/get_url_config")
async def get_url_config(process: str = Query(..., description="进程名")):
if not supports_legacy_url_config(process):
return JSONResponse({"error": "仅 tiktok*.py / youtube*.py 对应进程支持 URL 配置编辑"}, status_code=400)
config_file = CONFIG_DIR / "URL_config.ini"
if not config_file.exists():
return {"content": "# URL_config.ini 文件不存在,将创建空文件\n"}
try:
content = config_file.read_text(encoding="utf-8")
return {"content": content}
except Exception 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 supports_legacy_url_config(process):
return JSONResponse({"error": "仅 tiktok*.py / youtube*.py 对应进程支持 URL 配置编辑"}, status_code=400)
try:
data = await request.json()
content = data.get("content", "")
except:
return JSONResponse({"error": "无效的 JSON 数据"}, status_code=400)
config_file = CONFIG_DIR / "URL_config.ini"
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
config_file.write_text(content, encoding="utf-8")
ok, msg = mirror_url_config_to_first_channel(str(content))
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"message": "配置保存成功"}
except Exception as e:
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
def _script_entries_payload() -> list[dict]:
return [{"script": e["script"]} for e in SCRIPT_ENTRIES]
async def _relay_youtube_online_pm2_names() -> list[str]:
"""已启动的多路转播进程 youtube2__*(与单进程 youtube2 互斥)。"""
doc = list_channels_for_pro(CONFIG_DIR)
if not doc.get("ok"):
return []
out: list[str] = []
for ch in doc.get("channels") or []:
cid = str(ch.get("id") or "").strip()
if not cid:
continue
pnm = relay_pm2_name_for_channel(cid)
info = await control_get_process_info(pnm)
if str(info.get("process_status") or "") == "online":
out.append(pnm)
return out
async def _stop_legacy_youtube_if_online() -> None:
"""多路转播启动前必须停掉单进程 youtube2否则会读全局 URL_config.ini等同「所有线路一起推」。"""
if not use_builtin():
return
legacy = first_youtube_pm2_name(_script_entries_payload())
if not legacy:
return
info = await control_get_process_info(legacy)
if str(info.get("process_status") or "") != "online":
return
await control_stop(legacy)
await asyncio.sleep(0.5)
async def _legacy_youtube_conflict_response(process: str, sc: Optional[str]) -> JSONResponse | None:
legacy_pm2 = first_youtube_pm2_name(_script_entries_payload())
if sc != "youtube2.py" or process != legacy_pm2:
return None
online_relays = await _relay_youtube_online_pm2_names()
if not online_relays:
return None
return JSONResponse(
{
"output": (
"当前有多频道 Pro 推流进程正在运行,不能同时启动/重启单频道 youtube2"
f"请先停止:{', '.join(online_relays)}"
)
},
status_code=400,
)
def _relay_channel_ready_message(channel_id: str) -> str | None:
d = get_channel_detail(CONFIG_DIR, channel_id)
if not d:
return "频道不存在"
if not str(d.get("youtubeKey") or "").strip():
return "请先配置该频道的 YouTube 串流密钥"
if channel_id == first_relay_channel_id(CONFIG_DIR):
legacy = CONFIG_DIR / "URL_config.ini"
try:
body = legacy.read_text(encoding="utf-8-sig") if legacy.exists() else ""
except Exception:
body = read_url_config_for_channel(CONFIG_DIR, channel_id)
else:
body = read_url_config_for_channel(CONFIG_DIR, channel_id)
if not extract_url_lines_from_ini(body):
return "请先为该频道填写至少一个源站直播间地址"
return None
# ---------------- PM2 控制路由 ----------------
@app.get("/start")
async def start(request: Request, process: str = Query(..., description="进程名")):
if REQUIRE_PB:
tok = (request.headers.get("x-pb-token") or "").strip()
ok, msg, _ = await pb_auth_refresh_and_validate(tok)
if not ok:
return JSONResponse({"output": msg}, status_code=403)
if not process_valid(process):
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
cid = relay_channel_id_from_pm2_name(process)
sc = script_for_pm2(process)
conflict = await _legacy_youtube_conflict_response(process, sc)
if conflict is not None:
return conflict
if cid:
if cid == first_relay_channel_id(CONFIG_DIR):
ok, msg, _ = sync_legacy_files_to_first_channel(CONFIG_DIR)
if not ok:
return JSONResponse({"output": msg}, status_code=400)
ready_msg = _relay_channel_ready_message(cid)
if ready_msg:
return JSONResponse({"output": ready_msg}, status_code=400)
ok, msg = materialize_relay_channel_youtube_ini(CONFIG_DIR, cid)
if not ok:
return JSONResponse({"output": msg}, status_code=400)
await _stop_legacy_youtube_if_online()
output = await control_start(process)
return JSONResponse({"output": output})
@app.get("/stop")
async def stop(process: str = Query(..., description="进程名")):
if not process_valid(process):
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
effective_process, aliased_from = await resolve_effective_stop_process(process)
# 第一步:尝试优雅停止
ctrl_output = await control_stop(effective_process)
# 第二步:等待信号传播(通常 1~3 秒)
await asyncio.sleep(2.0)
# 第三步:录制类进程才强杀 ffmpegweb* 控制台不杀 ffmpeg
if not effective_process.startswith("web"):
await force_kill_ffmpeg_cross_platform(effective_process)
# 第四步:再等一小会儿,确认
await asyncio.sleep(1.0)
return JSONResponse({
"message": "已执行 stop"
+ (f"(实际停止 {effective_process}" if aliased_from else "")
+ ("" if effective_process.startswith("web") else " + 强杀 ffmpeg"),
"pm2_output": ctrl_output,
"note": "如果仍有残留,可多次点击停止或查看下方日志",
"stopped_process": effective_process,
"requested_process": process,
})
@app.get("/restart")
async def restart(request: Request, process: str = Query(..., description="进程名")):
if REQUIRE_PB:
tok = (request.headers.get("x-pb-token") or "").strip()
ok, msg, _ = await pb_auth_refresh_and_validate(tok)
if not ok:
return JSONResponse({"output": msg}, status_code=403)
if not process_valid(process):
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
cid = relay_channel_id_from_pm2_name(process)
sc = script_for_pm2(process)
conflict = await _legacy_youtube_conflict_response(process, sc)
if conflict is not None:
return conflict
stop_output = await control_stop(process)
await asyncio.sleep(1.0)
if not process.startswith("web"):
await force_kill_ffmpeg_cross_platform(process)
await asyncio.sleep(0.35)
if cid:
ready_msg = _relay_channel_ready_message(cid)
if ready_msg:
return JSONResponse({"output": f"{stop_output}\n{ready_msg}"}, status_code=400)
ok, msg = materialize_relay_channel_youtube_ini(CONFIG_DIR, cid)
if not ok:
return JSONResponse({"output": f"{stop_output}\n{msg}"}, status_code=400)
await _stop_legacy_youtube_if_online()
start_output = await control_start(process)
return JSONResponse({"output": f"{stop_output}\n{start_output}"})
@app.get("/status")
async def status(process: str = Query(..., description="进程名")):
if not process_valid(process):
return JSONResponse({
"raw_status": "",
"process_status": "invalid",
"recent_log": f"无效进程名: {process}",
"recent_error": ""
})
full_status_raw = await control_full_status()
full_status = strip_ansi_codes(full_status_raw)
info, status_source = await control_get_process_info_with_first_pro_alias(process)
recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
if info["process_status"] == "not_found":
recent_log = "进程未运行或未由本机管理器启动。\n"
recent_error = ""
runtime_process = status_source or process
ffmpeg_pids = _ffmpeg_pids_for_process(info, runtime_process)
out_age = _log_age_seconds(info.get("out_path"))
err_age = _log_age_seconds(info.get("err_path"))
ages = [x for x in (out_age, err_age) if x is not None]
log_age_seconds = min(ages) if ages else None
business_status, business_note = _business_status_from_runtime(
process_status=str(info["process_status"]),
recent_log=recent_log,
recent_error=recent_error,
ffmpeg_pids=ffmpeg_pids,
log_age_seconds=log_age_seconds,
)
return JSONResponse({
"raw_status": full_status,
"process_status": info["process_status"],
"recent_log": recent_log,
"recent_error": recent_error,
"business_status": business_status,
"business_note": business_note,
"ffmpeg_pids": ffmpeg_pids,
"log_age_seconds": log_age_seconds,
"is_pushing": business_status == "streaming",
"status_source": status_source or process,
})
@app.post("/logs/clear")
async def clear_logs(process: str = Query(..., description="进程名")):
if not process_valid(process):
return JSONResponse({"error": f"无效进程名: {process}"}, status_code=400)
info, status_source = await control_get_process_info_with_first_pro_alias(process)
cleared = 0
for key in ("out_path", "err_path"):
if clear_log_path(info.get(key)):
cleared += 1
return {
"message": "日志已清空",
"cleared": cleared,
"status_source": status_source or process,
}
@app.get("/health")
async def health():
return {"ok": True}
@app.get("/network_status")
async def network_status():
"""Google / 抖音 连通与简易网速(供仪表盘)。"""
try:
return await run_network_dashboard()
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/proxy_config")
async def get_proxy_config():
"""仅作用于录制子进程的代理(不写 Windows 系统代理)。"""
return read_proxy_config()
@app.post("/proxy_config")
async def post_proxy_config(request: Request):
try:
data = await request.json()
except Exception:
return JSONResponse({"error": "无效的 JSON"}, status_code=400)
if not isinstance(data, dict):
return JSONResponse({"error": "无效数据"}, status_code=400)
write_proxy_config(
{
"enabled": bool(data.get("enabled")),
"http": str(data.get("http") or ""),
"socks": str(data.get("socks") or ""),
}
)
return {"ok": True}
@app.get("/system_metrics")
async def system_metrics():
"""CPU/内存/磁盘/网卡速率与 ffmpeg 进程摘要。"""
try:
return system_metrics_snapshot()
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
# ---------------- YouTube OAuth串流密钥 ----------------
@app.get("/youtube/oauth/start")
async def youtube_oauth_start():
"""返回 Google 授权页 URL前端应用浏览器打开。"""
try:
url, state = oauth_authorization_url()
return {"url": url, "state": state}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=400)
@app.get("/youtube/oauth/callback")
async def youtube_oauth_callback(code: str = Query(...), state: str = Query(...)):
"""Google 授权完成后的回调(须与 google_oauth.json 中 redirect_uri 一致)。"""
try:
oauth_exchange_code(code, state)
except Exception as e:
err = html.escape(str(e))
return HTMLResponse(
f"<!DOCTYPE html><html><head><meta charset='utf-8'><title>授权失败</title></head>"
f"<body style='font-family:system-ui;padding:24px;background:#1a1d24;color:#f56565;'>"
f"<p>授权失败:{err}</p><p style='color:#8b929e;font-size:13px;'>请关闭此页后重试。</p></body></html>",
status_code=400,
)
return HTMLResponse(
"<!DOCTYPE html><html><head><meta charset='utf-8'><title>授权成功</title></head>"
"<body style='font-family:system-ui;padding:24px;background:#1a1d24;color:#e8eaed;'>"
"<p>授权成功,可关闭此页并返回「直播录制助手」。</p>"
"<p style='color:#8b929e;font-size:13px;'>若浏览器未自动关闭,请手动关闭本标签页。</p>"
"</body></html>"
)
@app.get("/youtube/oauth/status")
async def youtube_oauth_status_route():
return youtube_oauth_status_snapshot()
@app.post("/youtube/stream/fetch_key")
async def youtube_stream_fetch_key(process: str = Query(..., description="进程名")):
"""调用 YouTube Live API 获取串流密钥并写入 config/youtube.ini。"""
if process not in VALID_PROCESSES:
return JSONResponse({"error": "无效进程名"}, status_code=400)
sc = script_for_pm2(process)
if not sc or not is_youtube_script(sc):
return JSONResponse({"error": "仅 youtube*.py 对应进程可使用此功能"}, status_code=400)
try:
return fetch_stream_key_and_write_ini()
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
# ---------------- 进程列表(供前端同步,由 discover_script_entries 自动扫描) ----------------
@app.get("/process_monitor")
async def process_monitor():
ensure_default_relay_config(CONFIG_DIR)
entries = [
{"pm2": p["pm2"], "script": p["script"], "label": p["label"]}
for p in PROCESS_MONITOR
]
doc = list_channels_for_pro(CONFIG_DIR)
if doc.get("ok"):
for ch in doc.get("channels") or []:
cid = str(ch.get("id") or "").strip()
if not cid:
continue
entries.append(
{
"pm2": f"youtube2__{cid}",
"script": "youtube2.py",
"label": f"YouTube {ch.get('name', cid)}",
}
)
return {"entries": entries}
# ---------------- 首页 ----------------
@app.get("/")
async def index():
return FileResponse(BASE_DIR / "index.html")