"""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"(?Pframe|fps|bitrate|speed|drop|dup)\s*=\s*(?P[^\s]+)", re.IGNORECASE ) _TIME_RE = re.compile(r"time\s*=\s*(?P\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], }