Files
gitlab-instance-0a899031_do…/backup/robust_relay.py
2025-12-01 18:01:58 +08:00

227 lines
8.8 KiB
Python
Raw 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.
# robust_relay.py
# 高稳定性 YouTube 转播模块(防误判下播 + 看门狗 + 自动重连 + token刷新
# 作者:融合 A+B 代码精华,专为抖音/抖音国际版长期无人值守转播设计
import subprocess
import time
import threading
import os
import signal
import datetime
from typing import List, Optional, Callable
# ==================== 配置常量(可全局覆盖) ====================
WATCHDOG_SILENCE = 600 # 10分钟无输出 → 软重启
WATCHDOG_GRACE = 120 # 启动宽限期
REFRESH_URL_EVERY = 300 # 每5分钟强制刷新 real_url防token过期
OFFLINE_DEBOUNCE = 3 # 连续3次API返回下播才认为真下播
OFFLINE_WINDOW = 120 # 最近120秒有活跃就忽略下播信号
SOFT_RESTART_COOLDOWN = 90 # 软重启最小间隔
# ==================== 轻量探活 ====================
def _probe_alive(url: str, timeout: float = 5.0) -> bool:
if not url:
return False
try:
import urllib.request, contextlib
opener = urllib.request.build_opener()
if hasattr(urllib.request, 'getproxies'):
proxy = urllib.request.getproxies().get('http')
if proxy:
opener.add_handler(urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}, method="HEAD")
with contextlib.closing(opener.open(req, timeout=timeout)) as r:
if r.code >= 400:
return False
head = r.read(256)
if url.lower().endswith(".m3u8"):
return b"#EXTM3U" in head.upper()
if url.lower().endswith(".flv"):
return head.startswith(b"FLV")
return len(head) > 0
except:
return False
# ==================== 增强版 ffmpeg 启动器 ====================
def _launch_ffmpeg(
input_url: str,
youtube_rtmp: str,
anchor_name: str,
logger: Callable[[str], None],
extra_cmd: List[str] = None
) -> subprocess.Popen:
cmd = [
"ffmpeg", "-nostdin", "-hide_banner", "-stats", "-stats_period", "60", "-loglevel", "info",
# 输入端超强重连
"-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_at_eof", "1",
"-reconnect_delay_max", "30", "-rw_timeout", "20000000",
# 极致低延迟 + 时间戳修复
"-fflags", "+genpts+igndts+flush_packets", "-flags", "+global_header",
"-thread_queue_size", "4096", "-analyzeduration", "0", "-probesize", "32",
"-i", input_url,
# copy 模式(质量无损)
"-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc",
"-map", "0:v?", "-map", "0:a?",
# YouTube 强制参数
"-f", "mpegts", "-g", "2", "-rtmp_buffer", "1000", "-rtmp_live", "live",
"-max_delay", "0", "-tune", "zerolatency", "-avoid_negative_ts", "make_zero",
"-muxdelay", "0", "-muxpreload", "0",
"-fflags", "+nobuffer+flush_packets", "-avioflags", "direct",
]
if extra_cmd:
cmd.extend(extra_cmd)
cmd.append(youtube_rtmp)
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=0,
creationflags=creationflags
)
# 用于看门狗
proc.last_output_ts = time.time()
def _reader():
try:
for line in proc.stdout: # type: ignore
if not line:
break
text = line.decode(errors="ignore").rstrip()
proc.last_output_ts = time.time()
if any(k in text for k in ["bitrate=", "frame=", "speed=", "fps="]):
logger(f"[{anchor_name}] {text}")
except:
pass
threading.Thread(target=_reader, daemon=True).start()
return proc
# ==================== 主函数:高稳定性转播(替代原 check_subprocess ====================
def robust_youtube_relay(
anchor_name: str,
real_url: str,
youtube_rtmp: str,
logger: Callable[[str], None],
get_latest_stream_url: Callable[[], Optional[str]], # 用于刷新 real_url如有token过期
is_stream_still_live: Callable[[], bool], # 检查主播是否在播API
extra_ffmpeg_args: List[str] = None, # 可选:加 -f segment 分段保存
heartbeat_callback: Callable[[int], None] = None # 可选:每分钟回调已运行秒数
) -> bool:
"""
返回值:
True = 主播已下播(正常结束)
False = 发生未知错误(建议计数错误次数)
"""
proc: Optional[subprocess.Popen] = None
start_time = time.time()
last_refresh = 0.0
last_soft_restart = 0.0
offline_count = 0
last_live_ts = time.time()
try:
proc = _launch_ffmpeg(real_url, youtube_rtmp, anchor_name, logger, extra_ffmpeg_args)
logger(f"[{anchor_name}] 已开始高稳定性转播 → {youtube_rtmp.split('/')[-1]}")
while True:
now = time.time()
elapsed = int(now - start_time)
silence = now - proc.last_output_ts if proc else 0
# 1. 进程意外退出 → 重启
if proc and proc.poll() is not None:
logger(f"[{anchor_name}] ffmpeg 意外退出3秒后重启...")
time.sleep(3)
proc = _launch_ffmpeg(real_url, youtube_rtmp, anchor_name, logger, extra_ffmpeg_args)
start_time = time.time()
continue
# 2. 看门狗:长时间无输出 → 软重启
if elapsed > WATCHDOG_GRACE and silence > WATCHDOG_SILENCE:
if now - last_soft_restart > SOFT_RESTART_COOLDOWN:
logger(f"[{anchor_name}] 检测到卡顿({silence:.0f}s无输出软重启 ffmpeg...")
last_soft_restart = now
if proc and proc.stdin:
proc.stdin.write(b'q')
proc.stdin.flush()
time.sleep(4)
proc = _launch_ffmpeg(real_url, youtube_rtmp, anchor_name, logger, extra_ffmpeg_args)
start_time = time.time()
continue
# 3. 定期刷新播放地址防token过期
if now - last_refresh > REFRESH_URL_EVERY:
new_url = get_latest_stream_url()
if new_url and new_url != real_url:
logger(f"[{anchor_name}] 检测到新的播放地址,切换中...")
real_url = new_url
if proc and proc.stdin:
proc.stdin.write(b'q')
time.sleep(3)
proc = _launch_ffmpeg(real_url, youtube_rtmp, anchor_name, logger, extra_ffmpeg_args)
start_time = time.time()
last_refresh = now
# 4. 下播防抖判定(核心防误判)
try:
still_live = is_stream_still_live()
except:
still_live = True # 接口异常不判下播
if not still_live:
# 交叉验证:流还能访问 → 忽略API误判
if _probe_alive(real_url):
logger(f"[{anchor_name}] API判下播但流仍活跃忽略本次误判")
still_live = True
last_live_ts = now
if still_live:
offline_count = 0
last_live_ts = now
else:
if now - last_live_ts < OFFLINE_WINDOW:
logger(f"[{anchor_name}] 短期掉线,忽略")
else:
offline_count += 1
if offline_count >= OFFLINE_DEBOUNCE:
logger(f"[{anchor_name}] 连续{offline_count}次确认下播,结束转播")
if proc and proc.stdin:
proc.stdin.write(b'q')
return True
else:
logger(f"[{anchor_name}] 疑似下播 {offline_count}/{OFFLINE_DEBOUNCE},继续观察")
# 5. 心跳日志 & 回调
if elapsed % 60 < 2:
h, m = divmod(elapsed // 60, 60)
logger(f"[{anchor_name}] 正在转播 {h:02d}:{m:02d}")
if heartbeat_callback:
heartbeat_callback(elapsed)
time.sleep(8)
except KeyboardInterrupt:
logger(f"[{anchor_name}] 手动终止转播")
return True
except Exception as e:
logger(f"[{anchor_name}] 严重错误: {e}")
return False
finally:
if proc:
try:
proc.stdin.write(b'q') if proc.stdin else None
proc.wait(timeout=8)
except:
proc.kill()