''
This commit is contained in:
@@ -11,6 +11,7 @@ import re
|
||||
import os
|
||||
import configparser
|
||||
|
||||
|
||||
class StreamEnded(Exception):
|
||||
"""主播真实下播的专用异常"""
|
||||
pass
|
||||
@@ -33,40 +34,31 @@ def _stderr_reader_thread(proc, q):
|
||||
break
|
||||
q.put(line)
|
||||
except Exception as e:
|
||||
q.put(f"__ERR__READER__:{e}")
|
||||
finally:
|
||||
q.put(None)
|
||||
|
||||
|
||||
def _cleanup_proc(proc):
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
except:
|
||||
try:
|
||||
proc.kill()
|
||||
except:
|
||||
pass
|
||||
q.put(f"__ERR__READER__:{e}")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
q.put(None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _detect_nvenc():
|
||||
def cleanup_proc(proc):
|
||||
try:
|
||||
if platform.system().lower() not in ["windows", "linux"]:
|
||||
return "libx264", "veryfast", ["-tune", "zerolatency"]
|
||||
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5)
|
||||
if check.returncode != 0:
|
||||
return "libx264", "veryfast", ["-tune", "zerolatency"]
|
||||
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
|
||||
if "h264_nvenc" in (enc.stdout or ""):
|
||||
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
return "h264_nvenc", "p5", ["-tune", "ll", "-rc", "vbr", "-cq", "21", "-b:v", "5200k"]
|
||||
return "libx264", "veryfast", ["-tune", "zerolatency"]
|
||||
except Exception as e:
|
||||
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
|
||||
return "libx264", "veryfast", ["-tune", "zerolatency"]
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def start_douyin_youtube_ffplay(
|
||||
@@ -78,18 +70,20 @@ def start_douyin_youtube_ffplay(
|
||||
recording: set,
|
||||
recording_time_list: dict,
|
||||
running_list: list,
|
||||
monitoring: list, # 可变列表,如 [count],用于并发计数
|
||||
monitoring: list,
|
||||
clear_record_info,
|
||||
color_obj,
|
||||
):
|
||||
"""
|
||||
抖音 → YouTube 多路推流主函数(支持 config/youtube.ini 中只配置 stream key)
|
||||
抖音 → YouTube 推流主函数(单路推流)
|
||||
只从 config/youtube.ini 读取一个 stream key(取第一个有效值)
|
||||
"""
|
||||
|
||||
# ====================== 读取 YouTube stream key 配置 ======================
|
||||
config_file = "config/youtube.ini"
|
||||
rtmp_base = "rtmp://a.rtmp.youtube.com/live2/"
|
||||
default_key = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
rtmp_targets = []
|
||||
default_key = "qxvb-r47b-r5ju-6ud3-6k7z" # 备用默认 key
|
||||
youtube_rtmp = rtmp_base + default_key # 默认值
|
||||
|
||||
if os.path.exists(config_file):
|
||||
config = configparser.ConfigParser()
|
||||
@@ -97,21 +91,13 @@ def start_douyin_youtube_ffplay(
|
||||
if config.has_section("youtube"):
|
||||
for key_name, value in config.items("youtube"):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
continue
|
||||
if value.startswith("rtmp://"):
|
||||
rtmp_targets.append(value)
|
||||
else:
|
||||
rtmp_targets.append(rtmp_base + value)
|
||||
|
||||
if not rtmp_targets:
|
||||
rtmp_targets = [rtmp_base + default_key]
|
||||
print(f"[WARN] 未在 {config_file} 中找到有效配置,使用默认 stream key 推流")
|
||||
|
||||
num_targets = len(rtmp_targets)
|
||||
print(f"[INFO] 加载了 {num_targets} 个 YouTube 推流目标")
|
||||
|
||||
tee_outputs = "|".join(f"[f=flv:flvflags=no_duration_filesize]{url}" for url in rtmp_targets)
|
||||
if value:
|
||||
if value.startswith("rtmp://"):
|
||||
youtube_rtmp = value
|
||||
else:
|
||||
youtube_rtmp = rtmp_base + value
|
||||
print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}")
|
||||
break # 只取第一个有效 key,保持单路推流(不加多路逻辑)
|
||||
|
||||
# ====================== 常量配置 ======================
|
||||
MAX_RETRY_DELAY = 90
|
||||
@@ -122,9 +108,17 @@ def start_douyin_youtube_ffplay(
|
||||
STALE_OUTPUT_SECONDS = 40
|
||||
|
||||
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 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",
|
||||
]
|
||||
|
||||
@@ -138,12 +132,26 @@ def start_douyin_youtube_ffplay(
|
||||
timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
|
||||
clean_title = sanitize_title(title)
|
||||
stream_title = sanitize_title(f"{anchor_name}{('_' + clean_title) if clean_title else ''}_{timestamp_str}")
|
||||
print(f"[INFO] {rec_info} 开始推流到 YouTube ({num_targets} 路): {stream_title}")
|
||||
print(f"[INFO] {rec_info} 开始推流到 YouTube: {stream_title}")
|
||||
|
||||
# ====================== 编码器检测 ======================
|
||||
codec_v, preset_v, extra_tune_params = _detect_nvenc()
|
||||
# ====================== NVENC 检测 ======================
|
||||
codec_v = "libx264"
|
||||
preset_v = "veryfast"
|
||||
tune_param = ["-tune", "zerolatency"]
|
||||
try:
|
||||
if platform.system().lower() in ["windows", "linux"]:
|
||||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5)
|
||||
if check.returncode == 0:
|
||||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
|
||||
if "h264_nvenc" in (enc.stdout or ""):
|
||||
codec_v = "h264_nvenc"
|
||||
preset_v = "p5"
|
||||
tune_param = ["-tune", "ll", "-rc", "vbr", "-cq", "21", "-b:v", "2500k"]
|
||||
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||||
except Exception as e:
|
||||
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
|
||||
|
||||
# ====================== FFmpeg 命令(1080p + tee 多路) ======================
|
||||
# ====================== FFmpeg 命令(1080p 单路推流,原样保留) ======================
|
||||
ffmpeg_command = [
|
||||
"ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
|
||||
"-stats", "-stats_period", "1",
|
||||
@@ -164,7 +172,7 @@ def start_douyin_youtube_ffplay(
|
||||
|
||||
"-c:v", codec_v,
|
||||
"-preset", preset_v,
|
||||
*extra_tune_params,
|
||||
*tune_param,
|
||||
"-profile:v", "high",
|
||||
|
||||
"-b:v", "5200k", "-maxrate", "5800k", "-bufsize", "10400k",
|
||||
@@ -180,109 +188,119 @@ def start_douyin_youtube_ffplay(
|
||||
"-af", "aresample=async=1:first_pts=0",
|
||||
|
||||
"-flags", "+global_header",
|
||||
"-f", "tee", tee_outputs
|
||||
"-flvflags", "no_duration_filesize",
|
||||
"-f", "flv", youtube_rtmp
|
||||
]
|
||||
|
||||
# ====================== 主循环 ======================
|
||||
# ====================== 主重试循环(原样保留) ======================
|
||||
proc = None
|
||||
stderr_q = queue.Queue()
|
||||
stderr_q = None
|
||||
reader_t = None
|
||||
retry_delay = INITIAL_RETRY_DELAY
|
||||
|
||||
try:
|
||||
while True:
|
||||
if retry_delay > INITIAL_RETRY_DELAY:
|
||||
print(f"[WARN] 防风控等待 {retry_delay}s 后重连...")
|
||||
time.sleep(retry_delay)
|
||||
try:
|
||||
if retry_delay > INITIAL_RETRY_DELAY:
|
||||
print(f"[WARN] 防风控等待 {retry_delay}s 后重新连接抖音源...")
|
||||
time.sleep(retry_delay)
|
||||
|
||||
_cleanup_proc(proc)
|
||||
stderr_q = queue.Queue()
|
||||
cleanup_proc(proc)
|
||||
stderr_q = queue.Queue()
|
||||
|
||||
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
proc.last_out_ts = time.time()
|
||||
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动 YouTube 推流 → {stream_title}")
|
||||
proc = subprocess.Popen(
|
||||
ffmpeg_command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
proc.last_out_ts = time.time()
|
||||
|
||||
reader_t = threading.Thread(target=_stderr_reader_thread, args=(proc, stderr_q))
|
||||
reader_t.daemon = True
|
||||
reader_t.start()
|
||||
reader_t = threading.Thread(target=_stderr_reader_thread, args=(proc, stderr_q))
|
||||
reader_t.daemon = True
|
||||
reader_t.start()
|
||||
|
||||
start_time = last_frame_time = time.time()
|
||||
start_time = last_frame_time = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
line = stderr_q.get(timeout=STDERR_QUEUE_TIMEOUT)
|
||||
except queue.Empty:
|
||||
if time.time() - proc.last_out_ts > STALE_OUTPUT_SECONDS:
|
||||
print("[WARN] 长时间无日志,强制重启 ffmpeg 进程")
|
||||
_cleanup_proc(proc)
|
||||
break
|
||||
continue
|
||||
try:
|
||||
line = stderr_q.get(timeout=STDERR_QUEUE_TIMEOUT)
|
||||
except queue.Empty:
|
||||
if time.time() - getattr(proc, "last_out_ts", time.time()) > STALE_OUTPUT_SECONDS:
|
||||
print("[WARN] 长时间无任何日志,强制重启进程")
|
||||
cleanup_proc(proc)
|
||||
break
|
||||
continue
|
||||
|
||||
if line is None:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
continue
|
||||
if line is None:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
continue
|
||||
|
||||
line = line.strip()
|
||||
if line.startswith("__ERR__READER__"):
|
||||
print(f"[ERROR] stderr reader 异常: {line}")
|
||||
continue
|
||||
line = line.strip()
|
||||
if line.startswith("__ERR__READER__"):
|
||||
print(f"[ERROR] stderr reader 异常: {line}")
|
||||
continue
|
||||
|
||||
if line:
|
||||
proc.last_out_ts = time.time()
|
||||
print(f"[FFmpeg] {line}")
|
||||
line_lower = line.lower()
|
||||
if line:
|
||||
proc.last_out_ts = time.time()
|
||||
print(f"[FFmpeg] {line}")
|
||||
line_lower = line.lower()
|
||||
|
||||
if any(kw in line_lower for kw in END_KEYWORDS):
|
||||
print("[ERROR] 检测到主播真实下播或被拒,停止推流")
|
||||
raise StreamEnded()
|
||||
|
||||
if re.search(r'frame=\s*\d+', line_lower) or "fps=" in line_lower:
|
||||
last_frame_time = time.time()
|
||||
|
||||
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 any(kw in line_lower for kw in END_KEYWORDS):
|
||||
print("[ERROR] 检测到主播真下播或被明确拒绝,停止推流")
|
||||
cleanup_proc(proc)
|
||||
raise StreamEnded()
|
||||
|
||||
except StreamEnded:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 监控循环异常: {e}")
|
||||
traceback.print_exc()
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
break
|
||||
if re.search(r'frame=\s*\d+', line_lower) or "fps=" in line_lower:
|
||||
last_frame_time = time.time()
|
||||
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
continue
|
||||
if time.time() - start_time > START_CHECK_AFTER:
|
||||
if time.time() - last_frame_time > NO_FRAME_TIMEOUT:
|
||||
print(f"[WARN] 连续 {NO_FRAME_TIMEOUT}s 无新帧,判定主播已下播")
|
||||
cleanup_proc(proc)
|
||||
raise StreamEnded()
|
||||
|
||||
except StreamEnded:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 内层异常: {e}")
|
||||
traceback.print_exc()
|
||||
cleanup_proc(proc)
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
break
|
||||
|
||||
continue
|
||||
|
||||
except StreamEnded:
|
||||
print(f"[INFO] 主播已下播,停止 YouTube 转推 → {record_name}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 外层异常: {e}")
|
||||
traceback.print_exc()
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
continue
|
||||
|
||||
except StreamEnded:
|
||||
print(f"[INFO] 主播已下播,停止 YouTube 转推 → {record_name}")
|
||||
except KeyboardInterrupt:
|
||||
print("[INFO] 用户中断,停止推流")
|
||||
except Exception as e:
|
||||
print(f"[FATAL] 推流异常: {e}")
|
||||
print(f"[FATAL] YouTube 推流最外层异常: {e}")
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
_cleanup_proc(proc)
|
||||
cleanup_proc(proc)
|
||||
|
||||
try:
|
||||
recording.discard(record_name)
|
||||
recording_time_list.pop(record_name, None)
|
||||
if real_url in running_list:
|
||||
running_list.remove(real_url)
|
||||
monitoring[0] = max(0, monitoring[0] - 1) # 与 douyin_srs_ffplay.py 保持一致
|
||||
monitoring[0] = max(0, monitoring[0] - 1)
|
||||
clear_record_info(record_name, real_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
color_obj.print_colored(f"[{record_name}] YouTube 推流已停止并清理完毕\n", color_obj.GREEN)
|
||||
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)
|
||||
Reference in New Issue
Block a user