This commit is contained in:
eric
2026-03-25 09:40:29 -05:00
parent 23a8996114
commit 5996df8702
72 changed files with 1347 additions and 18322 deletions

260
web2.py
View File

@@ -29,16 +29,25 @@ from web2_youtube_oauth import (
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,
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,
update_channel_key,
update_channel_keys_list,
write_url_config_for_channel,
)
from web2_youtube_ini import parse_youtube_ini, serialize_youtube_ini
from web2_host_caps import gpu_status_payload
from web2_douyin_preview import fetch_douyin_anchor_name
@asynccontextmanager
@@ -121,6 +130,22 @@ def _build_process_monitor():
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
@@ -129,6 +154,8 @@ URL_CONFIG_PM2 = frozenset(
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"]
@@ -147,18 +174,24 @@ def get_builtin_supervisor() -> BuiltinSupervisor:
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}")
@@ -222,6 +255,39 @@ 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)
@@ -266,6 +332,30 @@ async def relay_pro_save_channel_key(request: Request):
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)
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)
@@ -290,6 +380,30 @@ async def relay_pro_live_status():
)
@app.get("/host/gpu_status")
async def host_gpu_status():
return gpu_status_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:
@@ -343,6 +457,58 @@ async def save_config(request: Request, process: str = Query(..., description="
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]
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")
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="进程名")):
@@ -377,18 +543,73 @@ async def save_url_config(request: Request, process: str = Query(..., descriptio
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)
# ---------------- PM2 控制路由 ----------------
@app.get("/start")
async def start(process: str = Query(..., description="进程名")):
if process not in VALID_PROCESSES:
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)
legacy_pm2 = first_youtube_pm2_name(_script_entries_payload())
if not cid and sc == "youtube2.py" and process == legacy_pm2:
online_relays = await _relay_youtube_online_pm2_names()
if online_relays:
return JSONResponse(
{
"output": (
"当前有多路转播进程在运行,与单进程 youtube2 互斥(单进程会读全局 URL_config.ini"
f"请先停止:{', '.join(online_relays)}"
)
},
status_code=400,
)
if cid:
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 process not in VALID_PROCESSES:
if not process_valid(process):
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
# 第一步:尝试优雅停止
@@ -413,8 +634,14 @@ async def stop(process: str = Query(..., description="进程名")):
@app.get("/restart")
async def restart(process: str = Query(..., description="进程名")):
if process not in VALID_PROCESSES:
if not process_valid(process):
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
cid = relay_channel_id_from_pm2_name(process)
if cid:
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_restart(process)
if not process.startswith("web"):
await force_kill_ffmpeg_cross_platform(process)
@@ -423,7 +650,7 @@ async def restart(process: str = Query(..., description="进程名")):
@app.get("/status")
async def status(process: str = Query(..., description="进程名")):
if process not in VALID_PROCESSES:
if not process_valid(process):
return JSONResponse({
"raw_status": "",
"process_status": "invalid",
@@ -551,12 +778,25 @@ async def youtube_stream_fetch_key(process: str = Query(..., description="进程
# ---------------- 进程列表(供前端同步,由 discover_script_entries 自动扫描) ----------------
@app.get("/process_monitor")
async def process_monitor():
return {
"entries": [
{"pm2": p["pm2"], "script": p["script"], "label": p["label"]}
for p in 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}
# ---------------- 首页 ----------------