重构无人直播助手:内嵌 youLIVE、摄像头直推、推流诊断与 UI 焕新

将 README 改写为产品文档;移除微信/EasyTier/工作台等遗留模块;新增 youLIVE 目标端监控、摄像头推流、质量守卫与转播历史能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric
2026-07-07 20:50:44 -05:00
parent d28e64f4ef
commit d61526698d
140 changed files with 24209 additions and 3721 deletions

393
web2.py
View File

@@ -28,29 +28,48 @@ from web2_youtube_oauth import (
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_process_runtime import summarize_process_activity
from web2_config_governance import (
list_config_backups,
restore_config_backup,
write_config_text,
)
from web2_quality_guard import build_youtube_quality_guard_report
from web2_youtube_diagnostics import build_youtube_diagnostics_report
from web2_relay_log import (
import_relay_sessions_from_log,
list_relay_sessions,
relay_db_path,
sync_relay_from_recent_log,
track_process_status_transition,
)
from web2_relay_pro import (
add_relay_channel,
build_relay_live_status,
ensure_default_relay_config,
extract_url_lines_from_ini,
first_youtube_pm2_name,
channel_configured_for_monitor,
get_channel_by_id,
get_channel_detail,
get_single_youtube_watch_url,
get_youtube_watch_url,
list_channels_for_pro,
materialize_relay_channel_youtube_ini,
read_url_config_for_channel,
relay_pm2_name_for_channel,
remove_relay_channel,
set_youtube_watch_url,
sync_channel_to_legacy_files,
update_channel_key,
update_channel_keys_list,
watch_url_for_relay_process,
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_camera import camera_config_ready, list_capture_devices, load_camera_config, save_camera_config
from web2_pocketbase import REQUIRE_PB, pb_auth_refresh_and_validate
@@ -66,12 +85,20 @@ app = FastAPI(title="多进程录制控制台", lifespan=lifespan)
BASE_DIR = Path(__file__).parent
CONFIG_DIR = BASE_DIR / "config"
DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs"
RELAY_DB = relay_db_path(BASE_DIR)
_PROCESS_STATUS_CACHE: dict[str, str] = {}
MAX_LOG_LINES = 300
def _label_short(prefix: str) -> str:
return {"tiktok": "TikTok", "youtube": "YouTube", "obs": "OBS", "web": "Web 控制台"}.get(prefix, prefix)
return {
"tiktok": "TikTok",
"youtube": "YouTube",
"camera": "摄像头",
"obs": "OBS",
"web": "Web 控制台",
}.get(prefix, prefix)
def discover_script_entries() -> tuple:
@@ -88,6 +115,10 @@ def discover_script_entries() -> tuple:
if path.is_file() and path.resolve() != web_path:
entries.append({"script": path.name, "label": _label_short("youtube")})
for path in sorted(base.glob("camera*.py")):
if path.is_file() and path.resolve() != web_path:
entries.append({"script": path.name, "label": _label_short("camera")})
for path in sorted(base.glob("obs*.sh")):
if path.is_file():
entries.append({"script": path.name, "label": _label_short("obs")})
@@ -112,6 +143,10 @@ def _stem(script: str) -> str:
return Path(script).stem
def is_camera_script(script: str) -> bool:
return script.endswith(".py") and _stem(script).startswith("camera")
def is_youtube_script(script: str) -> bool:
return script.endswith(".py") and _stem(script).startswith("youtube")
@@ -242,12 +277,9 @@ app.add_middleware(
allow_headers=["*"],
)
register_wechat_chatbot_routes(app, BASE_DIR)
@app.get("/client/overview")
async def client_overview():
"""Electron 工作台:频道列表、微信摘要。"""
"""Electron 工作台:频道列表摘要。"""
return build_client_overview(CONFIG_DIR, BASE_DIR)
@@ -300,6 +332,28 @@ async def relay_pro_channel_detail(channel_id: str = Query(..., description="频
return d
@app.get("/relay_pro/youtube_watch")
async def relay_pro_youtube_watch_get(channel_id: str = Query("single", description="频道 id单路填 single")):
cid = str(channel_id or "single").strip() or "single"
return {"channelId": cid, "url": get_youtube_watch_url(CONFIG_DIR, cid)}
@app.post("/relay_pro/youtube_watch")
async def relay_pro_youtube_watch_post(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 "single").strip() or "single"
url = str(data.get("url") or data.get("youtubeWatchUrl") or "")
ok, msg = set_youtube_watch_url(CONFIG_DIR, cid, url)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"message": msg, "channelId": cid, "url": get_youtube_watch_url(CONFIG_DIR, cid)}
@app.get("/relay_pro/url_config")
async def relay_pro_get_url_config(channel_id: str = Query(...)):
return {"content": read_url_config_for_channel(CONFIG_DIR, channel_id)}
@@ -481,33 +535,129 @@ def _ffmpeg_pids_for_process(info: dict, process_name: str) -> list[int]:
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", "进程在线,正在监测源站"
def _config_rel_for_channel(channel_id: str, kind: str) -> str:
if kind == "url":
return f"relay_pro/url_config.ch_{channel_id}.ini"
return "youtube.ini"
def _url_lines_for_process(process: str) -> str:
cid = relay_channel_id_from_pm2_name(process)
if cid:
return read_url_config_for_channel(CONFIG_DIR, cid)
p = CONFIG_DIR / "URL_config.ini"
if p.is_file():
try:
return p.read_text(encoding="utf-8")
except OSError:
pass
return ""
def _youtube_ini_text_for_process(process: str) -> str:
cid = relay_channel_id_from_pm2_name(process)
if cid:
d = get_channel_detail(CONFIG_DIR, cid)
key = (d or {}).get("youtubeKey") or ""
return f"[youtube]\nkey = {key}\n"
p = CONFIG_DIR / "youtube.ini"
if p.is_file():
try:
return p.read_text(encoding="utf-8")
except OSError:
pass
return ""
def _config_snapshot_for_process(process: str) -> dict[str, str]:
return {
"url_lines": _url_lines_for_process(process),
"youtube_ini_text": _youtube_ini_text_for_process(process),
"watch_url": watch_url_for_relay_process(CONFIG_DIR, process),
}
def _youtube_guard_targets(process: str) -> tuple[list[str], JSONResponse | None]:
ensure_default_relay_config(CONFIG_DIR)
proc = (process or "").strip()
if proc:
if not process_valid(proc):
return [], JSONResponse({"error": "无效进程名"}, status_code=400)
return [proc], None
targets: list[str] = []
for p in PROCESS_MONITOR:
sc = p.get("script") or ""
if is_youtube_script(sc):
targets.append(p["pm2"])
doc = list_channels_for_pro(CONFIG_DIR)
for ch in (doc.get("channels") or []) if doc.get("ok") else []:
cid = str(ch.get("id") or "").strip()
if not cid or not channel_configured_for_monitor(CONFIG_DIR, cid):
continue
pname = relay_pm2_name_for_channel(cid)
if pname not in targets:
targets.append(pname)
return targets, None
async def _youtube_guard_rows(targets: list[str]) -> tuple[list[dict], dict[str, dict[str, str]]]:
rows: list[dict] = []
config_by_process: dict[str, dict[str, str]] = {}
for proc in targets:
row = await _status_payload_for_process(proc)
label = proc
for p in PROCESS_MONITOR:
if p["pm2"] == proc:
label = p.get("label") or proc
break
cid = relay_channel_id_from_pm2_name(proc)
if cid:
d = get_channel_detail(CONFIG_DIR, cid)
if d:
label = str(d.get("name") or label)
row["label"] = label
rows.append(row)
config_by_process[proc] = _config_snapshot_for_process(proc)
return rows, config_by_process
async def _status_payload_for_process(process: str) -> dict:
info = await control_get_process_info(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 = ""
ffmpeg_pids = _ffmpeg_pids_for_process(info, 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
activity = summarize_process_activity(
process,
str(info["process_status"]),
recent_log,
recent_error,
log_age_seconds=log_age_seconds,
ffmpeg_pids=ffmpeg_pids,
)
return {
"process": process,
"process_status": info["process_status"],
"recent_log": recent_log,
"recent_error": recent_error,
"log_age_seconds": log_age_seconds,
"ffmpeg_pids": activity["ffmpeg_pids"],
"ffmpeg_active": activity.get("ffmpeg_active"),
"business_status": activity["business_status"],
"business_note": activity["business_note"],
"is_pushing": activity["is_pushing"],
"ffmpeg_frame": activity.get("ffmpeg_frame"),
"ffmpeg_time_seconds": activity.get("ffmpeg_time_seconds"),
"ffmpeg_speed": activity.get("ffmpeg_speed"),
"ffmpeg_progress_stalled": activity.get("ffmpeg_progress_stalled"),
"ffmpeg_speed_low": activity.get("ffmpeg_speed_low"),
}
# ---------------- 配置路由youtube.ini仅 youtube ----------------
@@ -538,10 +688,9 @@ async def save_config(request: Request, process: str = Query(..., description="
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")
write_config_text(CONFIG_DIR, "youtube.ini", content, reason="save_config")
return {"message": "配置保存成功"}
except Exception as e:
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
@@ -593,10 +742,9 @@ async def youtube_ini_model_post(request: Request, process: str = Query(..., des
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")
write_config_text(CONFIG_DIR, "youtube.ini", content, reason="ini_model")
return {"message": "配置保存成功"}
except Exception as e:
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
@@ -630,7 +778,7 @@ async def save_url_config(request: Request, process: str = Query(..., descriptio
config_file = CONFIG_DIR / "URL_config.ini"
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
config_file.write_text(content, encoding="utf-8")
write_config_text(CONFIG_DIR, "URL_config.ini", content, reason="save_url_config")
return {"message": "配置保存成功"}
except Exception as e:
return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
@@ -701,6 +849,37 @@ def _relay_channel_ready_message(channel_id: str) -> str | None:
# ---------------- PM2 控制路由 ----------------
def _camera_ready_message(process: str) -> str | None:
if not is_camera_script(script_for_pm2(process) or ""):
return None
return camera_config_ready(CONFIG_DIR)
@app.get("/camera/devices")
async def camera_devices():
return list_capture_devices()
@app.get("/camera/config")
async def camera_config_get():
return {"config": load_camera_config(CONFIG_DIR)}
@app.post("/camera/config")
async def camera_config_post(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)
patch = data.get("config") if isinstance(data.get("config"), dict) else data
ok, msg = save_camera_config(CONFIG_DIR, patch)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"message": msg, "config": load_camera_config(CONFIG_DIR)}
@app.get("/start")
async def start(request: Request, process: str = Query(..., description="进程名")):
if REQUIRE_PB:
@@ -723,6 +902,9 @@ async def start(request: Request, process: str = Query(..., description="进程
if not ok:
return JSONResponse({"output": msg}, status_code=400)
await _stop_legacy_youtube_if_online()
cam_msg = _camera_ready_message(process)
if cam_msg:
return JSONResponse({"output": cam_msg}, status_code=400)
output = await control_start(process)
return JSONResponse({"output": output})
@@ -781,6 +963,9 @@ async def restart(request: Request, process: str = Query(..., description="进
if not ok:
return JSONResponse({"output": f"{stop_output}\n{msg}"}, status_code=400)
await _stop_legacy_youtube_if_online()
cam_msg = _camera_ready_message(process)
if cam_msg:
return JSONResponse({"output": f"{stop_output}\n{cam_msg}"}, status_code=400)
start_output = await control_start(process)
return JSONResponse({"output": f"{stop_output}\n{start_output}"})
@@ -797,37 +982,34 @@ 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)
recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
payload = await _status_payload_for_process(process)
ps = str(payload["process_status"])
if info["process_status"] == "not_found":
recent_log = "进程未运行或未由本机管理器启动。\n"
recent_error = ""
prev = _PROCESS_STATUS_CACHE.get(process)
if prev != ps:
track_process_status_transition(process, prev or "", ps, db_path=RELAY_DB)
_PROCESS_STATUS_CACHE[process] = ps
ffmpeg_pids = _ffmpeg_pids_for_process(info, 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,
)
url_lines = _url_lines_for_process(process)
channel_name = ""
cid = relay_channel_id_from_pm2_name(process)
if cid:
d = get_channel_detail(CONFIG_DIR, cid)
channel_name = str((d or {}).get("name") or "")
try:
sync_relay_from_recent_log(
process,
str(payload.get("recent_log") or ""),
channel_name=channel_name,
url_lines=url_lines,
db_path=RELAY_DB,
)
except Exception:
pass
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",
**payload,
})
@@ -931,6 +1113,85 @@ async def youtube_stream_fetch_key(process: str = Query(..., description="进程
# ---------------- 进程列表(供前端同步,由 discover_script_entries 自动扫描) ----------------
@app.get("/youtube/quality_guard")
async def youtube_quality_guard(process: str = Query("", description="进程名,空则汇总全部 YouTube 线路")):
targets, err = _youtube_guard_targets(process)
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)
@app.get("/youtube/diagnostics")
async def youtube_diagnostics(process: str = Query("", description="进程名,空则汇总全部 YouTube 线路")):
targets, err = _youtube_guard_targets(process)
if err is not None:
return err
rows, config_by_process = await _youtube_guard_rows(targets)
return build_youtube_diagnostics_report(rows, config_by_process=config_by_process)
@app.get("/youtube/relay/sessions")
async def youtube_relay_sessions(
process: str = Query("", description="PM2 进程名"),
q: str = Query("", description="搜索主播/URL/房间"),
status: str = Query("", description="active | ended"),
limit: int = Query(30, ge=1, le=200),
offset: int = Query(0, ge=0),
):
return list_relay_sessions(
process=process,
q=q,
status=status,
limit=limit,
offset=offset,
db_path=RELAY_DB,
)
@app.post("/youtube/relay/sessions/import")
async def youtube_relay_sessions_import(process: str = Query(..., description="进程名")):
if not process_valid(process):
return JSONResponse({"error": "无效进程名"}, status_code=400)
info = await control_get_process_info(process)
log_path = 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),
db_path=RELAY_DB,
)
if result.get("error"):
return JSONResponse(result, status_code=400)
return result
@app.get("/config/backups")
async def config_backups(file: str = Query(..., description="相对 config 的路径,如 youtube.ini")):
rel = file.strip().replace("\\", "/").lstrip("/")
if ".." in rel.split("/"):
return JSONResponse({"error": "非法路径"}, status_code=400)
return {"file": rel, "backups": list_config_backups(CONFIG_DIR, rel)}
@app.post("/config/restore")
async def config_restore(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("/")
backup_id = str(data.get("backupId") or data.get("backup_id") or "").strip()
if not rel or not backup_id or ".." in rel.split("/"):
return JSONResponse({"error": "参数无效"}, status_code=400)
ok, msg = restore_config_backup(CONFIG_DIR, rel, backup_id)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"message": msg}
@app.get("/process_monitor")
async def process_monitor():
ensure_default_relay_config(CONFIG_DIR)
@@ -942,7 +1203,7 @@ 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:
if not cid or not channel_configured_for_monitor(CONFIG_DIR, cid):
continue
entries.append(
{