This commit is contained in:
eric
2026-05-20 23:50:43 -05:00
parent 882ea9268c
commit e6e4f7a02e
17 changed files with 570 additions and 188 deletions

169
web2.py
View File

@@ -35,6 +35,7 @@ from web2_relay_pro import (
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,
@@ -44,8 +45,10 @@ from web2_relay_pro import (
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
@@ -157,6 +160,13 @@ URL_CONFIG_PM2 = frozenset(
)
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"
@@ -213,6 +223,32 @@ async def control_get_process_info(process: str) -> dict:
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。"""
@@ -302,6 +338,13 @@ async def relay_pro_channel_detail(channel_id: str = Query(..., description="频
@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)}
@@ -315,6 +358,12 @@ async def relay_pro_post_url_config(request: Request, channel_id: str = Query(..
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}
@@ -333,6 +382,8 @@ async def relay_pro_save_channel_key(request: Request):
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}
@@ -357,6 +408,11 @@ async def relay_pro_save_channel_keys(request: Request):
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}
@@ -368,6 +424,14 @@ async def relay_pro_sync_active(channel_id: str = Query(...)):
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])
@@ -375,10 +439,14 @@ async def relay_pro_live_status():
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,
control_get_process_info,
get_info,
read_tail,
yt,
)
@@ -431,6 +499,18 @@ async def read_log_path(path: str, lines: int) -> str:
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
@@ -511,6 +591,24 @@ def _business_status_from_runtime(
# ---------------- 配置路由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)
@@ -542,6 +640,16 @@ async def save_config(request: Request, process: str = Query(..., description="
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)
@@ -597,6 +705,9 @@ async def youtube_ini_model_post(request: Request, process: str = Query(..., des
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)
@@ -604,7 +715,7 @@ async def youtube_ini_model_post(request: Request, process: str = Query(..., des
# ---------------- 配置路由URL_config.iniyoutube 和 tiktok 共享) ----------------
@app.get("/get_url_config")
async def get_url_config(process: str = Query(..., description="进程名")):
if process not in URL_CONFIG_PM2:
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"
@@ -618,7 +729,7 @@ async def get_url_config(process: str = Query(..., description="进程名")):
@app.post("/save_url_config")
async def save_url_config(request: Request, process: str = Query(..., description="进程名")):
if process not in URL_CONFIG_PM2:
if not supports_legacy_url_config(process):
return JSONResponse({"error": "仅 tiktok*.py / youtube*.py 对应进程支持 URL 配置编辑"}, status_code=400)
try:
@@ -631,6 +742,9 @@ async def save_url_config(request: Request, process: str = Query(..., descriptio
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)
@@ -694,7 +808,14 @@ def _relay_channel_ready_message(channel_id: str) -> str | None:
return "频道不存在"
if not str(d.get("youtubeKey") or "").strip():
return "请先配置该频道的 YouTube 串流密钥"
body = read_url_config_for_channel(CONFIG_DIR, channel_id)
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
@@ -716,6 +837,10 @@ async def start(request: Request, process: str = Query(..., description="进程
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)
@@ -732,23 +857,29 @@ 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(process)
ctrl_output = await control_stop(effective_process)
# 第二步:等待信号传播(通常 1~3 秒)
await asyncio.sleep(2.0)
# 第三步:录制类进程才强杀 ffmpegweb* 控制台不杀 ffmpeg
if not process.startswith("web"):
await force_kill_ffmpeg_cross_platform(process)
if not effective_process.startswith("web"):
await force_kill_ffmpeg_cross_platform(effective_process)
# 第四步:再等一小会儿,确认
await asyncio.sleep(1.0)
return JSONResponse({
"message": "已执行 stop" + ("" if process.startswith("web") else " + 强杀 ffmpeg"),
"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,
})
@@ -797,7 +928,7 @@ async def status(process: str = Query(..., description="进程名")):
full_status_raw = await control_full_status()
full_status = strip_ansi_codes(full_status_raw)
info = await control_get_process_info(process)
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)
@@ -805,7 +936,8 @@ async def status(process: str = Query(..., description="进程名")):
recent_log = "进程未运行或未由本机管理器启动。\n"
recent_error = ""
ffmpeg_pids = _ffmpeg_pids_for_process(info, process)
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]
@@ -828,9 +960,26 @@ async def status(process: str = Query(..., description="进程名")):
"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}