# 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 # Demucs 管道(可选,用于防版权) try: from demucs_pipeline import ( _check_demucs_available, run_demucs_pipeline, ) _DEMUCS_MODULE_AVAILABLE = True except ImportError: _DEMUCS_MODULE_AVAILABLE = False _check_demucs_available = lambda: False 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 # 默认值 enable_demucs = False demucs_use_gpu = True 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"): key_lower = key_name.strip().lower() val = value.strip() if key_lower == "enable_demucs": enable_demucs = val.lower() in ("1", "true", "yes", "是") elif key_lower == "demucs_use_gpu": demucs_use_gpu = val.lower() not in ("0", "false", "no", "否") elif val and key_lower in ("key", "stream_key", "streamkey"): youtube_rtmp = val if val.startswith("rtmp://") else rtmp_base + val print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}") # 兼容:若上面未匹配到 key,取第一个非配置项的值作为 stream key if youtube_rtmp == rtmp_base + default_key: for k, v in config.items("youtube"): if k.strip().lower() not in ("enable_demucs", "demucs_use_gpu") and v.strip(): v = v.strip() youtube_rtmp = v if v.startswith("rtmp://") else rtmp_base + v print(f"[INFO] 从 {config_file} 读取 YouTube stream key: {youtube_rtmp}") break # ====================== 常量配置 ====================== 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}") nvenc_params = { "codec_v": codec_v, "preset_v": preset_v, "tune_param": tune_param, "demucs_use_gpu": demucs_use_gpu, } use_demucs = enable_demucs and _DEMUCS_MODULE_AVAILABLE and _check_demucs_available() # 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", # 改4:1080p可音频上 128k(YouTube 推荐) "-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下推荐使用最新FFmpeg(apt 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) # "-flvflags", "no_duration_filesize", # "-f", "flv", 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 = None if use_demucs: try: run_demucs_pipeline( real_url=real_url, youtube_rtmp=youtube_rtmp, douyin_headers=douyin_headers, nvenc_params=nvenc_params, end_keywords=END_KEYWORDS, stderr_queue_timeout=STDERR_QUEUE_TIMEOUT, no_frame_timeout=NO_FRAME_TIMEOUT, start_check_after=START_CHECK_AFTER, stale_output_seconds=STALE_OUTPUT_SECONDS, stream_ended_exception=StreamEnded, cleanup_proc_fn=cleanup_proc, stderr_reader_fn=_stderr_reader_thread, ) except StreamEnded: raise except Exception as e: print(f"[WARN] Demucs 管道异常,回退原始推流: {e}") use_demucs = False if not use_demucs: 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)