419 lines
16 KiB
Python
419 lines
16 KiB
Python
# 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 json
|
||
import configparser
|
||
|
||
from web2_proxy_config import merge_child_env
|
||
|
||
|
||
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 配置 ======================
|
||
config_file = os.environ.get("YOUTUBE_INI_PATH", "config/youtube.ini")
|
||
rtmps_base = "rtmps://a.rtmp.youtube.com/live2/"
|
||
rtmp_base = "rtmp://a.rtmp.youtube.com/live2/"
|
||
use_rtmps = True
|
||
# 720p 竖屏 YouTube 建议 4–6 Mbps;1080p 竖屏适当提高码率
|
||
b_v, maxrate, bufsize = "4000k", "4500k", "8000k"
|
||
stream_key = None
|
||
fast_audio = False # True=轻量音频链(降噪关)
|
||
output_resolution = "720p"
|
||
force_software_video = False
|
||
audio_denoise_on = False
|
||
has_audio_denoise_key = False
|
||
|
||
if os.path.exists(config_file):
|
||
config = configparser.ConfigParser()
|
||
config.read(config_file, encoding="utf-8")
|
||
if config.has_section("youtube"):
|
||
for k, v in config.items("youtube"):
|
||
v = (v or "").strip()
|
||
if k == "rtmps" and v.lower() in ("0", "false", "否"):
|
||
use_rtmps = False
|
||
elif k == "output_resolution" and v:
|
||
output_resolution = v.lower()
|
||
elif k == "use_gpu" and v:
|
||
force_software_video = v.lower() in ("否", "0", "false", "无", "关", "off")
|
||
elif k == "audio_denoise":
|
||
has_audio_denoise_key = True
|
||
audio_denoise_on = v.lower() in ("1", "true", "yes", "是", "开", "on")
|
||
elif k == "fast_audio" and v.lower() in ("1", "true", "yes", "是", "开", "on"):
|
||
fast_audio = True
|
||
elif k == "bitrate" and v:
|
||
try:
|
||
n = int(v)
|
||
b_v, maxrate, bufsize = f"{n}k", f"{min(n * 12 // 10, 8000)}k", f"{n * 2}k"
|
||
except ValueError:
|
||
pass
|
||
elif (k == "key" or k == "stream_key") and v and not v.startswith("#"):
|
||
stream_key = v
|
||
if has_audio_denoise_key:
|
||
fast_audio = not audio_denoise_on
|
||
|
||
if "1080" in output_resolution:
|
||
b_v, maxrate, bufsize = "6500k", "7800k", "13000k"
|
||
if stream_key:
|
||
if stream_key.startswith("rtmp://") or stream_key.startswith("rtmps://"):
|
||
youtube_rtmp = stream_key
|
||
else:
|
||
youtube_rtmp = (rtmps_base if use_rtmps else rtmp_base) + stream_key
|
||
print(f"[INFO] 从 {config_file} 读取 YouTube 推流地址")
|
||
else:
|
||
raise ValueError(f"请在 {config_file} 中配置 key=你的stream_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
|
||
RECONNECT_DELAY_MAX = 10
|
||
|
||
END_KEYWORDS = [
|
||
"connection reset by peer",
|
||
"failed to write trailer",
|
||
"invalid data found",
|
||
"server returned 403",
|
||
"server returned 404",
|
||
"server returned 500",
|
||
"ingestion error",
|
||
"access denied",
|
||
"packet too short",
|
||
"client disconnected",
|
||
"live has ended",
|
||
"no route to host",
|
||
"connection timed out",
|
||
"connection refused",
|
||
"error writing output",
|
||
"broken pipe",
|
||
"i/o error",
|
||
]
|
||
|
||
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}")
|
||
try:
|
||
print(
|
||
"[RELAY_META] "
|
||
+ json.dumps(
|
||
{
|
||
"ts": int(time.time()),
|
||
"anchor": anchor_name,
|
||
"douyinHint": (real_url or record_name or "")[:400],
|
||
"streamTitle": stream_title,
|
||
"youtubeKeySuffix": (stream_key[-8:] if stream_key and len(stream_key) > 8 else stream_key),
|
||
},
|
||
ensure_ascii=False,
|
||
),
|
||
flush=True,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
if "1080" in output_resolution:
|
||
_sw, _sh = "1080", "1920"
|
||
else:
|
||
_sw, _sh = "720", "1280"
|
||
scale_vf = (
|
||
f"fps=30,scale={_sw}:{_sh}:force_original_aspect_ratio=decrease:flags=bilinear,"
|
||
f"pad={_sw}:{_sh}:(ow-iw)/2:(oh-ih)/2:black"
|
||
)
|
||
|
||
# ====================== NVENC 检测(符合 YouTube 推荐比特率)======================
|
||
codec_v = "libx264"
|
||
# ultrafast+zerolatency:在软编下尽量让 speed≥1.0;画质略降、直播可接受
|
||
video_args = ["-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "high", "-level", "4.0",
|
||
"-b:v", b_v, "-maxrate", maxrate, "-bufsize", bufsize]
|
||
try:
|
||
if (
|
||
not force_software_video
|
||
and 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"
|
||
video_args = ["-profile:v", "high", "-level", "4.0",
|
||
"-tune", "ll", "-rc", "cbr", "-b:v", b_v,
|
||
"-maxrate", maxrate, "-bufsize", bufsize]
|
||
print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速")
|
||
elif force_software_video:
|
||
print("[INFO] use_gpu=否/无:强制软件编码 libx264")
|
||
except Exception as e:
|
||
print(f"[WARN] NVENC 检测失败,回退软件编码: {e}")
|
||
|
||
# ====================== 开源音频处理:背景音乐弱化 + 防版权 ======================
|
||
_script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
_ffmpeg_cwd = None
|
||
if fast_audio:
|
||
print("[INFO] fast_audio=开:轻量音频链(无 arnndn/长 EQ),减轻 CPU 以利 speed≥1.0")
|
||
_af_chain = "afftdn=nf=-26:tn=1,aresample=async=1:first_pts=0"
|
||
else:
|
||
# 优先 RNNoise(arnndn),无模型则用 afftdn;aphaser 相位扰动可干扰 Content ID 指纹
|
||
_arnndn_paths = [
|
||
os.path.join(_script_dir, "arnndn-models", "cb.rnnn"),
|
||
os.path.join(os.path.dirname(_script_dir), "arnndn-models", "cb.rnnn"),
|
||
"arnndn-models/cb.rnnn",
|
||
]
|
||
_arnndn_model = None
|
||
for _p in _arnndn_paths:
|
||
if os.path.isfile(_p):
|
||
_arnndn_model = os.path.normpath(_p)
|
||
break
|
||
if _arnndn_model:
|
||
print(f"[INFO] 使用 RNNoise 降噪模型: {_arnndn_model}")
|
||
_arnndn_base = os.path.dirname(os.path.dirname(os.path.abspath(_arnndn_model)))
|
||
_af_denoise = "arnndn=m=arnndn-models/cb.rnnn:mix=0.82,"
|
||
_ffmpeg_cwd = _arnndn_base
|
||
else:
|
||
print("[INFO] 未找到 arnndn 模型,使用 afftdn 降噪(可克隆 richardpl/arnndn-models 到 arnndn-models/)")
|
||
_af_denoise = "afftdn=nf=-26:tn=1,"
|
||
|
||
# 强化版:收紧带通 + 频率偏移 + 音高微调 + 多段EQ + 相位扰动(纯 FFmpeg 理论上限 ~70%)
|
||
_af_chain = (
|
||
_af_denoise
|
||
+ "highpass=f=200,lowpass=f=3800,"
|
||
+ "equalizer=f=150:width_type=o:width=2.5:g=-14,"
|
||
+ "equalizer=f=350:width_type=o:width=2:g=-10,"
|
||
+ "equalizer=f=600:width_type=o:width=1.8:g=-8,"
|
||
+ "equalizer=f=1000:width_type=o:width=1.5:g=-6,"
|
||
+ "equalizer=f=2500:width_type=o:width=1.2:g=-4,"
|
||
+ "acompressor=threshold=-26dB:ratio=5:attack=8:release=80:makeup=5,"
|
||
+ "afreqshift=shift=22,"
|
||
+ "asetrate=48000*1.035,aresample=48000,"
|
||
+ "aphaser=type=t:decay=0.4:delay=3:speed=0.7,"
|
||
+ "volume=1.12,"
|
||
+ "aresample=async=1:first_pts=0"
|
||
)
|
||
|
||
# 720p 竖屏,符合 YouTube 推荐:4–6 Mbps、2s 关键帧、AAC 128k
|
||
_video_mid = ["-vsync", "cfr", "-g", "60", "-keyint_min", "60", "-r", "30",
|
||
"-bf", "0", "-sc_threshold", "0"]
|
||
if codec_v == "libx264":
|
||
_video_mid += ["-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0"]
|
||
# -re:按源时间戳节奏拉流,避免突发过快;不用 realtime/arealtime(输入抖动时易让 YouTube 判为数据不足)
|
||
ffmpeg_command = [
|
||
"ffmpeg", "-y", "-loglevel", "info", "-nostdin",
|
||
"-stats", "-stats_period", "1",
|
||
"-rw_timeout", "50000000",
|
||
"-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1",
|
||
"-reconnect_delay_max", str(RECONNECT_DELAY_MAX),
|
||
"-fflags", "+genpts+discardcorrupt",
|
||
"-err_detect", "ignore_err",
|
||
"-max_delay", "500000",
|
||
"-thread_queue_size", "2048",
|
||
"-filter_threads", "4",
|
||
"-headers", douyin_headers,
|
||
"-re",
|
||
"-i", real_url,
|
||
# bilinear 比 lanczos 轻很多,利于 speed 稳定在 ≥1.0x
|
||
"-vf", scale_vf,
|
||
"-c:v", codec_v,
|
||
*video_args,
|
||
*_video_mid,
|
||
"-pix_fmt", "yuv420p",
|
||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2",
|
||
"-af", _af_chain,
|
||
"-flags", "+global_header",
|
||
"-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}")
|
||
_popen_kw = dict(
|
||
stdin=subprocess.DEVNULL,
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
bufsize=1,
|
||
universal_newlines=True,
|
||
)
|
||
if _ffmpeg_cwd:
|
||
_popen_kw["cwd"] = _ffmpeg_cwd
|
||
_popen_kw["env"] = merge_child_env()
|
||
proc = subprocess.Popen(ffmpeg_command, **_popen_kw)
|
||
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) |