's'
This commit is contained in:
10
config/youtube.ini
Normal file
10
config/youtube.ini
Normal 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
288
douyin_youtube_ffplay.py
Normal 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
4
obs.sh
Normal 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
2202
youtube.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user