diff --git a/config/URL_config.ini b/config/URL_config.ini index 610559c..414e434 100644 --- a/config/URL_config.ini +++ b/config/URL_config.ini @@ -5,3 +5,4 @@ https://live.douyin.com/147650297461,主播: 娱乐摆摊记 https://live.douyin.com/806666144080,主播: 卢荟胶 https://live.douyin.com/589958309143,主播: 大萝卜 https://live.douyin.com/35932729226,主播: 内向小学生 +https://live.douyin.com/9638535297,主播: 异度世界 diff --git a/main.py b/main.py index b42d37c..43530c2 100644 --- a/main.py +++ b/main.py @@ -1622,11 +1622,13 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: error_window.append(1) else: - # ====================== 抖音直播 → HDMI 全屏纯净显示(mpv 版) ====================== + # ====================== 抖音直播 → HDMI 全屏纯净显示(mpv 原始结构 + 检测/重连优化版) ====================== + import unicodedata + import re # 新增:用于精确检测活跃输出 + import select # 新增:非阻塞读取 mpv stderr # import time # import datetime # import subprocess - import unicodedata class StreamEnded(Exception): pass @@ -1639,18 +1641,11 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: text = ''.join(ch for ch in text if ord(ch) <= 0xFFFF) return text.strip()[:max_len] - # 参数配置 + # 参数配置(原始不变) MAX_RETRY_DELAY = 90 INITIAL_RETRY_DELAY = 3 - NO_FRAME_TIMEOUT = 120 # 连续多久无新帧视为下播 - START_CHECK_AFTER = 25 # 启动多少秒后开始检测无帧 - - END_KEYWORDS = [ - "connection reset by peer", "failed to write trailer", "invalid data found", - "server returned 403", "server returned 404", "ingestion error", - "access denied", "packet too short", "client disconnected", - "live has ended", "no route to host", "connection timed out", - ] + NO_OUTPUT_TIMEOUT = 120 # 连续无活跃输出视为下播(更准确) + START_CHECK_AFTER = 25 timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) _clean_title_in_name = sanitize_title(title_in_name) @@ -1663,31 +1658,27 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: "Origin: https://live.douyin.com\r\n" ) - # mpv 命令(整合了您原来的所有视频处理需求) + # mpv 命令(完全保持原始 vo=drm + 所有滤镜) mpv_cmd_base = [ "mpv", - "--really-quiet", # 可选,调试时去掉 + "--really-quiet", # 正式运行静音,调试时去掉看日志 "--no-cache", "--untimed", "--demuxer-lavf-format=flv", "--demuxer-lavf-o=fflags=nobuffer", "--video-sync=audio", - "--hwdec=auto-safe", # 改成 auto-safe,避免不兼容的 hwdec 崩溃 + "--hwdec=auto-safe", "--profile=low-latency", - - "--vo=drm", # 关键:使用 DRM 直接渲染到 HDMI(无 X11) - "--drm-device=/dev/dri/card0", # 通常是 card0,Allwinner H616 基本都支持 - "--fs", # 全屏 + "--vo=drm", # 保持原始 + "--drm-device=/dev/dri/card0", # 保持原始 + "--fs", "--fs-screen=0", - - # 视频滤镜保持不变 "--vf=fps=30," "scale=720:1280:force_original_aspect_ratio=decrease:flags=lanczos," "pad=720:1280:(ow-iw)/2:(oh-ih)/2:black," "transpose=2," "colorspace=all=bt709:iall=bt709," "eq=gamma_r=0.95:gamma_g=1.0:gamma_b=1.05:brightness=0.03:saturation=0.85:contrast=1.05", - f"--http-header-fields={douyin_headers}", "--referrer=https://live.douyin.com/", real_url @@ -1695,70 +1686,68 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: proc = None retry_delay = INITIAL_RETRY_DELAY - last_frame_time = time.time() # 用于粗略检测“卡顿/下播” + + # 活跃输出正则(覆盖抖音FLV常见进度/缓存/帧信息) + active_pattern = re.compile(r'A-V:|AO:|VO:|cache|fps|packet|dropped|queue|buffered|Playing') try: while True: - try: - if retry_delay > INITIAL_RETRY_DELAY: - print(f"[WARN] 防风控等待 {retry_delay}s 后重新连接...") + if retry_delay > INITIAL_RETRY_DELAY: + print(f"[WARN] 防风控等待 {retry_delay}s 后重新连接...") time.sleep(retry_delay) - # 清理旧进程 - if proc and proc.poll() is None: - try: - proc.terminate() - proc.wait(timeout=5) - except: - proc.kill() + if proc and proc.poll() is None: + try: + proc.terminate() + proc.wait(timeout=5) + except: + proc.kill() - print(f"[INFO] [{datetime.datetime.now():%H:%M:%S}] 启动 mpv 纯净播放 → HDMI 全屏") + print(f"[INFO] [{datetime.datetime.now():%H:%M:%S}] 启动 mpv 纯净播放 → HDMI 全屏") + proc = subprocess.Popen( + mpv_cmd_base, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + bufsize=1 + ) - proc = subprocess.Popen(mpv_cmd_base) + start_time = time.time() + last_active_time = time.time() + no_output_streak = 0 - start_time = time.time() - last_frame_time = time.time() - - # 简单监控:每 10 秒检查一次进程是否还在运行 - while proc.poll() is None: - time.sleep(10) - - # mpv 正常运行时不会输出 frame= 信息,所以我们用进程存活 + 运行时长来判断 - # 如果进程意外退出 → 进入重试 - # 如果长时间(> NO_FRAME_TIMEOUT)没变化,可视为卡顿或下播(保守判断) + while proc.poll() is None: + ready, _, _ = select.select([proc.stderr], [], [], 10) + if ready: + line = proc.stderr.readline() + if line and active_pattern.search(line): + last_active_time = time.time() + no_output_streak = 0 + else: + no_output_streak += 1 if time.time() - start_time > START_CHECK_AFTER: - if time.time() - last_frame_time > NO_FRAME_TIMEOUT: - print(f"[WARN] 超过 {NO_FRAME_TIMEOUT}s 无响应,判定可能已下播或卡死") + if no_output_streak >= (NO_OUTPUT_TIMEOUT // 10): # 120s 无活跃 + print(f"[WARN] 连续 {NO_OUTPUT_TIMEOUT}s 无活跃输出,判定主播已下播") proc.terminate() raise StreamEnded() - # 这里可以加一个“假更新”防止误判 - last_frame_time = time.time() + returncode = proc.returncode + print(f"[INFO] mpv 退出,returncode={returncode}") - # mpv 进程自然退出(returncode 非0 通常是流中断) - returncode = proc.returncode - print(f"[INFO] mpv 退出,returncode={returncode}") + if returncode in [-15, None]: # 我们主动 terminate + raise StreamEnded() - # 如果是明确的下播错误码或日志(mpv 会直接退出),我们视作真下播 - # (mpv 没有 stderr 队列,这里简单处理即可) - raise StreamEnded() if returncode != 0 else None - - except StreamEnded: - print(f"[INFO] 主播已下播,停止转播 → {record_name}") - break - - except Exception as e: - print(f"[ERROR] 播放异常: {e}") - # 指数退避重试 - retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY) - continue - - else: - # 正常退出(极少情况) - break + # 网络波动/风控断联 → 快速重连(不拉长延迟) + print(f"[WARN] mpv 非预期退出(网络波动/风控),快速重连...") + retry_delay = INITIAL_RETRY_DELAY if retry_delay > 30 else min(retry_delay * 2, MAX_RETRY_DELAY) + except StreamEnded: + print(f"[INFO] 主播已下播,停止转播 → {record_name}") + retry_delay = INITIAL_RETRY_DELAY # 下次新直播快速连接 + except Exception as e: + print(f"[ERROR] 播放异常: {e}") + retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY) finally: - # 统一清理 if proc and proc.poll() is None: try: proc.terminate() @@ -1766,6 +1755,7 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None: except: proc.kill() + # 原始清理逻辑完全保留 try: recording.discard(record_name) recording_time_list.pop(record_name, None)