This commit is contained in:
eric
2026-01-26 23:45:47 -06:00
parent 1595659dcf
commit 20c1bbd12c
4 changed files with 2504 additions and 0 deletions

10
config/youtube.ini Normal file
View File

@@ -0,0 +1,10 @@
[youtube]
# 只写 stream key推荐方式
key = qxvb-r47b-r5ju-6ud3-6k7z
# 如果想推多个频道,直接加更多 key自动多路推流
# main = qxvb-r47b-r5ju-6ud3-6k7z
# backup = xxxx-xxxx-xxxx-xxxx-xxxx
# 如果你想直接写完整 RTMP 地址(兼容旧方式)
# full_url = rtmp://a.rtmp.youtube.com/live2/qxvb-r47b-r5ju-6ud3-6k7z

288
douyin_youtube_ffplay.py Normal file
View File

@@ -0,0 +1,288 @@
# douyin_youtube_ffplay.py
import queue
import unicodedata
import platform
import traceback
import time
import datetime
import subprocess
import threading
import re
import os
import configparser
class StreamEnded(Exception):
"""主播真实下播的专用异常"""
pass
def sanitize_title(text: str, max_len: int = 120) -> str:
if not text:
return ""
text = unicodedata.normalize("NFKC", text)
text = ''.join(ch for ch in text if unicodedata.category(ch)[0] != 'C')
text = ''.join(ch for ch in text if ord(ch) <= 0xFFFF)
return text.strip()[:max_len]
def _stderr_reader_thread(proc, q):
try:
while True:
line = proc.stderr.readline()
if not line:
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
def _detect_nvenc():
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"]
def start_douyin_youtube_ffplay(
title: str,
anchor_name: str,
real_url: str,
rec_info: str,
record_name: str,
recording: set,
recording_time_list: dict,
running_list: list,
monitoring: list, # 可变列表,如 [count],用于并发计数
clear_record_info,
color_obj,
):
"""
抖音 → 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 = []
if os.path.exists(config_file):
config = configparser.ConfigParser()
config.read(config_file, encoding="utf-8")
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)
# ====================== 常量配置 ======================
MAX_RETRY_DELAY = 90
INITIAL_RETRY_DELAY = 3
STDERR_QUEUE_TIMEOUT = 1.0
NO_FRAME_TIMEOUT = 120
START_CHECK_AFTER = 25
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 timed out",
]
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"
)
# ====================== 标题处理 ======================
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}")
# ====================== 编码器检测 ======================
codec_v, preset_v, extra_tune_params = _detect_nvenc()
# ====================== FFmpeg 命令1080p + tee 多路) ======================
ffmpeg_command = [
"ffmpeg", "-y", "-loglevel", "info", "-nostdin", "-re",
"-stats", "-stats_period", "1",
"-rw_timeout", "30000000",
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
"-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
"-flags", "low_delay",
"-err_detect", "ignore_err",
"-max_delay", "80000",
"-thread_queue_size", "2048",
"-headers", douyin_headers,
"-i", real_url,
"-vf", "fps=30,scale=1080:1920:force_original_aspect_ratio=decrease:flags=lanczos,"
"pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black",
"-c:v", codec_v,
"-preset", preset_v,
*extra_tune_params,
"-profile:v", "high",
"-b:v", "5200k", "-maxrate", "5800k", "-bufsize", "10400k",
"-vsync", "cfr",
"-g", "60", "-keyint_min", "60",
"-r", "30",
"-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", "44100", "-ac", "2",
"-af", "aresample=async=1:first_pts=0",
"-flags", "+global_header",
"-f", "tee", tee_outputs
]
# ====================== 主循环 ======================
proc = None
stderr_q = queue.Queue()
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)
_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()
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()
while True:
try:
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
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
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 无新帧,判定主播下播")
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
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}")
traceback.print_exc()
finally:
_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 保持一致
clear_record_info(record_name, real_url)
except Exception:
pass
color_obj.print_colored(f"[{record_name}] YouTube 推流已停止并清理完毕\n", color_obj.GREEN)

4
obs.sh Normal file
View File

@@ -0,0 +1,4 @@
ffplay -fflags nobuffer -flags low_delay -framedrop \
-probesize 32 -analyzeduration 0 -sync ext -infbuf \
-vf "setpts=PTS-STARTPTS" \
'srt://127.0.0.1:10080?streamid=#!::r=live/livestream,m=request&latency=10'

2202
youtube.py Normal file

File diff suppressed because it is too large Load Diff