将 README 改写为产品文档;移除微信/EasyTier/工作台等遗留模块;新增 youLIVE 目标端监控、摄像头推流、质量守卫与转播历史能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
205 lines
7.7 KiB
Python
205 lines
7.7 KiB
Python
"""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],
|
||
}
|