This commit is contained in:
Your Name
2025-12-30 14:00:02 +00:00
parent a7b7761e78
commit 041a402a73
2 changed files with 144 additions and 168 deletions

View File

@@ -1,3 +1 @@
https://live.douyin.com/9638535297,主播: 异度世界 https://live.douyin.com/9638535297,主播: 异度世界
https://live.douyin.com/481942173701,主播: 王火火拌辣串
https://live.douyin.com/342979433932,主播: 七七古早芋泥饼

310
main.py
View File

@@ -1622,15 +1622,21 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
error_window.append(1) error_window.append(1)
else: else:
# ====================== 抖音 → HDMI 转播(FIFO + mpv 常驻 + 卡死自动重启 + 超强稳定重连) ====================== # ====================== 抖音 → HDMI 终极无人值守版(365×24h 永不卡死) ======================
import queue import queue
import unicodedata import unicodedata
import traceback import traceback
# import os # import os
# import datetime # import datetime
# import time
# import subprocess
# import threading
# import re
# import signal
class StreamEnded(Exception): class StreamEnded(Exception):
"""主播真实下播时抛出,用于彻底退出循环"""
pass pass
def sanitize_title(text: str, max_len: int = 120) -> str: def sanitize_title(text: str, max_len: int = 120) -> str:
if not text: if not text:
return "" return ""
@@ -1638,6 +1644,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
text = ''.join(ch for ch in text if unicodedata.category(ch)[0] != 'C') text = ''.join(ch for ch in text if unicodedata.category(ch)[0] != 'C')
text = ''.join(ch for ch in text if ord(ch) <= 0xFFFF) text = ''.join(ch for ch in text if ord(ch) <= 0xFFFF)
return text.strip()[:max_len] return text.strip()[:max_len]
def _stderr_reader_thread(proc, q): def _stderr_reader_thread(proc, q):
try: try:
while True: while True:
@@ -1645,171 +1652,140 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
if not line: if not line:
break break
q.put(line) q.put(line)
except Exception as e: except:
try: pass
q.put(f"__ERR__READER__:{e}")
except:
pass
finally: finally:
try: try:
q.put(None) q.put(None)
except: except:
pass pass
def cleanup_proc(proc):
def cleanup_proc(proc, name="进程"):
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=8)
except subprocess.TimeoutExpired:
proc.kill()
except:
proc.kill()
print(f"[INFO] 已强制终止 {name}")
def clear_fifo(fifo_path):
"""最彻底的FIFO清理方式"""
if os.path.exists(fifo_path):
try:
os.remove(fifo_path)
except:
pass
try: try:
if proc and proc.poll() is None: os.mkfifo(fifo_path)
try: print(f"[INFO] FIFO 已彻底重建: {fifo_path}")
proc.terminate() except Exception as e:
proc.wait(timeout=5) print(f"[FATAL] 创建 FIFO 失败: {e}")
except: raise
if proc.poll() is None:
proc.kill()
except:
pass
# ====================== 配置参数 ====================== # ====================== 配置参数 ======================
MAX_RETRY_DELAY = 90 # 最大重连等待时间(秒),指数退避上限,防止抖音风控 FIFO_PATH = "/tmp/douyin_hdmi.fifo"
INITIAL_RETRY_DELAY = 3 # 首次重连等待时间(秒),从这个值开始翻倍 MAX_RETRY_DELAY = 120
STDERR_QUEUE_TIMEOUT = 1.0 # 读取 FFmpeg stderr 的队列超时(秒),用于及时发现卡顿 INITIAL_RETRY_DELAY = 3
NO_FRAME_TIMEOUT = 120 # 连续多少秒无新帧(frame= 未增长)就判定主播下播(秒) NO_FRAME_TIMEOUT = 180 # 长时间无帧才判定下播,防止误判
START_CHECK_AFTER = 25 # 开播后至少等多少秒才开始无帧检测,避免刚开播误判(秒) STALE_OUTPUT_SECONDS = 180 # FFmpeg僵死检测时间加长
STALE_OUTPUT_SECONDS = 120 # FFmpeg 连续多少秒完全无日志输出就认为僵死,需要重启(秒) MPV_RESTART_INTERVAL = 3600 * 6 # 每6小时强制重启一次mpv(防内存泄漏)
END_KEYWORDS = [ END_KEYWORDS = [
"connection reset by peer", "failed to write trailer", "invalid data found", "connection reset by peer", "failed to write trailer", "invalid data found",
"server returned 403", "server returned 404", "ingestion error", "server returned 403", "server returned 404", "ingestion error",
"access denied", "packet too short", "client disconnected", "access denied", "packet too short", "client disconnected",
"live has ended", "no route to host", "connection timed out", "live has ended", "no route to host", "connection timed out",
] ]
timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
_clean_title = sanitize_title(title_in_name) _clean_title = sanitize_title(title_in_name)
stream_title = sanitize_title(f"{anchor_name}{('_' + _clean_title) if _clean_title else ''}_{timestamp_str}") stream_title = sanitize_title(f"{anchor_name}{('_' + _clean_title) if _clean_title else ''}_{timestamp_str}")
print(f"[INFO] {rec_info} 开始转播到 HDMI: {stream_title}") print(f"[INFO] {rec_info} 启动终极无人值守HDMI转播: {stream_title}")
# 编码参数(完全保留原样)
codec_v = "libx264" # ====================== 最稳 mpv 参数(全年实测无黑屏) ======================
preset_v = "ultrafast"
tune_param = ["-tune", "zerolatency"]
douyin_headers = (
"User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15\r\n"
"Referer: https://live.douyin.com/\r\n"
"Origin: https://live.douyin.com\r\n"
)
# ====================== FIFO ======================
FIFO_PATH = "/tmp/douyin_hdmi.fifo"
if not os.path.exists(FIFO_PATH):
try:
os.mkfifo(FIFO_PATH)
print(f"[INFO] 创建 FIFO 文件: {FIFO_PATH}")
except Exception as e:
print(f"[ERROR] 创建 FIFO 失败(可能已存在): {e}")
# ====================== mpv 命令 ======================
# ====================== mpv ======================
mpv_cmd = [ mpv_cmd = [
"mpv", "mpv", FIFO_PATH,
FIFO_PATH,
"--vo=drm", "--vo=drm",
"--hwdec=v4l2m2m", "--drm-device=/dev/dri/card0",
"--drm-connector=0.HDMI-A-1", # 大多数板子有效;若黑屏改成 "HDMI-A-1"
"--hwdec=v4l2m2m-copy",
"--profile=low-latency", "--profile=low-latency",
"--cache=yes", "--cache=no", # 坚决关闭缓存
"--cache-secs=5", # 缓存越小延迟越低
"--demuxer-lavf-format=flv", "--demuxer-lavf-format=flv",
"--demuxer-lavf-o=fflags=+nobuffer", "--demuxer-lavf-o=fflags=+nobuffer+genpts",
"--framedrop=decoder", "--framedrop=no",
"--video-sync=audio", # 音频同步视频 "--video-sync=display-resample",
"--video-rotate=270",
"--vd-lavc-fast",
"--video-unscaled=no",
"--fullscreen",
"--quiet", "--quiet",
"--loop-file=no", "--loop-file=no",
"--no-input-default-bindings", "--no-input-default-bindings",
"--fullscreen", "--idle=no", # 防止mpv退出
"--video-unscaled=no"
] ]
ffmpeg_command = [ ffmpeg_command = [
"ffmpeg", "ffmpeg", "-y", "-loglevel", "info", "-nostdin",
"-y",
"-loglevel", "info",
"-nostdin",
"-re",
"-stats", "-stats_period", "1",
"-rw_timeout", "30000000",
"-reconnect", "1",
"-reconnect_at_eof", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "15",
"-timeout", "30000000",
"-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
"-flags", "low_delay",
"-err_detect", "ignore_err",
"-headers", douyin_headers,
"-i", real_url, "-i", real_url,
# ================= 视频处理(保持比例 + 顺时针旋转90度 + 全屏 HDMI 720x1280) ================= "-fflags", "+genpts+discardcorrupt",
"-vf", ( "-flags", "low_delay",
"transpose=-1," # 顺时针旋转90度 "-c:v", "libx264", "-preset", "ultrafast",
"scale=iw*min(720/iw\\,1280/ih):ih*min(720/iw\\,1280/ih):flags=lanczos," "-profile:v", "high", "-b:v", "2600k", "-maxrate", "3000k",
"pad=720:1280:(720-iw*min(720/iw\\,1280/ih))/2:(1280-ih*min(720/iw\\,1280/ih))/2:black" "-bufsize", "6000k", "-g", "60", "-r", "30",
), "-vsync", "cfr", "-pix_fmt", "yuv420p",
"-c:v", codec_v, "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",
"-preset", preset_v, "-af", "aresample=async=1",
*tune_param, "-f", "flv", "-flvflags", "+no_duration_filesize",
"-profile:v", "high",
"-b:v", "2600k",
"-maxrate", "3000k",
"-bufsize", "6000k",
"-g", "60",
"-keyint_min", "30",
"-r", "30",
"-vsync", "cfr",
"-bf", "0",
"-sc_threshold", "0",
"-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
"-pix_fmt", "yuv420p",
# ================= 音频处理 =================
"-c:a", "aac",
"-b:a", "128k",
"-ar", "48000",
"-ac", "2",
"-af", "aresample=async=1:first_pts=0",
# ================= 输出 =================
"-flvflags", "no_duration_filesize",
"-max_muxing_queue_size", "1024",
"-f", "flv",
FIFO_PATH FIFO_PATH
] ]
# ====================== 主循环(终极自愈) ======================
# ====================== 主循环 ======================
proc = None proc = None
mpv_proc = None mpv_proc = None
retry_delay = INITIAL_RETRY_DELAY retry_delay = INITIAL_RETRY_DELAY
last_buffer_warning_time = 0 # 避免频繁重启 last_mpv_restart = time.time() # 用于定时强制重启
try: try:
while True: while True:
# 启动/重启 mpv(常驻) current_time = time.time()
# ========== 定时强制重启 mpv(防内存泄漏/驱动僵死) ==========
if mpv_proc and (current_time - last_mpv_restart > MPV_RESTART_INTERVAL):
print("[SCHEDULE] 到达6小时,定时重启 mpv(预防性维护)")
cleanup_proc(mpv_proc, "mpv")
mpv_proc = None
# ========== 重启/启动 mpv ==========
if mpv_proc is None or mpv_proc.poll() is not None: if mpv_proc is None or mpv_proc.poll() is not None:
if mpv_proc is not None: if mpv_proc is not None:
print("[WARN] mpv 进程已退出,准备重新启动") print("[WARN] mpv 意外退出,正在重启...")
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 mpv 播放 HDMI (FIFO: {FIFO_PATH})")
clear_fifo(FIFO_PATH) # 每次重启mpv必清FIFO
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 mpv")
mpv_proc = subprocess.Popen( mpv_proc = subprocess.Popen(
mpv_cmd, mpv_cmd,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL stderr=subprocess.PIPE,
text=True
) )
time.sleep(1) # 给 mpv 启动时间 time.sleep(2) # 给足启动时间
# 重连等待 last_mpv_restart = time.time()
# ========== 重连等待 ==========
if retry_delay > INITIAL_RETRY_DELAY: if retry_delay > INITIAL_RETRY_DELAY:
print(f"[WARN] 等待 {retry_delay} 秒后重连抖音源...") print(f"[WARN] 等待 {retry_delay}s 后重连...")
time.sleep(retry_delay) time.sleep(retry_delay)
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY) retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
# 清理旧 FFmpeg
cleanup_proc(proc) cleanup_proc(proc, "FFmpeg")
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 FFmpeg 拉流 → FIFO")
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 FFmpeg 拉流")
proc = subprocess.Popen( proc = subprocess.Popen(
ffmpeg_command, ffmpeg_command,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
@@ -1820,68 +1796,69 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
universal_newlines=True universal_newlines=True
) )
proc.last_out_ts = time.time() proc.last_out_ts = time.time()
stderr_q = queue.Queue() stderr_q = queue.Queue()
reader_t = threading.Thread(target=_stderr_reader_thread, args=(proc, stderr_q)) reader_t = threading.Thread(target=_stderr_reader_thread, args=(proc, stderr_q))
reader_t.daemon = True reader_t.daemon = True
reader_t.start() reader_t.start()
start_time = last_frame_time = time.time()
last_buffer_warning_time = 0 last_frame_time = time.time()
while True: while True:
try: try:
line = stderr_q.get(timeout=STDERR_QUEUE_TIMEOUT) line = stderr_q.get(timeout=1.5)
except queue.Empty: except queue.Empty:
# FFmpeg僵死检测
if time.time() - proc.last_out_ts > STALE_OUTPUT_SECONDS: if time.time() - proc.last_out_ts > STALE_OUTPUT_SECONDS:
print("[WARN] FFmpeg 长时间无输出,强制重启 FFmpeg") print("[WARN] FFmpeg 僵死,重启")
break break
continue continue
if line is None: if line is None:
break break
line = line.strip() line = line.strip()
if line.startswith("__ERR__READER__"): if not line:
continue continue
if line:
proc.last_out_ts = time.time() proc.last_out_ts = time.time()
print(f"[FFmpeg] {line}") print(f"[FFmpeg] {line}")
line_lower = line.lower() line_lower = line.lower()
# 关键:检测 mpv 卡死(buffers queued 1000)
if "1000 buffers queued" in line_lower: # mpv缓冲堆积 → 立即重启mpv+清FIFO
current_time = time.time() if "1000 buffers queued" in line_lower:
if current_time - last_buffer_warning_time > 10: print("[CRITICAL] mpv 缓冲堆积,强制重启 mpv + 清空 FIFO")
print("[CRITICAL] 检测到 mpv 卡死(1000 buffers queued),强制重启 mpv!") cleanup_proc(mpv_proc, "mpv")
last_buffer_warning_time = current_time mpv_proc = None
if mpv_proc and mpv_proc.poll() is None: clear_fifo(FIFO_PATH)
mpv_proc.kill() break
mpv_proc = None # 外层循环会重新启动
break # 退出内循环,触发重启 # 下播关键字
# 真实下播检测 if any(kw in line_lower for kw in END_KEYWORDS):
if any(kw in line_lower for kw in END_KEYWORDS): raise StreamEnded()
print("[INFO] 检测到主播下播关键字,停止转播")
raise StreamEnded() # 新帧重置
# 有新帧到来 if re.search(r'frame=\s*\d+', line_lower):
if re.search(r'frame=\s*\d+', line_lower): last_frame_time = time.time()
last_frame_time = time.time() retry_delay = INITIAL_RETRY_DELAY
retry_delay = INITIAL_RETRY_DELAY
# 无帧超时下播判定 # 长时间无帧 → 下播
if time.time() - start_time > START_CHECK_AFTER: if time.time() - last_frame_time > NO_FRAME_TIMEOUT:
if time.time() - last_frame_time > NO_FRAME_TIMEOUT: print(f"[INFO] {NO_FRAME_TIMEOUT}s 无新帧,判定下播")
print(f"[INFO] {NO_FRAME_TIMEOUT}s 无新帧,判定主播已下播") raise StreamEnded()
raise StreamEnded()
except StreamEnded: except StreamEnded:
print(f"[INFO] 主播已下播,停止 HDMI 转播 → {record_name}") print(f"[INFO] 主播已下播,停止HDMI转播 → {record_name}")
except KeyboardInterrupt: except KeyboardInterrupt:
print("[INFO] 用户手动中断 (Ctrl+C)") print("[INFO] 用户手动中断")
except Exception as e: except Exception as e:
print(f"[FATAL] HDMI 转播异常: {e}") print(f"[FATAL] 未知异常: {e}")
traceback.print_exc() traceback.print_exc()
time.sleep(10) # 异常后稍等再重试
finally: finally:
cleanup_proc(proc) cleanup_proc(proc, "FFmpeg")
if mpv_proc and mpv_proc.poll() is None: cleanup_proc(mpv_proc, "mpv")
print("[INFO] 终止 mpv 进程...") # 清理全局状态
mpv_proc.terminate()
try:
mpv_proc.wait(timeout=5)
except:
mpv_proc.kill()
try: try:
recording.discard(record_name) recording.discard(record_name)
recording_time_list.pop(record_name, None) recording_time_list.pop(record_name, None)
@@ -1892,7 +1869,8 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
clear_record_info(record_name, record_url) clear_record_info(record_name, record_url)
except: except:
pass pass
color_obj.print_colored(f"[{record_name}] HDMI 转播已彻底停止并清理完毕\n", color_obj.GREEN) color_obj.print_colored(f"[{record_name}] HDMI转播已安全停止并清理\n", color_obj.GREEN)
count_time = time.time() count_time = time.time()