From 617fdcb158ee0689e5d944006c2f4ffb131094f5 Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 27 Sep 2025 18:13:01 +0800 Subject: [PATCH] s'' --- back/back3 | 582 ++++++++++++++++++++++++++++++++++++++++++ config/URL_config.ini | 7 +- main.py | 69 +++-- 3 files changed, 635 insertions(+), 23 deletions(-) create mode 100644 back/back3 diff --git a/back/back3 b/back/back3 new file mode 100644 index 0000000..d2af416 --- /dev/null +++ b/back/back3 @@ -0,0 +1,582 @@ +# -*- encoding: utf-8 -*- + +""" +单进程 ffmpeg 推流:从 URL_config.ini 顺序检测直播源, +一旦当前源停播/异常,自动切到下一个正在直播的源并推流到 YouTube。 +仅做“检测+转推”,不做录制、分段、通知等。 +""" + +import asyncio +import os +import sys +import signal +import time +import datetime +import re +import uuid +import subprocess +from pathlib import Path +from typing import Any, List, Tuple, Optional + +import configparser +import urllib.request +from urllib.error import URLError, HTTPError + +import httpx +from src import spider, stream +from src.utils import logger, Color, check_md5 +from ffmpeg_install import check_ffmpeg, ffmpeg_path, current_env_path + +# --------------------- 基础环境 --------------------- +version = "relay-1.0.1" +os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path +script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] +config_file = f'{script_path}/config/config.ini' +url_config_file = f'{script_path}/config/URL_config.ini' +text_encoding = 'utf-8-sig' +color = Color() + +# ---------- YouTube 推流配置(读取 youtube.ini) ---------- +_yt_cfg = configparser.ConfigParser() +with open(f"{script_path}/config/youtube.ini", "r", encoding="utf-8-sig") as f: + _yt_cfg.read_file(f) + +def _yt_get(section: str, key: str, fallback=None, cast=str): + try: + if cast is bool: + return _yt_cfg.getboolean(section, key, fallback=fallback) + if cast is int: + return _yt_cfg.getint(section, key, fallback=fallback) + return _yt_cfg.get(section, key, fallback=fallback) + except Exception: + return fallback + +def _safe_int(v, lo, hi, default): + try: + v = int(v) + if v < lo or v > hi: + return default + return v + except Exception: + return default + +def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None): + node = (ingest or "a").strip().lower() + node = node if node.isalpha() and len(node) == 1 else "a" + scheme = "rtmps" if secure else "rtmp" + host = f"{node}.rtmps.youtube.com" if secure else f"{node}.rtmp.youtube.com" + return f"{scheme}://{host}/live2/{key}" + +# --------------------- 简化配置读取 --------------------- +def read_config_value(cfg: configparser.RawConfigParser, section: str, option: str, default_value: Any) -> Any: + try: + cfg.read(config_file, encoding=text_encoding) + if section not in cfg.sections(): + cfg.add_section(section) + return cfg.get(section, option) + except (configparser.NoSectionError, configparser.NoOptionError): + cfg.set(section, option, str(default_value)) + with open(config_file, 'w', encoding=text_encoding) as f: + cfg.write(f) + return default_value + +_CN_BOOL = {"是": True, "否": False} + +cfg = configparser.RawConfigParser() +use_proxy = _CN_BOOL.get(read_config_value(cfg, '录制设置', '是否使用代理ip(是/否)', "否"), False) +proxy_addr = read_config_value(cfg, '录制设置', '代理地址', "") +proxy_addr = proxy_addr if use_proxy and proxy_addr.strip() else None +# Cookie(按需) +dy_cookie = read_config_value(cfg, 'Cookie', '抖音cookie', '') +tiktok_cookie = read_config_value(cfg, 'Cookie', 'tiktok_cookie', '') + +# 可选:启动时检测系统代理(仅提示) +try: + if not use_proxy: + urllib.request.urlopen("https://www.google.com/", timeout=5) + print("提示:你的网络似乎直连可用(无需代理)。") +except Exception: + if not use_proxy: + color.print_colored("提示:无法直连海外站,如需 TikTok 请在 config.ini 开启代理并填写代理地址。", color.YELLOW) + +# --------------------- URL 列表读取 --------------------- +def parse_urls_from_file(path: str) -> List[str]: + """ + 读取 URL_config.ini,返回去重且保持相对顺序的 URL 列表。 + 支持行内格式: + - 纯 URL + - quality,url + - url,主播: xxx + - # 注释行 忽略 + """ + urls, seen = [], set() + if not os.path.isfile(path): + open(path, 'w', encoding=text_encoding).close() + print("URL_config.ini 为空,请写入直播间地址后再运行。") + return urls + + with open(path, "r", encoding=text_encoding, errors='ignore') as f: + for line in f: + s = line.strip() + if not s or s.startswith("#"): + continue + # 拆分(最多3段,取包含 https 或域名的那段) + parts = re.split(r"[,,]\s*", s) + cand = None + for p in parts: + if '://' in p or re.match(r"^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", p): + cand = p + break + if not cand: + continue + url = cand if '://' in cand else f"https://{cand}" + if url not in seen: + seen.add(url) + urls.append(url) + return urls + +# --------------------- 直播解析与选源 --------------------- +def is_flv_preferred_platform(link: str) -> bool: + # 抖音/TikTok 优先走 FLV;但 H265 不走 FLV + return any(i in link for i in ["douyin", "tiktok"]) + +def select_source_url(link: str, stream_info: dict) -> Optional[str]: + if not stream_info: + return None + if is_flv_preferred_platform(link): + flv = stream_info.get("flv_url") + if flv: + # 若 codec=h265 则不走 flv + try: + from urllib.parse import urlparse, parse_qs + qs = parse_qs(urlparse(flv).query or "") + if (qs.get("codec") or [""])[0].lower() == "h265": + logger.warning("FLV 不支持 h265,改用 HLS。") + else: + return flv + except Exception: + return flv + return stream_info.get("record_url") or stream_info.get("m3u8_url") or stream_info.get("flv_url") + +async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict: + """ + 调用项目现有 spider/stream,返回: + { + "anchor_name": str, + "is_live": bool, + "title": str?, + "record_url": "...", + "flv_url": "...", + "m3u8_url": "..." + } + """ + proxy = proxy_addr + port_info = {} + try: + if "douyin.com/" in url: + if 'v.douyin.com' not in url and '/user/' not in url: + j = await spider.get_douyin_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) + else: + j = await spider.get_douyin_app_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) + port_info = await stream.get_douyin_stream_url(j, record_quality_code, proxy) + elif url.startswith("https://www.tiktok.com/"): + if proxy: + j = await spider.get_tiktok_stream_data(url=url, proxy_addr=proxy, cookies=tiktok_cookie) + port_info = await stream.get_tiktok_stream_url(j, record_quality_code, proxy) + else: + logger.error("TikTok 需要代理,请在 config.ini 开启并配置代理。") + port_info = {"anchor_name": "", "is_live": False} + elif (".m3u8" in url) or (".flv" in url): + # 自定义直链 + port_info = { + "anchor_name": "自定义_" + str(uuid.uuid4())[:8], + "is_live": True, + } + if url.endswith(".flv"): + port_info["flv_url"] = url + else: + port_info["m3u8_url"] = url + else: + # 其它站点可按需扩展 + port_info = {"anchor_name": "", "is_live": False} + except Exception as e: + logger.error(f"解析失败: {e}") + port_info = {"anchor_name": "", "is_live": False} + return port_info + +# --------------------- ffmpeg 单进程推流 --------------------- +def build_youtube_output() -> Tuple[str, list, list]: + key = _yt_get("youtube", "key", fallback="", cast=str) + if not key: + logger.error("错误: config/youtube.ini 未配置 key") + sys.exit(1) + + secure = _yt_get("youtube", "secure", fallback=False, cast=bool) + ingest = _yt_get("youtube", "ingest", fallback="a", cast=str) + res_str = (_yt_get("youtube", "resolution", fallback="", cast=str) or "").strip() # 例如 "1080x1920" + + bv = _safe_int(_yt_get("youtube", "bitrate", fallback=5000, cast=int), 300, 20000, 5000) + ba = _safe_int(_yt_get("youtube", "audio_bitrate", fallback=128, cast=int), 64, 384, 128) + fps = _safe_int(_yt_get("youtube", "fps", fallback=30, cast=int), 10, 60, 30) + gop = _safe_int(_yt_get("youtube", "gop", fallback=2*fps, cast=int), fps, 4*fps, 2*fps) + + out_url = _build_youtube_rtmp_url(key, secure, ingest) + + # —— 默认强制转码(稳) —— # + v_args = [ + "-c:v", "libx264", + "-preset", "veryfast", + "-tune", "zerolatency", + "-profile:v", "high", + "-level:v", "4.2", + "-pix_fmt", "yuv420p", + "-r", str(fps), + "-g", str(gop), + "-b:v", f"{bv}k", + "-maxrate", f"{bv}k", + "-bufsize", f"{max(2*bv, 4000)}k", + ] + a_args = [ + "-c:a", "aac", + "-b:a", f"{ba}k", + "-ar", "44100", + "-ac", "2", + ] + + # 可选缩放/留黑边 + 避免 1088x1920(加 crop 强制精确尺寸) + if res_str: + try: + w, h = map(int, res_str.lower().split("x")) + vf = ( + f"scale={w}:{h}:force_original_aspect_ratio=decrease," + f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2," + f"crop={w}:{h}," + f"format=yuv420p" + ) + v_args = ["-vf", vf] + v_args + except Exception: + logger.warning(f"resolution '{res_str}' 非法,忽略") + + return out_url, v_args, a_args + +def launch_ffmpeg(input_url: str, + out_url: str, + extra_headers: Optional[str] = None, + v_args: Optional[list] = None, + a_args: Optional[list] = None) -> subprocess.Popen: + """ + 启动一个 ffmpeg 进程:输入 input_url,输出 RTMP/RTMPS 到 out_url。 + - v_args / a_args 由 build_youtube_output() 生成并传入 + - 启动 stdout reader 线程以防缓冲阻塞 + - 设置代理环境变量以兼容 https/hls + """ + import threading + user_agent = ("Mozilla/5.0 (Linux; Android 11; SM-G973U) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/87.0.4280.141 Mobile Safari/537.36") + + # 输入侧容错/重连/探测 + base = [ + "ffmpeg", "-nostdin", "-hide_banner", + "-stats", # 输出统计信息 + "-stats_period", "60", # 每60秒一次 + "-progress", "pipe:1", # 状态写到stdout,便于reader捕获 + "-loglevel", "info", + + # 输入容错 & 自动重连 + "-rw_timeout", "15000000", + "-timeout", "10000000", + "-reconnect", "1", + "-reconnect_streamed", "1", + "-reconnect_at_eof", "1", + "-reconnect_delay_max", "30", + + # UA/协议白名单 + "-user_agent", user_agent, + "-protocol_whitelist", "file,http,https,tcp,tls,crypto,udp,rtmp,rtp,httpproxy", + + # 缓冲 & 探测 + "-thread_queue_size", "4096", + "-analyzeduration", "10000000", + "-probesize", "10000000", + ] + + + # 平台 headers(referer/origin)要在 -i 之前 + if extra_headers: + base += ["-headers", extra_headers] + + # 代理:http_proxy/https_proxy 环境变量(同时保留 -http_proxy 兼容) + env = os.environ.copy() + if proxy_addr: + env["http_proxy"] = proxy_addr + env["https_proxy"] = proxy_addr + base = ["ffmpeg", "-http_proxy", proxy_addr] + base[1:] + + # 输入 + base += ["-re", "-i", input_url] + + # 通用容错 + base += [ + "-fflags", "+genpts+discardcorrupt+nobuffer", + "-use_wallclock_as_timestamps", "1", + "-avioflags", "direct", + "-rtbufsize", "100M", + ] + + # 视频/音频编码参数(使用 build_youtube_output 的结果) + v_args = v_args or [] + a_args = a_args or [] + base += v_args + a_args + + # 输出(flv 到 YT) + base += ["-f", "flv", out_url] + + # 打印命令便于排障(截断) + cmd_str = " ".join(base) + print("ffmpeg cmd:", cmd_str if len(cmd_str) < 500 else cmd_str[:500] + " ...") + + logger.info(f"启动推流:{input_url} -> {out_url}") + + creationflags = 0 + if os.name == "nt": + # 防止子进程挂住控制台(可选) + creationflags = subprocess.CREATE_NEW_PROCESS_GROUP + + p = subprocess.Popen( + base, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=0, # 避免行缓冲告警 & 尽快刷新 + env=env, + creationflags=creationflags + ) + + # 最近输出时间戳,用于 watchdog + p.last_out_ts = time.time() + + def _reader(): + try: + for line in iter(p.stdout.readline, b''): + if not line: + break + text = line.decode(errors="ignore").strip() + p.last_out_ts = time.time() + # 常见错误提示 + if "Server disconnected" in text: + logger.error(f"[YouTube输出异常] {text}") + elif "not supported" in text or "Invalid" in text or "invalid" in text: + logger.error(f"[转码异常] {text}") + except Exception: + pass + + t = threading.Thread(target=_reader, daemon=True) + t.start() + + return p + +def stop_ffmpeg(p: Optional[subprocess.Popen]): + if not p: + return + try: + if os.name == "nt": + if p.stdin: + try: + p.stdin.write(b"q") + p.stdin.flush() + except Exception: + pass + p.wait(timeout=6) + else: + p.send_signal(signal.SIGINT) + p.wait(timeout=10) + except Exception: + try: + p.kill() + except Exception: + pass + +# --------------------- 主循环(单进程转推+切源) --------------------- +def check_ffmpeg_existence() -> bool: + try: + result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True) + if result.returncode == 0: + print(result.stdout.splitlines()[0]) + except Exception: + pass + finally: + return bool(check_ffmpeg()) + +def now_str(): return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + +def headers_for_platform(platform_name: str, live_url: str) -> Optional[str]: + live_domain = '/'.join(live_url.split('/')[0:3]) + record_headers = { + 'PandaTV': 'origin:https://www.pandalive.co.kr', + 'WinkTV': 'origin:https://www.winktv.co.kr', + 'PopkonTV': 'origin:https://www.popkontv.com', + 'FlexTV': 'origin:https://www.flextv.co.kr', + '千度热播': 'referer:https://qiandurebo.com', + '17Live': 'referer:https://17.live/en/live/6302408', + '浪Live': 'referer:https://www.lang.live', + 'shopee': f'origin:{live_domain}', + 'Blued直播': 'referer:https://app.blued.cn' + } + return record_headers.get(platform_name) + +def detect_platform(url: str) -> str: + if "douyin.com" in url: return "抖音直播" + if "tiktok.com" in url: return "TikTok直播" + if url.endswith(".flv") or url.endswith(".m3u8"): return "自定义录制直播" + return "未知平台" + +def main(): + print("-----------------------------------------------------") + print("| DouyinLiveRecorder - 单进程转推版 |") + print("-----------------------------------------------------") + print(f"版本号: {version}\nGitHub: https://github.com/ihmily/DouyinLiveRecorder") + print("模式:只检测 + 单进程推流(停播自动切下一个 URL)") + print(".....................................................") + + if not check_ffmpeg_existence(): + logger.error("缺少 ffmpeg,程序退出") + sys.exit(1) + + urls = parse_urls_from_file(url_config_file) + if not urls: + logger.error("URL_config.ini 中没有可用 URL。") + sys.exit(1) + + # 顺序轮询索引 + idx = 0 + current_url = None + current_proc: Optional[subprocess.Popen] = None + current_platform = "" + current_anchor = "" + record_quality_code = "OD" # 固定“原画”;需要也可改为从 config.ini 读取 + + check_interval_live = 8 # 推流中检测当前源是否仍在直播的间隔秒 + scan_interval_when_idle = 2 # 没有任何可用源时的扫描间隔秒 + last_md5 = "" # URL 文件变化时热更新 + + # watchdog 参数 + watchdog_grace = 60 # 推流开始 60s 内不做静默判定 + watchdog_silence = 300 # 5 分钟完全无输出才重启 + + try: + while True: + # 动态热更新 URL 列表(URL_config.ini 有变更则重载) + try: + md5 = check_md5(url_config_file) + if md5 != last_md5: + urls = parse_urls_from_file(url_config_file) or urls + last_md5 = md5 + print(f"[{now_str()}] 已重载 URL 列表,数量:{len(urls)}") + # 若当前 URL 已不在列表,强制切换 + if current_url and current_url not in urls: + print(f"[{now_str()}] 当前URL已被移除,停止并重新选择。") + stop_ffmpeg(current_proc) + current_proc = None + current_url = None + except Exception as e: + logger.warning(f"URL 列表热更新失败:{e}") + + if current_proc and current_url: + retcode = current_proc.poll() + if retcode is not None: + color.print_colored(f"[{now_str()}] ffmpeg 已退出 (code={retcode}),切源…", color.YELLOW) + stop_ffmpeg(current_proc) + current_proc = None + current_url = None + continue + + # 检查是否停播(依赖 spider) + try: + port = asyncio.run(fetch_port_info(current_url, record_quality_code)) + still_live = bool(port.get("is_live")) + except Exception as e: + logger.error(f"检测当前源失败:{e}") + still_live = True # 容错:无法判断时先认为还活着 + + if still_live: + elapsed = int(time.time() - start_time) + h, m = divmod(elapsed, 3600) + m, s = divmod(m, 60) + dur_str = f"{h:02d}:{m:02d}:{s:02d}" + print(f"\r[{now_str()}] 正在转播({dur_str}):{current_platform} | {current_anchor} | {current_url}", end="") + + # ✅ watchdog(推流开始 > 60s 且 5 分钟完全无输出) + silence = time.time() - getattr(current_proc, "last_out_ts", 0) + if elapsed >= watchdog_grace and silence > watchdog_silence: + color.print_colored(f"\n[{now_str()}] 超过5分钟未见 ffmpeg 输出,判定异常,重启…", color.YELLOW) + stop_ffmpeg(current_proc) + current_proc = None + current_url = None + time.sleep(1) + continue + + time.sleep(check_interval_live) + continue + else: + color.print_colored(f"\n[{now_str()}] 主播停播,切换下一个源…", color.YELLOW) + stop_ffmpeg(current_proc) + current_proc = None + current_url = None + + else: + # 未在推流:从 urls 顺序找第一个 is_live 的 + if not urls: + time.sleep(scan_interval_when_idle) + continue + + tried = 0 + found = False + start_idx = idx + while tried < len(urls): + url = urls[idx] + idx = (idx + 1) % len(urls) + tried += 1 + + port = asyncio.run(fetch_port_info(url, record_quality_code)) + if not port.get("anchor_name"): + print(f"[{now_str()}] 获取失败,跳过:{url}") + continue + if not port.get("is_live"): + print(f"[{now_str()}] 未开播:{port.get('anchor_name','')} | {url}") + continue + + # 选取真实播放地址 + real_url = select_source_url(url, port) + if not real_url: + print(f"[{now_str()}] 未得到播放地址,跳过:{url}") + continue + + current_platform = detect_platform(url) + current_anchor = port.get("anchor_name", "") + headers = headers_for_platform(current_platform, url) + + # 启动 ffmpeg + out_url, v_args, a_args = build_youtube_output() + current_proc = launch_ffmpeg(real_url, out_url, headers, v_args=v_args, a_args=a_args) + current_url = url + start_time = time.time() + color.print_colored(f"[{now_str()}] 开始转播:{current_platform} | {current_anchor} | {url}", color.GREEN) + found = True + break + + if not found: + # 没有任何源在播,过会儿再试 + print(f"[{now_str()}] 当前无可用直播源,等待中…") + time.sleep(scan_interval_when_idle) + continue + except KeyboardInterrupt: + print("\n收到中断,正在退出…") + except Exception as e: + logger.error(f"主循环异常:{e}") + finally: + stop_ffmpeg(current_proc) + print("已退出。") + +if __name__ == "__main__": + main() diff --git a/config/URL_config.ini b/config/URL_config.ini index 008e741..e409898 100644 --- a/config/URL_config.ini +++ b/config/URL_config.ini @@ -1,6 +1,4 @@ -https://live.douyin.com/898038766356 -https://live.douyin.com/60379224194 -https://live.douyin.com/72212955083 +https://live.douyin.com/60379224194 https://live.douyin.com/621118100231 https://live.douyin.com/280422307519 https://live.douyin.com/114111936890 @@ -10,10 +8,7 @@ https://live.douyin.com/92876979328 https://live.douyin.com/136996589868 https://live.douyin.com/517715534931 https://live.douyin.com/460525712926 -https://live.douyin.com/456576000931 -https://live.douyin.com/745606325880 https://live.douyin.com/749687541944 -https://live.douyin.com/675343045974 https://live.douyin.com/673565298571 https://live.douyin.com/835571459859 https://live.douyin.com/152358755212 diff --git a/main.py b/main.py index d2af416..e49c354 100644 --- a/main.py +++ b/main.py @@ -28,7 +28,7 @@ from src.utils import logger, Color, check_md5 from ffmpeg_install import check_ffmpeg, ffmpeg_path, current_env_path # --------------------- 基础环境 --------------------- -version = "relay-1.0.1" +version = "relay-1.1.0" os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] config_file = f'{script_path}/config/config.ini' @@ -60,6 +60,10 @@ def _safe_int(v, lo, hi, default): except Exception: return default +def _round_res(w: int, h: int) -> Tuple[int, int]: + # 强制偶数,避免 1088/1918 这类非标准尺寸 + return (w & ~1, h & ~1) + def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None): node = (ingest or "a").strip().lower() node = node if node.isalpha() and len(node) == 1 else "a" @@ -243,10 +247,11 @@ def build_youtube_output() -> Tuple[str, list, list]: "-ac", "2", ] - # 可选缩放/留黑边 + 避免 1088x1920(加 crop 强制精确尺寸) + # 可选缩放/留黑边 + 避免 1088x1920(crop 强制精确尺寸)+ 偶数对齐 if res_str: try: w, h = map(int, res_str.lower().split("x")) + w, h = _round_res(w, h) vf = ( f"scale={w}:{h}:force_original_aspect_ratio=decrease," f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2," @@ -278,9 +283,9 @@ def launch_ffmpeg(input_url: str, # 输入侧容错/重连/探测 base = [ "ffmpeg", "-nostdin", "-hide_banner", - "-stats", # 输出统计信息 - "-stats_period", "60", # 每60秒一次 - "-progress", "pipe:1", # 状态写到stdout,便于reader捕获 + "-stats", + "-stats_period", "60", + "-progress", "pipe:1", "-loglevel", "info", # 输入容错 & 自动重连 @@ -301,7 +306,6 @@ def launch_ffmpeg(input_url: str, "-probesize", "10000000", ] - # 平台 headers(referer/origin)要在 -i 之前 if extra_headers: base += ["-headers", extra_headers] @@ -316,9 +320,10 @@ def launch_ffmpeg(input_url: str, # 输入 base += ["-re", "-i", input_url] - # 通用容错 + # 通用容错 + 时间戳修复 base += [ - "-fflags", "+genpts+discardcorrupt+nobuffer", + "-fflags", "+genpts+igndts+discardcorrupt+nobuffer", + "-xerror", "-use_wallclock_as_timestamps", "1", "-avioflags", "direct", "-rtbufsize", "100M", @@ -340,7 +345,6 @@ def launch_ffmpeg(input_url: str, creationflags = 0 if os.name == "nt": - # 防止子进程挂住控制台(可选) creationflags = subprocess.CREATE_NEW_PROCESS_GROUP p = subprocess.Popen( @@ -348,7 +352,7 @@ def launch_ffmpeg(input_url: str, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - bufsize=0, # 避免行缓冲告警 & 尽快刷新 + bufsize=0, # 立即刷新,避免行缓冲告警 env=env, creationflags=creationflags ) @@ -363,14 +367,20 @@ def launch_ffmpeg(input_url: str, break text = line.decode(errors="ignore").strip() p.last_out_ts = time.time() + + # 输出关键统计,便于观察码率/帧率 + if ("bitrate=" in text) or ("frame=" in text) or ("speed=" in text): + logger.info(f"[ffmpeg统计] {text}") + # 常见错误提示 if "Server disconnected" in text: - logger.error(f"[YouTube输出异常] {text}") + logger.error(f"[YouTube/RTMP输出异常] {text}") elif "not supported" in text or "Invalid" in text or "invalid" in text: logger.error(f"[转码异常] {text}") except Exception: pass + import threading t = threading.Thread(target=_reader, daemon=True) t.start() @@ -450,7 +460,8 @@ def main(): # 顺序轮询索引 idx = 0 - current_url = None + current_url: Optional[str] = None + current_real_url: Optional[str] = None # 实际播放地址(flv/m3u8) current_proc: Optional[subprocess.Popen] = None current_platform = "" current_anchor = "" @@ -460,9 +471,10 @@ def main(): scan_interval_when_idle = 2 # 没有任何可用源时的扫描间隔秒 last_md5 = "" # URL 文件变化时热更新 - # watchdog 参数 - watchdog_grace = 60 # 推流开始 60s 内不做静默判定 - watchdog_silence = 300 # 5 分钟完全无输出才重启 + # watchdog 参数(更稳) + watchdog_grace = 120 # 推流开始 120s 内不做静默判定 + watchdog_silence = 600 # 10 分钟完全无输出才重启 + refresh_real_url_every = 300 # 每 5 分钟刷新一次真实播放地址,避免 token 过期 try: while True: @@ -479,6 +491,7 @@ def main(): stop_ffmpeg(current_proc) current_proc = None current_url = None + current_real_url = None except Exception as e: logger.warning(f"URL 列表热更新失败:{e}") @@ -489,6 +502,7 @@ def main(): stop_ffmpeg(current_proc) current_proc = None current_url = None + current_real_url = None continue # 检查是否停播(依赖 spider) @@ -506,13 +520,32 @@ def main(): dur_str = f"{h:02d}:{m:02d}:{s:02d}" print(f"\r[{now_str()}] 正在转播({dur_str}):{current_platform} | {current_anchor} | {current_url}", end="") - # ✅ watchdog(推流开始 > 60s 且 5 分钟完全无输出) + # 定期刷新真实播放地址,避免 token 过期 + try: + if elapsed >= refresh_real_url_every and (elapsed % refresh_real_url_every) < check_interval_live: + new_port = asyncio.run(fetch_port_info(current_url, record_quality_code)) + new_real_url = select_source_url(current_url, new_port) + if new_real_url and new_real_url != current_real_url: + color.print_colored(f"\n[{now_str()}] 刷新播放地址(token 变化),重启 ffmpeg…", color.YELLOW) + stop_ffmpeg(current_proc) + out_url, v_args, a_args = build_youtube_output() + headers = headers_for_platform(current_platform, current_url) + current_proc = launch_ffmpeg(new_real_url, out_url, headers, v_args=v_args, a_args=a_args) + current_real_url = new_real_url + start_time = time.time() + time.sleep(check_interval_live) + continue + except Exception as e: + logger.warning(f"刷新播放地址失败:{e}") + + # ✅ watchdog(推流开始 > 120s 且 10 分钟完全无输出) silence = time.time() - getattr(current_proc, "last_out_ts", 0) if elapsed >= watchdog_grace and silence > watchdog_silence: - color.print_colored(f"\n[{now_str()}] 超过5分钟未见 ffmpeg 输出,判定异常,重启…", color.YELLOW) + color.print_colored(f"\n[{now_str()}] 超过10分钟未见 ffmpeg 输出,判定异常,重启…", color.YELLOW) stop_ffmpeg(current_proc) current_proc = None current_url = None + current_real_url = None time.sleep(1) continue @@ -523,6 +556,7 @@ def main(): stop_ffmpeg(current_proc) current_proc = None current_url = None + current_real_url = None else: # 未在推流:从 urls 顺序找第一个 is_live 的 @@ -560,6 +594,7 @@ def main(): out_url, v_args, a_args = build_youtube_output() current_proc = launch_ffmpeg(real_url, out_url, headers, v_args=v_args, a_args=a_args) current_url = url + current_real_url = real_url start_time = time.time() color.print_colored(f"[{now_str()}] 开始转播:{current_platform} | {current_anchor} | {url}", color.GREEN) found = True