's'
This commit is contained in:
169
web2.py
169
web2.py
@@ -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.ini,youtube 和 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)
|
||||
|
||||
# 第三步:录制类进程才强杀 ffmpeg(web* 控制台不杀 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}
|
||||
|
||||
Reference in New Issue
Block a user