This commit is contained in:
eric
2025-09-15 22:10:08 +08:00
parent cd6d39210e
commit 09648e67d3
2 changed files with 158 additions and 152 deletions

1
config.ini Normal file
View File

@@ -0,0 +1 @@
qxvb-r47b-r5ju-6ud3-6k7z

317
live.py
View File

@@ -3,202 +3,207 @@ import subprocess
import threading import threading
import asyncio import asyncio
import uuid import uuid
import queue
import time import time
import signal import signal
import sys import sys
import shutil import shutil
import logging
import spider import spider
import stream import stream
class AdaptiveHLS2YouTube: # ----------------- Logging Setup -----------------
def __init__(self, max_bitrate=5000, min_bitrate=3000, audio_bitrate=192, logging.basicConfig(
delay_sec=40, segment_time=2, check_interval=5): level=logging.INFO,
self.processes = {} format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S"
)
# ----------------- Low-Latency Douyin to YouTube Streamer -----------------
class DouyinToYouTubeStreamer:
def __init__(self, bitrate=5000, audio_bitrate=128, resolution='720x1280', fps=30, gop=60):
self.proc = None
self.stop_flag = threading.Event() self.stop_flag = threading.Event()
self.delay_sec = delay_sec self.bitrate = bitrate
self.segment_time = segment_time
self.check_interval = check_interval
self.buffer_queue = queue.Queue()
self.max_bitrate = max_bitrate
self.min_bitrate = min_bitrate
self.audio_bitrate = audio_bitrate self.audio_bitrate = audio_bitrate
self.current_bitrate = max_bitrate self.resolution = resolution
self.fps = fps
self.gop = gop
self.start_time = None
self.room_name = None
self.semaphore = threading.Semaphore(1)
self.pushing = False # 是否正在推流
def start_stream(self, real_url, youtube_key, room_name=None): def start_stream(self, real_url, youtube_key, room_name=None):
room_name = room_name or f"抖音_{str(uuid.uuid4())[:8]}" self.room_name = room_name or f"抖音_{str(uuid.uuid4())[:8]}"
print(f"[{room_name}] 启动推流,目标 YouTube Key: {youtube_key}") logging.info(f"[{self.room_name}] 启动低延迟推流,目标 YouTube Key: {youtube_key}")
threading.Thread(target=self._pull_thread, args=(real_url,), daemon=True).start() threading.Thread(target=self._run_ffmpeg_loop, args=(real_url, youtube_key), daemon=True).start()
threading.Thread(target=self._push_thread, args=(youtube_key, room_name), daemon=True).start() threading.Thread(target=self._monitor_thread, daemon=True).start()
threading.Thread(target=self._abr_thread, daemon=True).start()
def _pull_thread(self, real_url): def _build_ffmpeg_cmd(self, real_url, youtube_key):
cmd = [ user_agent = ("Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-G973U) AppleWebKit/537.36 "
"ffmpeg", "(KHTML, like Gecko) SamsungBrowser/14.2 Chrome/87.0.4280.141 Mobile Safari/537.36")
"-rw_timeout", "50000000", headers = "Referer: https://www.douyin.com/\r\nOrigin: https://www.douyin.com\r\n"
"-user_agent", "Mozilla/5.0",
"-headers", "Referer: https://www.douyin.com/",
"-fflags", "+nobuffer+genpts",
"-flags", "low_delay",
"-reconnect", "1",
"-reconnect_at_eof", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "10",
"-i", real_url,
"-c", "copy",
"-f", "mpegts",
"-"
]
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
print("[拉流] FFmpeg 启动成功")
while not self.stop_flag.is_set():
data = proc.stdout.read(188*500) # 读取更大块,减少空跑
if not data:
time.sleep(0.05)
continue
self.buffer_queue.put(data)
except Exception as e:
print(f"拉流异常: {e}")
def _push_thread(self, youtube_key, room_name):
min_buffer = max(1, self.delay_sec // self.segment_time)
while not self.stop_flag.is_set():
if self.buffer_queue.qsize() < min_buffer:
time.sleep(0.05)
continue
cmd = [ cmd = [
"ffmpeg", 'ffmpeg', '-y', '-v', 'error', '-hide_banner',
"-re", '-rw_timeout', '10000000',
"-f", "mpegts", '-analyzeduration', '1000000',
"-i", "-", '-probesize', '5000000',
"-c:v", "libx264", '-thread_queue_size', '512',
"-preset", "veryfast", '-user_agent', user_agent,
"-tune", "zerolatency", '-headers', headers,
"-b:v", f"{self.current_bitrate}k", '-protocol_whitelist', 'file,pipe,http,https,tcp,tls,udp,rtp,crypto',
"-maxrate", f"{self.current_bitrate}k", '-fflags', '+discardcorrupt+genpts',
"-bufsize", f"{self.current_bitrate*4}k", '-flags', 'low_delay',
"-r", "30", '-http_seekable', '0',
"-g", "60", '-avioflags', 'direct',
"-s", "720x1280", '-reconnect', '1',
"-c:a", "aac", '-reconnect_at_eof', '1',
"-b:a", f"{self.audio_bitrate}k", '-reconnect_streamed', '1',
"-ar", "44100", '-reconnect_delay_max', '5',
"-ac", "2", '-flush_packets', '1',
"-f", "flv", '-i', real_url,
"-rtmp_live", "live", '-c:v', 'libx264',
f"rtmp://a.rtmp.youtube.com/live2/{youtube_key}" '-preset', 'veryfast',
'-tune', 'zerolatency',
'-crf', '23',
'-pix_fmt', 'yuv420p',
'-profile:v', 'main',
'-level', '3.1',
'-x264-params', f'keyint={self.gop}:scenecut=0:min-keyint={self.gop//2}',
'-g', str(self.gop),
'-r', str(self.fps),
'-s', self.resolution,
'-b:v', f'{self.bitrate}k',
'-maxrate', f'{self.bitrate}k',
'-bufsize', f'{self.bitrate * 2}k',
'-c:a', 'aac',
'-b:a', f'{self.audio_bitrate}k',
'-ar', '44100',
'-ac', '2',
'-strict', '-2',
'-avoid_negative_ts', 'make_zero',
'-f', 'flv',
f'rtmp://a.rtmp.youtube.com/live2/{youtube_key}'
] ]
return cmd
def _run_ffmpeg_loop(self, real_url, youtube_key):
while not self.stop_flag.is_set():
try: try:
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE) with self.semaphore:
self.processes[room_name] = proc cmd = self._build_ffmpeg_cmd(real_url, youtube_key)
self.proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE,
bufsize=0, universal_newlines=False
)
self.start_time = time.time()
self.pushing = False
logging.info(f"[{self.room_name}] FFmpeg 启动成功 (PID: {self.proc.pid})")
def read_stderr(): def read_stderr():
while proc.poll() is None: try:
line = proc.stderr.readline() for line in iter(self.proc.stderr.readline, b''):
if self.stop_flag.is_set():
break
if line: if line:
print(f"[{room_name} FFmpeg]: {line.decode(errors='ignore').strip()}") decoded = line.decode('utf-8', errors='ignore').strip()
threading.Thread(target=read_stderr, daemon=True).start() logging.info(f"[{self.room_name} FFmpeg]: {decoded}")
# 判断推流是否成功
while not self.stop_flag.is_set(): if not self.pushing and "frame=" in decoded and "fps=" in decoded:
try: self.pushing = True
data = self.buffer_queue.get(timeout=1) logging.info(f"[{self.room_name}] 推流到 YouTube 成功")
proc.stdin.write(data)
proc.stdin.flush()
except queue.Empty:
continue
except Exception as e: except Exception as e:
print(f"[{room_name}] 推流异常: {e}") logging.error(f"[{self.room_name}] stderr线程异常: {e}")
def _abr_thread(self): stderr_thread = threading.Thread(target=read_stderr, daemon=True)
while not self.stop_flag.is_set(): stderr_thread.start()
queue_len = self.buffer_queue.qsize()
if queue_len > 100:
new_bitrate = max(self.min_bitrate, self.current_bitrate - 300)
elif queue_len < 30:
new_bitrate = min(self.max_bitrate, self.current_bitrate + 300)
else:
new_bitrate = self.current_bitrate
if new_bitrate != self.current_bitrate:
print(f"[ABR] 调整码率: {self.current_bitrate}k -> {new_bitrate}k")
self.current_bitrate = new_bitrate
time.sleep(self.check_interval)
def stop_all(self): self.proc.wait()
print("停止所有推流...") if not self.stop_flag.is_set():
self.stop_flag.set() logging.warning(f"[{self.room_name}] FFmpeg 退出 (返回码: {self.proc.returncode}),5秒后重启...")
for proc in self.processes.values(): self.pushing = False
if proc.poll() is None:
proc.terminate()
# ----------------- 抖音解析逻辑 -----------------
async def get_douyin_real_url(record_url, record_quality="原画"):
if 'v.douyin.com' not in record_url and '/user/' not in record_url:
json_data = await spider.get_douyin_stream_data(url=record_url)
else:
json_data = await spider.get_douyin_app_stream_data(url=record_url)
port_info = await stream.get_douyin_stream_url(json_data, record_quality, None)
return port_info.get("record_url")
# ----------------- 启动推流 -----------------
def start_douyin_to_youtube(record_url, youtube_key, room_name=None, delay_sec=40):
try:
real_url = asyncio.run(get_douyin_real_url(record_url))
except Exception as e: except Exception as e:
print(f"获取真实直播流失败: {e}") logging.error(f"[{self.room_name}] FFmpeg 启动异常: {e}")
return finally:
if not real_url: if self.proc and self.proc.poll() is None:
print("获取真实直播流失败") self.proc.terminate()
return try:
print(f"实际直播流: {real_url}") self.proc.wait(timeout=5)
hls2yt = AdaptiveHLS2YouTube(delay_sec=delay_sec) except subprocess.TimeoutExpired:
hls2yt.start_stream(real_url, youtube_key, room_name) self.proc.kill()
return hls2yt self.proc = None
if not self.stop_flag.is_set():
time.sleep(5)
def _monitor_thread(self):
# ----------------- 控制台监控面板 -----------------
def monitor_panel_thread(hls_instance, interval=2, max_width=50):
term_width, _ = shutil.get_terminal_size() term_width, _ = shutil.get_terminal_size()
bar_width = min(max_width, term_width - 30) bar_width = min(50, term_width - 30)
while not self.stop_flag.is_set():
if self.proc:
uptime = time.time() - self.start_time if self.start_time else 0
uptime_str = f"{int(uptime // 60):02d}:{int(uptime % 60):02d}"
status = "推流中" if self.pushing else "启动中"
bar_len = bar_width if self.pushing else 0
bar = "#" * bar_len + "-" * (bar_width - bar_len)
print(f"\r[{self.room_name}] [{bar}] {status} | 运行时间: {uptime_str} | 码率: {self.bitrate}k", end="")
else:
print(f"\r[{self.room_name}] FFmpeg 未运行", end="")
time.sleep(2)
while not hls_instance.stop_flag.is_set(): def stop(self):
queue_len = hls_instance.buffer_queue.qsize() logging.info(f"[{self.room_name}] 停止推流...")
bitrate = hls_instance.current_bitrate self.stop_flag.set()
active_push = any(proc and proc.poll() is None for proc in hls_instance.processes.values()) if self.proc and self.proc.poll() is None:
self.proc.terminate()
queue_bar_len = min(bar_width, queue_len) try:
queue_bar = "#" * queue_bar_len + "-" * (bar_width - queue_bar_len) self.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
print("\r", end="") self.proc.kill()
print(f"[监控] 队列: [{queue_bar}] {queue_len:4} | 码率: {bitrate:4}k | 推流活跃: {active_push}", end="")
time.sleep(interval)
# ----------------- 示例 ----------------- # ----------------- Douyin Stream Parsing -----------------
async def get_douyin_real_url(record_url, record_quality="原画", proxy_addr=None, cookies=''):
try:
if 'v.douyin.com' not in record_url and '/user/' not in record_url:
json_data = await spider.get_douyin_stream_data(url=record_url, proxy_addr=proxy_addr, cookies=cookies)
else:
json_data = await spider.get_douyin_app_stream_data(url=record_url, proxy_addr=proxy_addr, cookies=cookies)
port_info = await stream.get_douyin_stream_url(json_data, record_quality, proxy_addr)
return port_info.get("record_url")
except Exception as e:
logging.error(f"获取抖音真实流失败: {e}")
return None
def start_douyin_to_youtube(record_url, youtube_key, room_name=None, proxy_addr=None, cookies='', bitrate=5000):
real_url = asyncio.run(get_douyin_real_url(record_url, proxy_addr, cookies))
if not real_url:
logging.error("获取真实直播流失败")
return None
logging.info(f"实际直播流: {real_url}")
streamer = DouyinToYouTubeStreamer(bitrate=bitrate)
streamer.start_stream(real_url, youtube_key, room_name)
return streamer
# ----------------- Main Program -----------------
if __name__ == "__main__": if __name__ == "__main__":
DOUYIN_URL = "https://live.douyin.com/354098231885" DOUYIN_URL = "https://live.douyin.com/153648759493"
YOUTUBE_KEY = "qxvb-r47b-r5ju-6ud3-6k7z" YOUTUBE_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
hls_instance = start_douyin_to_youtube(DOUYIN_URL, YOUTUBE_KEY, delay_sec=40) hls_instance = start_douyin_to_youtube(DOUYIN_URL, YOUTUBE_KEY)
if not hls_instance: if not hls_instance:
print("推流实例创建失败,程序退出") logging.error("推流实例创建失败,程序退出")
sys.exit(1) sys.exit(1)
threading.Thread(target=monitor_panel_thread, args=(hls_instance,), daemon=True).start()
def signal_handler(sig, frame): def signal_handler(sig, frame):
if hls_instance: if hls_instance:
hls_instance.stop_all() hls_instance.stop()
sys.exit(0) sys.exit(0)
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGTERM, signal_handler)
print("程序已启动,按 Ctrl+C 停止...") logging.info("程序已启动,低延迟推流中,按 Ctrl+C 停止...")
while True: while True:
time.sleep(60) time.sleep(60)
# back3