Files
gitlab-instance-0a899031_do…/douyin_youtube_ffplay.py
2026-03-01 04:07:26 -06:00

535 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
try:
q.put(f"__ERR__READER__:{e}")
except Exception:
pass
finally:
try:
q.put(None)
except Exception:
pass
def cleanup_proc(proc):
try:
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(
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,
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" # 备用默认 key
youtube_rtmp = rtmp_base + default_key # 默认值
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 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
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: {stream_title}")
# ====================== 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}")
# 720p (最原始)
# 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,
# # 720p
# "-vf", "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=lanczos,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
# "-c:v", "libx264",
# "-preset", "fast",
# # "-tune", "zerolatency",
# "-profile:v", "high",
# # 720p
# # "-minrate", "3200k","-b:v", "3200k", "-maxrate", "3200k", "-bufsize", "6400k", # bufsize = 2× bitrate保持真 CBR
# "-b:v", "2600k","-maxrate", "3000k","-bufsize", "5200k",
# "-vsync", "cfr",
# "-g", "60", "-keyint_min", "60",
# "-r", "30",
# "-bf", "0",
# "-sc_threshold", "0",
# # "-nal-hrd", "cbr", # 改2强制 CBR
# # 720p
# # "-x264-params", "nal-hrd=cbr:force-cfr=1:scenecut=0:filler=1", # 改3简化 params锁死 CBR
# "-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
# "-pix_fmt", "yuv420p",
# "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", # 改41080p可音频上 128kYouTube 推荐)
# "-af", "aresample=async=1:first_pts=0",
# "-flags", "+global_header",
# "-flvflags", "no_duration_filesize",
# "-f", "flv", youtube_rtmp
# ]
# "arnndn=m=./arnndn-models/cb.rnnn:mix=0.85," # 人声增强
# 720p-低CPU + 背景音乐处理 + 防ContentID
# 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,
# # 视频处理 720p
# "-vf",
# "fps=25,scale=720:1280:force_original_aspect_ratio=decrease:flags=bicubic,"
# "pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
# "-c:v", "libx264",
# "-preset", "veryfast", # 降低CPU
# "-profile:v", "high",
# "-b:v", "2600k", "-maxrate", "3000k", "-bufsize", "5200k",
# "-vsync", "cfr",
# "-g", "50", "-keyint_min", "50", # 与fps=25匹配
# "-r", "25",
# "-bf", "0",
# "-sc_threshold", "0",
# "-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
# "-pix_fmt", "yuv420p",
# # 音频处理(轻量 + 背景音乐弱化 + 防Content ID
# "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",
# "-af",
# "afftdn=nf=-28:tn=1," # 轻 FFT 降噪,背景弱化
# "highpass=f=120,lowpass=f=4800," # 切低频鼓 & 高频镲
# "equalizer=f=250:width_type=o:width=2:g=-8," # 衰减低中频
# "equalizer=f=800:width_type=o:width=1.5:g=-5," # 衰减中频
# "acompressor=threshold=-30dB:ratio=4:attack=10:release=100:makeup=4," # 压动态
# "asetrate=48000*1.02,aresample=48000," # 音高微升
# "afreqshift=shift=15," # 小幅频率偏移
# "volume=1.1," # 音量补偿
# "aresample=async=1:first_pts=0", # 音视频同步
# # 推流参数
# "-flvflags", "no_duration_filesize",
# "-f", "flv", youtube_rtmp
# ]
# 1080p
# 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=25,scale=1080:1920:force_original_aspect_ratio=decrease:flags=bicubic,"
# "pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black",
# "-c:v", "libx264",
# "-preset", "superfast",
# "-tune", "zerolatency",
# "-profile:v", "high",
# "-b:v", "5200k", "-maxrate", "5800k", "-bufsize", "10400k",
# "-vsync", "cfr",
# "-g", "50", "-keyint_min", "50",
# "-r", "25",
# "-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",
# "afftdn=nf=-18:tn=1,"
# "highpass=f=120,lowpass=f=4600,"
# "equalizer=f=250:width_type=o:width=2:g=-6,"
# "equalizer=f=900:width_type=o:width=1.4:g=-4,"
# "asetrate=48000*1.012,aresample=48000,"
# "acompressor=threshold=-30dB:ratio=4:attack=10:release=120:makeup=4,"
# "volume=1.10,"
# "aresample=async=1:first_pts=0",
# "-flvflags", "no_duration_filesize",
# "-f", "flv", youtube_rtmp
# ]
# 1080p - 极致优化版本Ubuntu Server 专用,高质量 + YouTube直播推荐友好 + 强防Content ID
# 针对Ubuntu server通常CPU较强无iOS硬件限制
# - preset 升级到 medium 或 slow如果CPU能承受质量显著提升
# - 比特率保持8500k-10000k峰值符合YouTube 1080p直播高质量推荐
# - 音频防Content ID音高+5%、频率偏移、多段EQ衰减背景音乐、强压缩、相位扰动使用aphaser正确参数、轻噪门
# - 移除无效参数修复之前aphaser错误aphaser支持in_gain/out_gain/delay/decay/speed/type不支持feedback
# - Ubuntu下推荐使用最新FFmpegapt install ffmpeg 或从源码编译支持libx264
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", "50000",
"-thread_queue_size", "4096",
"-headers", douyin_headers,
"-i", real_url,
# 视频处理1080p 纵屏优化,黑边填充
"-vf",
"fps=25,scale=1080:1920:force_original_aspect_ratio=decrease:flags=bicubic,"
"pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black",
"-c:v", "libx264",
"-preset", "ultrafast", # Ubuntu server CPU通常能承受质量比faster好很多若掉帧可回退到faster或veryfast
"-threads", "4",
"-tune", "zerolatency",
"-profile:v", "high",
"-level", "4.2", # 支持1080p@25fps
# "-b:v", "8500k", "-maxrate", "10000k", "-bufsize", "20000k",
"-b:v", "4500k", "-maxrate", "5500k", "-bufsize", "9000k",
"-vsync", "cfr",
"-g", "50", "-keyint_min", "50",
"-r", "25",
"-bf", "0",
"-sc_threshold", "0",
"-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0:rc-lookahead=0",
"-pix_fmt", "yuv420p",
# 音频处理 - 防Content ID 强化版(多维度修改音频指纹)
"-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2",
"-af",
# "arnndn=m=./arnndn-models/cb.rnnn:mix=0.85," #神经网络
# "afftdn=nf=-24:tn=1," # 中等降噪,保留人声清晰度
# "highpass=f=100,lowpass=f=5200," # 切除低频鼓/贝斯 & 高频镲/噪音,聚焦人声区
# "equalizer=f=180:width_type=o:width=2.2:g=-12," # 强衰减低频(背景音乐基础层)
# "equalizer=f=350:width_type=o:width=1.8:g=-9," # 衰减低中频(常见旋律区)
# "equalizer=f=750:width_type=o:width=1.6:g=-7," # 中频衰减,破坏音乐结构
# "equalizer=f=2800:width_type=o:width=1.2:g=-5," # 高中频轻衰减,保持人声自然
# "acompressor=threshold=-26dB:ratio=5.5:attack=7:release=90:makeup=6," # 强压缩,改变动态指纹
# "asetrate=48000*1.05,atempo=1.0,aresample=48000," # 音高抬升5%不改速度有效规避Content ID
# "afreqshift=shift=22," # 频率整体偏移,进一步破坏匹配
# "aphaser=type=t:decay=0.35:delay=3.0:speed=0.5," # 正确参数轻微相位扰动triangular类型防检测
# "anlmdn=s=0.00012:p=0.0008:r=0.002," # 轻量非线性噪门,清理残余背景音乐
# "volume=1.08," # 补偿音量衰减
# "aresample=async=1:first_pts=0:min_hard_comp=0.100000:first_pts=0", # 强制音视频同步
# 推流优化FLV适合YouTube RTMP/RTMPS
# ... 所有编码/滤镜参数 ...
"-f", "flv",
"-flvflags", "no_duration_filesize",
youtube_rtmp
]
# 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",
# "-err_detect","ignore_err",
# "-max_delay","500000",
# "-thread_queue_size","512",
# # 抖音源
# "-headers", douyin_headers,
# "-i", real_url,
# # 视频处理
# "-vf","fps=25,scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black",
# # 编码
# "-c:v","libx264",
# "-preset","veryfast",
# "-profile:v","high",
# "-level","4.2",
# "-b:v","4500k","-maxrate","4500k","-bufsize","9000k",
# "-g","50","-keyint_min","50",
# "-r","25",
# "-pix_fmt","yuv420p",
# "-bf","0",
# "-sc_threshold","0",
# "-x264-params","force-cfr=1:scenecut=0:open_gop=0",
# # 音频(强烈建议简化)
# "-c:a","aac","-b:a","128k","-ar","44100","-ac","2",
# "-af","aresample=async=1,volume=1.05",
# # 推流
# "-flvflags","no_duration_filesize",
# "-f","flv", youtube_rtmp
# ]
proc = None
stderr_q = None
reader_t = None
retry_delay = INITIAL_RETRY_DELAY
try:
while True:
try:
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() - 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
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] 检测到主播真下播或被明确拒绝,停止推流")
cleanup_proc(proc)
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 无新帧,判定主播已下播")
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 Exception as e:
print(f"[FATAL] YouTube 推流最外层异常: {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)
clear_record_info(record_name, real_url)
except Exception:
pass
color_obj.print_colored(f"[{record_name}] YouTube 推流已彻底停止并清理完毕\n", color_obj.GREEN)