This commit is contained in:
eric
2026-07-10 05:15:29 -05:00
parent a0104f6517
commit 924d05d06e
71 changed files with 6815 additions and 680 deletions

362
web2.py
View File

@@ -31,14 +31,22 @@ from web2_youtube_oauth import (
from web2_client_overview import build_client_overview
from web2_process_runtime import process_family, summarize_process_activity
from web2_douyin_source_display import extract_douyin_source_meta
from web2_douyin_preview import fetch_douyin_anchor_name
from web2_config_governance import (
list_config_backups,
restore_config_backup,
write_config_text,
)
from web2_config_insights import analyze_config_content, optimize_config_content, read_config_file
from web2_youtube_ingest_auth import ingest_config_payload, verify_ingest_payload
from web2_youtube_pro_runtime import sync_all_youtube_pro_runtime_files, validate_youtube_runtime_files
from web2_relay_pro import channel_id_for_pm2_name, load_channels_document, save_channels_document
from web2_quality_guard import build_youtube_quality_guard_report
from web2_youtube_diagnostics import build_youtube_diagnostics_report
from web2_relay_log import (
close_active_relay_sessions,
get_relay_cover_path,
get_relay_session,
import_relay_sessions_from_log,
list_relay_sessions,
relay_db_path,
@@ -57,6 +65,7 @@ from web2_relay_pro import (
get_single_youtube_watch_url,
get_youtube_watch_url,
list_channels_for_pro,
relay_pm2_name_for_channel,
materialize_relay_channel_youtube_ini,
read_url_config_for_channel,
relay_pm2_name_for_channel,
@@ -68,7 +77,19 @@ from web2_relay_pro import (
watch_url_for_relay_process,
write_url_config_for_channel,
)
from web2_youtube_preflight import youtube_start_preflight_message
from web2_youtube_preflight import run_youtube_preflight, youtube_start_preflight_message
from web2_youtube_relay_heartbeat import read_relay_heartbeat
from web2_youtube_live_store import (
close_live_event,
extract_youtube_key_suffix,
get_live_event,
ingest_browser_telemetry,
list_live_events,
live_db_path,
record_control_action,
record_process_observation,
youtube_live_summary,
)
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_camera import camera_config_ready, list_capture_devices, load_camera_config, save_camera_config
@@ -99,7 +120,10 @@ BASE_DIR = Path(__file__).parent
CONFIG_DIR = BASE_DIR / "config"
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs"
RELAY_DB = relay_db_path(BASE_DIR)
LIVE_DB = live_db_path(BASE_DIR)
_PROCESS_STATUS_CACHE: dict[str, str] = {}
_LIVE_OBSERVE_LAST: dict[str, float] = {}
YOUTUBE_EVENT_STATUS_OBSERVE_SECONDS = 5.0
_SYSTEM_METRICS_TTL_SECONDS = 10.0
_SYSTEM_METRICS_CACHE: tuple[float, dict] | None = None
_SYSTEM_METRICS_CACHE_LOCK = asyncio.Lock()
@@ -107,6 +131,50 @@ _SYSTEM_METRICS_CACHE_LOCK = asyncio.Lock()
MAX_LOG_LINES = 300
def _relay_control_meta(process: str) -> dict[str, str]:
cid = relay_channel_id_from_pm2_name(process)
channel_name = ""
if cid:
d = get_channel_detail(CONFIG_DIR, cid)
channel_name = str((d or {}).get("name") or "")
cfg = _config_snapshot_for_process(process)
return {
"channel_id": cid or "",
"channel_name": channel_name,
"url_lines": cfg.get("url_lines") or _url_lines_for_process(process),
"youtube_ini_text": cfg.get("youtube_ini_text") or "",
"script": script_for_pm2(process) or "",
}
async def _record_youtube_control(process: str, action: str, *, note: str = "") -> None:
if not is_youtube_script(script_for_pm2(process) or ""):
return
meta = _relay_control_meta(process)
try:
await asyncio.to_thread(
record_control_action,
process,
action,
script=meta["script"],
url_lines=meta["url_lines"],
youtube_ini_text=meta["youtube_ini_text"],
channel_id=meta["channel_id"],
channel_name=meta["channel_name"],
note=note,
db_path=LIVE_DB,
)
except Exception:
pass
async def _close_youtube_relay_sessions(process: str, *, reason: str) -> None:
try:
await asyncio.to_thread(close_active_relay_sessions, process, reason=reason, db_path=RELAY_DB)
except Exception:
pass
def _label_short(prefix: str) -> str:
return {
"tiktok": "TikTok",
@@ -198,10 +266,16 @@ VALID_PROCESSES = frozenset(p["pm2"] for p in PROCESS_MONITOR)
def relay_channel_id_from_pm2_name(name: str) -> Optional[str]:
cid = channel_id_for_pm2_name(CONFIG_DIR, name)
if cid:
return cid
prefix = "youtube2__"
if not name.startswith(prefix):
return None
return name[len(prefix) :]
legacy = name[len(prefix) :]
if get_channel_by_id(CONFIG_DIR, legacy):
return legacy
return None
def process_valid(name: str) -> bool:
@@ -446,7 +520,10 @@ async def relay_pro_save_channel_keys(request: Request):
@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)
try:
ok, msg = await asyncio.to_thread(sync_channel_to_legacy_files, CONFIG_DIR, channel_id)
except Exception as exc:
return JSONResponse({"error": str(exc)}, status_code=500)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"message": msg}
@@ -494,10 +571,27 @@ async def douyin_room_previews(request: Request):
for u in urls[:16]:
if not isinstance(u, str) or not u.strip():
continue
out.append(fetch_douyin_anchor_name(u.strip()))
try:
out.append(fetch_douyin_anchor_name(u.strip()))
except Exception as exc:
out.append(
{
"ok": False,
"error": str(exc),
"url": u.strip(),
"room_id": "",
"anchor_name": "",
}
)
return {"previews": out}
@app.exception_handler(Exception)
async def web2_uncaught_exception_handler(request: Request, exc: Exception):
"""未捕获异常仍返回 JSON便于 CORS 与前端展示。"""
return JSONResponse({"error": str(exc) or exc.__class__.__name__}, status_code=500)
# ---------------- 工具函数 ----------------
async def read_log_path(path: str, lines: int) -> str:
if not path:
@@ -651,6 +745,13 @@ async def _youtube_guard_rows(targets: list[str]) -> tuple[list[dict], dict[str,
if d:
label = str(d.get("name") or label)
row["label"] = label
hb = read_relay_heartbeat(proc, base_dir=BASE_DIR)
if hb.get("state") in {"starting", "streaming", "source_failover"}:
if hb.get("speed") is not None:
row["ffmpeg_speed"] = hb.get("speed")
if hb.get("frame") is not None:
row["ffmpeg_frame"] = hb.get("frame")
row["relay_heartbeat"] = hb
rows.append(row)
config_by_process[proc] = _config_snapshot_for_process(proc)
return rows, config_by_process
@@ -1025,7 +1126,16 @@ async def start(request: Request, process: str = Query(..., description="进程
preflight_msg = await youtube_start_preflight_message()
if preflight_msg:
return JSONResponse({"output": preflight_msg}, status_code=400)
ch_doc, _ = load_channels_document(CONFIG_DIR)
channels = (ch_doc or {}).get("channels") or []
runtime_errors = await asyncio.to_thread(
validate_youtube_runtime_files, CONFIG_DIR, process, channels
)
if runtime_errors:
return JSONResponse({"output": "\n".join(runtime_errors)}, status_code=400)
await asyncio.to_thread(sync_all_youtube_pro_runtime_files, CONFIG_DIR, channels)
output = await control_start(process)
await _record_youtube_control(process, "start")
return JSONResponse({"output": output})
@@ -1047,6 +1157,9 @@ async def stop(process: str = Query(..., description="进程名")):
# 第四步:再等一小会儿,确认
await asyncio.sleep(1.0)
await _close_youtube_relay_sessions(process, reason="stopped")
await _record_youtube_control(process, "stop", note="manual stop")
return JSONResponse({
"message": "已执行 stop" + ("" if process.startswith("web") else " + 强杀 ffmpeg"),
"pm2_output": ctrl_output,
@@ -1075,6 +1188,9 @@ async def restart(request: Request, process: str = Query(..., description="进
await force_kill_ffmpeg_cross_platform(process)
await asyncio.sleep(0.35)
await _close_youtube_relay_sessions(process, reason="restart")
await _record_youtube_control(process, "stop", note="restart pre-start")
if cid:
ready_msg = _relay_channel_ready_message(cid)
if ready_msg:
@@ -1091,7 +1207,19 @@ async def restart(request: Request, process: str = Query(..., description="进
preflight_msg = await youtube_start_preflight_message()
if preflight_msg:
return JSONResponse({"output": f"{stop_output}\n{preflight_msg}"}, status_code=400)
ch_doc, _ = load_channels_document(CONFIG_DIR)
channels = (ch_doc or {}).get("channels") or []
runtime_errors = await asyncio.to_thread(
validate_youtube_runtime_files, CONFIG_DIR, process, channels
)
if runtime_errors:
return JSONResponse(
{"output": f"{stop_output}\n" + "\n".join(runtime_errors)},
status_code=400,
)
await asyncio.to_thread(sync_all_youtube_pro_runtime_files, CONFIG_DIR, channels)
start_output = await control_start(process)
await _record_youtube_control(process, "restart")
return JSONResponse({"output": f"{stop_output}\n{start_output}"})
@@ -1133,10 +1261,40 @@ async def status(
channel_name=channel_name,
url_lines=url_lines,
db_path=RELAY_DB,
base_dir=BASE_DIR,
)
except Exception:
pass
try:
hb = read_relay_heartbeat(process, base_dir=BASE_DIR)
observe_payload = {
"process_status": ps,
"recent_log": str(payload.get("recent_log") or ""),
"recent_error": str(payload.get("recent_error") or ""),
"is_pushing": ps == "online" and bool(hb.get("state") in {"starting", "streaming", "source_failover"}),
"live_status": str(hb.get("state") or ""),
"business_status": "streaming" if hb.get("state") == "streaming" else ps,
}
if hb:
observe_payload["relay_heartbeat"] = hb
if hb.get("speed") is not None:
observe_payload["ffmpeg_speed"] = hb.get("speed")
if hb.get("frame") is not None:
observe_payload["ffmpeg_frame"] = hb.get("frame")
now_ts = time.time()
last_ob = _LIVE_OBSERVE_LAST.get(process, 0.0)
if now_ts - last_ob >= YOUTUBE_EVENT_STATUS_OBSERVE_SECONDS:
_LIVE_OBSERVE_LAST[process] = now_ts
record_process_observation(
process,
observe_payload,
url_lines=url_lines,
db_path=LIVE_DB,
)
except Exception:
pass
return JSONResponse({
"raw_status": full_status,
**payload,
@@ -1255,7 +1413,10 @@ async def youtube_quality_guard(process: str = Query("", description="进程名
if err is not None:
return err
rows, config_by_process = await _youtube_guard_rows(targets)
return build_youtube_quality_guard_report(rows, config_by_process=config_by_process)
live = await asyncio.to_thread(youtube_live_summary, db_path=LIVE_DB)
return build_youtube_quality_guard_report(
rows, live_summary=live, config_by_process=config_by_process
)
@app.get("/youtube/diagnostics")
@@ -1267,10 +1428,21 @@ async def youtube_diagnostics(process: str = Query("", description="进程名,
return build_youtube_diagnostics_report(rows, config_by_process=config_by_process)
@app.get("/youtube/preflight")
async def youtube_preflight():
"""开播前网络体检Google / YouTube / 抖音 + 录制代理)。"""
try:
return await run_youtube_preflight()
except Exception as e:
return JSONResponse({"error": str(e), "ok": False}, status_code=500)
@app.get("/youtube/relay/sessions")
async def youtube_relay_sessions(
process: str = Query("", description="PM2 进程名"),
q: str = Query("", description="搜索主播/URL/房间"),
date_from: str = Query("", description="起始日期 YYYY-MM-DD"),
date_to: str = Query("", description="结束日期 YYYY-MM-DD"),
status: str = Query("", description="active | ended"),
limit: int = Query(30, ge=1, le=200),
offset: int = Query(0, ge=0),
@@ -1278,6 +1450,8 @@ async def youtube_relay_sessions(
return list_relay_sessions(
process=process,
q=q,
date_from=date_from,
date_to=date_to,
status=status,
limit=limit,
offset=offset,
@@ -1285,19 +1459,118 @@ async def youtube_relay_sessions(
)
@app.post("/youtube/relay/sessions/import")
async def youtube_relay_sessions_import(process: str = Query(..., description="进程名")):
@app.get("/youtube/relay/sessions/{session_id}")
async def youtube_relay_session_detail(session_id: str):
session = get_relay_session(session_id, db_path=RELAY_DB)
if not session:
return JSONResponse({"error": "会话不存在"}, status_code=404)
return {"session": session}
@app.get("/youtube/relay/sessions/{session_id}/cover")
async def youtube_relay_session_cover(session_id: int):
path = get_relay_cover_path(session_id, db_path=RELAY_DB)
if not path:
return JSONResponse({"error": "封面不存在"}, status_code=404)
return FileResponse(path, media_type="image/jpeg")
@app.get("/youtube/relay/heartbeat")
async def youtube_relay_heartbeat(process: str = Query(..., description="PM2 进程名")):
if not process_valid(process):
return JSONResponse({"error": "无效进程名"}, status_code=400)
hb = read_relay_heartbeat(process, base_dir=BASE_DIR)
return {"process": process, "heartbeat": hb, "fresh": bool(hb.get("updated_at"))}
@app.get("/youtube/live/summary")
async def youtube_live_summary_route():
try:
return youtube_live_summary(db_path=LIVE_DB)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/youtube/live/events")
async def youtube_live_events(
process: str = Query("", description="PM2 进程名"),
status: str = Query("", description="active | ended | error"),
limit: int = Query(30, ge=1, le=200),
offset: int = Query(0, ge=0),
):
return list_live_events(
process=process,
status=status,
limit=limit,
offset=offset,
db_path=LIVE_DB,
)
@app.get("/youtube/live/events/{event_id}")
async def youtube_live_event_detail(event_id: str):
event = get_live_event(event_id, db_path=LIVE_DB)
if not event:
return JSONResponse({"error": "事件不存在"}, status_code=404)
return {"event": event}
@app.post("/youtube/live/events/{event_id}/close")
async def youtube_live_event_close(event_id: str, request: Request):
try:
data = await request.json()
except Exception:
data = {}
reason = str(data.get("reason") or "manual")
result = close_live_event(event_id, reason=reason, db_path=LIVE_DB)
if not result:
return JSONResponse({"error": "事件不存在"}, status_code=404)
return result
@app.post("/youtube/live/ingest")
async def youtube_live_ingest(request: Request):
try:
data = await request.json()
except Exception:
return JSONResponse({"error": "无效的 JSON"}, status_code=400)
if not isinstance(data, dict):
return JSONResponse({"error": "无效的 JSON"}, status_code=400)
ok, msg = verify_ingest_payload(data)
if not ok:
return JSONResponse({"error": msg}, status_code=403)
return ingest_browser_telemetry(data, db_path=LIVE_DB)
@app.get("/youtube/live/ingest_config")
async def youtube_live_ingest_config():
return ingest_config_payload()
@app.post("/youtube/relay/sessions/import")
async def youtube_relay_sessions_import(request: Request, process: str = Query(..., description="进程名")):
if not process_valid(process):
return JSONResponse({"error": "无效进程名"}, status_code=400)
replace_imported = False
log_path_override = ""
try:
body = await request.json()
if isinstance(body, dict):
replace_imported = bool(body.get("replace_imported"))
log_path_override = str(body.get("log_path") or "").strip()
except Exception:
pass
info = await control_get_process_info(process)
log_path = info.get("out_path") or ""
log_path = log_path_override or info.get("out_path") or ""
if not log_path:
return JSONResponse({"error": "无法定位 PM2 日志路径"}, status_code=400)
result = import_relay_sessions_from_log(
Path(log_path),
process=process,
url_lines=_url_lines_for_process(process),
youtube_key_suffix=extract_youtube_key_suffix(_youtube_ini_text_for_process(process)),
db_path=RELAY_DB,
replace_imported=replace_imported,
)
if result.get("error"):
return JSONResponse(result, status_code=400)
@@ -1328,6 +1601,72 @@ async def config_restore(request: Request):
return {"message": msg}
@app.post("/config_optimize")
async def config_optimize(request: Request):
"""配置优化(参照 d2ypp2 config_governance.optimize_content"""
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)
action = str(data.get("action") or "").strip()
content = str(data.get("content") or "")
rel = str(data.get("file") or "").strip().replace("\\", "/").lstrip("/")
if ".." in rel.split("/"):
return JSONResponse({"error": "非法路径"}, status_code=400)
if not content and rel:
content = read_config_file(CONFIG_DIR, rel)
try:
result = optimize_config_content(action, rel, content)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
if data.get("save") and rel:
write_config_text(CONFIG_DIR, rel, result["content"], reason=action)
result["message"] = f"{result['message']},并已保存"
return result
@app.get("/config_insights")
async def config_insights_get(file: str = Query(..., description="相对 config 的路径")):
rel = file.strip().replace("\\", "/").lstrip("/")
if ".." in rel.split("/"):
return JSONResponse({"error": "非法路径"}, status_code=400)
content = read_config_file(CONFIG_DIR, rel)
if not content and not (CONFIG_DIR / rel).is_file():
return JSONResponse({"error": "文件不存在"}, status_code=404)
return analyze_config_content(rel, content)
@app.post("/config_insights")
async def config_insights_post(request: Request):
try:
data = await request.json()
except Exception:
return JSONResponse({"error": "无效的 JSON"}, status_code=400)
rel = str(data.get("file") or "").strip().replace("\\", "/").lstrip("/")
content = str(data.get("content") or "")
if ".." in rel.split("/"):
return JSONResponse({"error": "非法路径"}, status_code=400)
return analyze_config_content(rel, content)
@app.post("/relay_pro/channels_save")
async def relay_pro_channels_save(request: Request):
try:
data = await request.json()
except Exception:
return JSONResponse({"error": "无效的 JSON"}, status_code=400)
channels = data.get("channels") if isinstance(data.get("channels"), list) else data
if not isinstance(channels, list):
return JSONResponse({"error": "channels 必须是数组"}, status_code=400)
ok, msg = save_channels_document(CONFIG_DIR, channels)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
sync_errors = sync_all_youtube_pro_runtime_files(CONFIG_DIR, channels)
return {"message": msg, "runtime_errors": sync_errors}
def _process_monitor_family_matches(entry: dict, family: str | None) -> bool:
target = (family or "").strip().lower()
if not target or target == "all":
@@ -1381,11 +1720,14 @@ async def process_monitor(
if doc.get("ok"):
for ch in doc.get("channels") or []:
cid = str(ch.get("id") or "").strip()
if not cid or not channel_configured_for_monitor(CONFIG_DIR, cid):
if not cid:
continue
pm2 = relay_pm2_name_for_channel(cid, CONFIG_DIR)
if any(e.get("pm2") == pm2 for e in entries):
continue
entries.append(
{
"pm2": f"youtube2__{cid}",
"pm2": pm2,
"script": "youtube2.py",
"label": f"YouTube {ch.get('name', cid)}",
}