将 README 改写为产品文档;移除微信/EasyTier/工作台等遗留模块;新增 youLIVE 目标端监控、摄像头推流、质量守卫与转播历史能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
227 lines
8.4 KiB
Python
227 lines
8.4 KiB
Python
"""Process runtime diagnosis (ported from sh2/src/process_runtime_status.py)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
|
|
_FFMPEG_PROGRESS_RE = re.compile(
|
|
r"frame=\s*(?P<frame>\d+).*?"
|
|
r"time=(?P<h>\d+):(?P<m>\d+):(?P<s>\d+(?:\.\d+)?).*?"
|
|
r"speed=\s*(?P<speed>\d+(?:\.\d+)?)x",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def normalize_log_text(text: str) -> str:
|
|
if not text:
|
|
return ""
|
|
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
|
|
|
|
def process_family(process_name: str) -> str:
|
|
low = (process_name or "").strip().lower()
|
|
if low.startswith("camera") or low.startswith("douyin_youtube"):
|
|
return "youtube"
|
|
if low.startswith("youtube"):
|
|
return "youtube"
|
|
if low.startswith("tiktok"):
|
|
return "tiktok"
|
|
if low.startswith("obs"):
|
|
return "obs"
|
|
return "generic"
|
|
|
|
|
|
def _has_any(text: str, patterns: tuple[str, ...]) -> bool:
|
|
return any(p in text for p in patterns)
|
|
|
|
|
|
def _looks_like_youtube_key_rejected(tail: str) -> bool:
|
|
youtube_output = _has_any(
|
|
tail,
|
|
(
|
|
"rtmps://a.rtmps.youtube.com/live2/",
|
|
"rtmp://a.rtmp.youtube.com/live2/",
|
|
"youtube.com/live2/",
|
|
),
|
|
)
|
|
if not youtube_output:
|
|
return False
|
|
if not _has_any(tail, ("error opening output", "error opening output file", "error opening output files")):
|
|
return False
|
|
return _has_any(
|
|
tail,
|
|
(
|
|
"io error: end of file",
|
|
"input/output error",
|
|
"error in the pull function",
|
|
"tls @",
|
|
"401",
|
|
"403",
|
|
"unauthorized",
|
|
"forbidden",
|
|
),
|
|
)
|
|
|
|
|
|
def _looks_like_ffmpeg_progress(tail: str) -> bool:
|
|
return "frame=" in tail and "time=" in tail and _has_any(tail, ("bitrate=", "speed="))
|
|
|
|
|
|
def _looks_like_ffmpeg_starting(tail: str) -> bool:
|
|
return _has_any(
|
|
tail,
|
|
(
|
|
"starting ffmpeg",
|
|
"ffmpeg command",
|
|
"opening input",
|
|
"opening output",
|
|
"rtmps://a.rtmps.youtube.com/live2/",
|
|
"rtmp://a.rtmp.youtube.com/live2/",
|
|
"youtube.com/live2/",
|
|
),
|
|
)
|
|
|
|
|
|
def _ffmpeg_progress_samples(tail: str) -> list[dict[str, float | int]]:
|
|
samples: list[dict[str, float | int]] = []
|
|
for match in _FFMPEG_PROGRESS_RE.finditer(tail or ""):
|
|
try:
|
|
frame = int(match.group("frame"))
|
|
media_time = (
|
|
int(match.group("h")) * 3600
|
|
+ int(match.group("m")) * 60
|
|
+ float(match.group("s"))
|
|
)
|
|
speed = float(match.group("speed"))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
samples.append({"frame": frame, "time": media_time, "speed": speed})
|
|
return samples[-8:]
|
|
|
|
|
|
def _ffmpeg_progress_stalled(samples: list[dict[str, float | int]]) -> bool:
|
|
if len(samples) < 3:
|
|
return False
|
|
tail = samples[-3:]
|
|
frames = {int(row["frame"]) for row in tail}
|
|
times = {round(float(row["time"]), 2) for row in tail}
|
|
return len(frames) == 1 and len(times) == 1
|
|
|
|
|
|
def _ffmpeg_speed_low(samples: list[dict[str, float | int]], threshold: float = 0.98) -> bool:
|
|
if len(samples) < 4:
|
|
return False
|
|
tail = samples[-4:]
|
|
if not all(float(row["speed"]) < threshold for row in tail):
|
|
return False
|
|
return int(tail[-1]["frame"]) > int(tail[0]["frame"]) or float(tail[-1]["time"]) > float(tail[0]["time"])
|
|
|
|
|
|
_DISCONNECT_PATTERNS = (
|
|
"client disconnected",
|
|
"connection reset by peer",
|
|
"broken pipe",
|
|
"connection timed out",
|
|
"i/o error",
|
|
"error submitting a packet to muxer",
|
|
"failed to update header",
|
|
)
|
|
|
|
|
|
def _looks_like_stream_disconnected(tail: str) -> bool:
|
|
return _has_any(tail, _DISCONNECT_PATTERNS)
|
|
|
|
|
|
def summarize_process_activity(
|
|
process_name: str,
|
|
process_status: str,
|
|
recent_log: str,
|
|
recent_error: str,
|
|
*,
|
|
log_age_seconds: float | None = None,
|
|
ffmpeg_pids: list[int] | None = None,
|
|
) -> dict[str, Any]:
|
|
pids = sorted({int(pid) for pid in (ffmpeg_pids or []) if int(pid) > 0})
|
|
status = (process_status or "").strip().lower()
|
|
family = process_family(process_name)
|
|
tail = normalize_log_text(f"{recent_log}\n{recent_error}")[-8000:].lower()
|
|
progress_samples = _ffmpeg_progress_samples(tail)
|
|
latest_progress = progress_samples[-1] if progress_samples else {}
|
|
progress_stalled = _ffmpeg_progress_stalled(progress_samples)
|
|
speed_low = _ffmpeg_speed_low(progress_samples)
|
|
|
|
business_status = "inactive"
|
|
business_note = "进程未运行"
|
|
log_recent = log_age_seconds is None or log_age_seconds <= 20
|
|
has_recent_ffmpeg_progress = _looks_like_ffmpeg_progress(tail) and log_recent
|
|
has_ffmpeg_worker = bool(pids)
|
|
|
|
if family == "youtube" and not has_recent_ffmpeg_progress and _looks_like_youtube_key_rejected(tail):
|
|
business_status = "youtube_key_rejected"
|
|
business_note = "YouTube 拒绝了 RTMPS 推流,串流密钥可能无效、过期或与直播活动不匹配"
|
|
elif status in {"online", "running"}:
|
|
if _has_any(
|
|
tail,
|
|
("请在 ", "配置 key", "stream_key", "key=你的stream_key", "串流密钥", "至少需要一个有效"),
|
|
):
|
|
business_status = "misconfigured"
|
|
business_note = "缺少或无法使用 YouTube 串流密钥"
|
|
elif family == "youtube" and _looks_like_stream_disconnected(tail) and not has_recent_ffmpeg_progress:
|
|
business_status = "stream_disconnected"
|
|
business_note = "推流连接已断开,正在等待自动重连或需检查网络"
|
|
elif family == "youtube" and has_recent_ffmpeg_progress and has_ffmpeg_worker and progress_stalled:
|
|
business_status = "source_stalled"
|
|
business_note = "FFmpeg 在运行,但帧/时间进度已停滞"
|
|
elif family == "youtube" and has_recent_ffmpeg_progress and has_ffmpeg_worker and speed_low:
|
|
business_status = "relay_slow"
|
|
business_note = "FFmpeg 在运行,但近期推流速度低于实时"
|
|
elif has_recent_ffmpeg_progress or (has_ffmpeg_worker and _looks_like_ffmpeg_progress(tail)):
|
|
business_status = "streaming"
|
|
business_note = "FFmpeg 正在推流"
|
|
elif family == "youtube" and has_ffmpeg_worker and _looks_like_ffmpeg_starting(tail) and log_recent:
|
|
business_status = "relay_starting"
|
|
business_note = "FFmpeg 已启动,尚未观察到视频进度"
|
|
elif log_age_seconds is not None and log_age_seconds > 180:
|
|
business_status = "stale"
|
|
business_note = f"进程在线但日志 {int(log_age_seconds)} 秒未更新"
|
|
elif family == "youtube" and _has_any(
|
|
tail,
|
|
("等待直播", "循环等待", "检测直播间中", "没有正在监测的直播", "无录制任务", "网址内容获取失败"),
|
|
):
|
|
business_status = "waiting_source"
|
|
business_note = "正在等待源站开播"
|
|
elif family == "youtube" and _has_any(
|
|
tail,
|
|
("开始推流到 youtube", "推流到 youtube", "正在直播中", "[relay_meta]"),
|
|
):
|
|
business_status = "source_live"
|
|
business_note = "源站已开播,但暂未检测到 FFmpeg 推流"
|
|
elif "traceback" in tail or " error " in tail or "[error]" in tail or "错误信息" in tail:
|
|
business_status = "error"
|
|
business_note = "近期日志中出现异常"
|
|
else:
|
|
business_status = "monitoring"
|
|
business_note = "进程在线,正在监测源站"
|
|
elif status in {"stopped", "stopping", "not_found"}:
|
|
business_status = "stopped"
|
|
business_note = "进程未运行"
|
|
elif status == "invalid":
|
|
business_note = "无效的进程名"
|
|
|
|
return {
|
|
"business_status": business_status,
|
|
"business_note": business_note,
|
|
"ffmpeg_pids": pids,
|
|
"ffmpeg_active": bool(pids),
|
|
"log_age_seconds": log_age_seconds,
|
|
"ffmpeg_frame": latest_progress.get("frame"),
|
|
"ffmpeg_time_seconds": latest_progress.get("time"),
|
|
"ffmpeg_speed": latest_progress.get("speed"),
|
|
"ffmpeg_progress_stalled": progress_stalled,
|
|
"ffmpeg_speed_low": speed_low,
|
|
"is_pushing": business_status in {"streaming", "relay_slow"}
|
|
or (business_status == "source_stalled" and has_recent_ffmpeg_progress and has_ffmpeg_worker),
|
|
}
|