's'
This commit is contained in:
227
robust_relay.py
Normal file
227
robust_relay.py
Normal file
@@ -0,0 +1,227 @@
|
||||
# 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()
|
||||
Reference in New Issue
Block a user