'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