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

309
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 _build_ffmpeg_cmd(self, real_url, youtube_key):
user_agent = ("Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-G973U) AppleWebKit/537.36 "
"(KHTML, like Gecko) SamsungBrowser/14.2 Chrome/87.0.4280.141 Mobile Safari/537.36")
headers = "Referer: https://www.douyin.com/\r\nOrigin: https://www.douyin.com\r\n"
def _pull_thread(self, real_url):
cmd = [ cmd = [
"ffmpeg", 'ffmpeg', '-y', '-v', 'error', '-hide_banner',
"-rw_timeout", "50000000", '-rw_timeout', '10000000',
"-user_agent", "Mozilla/5.0", '-analyzeduration', '1000000',
"-headers", "Referer: https://www.douyin.com/", '-probesize', '5000000',
"-fflags", "+nobuffer+genpts", '-thread_queue_size', '512',
"-flags", "low_delay", '-user_agent', user_agent,
"-reconnect", "1", '-headers', headers,
"-reconnect_at_eof", "1", '-protocol_whitelist', 'file,pipe,http,https,tcp,tls,udp,rtp,crypto',
"-reconnect_streamed", "1", '-fflags', '+discardcorrupt+genpts',
"-reconnect_delay_max", "10", '-flags', 'low_delay',
"-i", real_url, '-http_seekable', '0',
"-c", "copy", '-avioflags', 'direct',
"-f", "mpegts", '-reconnect', '1',
"-" '-reconnect_at_eof', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '5',
'-flush_packets', '1',
'-i', real_url,
'-c:v', 'libx264',
'-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}'
] ]
try: return cmd
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): def _run_ffmpeg_loop(self, real_url, youtube_key):
min_buffer = max(1, self.delay_sec // self.segment_time)
while not self.stop_flag.is_set(): while not self.stop_flag.is_set():
if self.buffer_queue.qsize() < min_buffer:
time.sleep(0.05)
continue
cmd = [
"ffmpeg",
"-re",
"-f", "mpegts",
"-i", "-",
"-c:v", "libx264",
"-preset", "veryfast",
"-tune", "zerolatency",
"-b:v", f"{self.current_bitrate}k",
"-maxrate", f"{self.current_bitrate}k",
"-bufsize", f"{self.current_bitrate*4}k",
"-r", "30",
"-g", "60",
"-s", "720x1280",
"-c:a", "aac",
"-b:a", f"{self.audio_bitrate}k",
"-ar", "44100",
"-ac", "2",
"-f", "flv",
"-rtmp_live", "live",
f"rtmp://a.rtmp.youtube.com/live2/{youtube_key}"
]
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 line: if self.stop_flag.is_set():
print(f"[{room_name} FFmpeg]: {line.decode(errors='ignore').strip()}") break
threading.Thread(target=read_stderr, daemon=True).start() if line:
decoded = line.decode('utf-8', errors='ignore').strip()
logging.info(f"[{self.room_name} FFmpeg]: {decoded}")
# 判断推流是否成功
if not self.pushing and "frame=" in decoded and "fps=" in decoded:
self.pushing = True
logging.info(f"[{self.room_name}] 推流到 YouTube 成功")
except Exception as e:
logging.error(f"[{self.room_name}] stderr线程异常: {e}")
while not self.stop_flag.is_set(): stderr_thread = threading.Thread(target=read_stderr, daemon=True)
try: stderr_thread.start()
data = self.buffer_queue.get(timeout=1)
proc.stdin.write(data) self.proc.wait()
proc.stdin.flush() if not self.stop_flag.is_set():
except queue.Empty: logging.warning(f"[{self.room_name}] FFmpeg 退出 (返回码: {self.proc.returncode})5秒后重启...")
continue self.pushing = False
except Exception as e: except Exception as e:
print(f"[{room_name}] 推流异常: {e}") logging.error(f"[{self.room_name}] FFmpeg 启动异常: {e}")
finally:
if self.proc and self.proc.poll() is None:
self.proc.terminate()
try:
self.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc = None
if not self.stop_flag.is_set():
time.sleep(5)
def _abr_thread(self): def _monitor_thread(self):
term_width, _ = shutil.get_terminal_size()
bar_width = min(50, term_width - 30)
while not self.stop_flag.is_set(): while not self.stop_flag.is_set():
queue_len = self.buffer_queue.qsize() if self.proc:
if queue_len > 100: uptime = time.time() - self.start_time if self.start_time else 0
new_bitrate = max(self.min_bitrate, self.current_bitrate - 300) uptime_str = f"{int(uptime // 60):02d}:{int(uptime % 60):02d}"
elif queue_len < 30: status = "推流中" if self.pushing else "启动中"
new_bitrate = min(self.max_bitrate, self.current_bitrate + 300) 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: else:
new_bitrate = self.current_bitrate print(f"\r[{self.room_name}] FFmpeg 未运行", end="")
if new_bitrate != self.current_bitrate: time.sleep(2)
print(f"[ABR] 调整码率: {self.current_bitrate}k -> {new_bitrate}k")
self.current_bitrate = new_bitrate
time.sleep(self.check_interval)
def stop_all(self): def stop(self):
print("停止所有推流...") logging.info(f"[{self.room_name}] 停止推流...")
self.stop_flag.set() self.stop_flag.set()
for proc in self.processes.values(): if self.proc and self.proc.poll() is None:
if proc.poll() is None: self.proc.terminate()
proc.terminate() try:
self.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self.proc.kill()
# ----------------- 抖音解析逻辑 ----------------- # ----------------- Douyin Stream Parsing -----------------
async def get_douyin_real_url(record_url, record_quality="原画"): async def get_douyin_real_url(record_url, record_quality="原画", proxy_addr=None, cookies=''):
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: try:
real_url = asyncio.run(get_douyin_real_url(record_url)) 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: except Exception as e:
print(f"获取真实直播流失败: {e}") logging.error(f"获取抖音真实流失败: {e}")
return 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: if not real_url:
print("获取真实直播流失败") logging.error("获取真实直播流失败")
return return None
print(f"实际直播流: {real_url}") logging.info(f"实际直播流: {real_url}")
hls2yt = AdaptiveHLS2YouTube(delay_sec=delay_sec) streamer = DouyinToYouTubeStreamer(bitrate=bitrate)
hls2yt.start_stream(real_url, youtube_key, room_name) streamer.start_stream(real_url, youtube_key, room_name)
return hls2yt return streamer
# ----------------- 控制台监控面板 ----------------- # ----------------- Main Program -----------------
def monitor_panel_thread(hls_instance, interval=2, max_width=50):
term_width, _ = shutil.get_terminal_size()
bar_width = min(max_width, term_width - 30)
while not hls_instance.stop_flag.is_set():
queue_len = hls_instance.buffer_queue.qsize()
bitrate = hls_instance.current_bitrate
active_push = any(proc and proc.poll() is None for proc in hls_instance.processes.values())
queue_bar_len = min(bar_width, queue_len)
queue_bar = "#" * queue_bar_len + "-" * (bar_width - queue_bar_len)
print("\r", end="")
print(f"[监控] 队列: [{queue_bar}] {queue_len:4} | 码率: {bitrate:4}k | 推流活跃: {active_push}", end="")
time.sleep(interval)
# ----------------- 示例 -----------------
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