Files
gitlab-instance-0a899031_dy…/backup/bnack12
2025-09-24 14:55:54 +08:00

323 lines
14 KiB
Plaintext
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.
import os
import subprocess
import threading
import asyncio
import uuid
import time
import signal
import sys
import logging
import platform
import aiohttp
import spider # 你的 spider 模块
import stream # 你的 stream 模块
import configparser
from typing import Any, Optional
# ----------------- Logging Setup -----------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S"
)
# ----------------- Douyin to YouTube Streamer -----------------
class DouyinToYouTubeStreamer:
def __init__(self, bitrate=5000, audio_bitrate=128, resolution='720x1280',
fps=30, gop=60, max_minutes=None, enable_recording=False):
self.push_proc = None
self.record_proc = None
self.stop_flag = threading.Event()
self.bitrate = bitrate
self.audio_bitrate = audio_bitrate
self.resolution = resolution
self.fps = fps
self.gop = gop
self.start_time = None
self.room_name = None
self.pushing = False
self.out_file = None
self.max_minutes = max_minutes
self.enable_recording = enable_recording
os.makedirs("records", exist_ok=True)
def start_stream(self, real_url, youtube_key, room_name=None):
self.room_name = room_name or f"抖音_{str(uuid.uuid4())[:8]}"
logging.info(f"[{self.room_name}] 启动低延迟推流,目标 YouTube Key: {youtube_key}")
threading.Thread(target=self._run_stream_loop,
args=(real_url, youtube_key), daemon=True).start()
threading.Thread(target=self._monitor_thread, daemon=True).start()
def _build_push_cmd(self, real_url, youtube_key):
user_agent = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36")
headers = "Referer: https://www.douyin.com/\r\nOrigin: https://www.douyin.com\r\n"
cmd = [
'ffmpeg', '-y',
'-rw_timeout', '10000000',
'-analyzeduration', '1000000',
'-probesize', '5000000',
'-user_agent', user_agent,
'-headers', headers,
'-protocol_whitelist', 'file,pipe,http,https,tcp,tls,udp,rtp,crypto',
'-fflags', '+discardcorrupt+genpts',
'-flags', 'low_delay',
'-http_seekable', '0',
'-i', real_url,
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
'-b:v', f'{self.bitrate}k',
'-maxrate', f'{self.bitrate}k',
'-bufsize', f'{self.bitrate * 2}k',
'-r', str(self.fps),
'-g', str(self.gop),
'-s', self.resolution,
'-c:a', 'aac', '-b:a', f'{self.audio_bitrate}k', '-ar', '44100', '-ac', '2',
'-f', 'flv',
f'rtmp://a.rtmp.youtube.com/live2/{youtube_key}'
]
return cmd
def _build_record_cmd(self, real_url):
base_name = f"{self.room_name}_{int(time.time())}.mp4"
self.out_file = os.path.join("records", base_name).replace("\\", "/")
cmd = [
'ffmpeg', '-y',
'-i', real_url,
'-c:v', 'libx264',
'-preset', 'veryfast',
'-tune', 'zerolatency',
'-c:a', 'aac',
'-b:a', f'{self.audio_bitrate}k',
'-ar', '44100',
'-ac', '2',
'-movflags', '+faststart+frag_keyframe',
self.out_file
]
return cmd
def _run_stream_loop(self, real_url, youtube_key):
while not self.stop_flag.is_set():
try:
self.start_time = time.time()
self.pushing = False
push_cmd = self._build_push_cmd(real_url, youtube_key)
self.push_proc = subprocess.Popen(
push_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
bufsize=1, universal_newlines=True
)
logging.info(f"[{self.room_name}] 推流进程 PID: {self.push_proc.pid}")
if self.enable_recording:
record_cmd = self._build_record_cmd(real_url)
self.record_proc = subprocess.Popen(
record_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
bufsize=1, universal_newlines=True
)
logging.info(f"[{self.room_name}] 本地录制开始: {self.out_file}")
def monitor_proc(proc, name):
try:
while not self.stop_flag.is_set() and proc and proc.poll() is None:
line = proc.stderr.readline()
if not line:
continue
if name == "push" and not self.pushing and "frame=" in line and "fps=" in line:
self.pushing = True
logging.info(f"[{self.room_name}] 推流到 YouTube 成功")
except Exception as e:
logging.error(f"[{self.room_name} {name} stderr异常: {e}")
threading.Thread(target=monitor_proc, args=(self.push_proc, "push"), daemon=True).start()
if self.enable_recording:
threading.Thread(target=monitor_proc, args=(self.record_proc, "record"), daemon=True).start()
while not self.stop_flag.is_set():
elapsed = time.time() - self.start_time
if self.max_minutes and elapsed >= self.max_minutes * 60:
logging.warning(f"[{self.room_name}] 超过 {self.max_minutes} 分钟,自动停止推流和录制")
self.stop()
break
if self.push_proc.poll() is not None:
logging.warning(f"[{self.room_name}] 推流进程退出,准备重新遍历 list.ini ...")
self.stop_flag.set()
break
if self.enable_recording and self.record_proc and self.record_proc.poll() is not None:
logging.warning(f"[{self.room_name}] 录制进程退出1秒后重启...")
self._terminate_procs()
break
time.sleep(1)
except Exception as e:
logging.error(f"[{self.room_name}] FFmpeg异常: {e}")
finally:
self._terminate_procs()
if not self.stop_flag.is_set():
time.sleep(1)
def _terminate_procs(self):
for proc in [self.push_proc, self.record_proc]:
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()
self.push_proc, self.record_proc = None, None
def _monitor_thread(self):
while not self.stop_flag.is_set():
uptime = int(time.time() - self.start_time) if self.start_time else 0
m, s = divmod(uptime, 60)
status = "推流中" if self.pushing else "启动中"
print(f"\r[{self.room_name}] {status} | 运行时间: {m:02d}:{s:02d} | 码率: {self.bitrate}k", end="")
time.sleep(1)
def stop(self):
logging.info(f"[{self.room_name}] 停止推流和录制...")
self.stop_flag.set()
self._terminate_procs()
# ----------------- Douyin Stream Parsing -----------------
async def get_douyin_real_url(record_url, record_quality="原画", proxy_addr=None, cookies=''):
headers = {
"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"),
"Referer": "https://www.douyin.com/",
"Origin": "https://www.douyin.com",
"Accept": "application/json, text/javascript, */*; q=0.01",
}
session_args = {"headers": headers}
if proxy_addr:
session_args["proxy"] = proxy_addr
try:
async with aiohttp.ClientSession(**session_args) as session:
for attempt in range(5): # 增加重试次数到5次
try:
async with session.get(record_url, cookies=cookies, timeout=10) as resp:
text = await resp.text()
if not text.strip() or "captcha" in text.lower():
logging.warning(f"第{attempt+1}次尝试获取流失败,返回内容为空或验证码,重试...")
await asyncio.sleep(2) # 增加重试间隔
continue
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)
if json_data is None:
logging.warning(f"第{attempt+1}次spider解析返回None重试...")
await asyncio.sleep(2)
continue
port_info = await stream.get_douyin_stream_url(
json_data, record_quality, proxy_addr)
if port_info and isinstance(port_info, dict) and port_info.get("record_url"):
logging.info(f"成功解析直播流: {record_url}")
return port_info.get("record_url")
else:
logging.warning(f"第{attempt+1}次尝试解析失败port_info无效: {port_info}")
await asyncio.sleep(2)
except json.JSONDecodeError as e:
logging.warning(f"第{attempt+1}次JSON解析失败: {e},重试...")
await asyncio.sleep(2)
except aiohttp.ClientError as e:
logging.warning(f"第{attempt+1}次网络请求失败: {e},重试...")
await asyncio.sleep(2)
except Exception as e:
logging.warning(f"第{attempt+1}次尝试抓取抖音流异常: {e}")
await asyncio.sleep(2)
logging.error(f"多次尝试后仍获取抖音真实流失败: {record_url}")
return None
except Exception as e:
logging.error(f"初始化 aiohttp session 失败: {e}")
return None
def start_douyin_to_youtube(record_url, youtube_key, real_url: Optional[str] = None, room_name=None,
proxy_addr=None, cookies='', bitrate=5000, enable_recording=False):
if real_url is None:
real_url = asyncio.run(get_douyin_real_url(record_url, proxy_addr=proxy_addr, cookies=cookies))
if not real_url:
logging.error(f"获取真实直播流失败: {record_url}")
return None
logging.info(f"实际直播流: {real_url}")
streamer = DouyinToYouTubeStreamer(bitrate=bitrate, enable_recording=enable_recording, max_minutes=None)
streamer.start_stream(real_url, youtube_key, room_name)
return streamer
# ----------------- Auto Scheduler -----------------
def load_config():
config = configparser.ConfigParser()
config.read("config.ini", encoding="utf-8")
youtube_key = config.get("youtube", "key", fallback=None)
return youtube_key
def load_list():
urls = []
seen = set()
with open("list.ini", "r", encoding="utf-8") as f:
for line in f:
url = line.strip()
if url and not url.startswith("#") and url not in seen:
urls.append(url)
seen.add(url)
logging.info(f"加载 {len(urls)} 个唯一直播间URL按文件顺序排列")
return urls
def find_live_and_stream(youtube_key):
urls = load_list()
for i, url in enumerate(urls, 1):
logging.info(f"检查第 {i}/{len(urls)} 个URL: {url}")
real_url = asyncio.run(get_douyin_real_url(url))
if real_url:
logging.info(f"找到可用直播流: {url}")
streamer = start_douyin_to_youtube(url, youtube_key, real_url=real_url, room_name=f"抖音_{url.split('/')[-1]}", enable_recording=False)
if streamer:
return streamer
else:
logging.warning(f"{url} 流检测成功但启动失败,继续下一个...")
else:
logging.info(f"{url} 暂无直播,继续检测...")
return None
# ----------------- Main Program -----------------
if __name__ == "__main__":
youtube_key = load_config()
if not youtube_key:
logging.error("配置文件 config.ini 中未找到 YouTube Key")
sys.exit(1)
streamer_instance = None
def signal_handler(sig, frame):
if streamer_instance:
streamer_instance.stop()
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
logging.info("程序已启动,自动检测抖音直播...")
while True:
if not streamer_instance or streamer_instance.stop_flag.is_set():
streamer_instance = find_live_and_stream(youtube_key)
if not streamer_instance:
logging.info("未找到正在直播的房间60秒后重试...")
time.sleep(60)
time.sleep(10)