Files
gitlab-instance-0a899031_d2…/web2_quality_guard.py
eric d61526698d 重构无人直播助手:内嵌 youLIVE、摄像头直推、推流诊断与 UI 焕新
将 README 改写为产品文档;移除微信/EasyTier/工作台等遗留模块;新增 youLIVE 目标端监控、摄像头推流、质量守卫与转播历史能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 20:50:44 -05:00

205 lines
7.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""YouTube relay quality scoring (from sh2 youtube_quality_guard.py, client-simplified)."""
from __future__ import annotations
import re
import time
from typing import Any
_FFMPEG_KV_RE = re.compile(
r"(?P<key>frame|fps|bitrate|speed|drop|dup)\s*=\s*(?P<value>[^\s]+)", re.IGNORECASE
)
_TIME_RE = re.compile(r"time\s*=\s*(?P<value>\d{2}:\d{2}:\d{2}(?:\.\d+)?)", re.IGNORECASE)
_URL_RE = re.compile(r"https?://[^\s\"'<>]+", re.IGNORECASE)
_ROOM_RE = re.compile(r"live\.douyin\.com/([a-zA-Z0-9_-]+)", re.IGNORECASE)
def _as_float(value: Any) -> float | None:
text = str(value or "").strip().lower().replace("x", "").replace("kbits/s", "").replace("kb/s", "")
text = text.replace(",", "")
match = re.search(r"-?\d+(?:\.\d+)?", text)
if not match:
return None
try:
return float(match.group(0))
except ValueError:
return None
def _as_int(value: Any) -> int | None:
parsed = _as_float(value)
return None if parsed is None else max(0, int(parsed))
def parse_ffmpeg_metrics(*texts: str) -> dict[str, Any]:
raw = "\n".join(str(item or "") for item in texts)
tail = raw[-16000:]
metrics: dict[str, Any] = {}
stats_line = ""
for line in reversed([ln.strip() for ln in tail.splitlines() if ln.strip()]):
lower = line.lower()
if "frame=" in lower and "speed=" in lower:
stats_line = line
break
if stats_line:
for match in _FFMPEG_KV_RE.finditer(stats_line):
key = match.group("key").lower()
value = match.group("value")
if key in {"frame", "drop", "dup"}:
metrics[key] = _as_int(value)
elif key in {"fps", "speed", "bitrate"}:
metrics[key] = _as_float(value)
time_match = _TIME_RE.search(stats_line)
if time_match:
metrics["encoded_time"] = time_match.group("value")
lower = tail.lower()
metrics["error_count"] = sum(
lower.count(token)
for token in (
"error",
"failed",
"connection refused",
"timed out",
"broken pipe",
"invalid data",
)
)
metrics["reconnect_count"] = sum(lower.count(token) for token in ("reconnect", "retry", "connection reset"))
return metrics
def _count_urls(text: str) -> int:
return len(_URL_RE.findall(str(text or "")))
def _ini_has_key(text: str) -> bool:
return bool(re.search(r"^\s*key\s*=\s*\S+", str(text or ""), re.IGNORECASE | re.MULTILINE))
def _ini_has_rtmps(text: str) -> bool:
lower = str(text or "").lower()
return any(token in lower for token in ("rtmps = 是", "rtmps=true", "rtmps = true", "rtmps=1"))
def _source_hint(url_lines: str, recent_log: str) -> dict[str, str]:
combined = f"{url_lines}\n{recent_log}"
urls = _URL_RE.findall(combined)
source_url = urls[0] if urls else ""
room = _room_from_text(source_url) if source_url else ""
return {"source_url": source_url, "source_room_id": room}
def _room_from_text(text: str) -> str:
m = _ROOM_RE.search(text or "")
return m.group(1) if m else ""
def _lane_score(row: dict[str, Any], metrics: dict[str, Any], cfg: dict[str, str]) -> tuple[int, list[dict[str, str]]]:
score = 100
issues: list[dict[str, str]] = []
if not row.get("ffmpeg_active") and not row.get("is_pushing"):
score -= 35
issues.append({"level": "critical", "text": "FFmpeg 未检测到推流进程"})
if not row.get("is_pushing"):
score -= 25
issues.append({"level": "critical", "text": "当前没有稳定推流输出"})
speed = metrics.get("speed") or row.get("ffmpeg_speed")
if isinstance(speed, (int, float)) and speed < 0.98:
score -= 18
issues.append({"level": "warning", "text": f"编码速度 {speed:.2f}x低于实时要求"})
if row.get("ffmpeg_speed_low"):
score -= 10
issues.append({"level": "warning", "text": "近期推流速度持续偏低"})
if row.get("ffmpeg_progress_stalled"):
score -= 15
issues.append({"level": "warning", "text": "FFmpeg 进度停滞"})
biz = str(row.get("business_status") or "")
if biz == "youtube_key_rejected":
score -= 40
issues.append({"level": "critical", "text": "YouTube 串流密钥被拒绝"})
elif biz == "misconfigured":
score -= 30
issues.append({"level": "critical", "text": "串流密钥或配置不完整"})
if int(metrics.get("error_count") or 0) > 0:
score -= min(30, int(metrics.get("error_count") or 0) * 6)
issues.append({"level": "warning", "text": "日志出现 FFmpeg/网络错误"})
url_count = _count_urls(cfg.get("url_lines", ""))
has_key = _ini_has_key(cfg.get("youtube_ini_text", ""))
if not has_key:
score = max(0, score - 30)
issues.append({"level": "critical", "text": "YouTube 推流 key 未配置"})
if url_count == 0:
score = max(0, score - 15)
issues.append({"level": "warning", "text": "没有配置转播源 URL"})
if has_key and not _ini_has_rtmps(cfg.get("youtube_ini_text", "")):
score = max(0, score - 8)
issues.append({"level": "warning", "text": "建议使用 RTMPS 推流"})
watch = str(cfg.get("watch_url") or "").strip()
if not watch:
score = max(0, score - 6)
issues.append({"level": "info", "text": "未配置 YouTube 目标监听地址(弹幕/观众数)"})
if not issues and row.get("is_pushing"):
issues.append({"level": "ok", "text": "推流链路正常"})
return max(0, min(100, score)), issues[:6]
def build_youtube_quality_guard_report(
rows: list[dict[str, Any]],
*,
config_by_process: dict[str, dict[str, str]] | None = None,
) -> dict[str, Any]:
config_by_process = config_by_process or {}
lanes: list[dict[str, Any]] = []
recommendations: list[dict[str, str]] = []
for row in rows:
process = str(row.get("process") or row.get("pm2") or "")
pushing = bool(row.get("is_pushing"))
metrics = (
parse_ffmpeg_metrics(str(row.get("recent_log") or ""), str(row.get("recent_error") or ""))
if pushing
else {}
)
cfg = config_by_process.get(process) or {}
hint = _source_hint(cfg.get("url_lines", ""), str(row.get("recent_log") or ""))
score, issues = _lane_score(row, metrics, cfg)
lanes.append(
{
"process": process,
"label": row.get("label") or process,
"source_url": hint["source_url"],
"source_room_id": hint["source_room_id"],
"status": row.get("business_status") or row.get("process_status") or "",
"is_pushing": pushing,
"ffmpeg_active": bool(row.get("ffmpeg_active") or row.get("ffmpeg_pids")),
"score": score,
"metrics": metrics,
"issues": issues,
}
)
pushing_count = sum(1 for lane in lanes if lane["is_pushing"])
score = int(sum(lane["score"] for lane in lanes) / len(lanes)) if lanes else 0
if pushing_count == 0:
recommendations.append({"level": "critical", "text": "没有正在推流的线路"})
elif all(lane["score"] >= 80 for lane in lanes if lane["is_pushing"]):
recommendations.append({"level": "ok", "text": "推流质量良好,保持监控即可"})
if any(lane["status"] == "relay_slow" for lane in lanes):
recommendations.append({"level": "warning", "text": "存在推流速度偏低的线路,检查 CPU/GPU 与网络"})
return {
"generated_at": time.time(),
"score": max(0, min(100, score)),
"summary": {"lanes": len(lanes), "pushing_lanes": pushing_count},
"lanes": lanes,
"recommendations": recommendations[:8],
}