Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f59234a601 | ||
|
|
6ed686932f | ||
|
|
6d3f50e1e3 | ||
|
|
8f496f2109 | ||
|
|
a49143093c | ||
|
|
fedc756642 | ||
|
|
2f379f2559 | ||
|
|
a0a86b76c0 | ||
|
|
8c91808c7d | ||
|
|
89a96faf11 | ||
|
|
8395418a12 | ||
|
|
ea12e8bfaa | ||
|
|
94f7939b48 | ||
|
|
fc2da4ebfb | ||
|
|
da736cf51b | ||
|
|
3c09c7bd76 | ||
|
|
a9ca6e16a9 | ||
|
|
f03b747943 | ||
|
|
0504c104d3 | ||
|
|
687294ce5d | ||
|
|
696e00a144 | ||
|
|
2c4b0547f3 | ||
|
|
e32896165b | ||
|
|
f637e0b6c3 | ||
|
|
09648e67d3 | ||
|
|
cd6d39210e | ||
|
|
2b5f9a9b90 | ||
|
|
682350c804 | ||
|
|
eab967aabe | ||
|
|
2a977b1b7e | ||
|
|
a6db843ea0 | ||
|
|
88d54db72d | ||
|
|
5bf88b1417 | ||
|
|
6223b39b9e | ||
|
|
4a89940e5e | ||
|
|
24e55f36b1 | ||
|
|
2c609a7bb2 | ||
|
|
e9e018db08 | ||
|
|
752f11e9a6 | ||
|
|
6bbf547ab2 | ||
|
|
f3e8192b62 | ||
|
|
aa26e03ca1 | ||
|
|
e49b8b6865 | ||
|
|
77561952c2 | ||
|
|
b272565386 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,5 @@
|
||||
cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
records/
|
||||
*.ts
|
||||
@@ -145,7 +145,7 @@ def start_douyin_to_youtube(record_url, youtube_key, room_name=None, delay_sec=3
|
||||
|
||||
# ----------------- 示例 -----------------
|
||||
if __name__ == "__main__":
|
||||
DOUYIN_URL = "https://live.douyin.com/640087893839"
|
||||
DOUYIN_URL = "https://live.douyin.com/743565594721"
|
||||
YOUTUBE_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
|
||||
hls_instance = start_douyin_to_youtube(DOUYIN_URL, YOUTUBE_KEY, delay_sec=30)
|
||||
278
backup/back10
Normal file
278
backup/back10
Normal file
@@ -0,0 +1,278 @@
|
||||
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
|
||||
# ----------------- 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 # None 表示无限制
|
||||
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}")
|
||||
|
||||
# ---- monitor stderr 阻塞读取(Windows兼容) ----
|
||||
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}] 推流进程退出,1秒后重启...")
|
||||
time.sleep(1)
|
||||
break
|
||||
if self.enable_recording and self.record_proc and self.record_proc.poll() is not None:
|
||||
logging.warning(f"[{self.room_name}] 录制进程退出,1秒后重启...")
|
||||
time.sleep(1)
|
||||
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(3):
|
||||
try:
|
||||
async with session.get(record_url, cookies=cookies) as resp:
|
||||
text = await resp.text()
|
||||
if not text or "captcha" in text.lower():
|
||||
logging.warning(f"第{attempt+1}次尝试获取流失败,返回内容为空或验证码,重试...")
|
||||
await asyncio.sleep(1)
|
||||
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)
|
||||
|
||||
port_info = await stream.get_douyin_stream_url(
|
||||
json_data, record_quality, proxy_addr)
|
||||
if port_info and port_info.get("record_url"):
|
||||
return port_info.get("record_url")
|
||||
else:
|
||||
logging.warning(f"第{attempt+1}次尝试解析失败,port_info为空")
|
||||
await asyncio.sleep(1)
|
||||
except Exception as e:
|
||||
logging.warning(f"第{attempt+1}次尝试抓取抖音流异常: {e}")
|
||||
await asyncio.sleep(1)
|
||||
logging.error("多次尝试后仍获取抖音真实流失败")
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.error(f"初始化 aiohttp session 失败: {e}")
|
||||
return None
|
||||
|
||||
def start_douyin_to_youtube(record_url, youtube_key, room_name=None,
|
||||
proxy_addr=None, cookies='', bitrate=5000, enable_recording=False):
|
||||
real_url = asyncio.run(get_douyin_real_url(record_url, proxy_addr=proxy_addr, cookies=cookies))
|
||||
if not real_url:
|
||||
logging.error("获取真实直播流失败")
|
||||
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
|
||||
|
||||
|
||||
# ----------------- Main Program -----------------
|
||||
if __name__ == "__main__":
|
||||
DOUYIN_URL = "https://live.douyin.com/626341898042"
|
||||
YOUTUBE_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
|
||||
# enable_recording=True 可开启本地录制
|
||||
streamer_instance = start_douyin_to_youtube(DOUYIN_URL, YOUTUBE_KEY, enable_recording=False)
|
||||
if not streamer_instance:
|
||||
logging.error("推流实例创建失败,程序退出")
|
||||
sys.exit(1)
|
||||
|
||||
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("程序已启动,低延迟推流中,按 Ctrl+C 停止...")
|
||||
while True:
|
||||
time.sleep(60)
|
||||
303
backup/back11
Normal file
303
backup/back11
Normal file
@@ -0,0 +1,303 @@
|
||||
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
|
||||
|
||||
# ----------------- 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
|
||||
|
||||
# ----------------- 关键修改 -----------------
|
||||
# 如果推流挂掉,不再无限重启,而是退出循环,通知主循环重新遍历 list.ini
|
||||
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(3):
|
||||
try:
|
||||
async with session.get(record_url, cookies=cookies) as resp:
|
||||
text = await resp.text()
|
||||
if not text or "captcha" in text.lower():
|
||||
logging.warning(f"第{attempt+1}次尝试获取流失败,返回内容为空或验证码,重试...")
|
||||
await asyncio.sleep(1)
|
||||
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)
|
||||
|
||||
port_info = await stream.get_douyin_stream_url(
|
||||
json_data, record_quality, proxy_addr)
|
||||
if port_info and port_info.get("record_url"):
|
||||
return port_info.get("record_url")
|
||||
else:
|
||||
logging.warning(f"第{attempt+1}次尝试解析失败,port_info为空")
|
||||
await asyncio.sleep(1)
|
||||
except Exception as e:
|
||||
logging.warning(f"第{attempt+1}次尝试抓取抖音流异常: {e}")
|
||||
await asyncio.sleep(1)
|
||||
logging.error("多次尝试后仍获取抖音真实流失败")
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.error(f"初始化 aiohttp session 失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def start_douyin_to_youtube(record_url, youtube_key, room_name=None,
|
||||
proxy_addr=None, cookies='', bitrate=5000, enable_recording=False):
|
||||
real_url = asyncio.run(get_douyin_real_url(record_url, proxy_addr=proxy_addr, cookies=cookies))
|
||||
if not real_url:
|
||||
logging.error("获取真实直播流失败")
|
||||
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 = []
|
||||
with open("list.ini", "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
url = line.strip()
|
||||
if url and not url.startswith("#"):
|
||||
urls.append(url)
|
||||
return urls
|
||||
|
||||
|
||||
def find_live_and_stream(youtube_key):
|
||||
urls = load_list()
|
||||
for url in urls:
|
||||
logging.info(f"尝试解析 {url} 是否直播中...")
|
||||
real_url = asyncio.run(get_douyin_real_url(url))
|
||||
if real_url:
|
||||
logging.info(f"找到可用直播流: {url}")
|
||||
return start_douyin_to_youtube(url, youtube_key, enable_recording=False)
|
||||
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)
|
||||
323
backup/back12
Normal file
323
backup/back12
Normal file
@@ -0,0 +1,323 @@
|
||||
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)
|
||||
443
backup/back13
Normal file
443
backup/back13
Normal file
@@ -0,0 +1,443 @@
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
import uuid
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
import logging
|
||||
import aiohttp
|
||||
import requests
|
||||
import spider
|
||||
import stream
|
||||
import configparser
|
||||
from typing import Any, Optional
|
||||
from rich.console import Console
|
||||
from rich.progress import Progress
|
||||
from rich.table import Table
|
||||
import pyttsx3
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
import typer
|
||||
import json
|
||||
|
||||
# ----------------- 日志和 Rich 设置 -----------------
|
||||
console = Console()
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO)
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S"))
|
||||
logger.addHandler(handler)
|
||||
logging.getLogger('comtypes').setLevel(logging.ERROR)
|
||||
|
||||
# ----------------- 配置和状态 -----------------
|
||||
class Config:
|
||||
youtube_key = None
|
||||
live_urls = []
|
||||
config_changed = False
|
||||
streamer_instance = None
|
||||
|
||||
CONFIG = Config()
|
||||
|
||||
# ----------------- 文件监控 -----------------
|
||||
class ConfigFileHandler(FileSystemEventHandler):
|
||||
def on_modified(self, event):
|
||||
if event.src_path.endswith(("config.ini", "list.ini")):
|
||||
console.log(f"[yellow]🛠️ 检测到 {event.src_path} 变更,正在重新加载配置...[/yellow]")
|
||||
CONFIG.config_changed = True
|
||||
|
||||
def start_file_watcher():
|
||||
observer = Observer()
|
||||
observer.schedule(ConfigFileHandler(), path=".", recursive=False)
|
||||
observer.start()
|
||||
console.log("[cyan]📂 已启动文件监控,实时检测 config.ini 和 list.ini 变更[/cyan]")
|
||||
return observer
|
||||
|
||||
# ----------------- 抖音到 YouTube 推流类 -----------------
|
||||
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
|
||||
try:
|
||||
self.tts_engine = pyttsx3.init()
|
||||
except Exception as e:
|
||||
console.log(f"[red]❌ 初始化 pyttsx3 失败: {e},语音播报不可用[/red]")
|
||||
self.tts_engine = None
|
||||
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]}"
|
||||
console.log(f"[green]🚀 [{self.room_name}] 启动推流到 YouTube[/green]")
|
||||
if self.tts_engine:
|
||||
self.tts_engine.say(f"正在为直播间 {self.room_name} 启动推流")
|
||||
self.tts_engine.runAndWait()
|
||||
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, attempt=0):
|
||||
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"
|
||||
rtmp_servers = [f'rtmp://a.rtmp.youtube.com/live2/{youtube_key}', f'rtmp://b.rtmp.youtube.com/live2/{youtube_key}']
|
||||
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',
|
||||
'-reconnect', '1', '-reconnect_streamed', '1', '-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',
|
||||
rtmp_servers[min(attempt, len(rtmp_servers)-1)]
|
||||
]
|
||||
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):
|
||||
attempt = 0
|
||||
max_attempts = 2
|
||||
while not self.stop_flag.is_set() and attempt < max_attempts:
|
||||
try:
|
||||
self.start_time = time.time()
|
||||
self.pushing = False
|
||||
push_cmd = self._build_push_cmd(real_url, youtube_key, attempt)
|
||||
self.push_proc = subprocess.Popen(push_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, bufsize=1, universal_newlines=True)
|
||||
console.log(f"[cyan]🔄 [{self.room_name}] 推流进程 PID: {self.push_proc.pid}[/cyan]")
|
||||
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)
|
||||
console.log(f"[cyan]📹 [{self.room_name}] 本地录制: {self.out_file}[/cyan]")
|
||||
|
||||
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
|
||||
console.log(f"[green]✅ [{self.room_name}] 推流成功[/green]")
|
||||
if self.tts_engine:
|
||||
self.tts_engine.say(f"直播间 {self.room_name} 推流成功")
|
||||
self.tts_engine.runAndWait()
|
||||
except Exception as e:
|
||||
console.log(f"[red]❌ [{self.room_name}] {name} stderr 异常: {e}[/red]")
|
||||
self._send_ntfy_notification(title="推流错误", message=f"{self.room_name} {name} 异常: {str(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:
|
||||
console.log(f"[yellow]⏰ [{self.room_name}] 超过 {self.max_minutes} 分钟,停止推流[/yellow]")
|
||||
self.stop()
|
||||
break
|
||||
if self.push_proc.poll() is not None:
|
||||
console.log(f"[yellow]⚠️ [{self.room_name}] 推流进程退出,正在重试...[/yellow]")
|
||||
self._send_ntfy_notification(title="推流中断", message=f"{self.room_name} 推流进程退出")
|
||||
attempt += 1
|
||||
break
|
||||
if self.enable_recording and self.record_proc and self.record_proc.poll() is not None:
|
||||
console.log(f"[yellow]⚠️ [{self.room_name}] 录制进程退出,1秒后重启...[/yellow]")
|
||||
self._terminate_procs()
|
||||
break
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
console.log(f"[red]❌ [{self.room_name}] FFmpeg 异常: {e}[/red]")
|
||||
self._send_ntfy_notification(title="FFmpeg 错误", message=f"{self.room_name} FFmpeg 异常: {str(e)}")
|
||||
attempt += 1
|
||||
finally:
|
||||
self._terminate_procs()
|
||||
if not self.stop_flag.is_set() and attempt < max_attempts:
|
||||
time.sleep(2)
|
||||
elif attempt >= max_attempts:
|
||||
console.log(f"[red]❌ [{self.room_name}] 达到最大重试次数,停止推流[/red]")
|
||||
self.stop_flag.set()
|
||||
|
||||
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 _send_ntfy_notification(self, title: str, message: str):
|
||||
ntfy_url = "https://ntfy.sh/your_topic"
|
||||
try:
|
||||
requests.post(ntfy_url, data=message.encode('utf-8'), headers={"Title": title.encode('utf-8')})
|
||||
console.log(f"[cyan]📢 发送 ntfy 通知: {title}[/cyan]")
|
||||
except Exception as e:
|
||||
console.log(f"[red]❌ 发送 ntfy 通知失败: {e}[/red]")
|
||||
|
||||
def _monitor_thread(self):
|
||||
with Progress(console=console) as progress:
|
||||
task = progress.add_task(f"[cyan]📡 [{self.room_name}] 推流状态[/cyan]", total=None)
|
||||
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 "启动中"
|
||||
progress.update(task, description=f"[cyan]📡 [{self.room_name}] {status} | 时间: {m:02d}:{s:02d} | 码率: {self.bitrate}k[/cyan]")
|
||||
time.sleep(1)
|
||||
|
||||
def stop(self):
|
||||
console.log(f"[yellow]🛑 [{self.room_name}] 停止推流和录制[/yellow]")
|
||||
if self.tts_engine:
|
||||
self.tts_engine.say(f"直播间 {self.room_name} 停止推流")
|
||||
self.tts_engine.runAndWait()
|
||||
self.stop_flag.set()
|
||||
self._terminate_procs()
|
||||
|
||||
# ----------------- 抖音流解析 -----------------
|
||||
async def get_douyin_real_url(record_url, record_quality="原画", proxy_addr=None, cookies='', max_retries=2):
|
||||
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
|
||||
if cookies:
|
||||
session_args["cookies"] = cookies
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(**session_args) as session:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
async with session.get(record_url, timeout=5) as resp:
|
||||
console.log(f"[cyan]🔗 HTTP 请求: {record_url} -> 状态码 {resp.status}[/cyan]")
|
||||
text = await resp.text()
|
||||
if not text.strip() or "captcha" in text.lower():
|
||||
console.log(f"[yellow]⚠️ [{record_url}] 第 {attempt+1}/{max_retries} 次: 验证码或空响应[/yellow]")
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
if 'v.douyin.com' not in record_url and '/user/' not in record_url:
|
||||
try:
|
||||
json_data = await spider.get_douyin_stream_data(url=record_url, proxy_addr=proxy_addr, cookies=cookies)
|
||||
except Exception as e:
|
||||
console.log(f"[yellow]⚠️ [{record_url}] spider.get_douyin_stream_data 异常: {e}[/yellow]")
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
else:
|
||||
try:
|
||||
json_data = await spider.get_douyin_app_stream_data(url=record_url, proxy_addr=proxy_addr, cookies=cookies)
|
||||
except Exception as e:
|
||||
console.log(f"[yellow]⚠️ [{record_url}] spider.get_douyin_app_stream_data 异常: {e}[/yellow]")
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
if json_data is None:
|
||||
console.log(f"[yellow]⚠️ [{record_url}] 第 {attempt+1}/{max_retries} 次: 解析返回 None[/yellow]")
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
try:
|
||||
port_info = await stream.get_douyin_stream_url(json_data, record_quality, proxy_addr)
|
||||
except Exception as e:
|
||||
console.log(f"[yellow]⚠️ [{record_url}] stream.get_douyin_stream_url 异常: {e}[/yellow]")
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
if port_info and isinstance(port_info, dict) and port_info.get("record_url"):
|
||||
console.log(f"[green]✅ [{record_url}] 成功解析直播流[/green]")
|
||||
return port_info.get("record_url")
|
||||
else:
|
||||
anchor_name = port_info.get('anchor_name', '未知')
|
||||
console.log(f"[yellow]⚠️ [{record_url}] 第 {attempt+1}/{max_retries} 次: {anchor_name} 未开播[/yellow]")
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
except json.JSONDecodeError as e:
|
||||
console.log(f"[yellow]⚠️ [{record_url}] 第 {attempt+1}/{max_retries} 次: JSON 解析失败 - {e}[/yellow]")
|
||||
await asyncio.sleep(2)
|
||||
except aiohttp.ClientError as e:
|
||||
console.log(f"[yellow]⚠️ [{record_url}] 第 {attempt+1}/{max_retries} 次: 网络请求失败 - {e}[/yellow]")
|
||||
await asyncio.sleep(2)
|
||||
except Exception as e:
|
||||
console.log(f"[yellow]⚠️ [{record_url}] 第 {attempt+1}/{max_retries} 次: 抓取异常 - {e}[/yellow]")
|
||||
await asyncio.sleep(2)
|
||||
console.log(f"[red]❌ [{record_url}] 多次尝试后无法获取直播流[/red]")
|
||||
DouyinToYouTubeStreamer()._send_ntfy_notification(title="直播流解析失败", message=f"多次尝试后无法获取直播流: {record_url}")
|
||||
return None
|
||||
except Exception as e:
|
||||
console.log(f"[red]❌ [{record_url}] 初始化 aiohttp 会话失败: {e}[/red]")
|
||||
DouyinToYouTubeStreamer()._send_ntfy_notification(title="会话初始化失败", message=f"初始化 aiohttp 会话失败: {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:
|
||||
console.log(f"[red]❌ [{record_url}] 无法获取真实直播流[/red]")
|
||||
return None
|
||||
console.log(f"[green]✅ [{record_url}] 真实直播流: {real_url}[/green]")
|
||||
streamer = DouyinToYouTubeStreamer(bitrate=bitrate, enable_recording=enable_recording, max_minutes=None)
|
||||
streamer.start_stream(real_url, youtube_key, room_name)
|
||||
return streamer
|
||||
|
||||
# ----------------- 自动调度 -----------------
|
||||
def load_config():
|
||||
config = configparser.ConfigParser()
|
||||
try:
|
||||
config.read("config.ini", encoding="utf-8")
|
||||
youtube_key = config.get("youtube", "key", fallback=None)
|
||||
if youtube_key:
|
||||
console.log("[green]🔑 已加载 YouTube 密钥[/green]")
|
||||
else:
|
||||
console.log("[red]❌ config.ini 未找到 YouTube 密钥[/red]")
|
||||
return youtube_key
|
||||
except Exception as e:
|
||||
console.log(f"[red]❌ 加载 config.ini 失败: {e}[/red]")
|
||||
return None
|
||||
|
||||
def load_list():
|
||||
urls = []
|
||||
seen = set()
|
||||
try:
|
||||
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)
|
||||
console.log(f"[cyan]📋 已加载 {len(urls)} 个直播间 URL[/cyan]")
|
||||
return urls
|
||||
except FileNotFoundError:
|
||||
console.log("[red]❌ 未找到 list.ini 文件[/red]")
|
||||
except Exception as e:
|
||||
console.log(f"[red]❌ 加载 list.ini 失败: {e}[/red]")
|
||||
return []
|
||||
|
||||
async def find_live_and_stream(youtube_key):
|
||||
urls = load_list()
|
||||
if not urls:
|
||||
console.log("[red]❌ 无直播间 URL,退出检测[/red]")
|
||||
return None
|
||||
|
||||
table = Table(title="直播间检测状态", show_lines=True)
|
||||
table.add_column("序号", style="cyan", width=6)
|
||||
table.add_column("URL", style="magenta", width=40)
|
||||
table.add_column("主播", style="yellow", width=20)
|
||||
table.add_column("状态", style="green", width=10)
|
||||
status_dict = {}
|
||||
|
||||
with Progress(console=console, transient=True) as progress:
|
||||
task = progress.add_task("[cyan]🔍 检测直播间...[/cyan]", total=len(urls))
|
||||
for index, url in enumerate(urls, 1):
|
||||
console.log(f"[cyan]🔍 检查 [{index}/{len(urls)}] {url}[/cyan]")
|
||||
real_url = await get_douyin_real_url(url)
|
||||
status = "直播中" if real_url else "未开播"
|
||||
anchor_name = "未知"
|
||||
if not real_url:
|
||||
try:
|
||||
json_data = await spider.get_douyin_stream_data(url)
|
||||
anchor_name = json_data.get('anchor_name', '未知') if json_data else "未知"
|
||||
except Exception as e:
|
||||
console.log(f"[yellow]⚠️ [{url}] 获取主播信息失败: {e}[/yellow]")
|
||||
status_dict[url] = (index, url, anchor_name, status)
|
||||
|
||||
# 更新表格
|
||||
table.rows = [] # 清空并重新添加以保持顺序
|
||||
for _, u, a, s in sorted(status_dict.values(), key=lambda x: x[0]):
|
||||
table.add_row(str(_), u, a, s)
|
||||
console.print(table)
|
||||
progress.update(task, advance=1)
|
||||
|
||||
if real_url:
|
||||
console.log(f"[green]✅ [{url}] 发现直播流[/green]")
|
||||
streamer = start_douyin_to_youtube(url, youtube_key, real_url=real_url, room_name=f"抖音_{url.split('/')[-1]}")
|
||||
if streamer:
|
||||
console.log(f"[green]🚀 [{url}] 推流已启动,停止后续检测[/green]")
|
||||
return streamer
|
||||
else:
|
||||
console.log(f"[yellow]⚠️ [{url}] 启动推流失败,继续检测下一个[/yellow]")
|
||||
else:
|
||||
console.log(f"[yellow]⚠️ [{url}] 未开播,继续检测下一个[/yellow]")
|
||||
|
||||
console.log("[yellow]⚠️ 所有直播间检测完成,未发现直播[/yellow]")
|
||||
return None
|
||||
|
||||
# ----------------- 主程序 -----------------
|
||||
if __name__ == "__main__":
|
||||
app = typer.Typer()
|
||||
|
||||
@app.command()
|
||||
def start():
|
||||
youtube_key = load_config()
|
||||
if not youtube_key:
|
||||
console.log("[red]❌ 未找到 YouTube 密钥,退出[/red]")
|
||||
sys.exit(1)
|
||||
CONFIG.youtube_key = youtube_key
|
||||
CONFIG.live_urls = load_list()
|
||||
if not CONFIG.live_urls:
|
||||
console.log("[red]❌ 无直播间 URL,退出[/red]")
|
||||
sys.exit(1)
|
||||
observer = start_file_watcher()
|
||||
console.log("[green]✅ 程序启动,自动检测抖音直播[/green]")
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
while True:
|
||||
try:
|
||||
if CONFIG.config_changed:
|
||||
console.log("[yellow]🛠️ 重新加载配置[/yellow]")
|
||||
CONFIG.youtube_key = load_config()
|
||||
CONFIG.live_urls = load_list()
|
||||
CONFIG.config_changed = False
|
||||
if not CONFIG.streamer_instance or CONFIG.streamer_instance.stop_flag.is_set():
|
||||
console.log("[cyan]🔄 推流中断或未启动,开始新一轮遍历检测...[/cyan]")
|
||||
CONFIG.streamer_instance = loop.run_until_complete(find_live_and_stream(CONFIG.youtube_key))
|
||||
if not CONFIG.streamer_instance:
|
||||
console.log("[yellow]⚠️ 未发现直播间,10秒后重试[/yellow]")
|
||||
time.sleep(10)
|
||||
time.sleep(5)
|
||||
except Exception as e:
|
||||
console.log(f"[red]❌ 主循环异常: {e},5秒后重试[/red]")
|
||||
time.sleep(5)
|
||||
|
||||
@app.command()
|
||||
def stop():
|
||||
if CONFIG.streamer_instance:
|
||||
CONFIG.streamer_instance.stop()
|
||||
console.log("[yellow]🛑 推流已停止[/yellow]")
|
||||
else:
|
||||
console.log("[yellow]⚠️ 无活跃推流[/yellow]")
|
||||
|
||||
@app.command()
|
||||
def status():
|
||||
if CONFIG.streamer_instance and not CONFIG.streamer_instance.stop_flag.is_set():
|
||||
console.log(f"[green]✅ 推流活跃: {CONFIG.streamer_instance.room_name}[/green]")
|
||||
else:
|
||||
console.log("[yellow]⚠️ 无活跃推流[/yellow]")
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
if CONFIG.streamer_instance:
|
||||
CONFIG.streamer_instance.stop()
|
||||
console.log("[yellow]🛑 程序终止[/yellow]")
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
app()
|
||||
342
backup/back14
Normal file
342
backup/back14
Normal file
@@ -0,0 +1,342 @@
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
import uuid
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
import logging
|
||||
import aiohttp
|
||||
import spider # 您的 spider 模块
|
||||
import stream # 您的 stream 模块
|
||||
import configparser
|
||||
from typing import Optional, Tuple
|
||||
from colorama import init, Fore, Style
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
# ----------------- 日志设置 -----------------
|
||||
# 初始化 colorama
|
||||
init(autoreset=True)
|
||||
|
||||
# 创建 logs 目录
|
||||
LOG_DIR = "logs"
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
LOG_FILE = os.path.join(LOG_DIR, "app.log")
|
||||
|
||||
# 自定义彩色日志 Formatter
|
||||
class ColorFormatter(logging.Formatter):
|
||||
COLORS = {
|
||||
logging.DEBUG: Fore.BLUE,
|
||||
logging.INFO: Fore.CYAN,
|
||||
logging.WARNING: Fore.YELLOW,
|
||||
logging.ERROR: Fore.RED,
|
||||
logging.CRITICAL: Fore.RED + Style.BRIGHT,
|
||||
}
|
||||
|
||||
def format(self, record):
|
||||
color = self.COLORS.get(record.levelno, "")
|
||||
message = super().format(record)
|
||||
return f"{color}{message}{Style.RESET_ALL}"
|
||||
|
||||
# 控制台日志(INFO 以上,带颜色)
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_formatter = ColorFormatter("%(asctime)s [%(levelname)s] %(message)s", "%H:%M:%S")
|
||||
console_handler.setFormatter(console_formatter)
|
||||
|
||||
# 文件日志(DEBUG 以上,自动滚动,5MB*5份)
|
||||
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=5*1024*1024, backupCount=5, encoding="utf-8")
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S")
|
||||
file_handler.setFormatter(file_formatter)
|
||||
|
||||
# 根 logger
|
||||
logging.basicConfig(level=logging.DEBUG, handlers=[console_handler, file_handler])
|
||||
logger = logging.getLogger("main")
|
||||
|
||||
# 抑制不必要的三方日志
|
||||
logging.getLogger("aiohttp").setLevel(logging.CRITICAL)
|
||||
logging.getLogger("aiohttp.client").setLevel(logging.CRITICAL)
|
||||
logging.getLogger("httpcore").setLevel(logging.CRITICAL)
|
||||
logging.getLogger("urllib3").setLevel(logging.CRITICAL)
|
||||
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
|
||||
if hasattr(spider, 'logger'):
|
||||
spider.logger.setLevel(logging.CRITICAL)
|
||||
if hasattr(stream, 'logger'):
|
||||
stream.logger.setLevel(logging.CRITICAL)
|
||||
|
||||
# ----------------- 主逻辑 -----------------
|
||||
class DouyinToYouTubeStreamer:
|
||||
def __init__(self, bitrate=5000, audio_bitrate=128, resolution='720x1280', fps=30, gop=60, 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.enable_recording = enable_recording
|
||||
os.makedirs("records", exist_ok=True)
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
|
||||
def start_stream(self, real_url, youtube_key, room_name=None):
|
||||
self.room_name = room_name or f"抖音_{str(uuid.uuid4())[:8]}"
|
||||
self.logger.info(f"{Fore.GREEN}[{self.room_name}] 启动推流到 YouTube{Style.RESET_ALL}")
|
||||
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"
|
||||
return [
|
||||
'ffmpeg', '-y',
|
||||
'-rw_timeout', '10000000',
|
||||
'-user_agent', user_agent,
|
||||
'-headers', headers,
|
||||
'-protocol_whitelist', 'file,pipe,http,https,tcp,tls,udp,rtp,crypto',
|
||||
'-fflags', '+discardcorrupt+genpts',
|
||||
'-flags', 'low_delay',
|
||||
'-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}'
|
||||
]
|
||||
|
||||
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("\\", "/")
|
||||
return [
|
||||
'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',
|
||||
self.out_file
|
||||
]
|
||||
|
||||
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,
|
||||
text=True, bufsize=1
|
||||
)
|
||||
self.logger.info(f"{Fore.CYAN}[{self.room_name}] 推流进程 PID: {self.push_proc.pid}{Style.RESET_ALL}")
|
||||
|
||||
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,
|
||||
text=True, bufsize=1
|
||||
)
|
||||
self.logger.info(f"{Fore.CYAN}[{self.room_name}] 录制到: {self.out_file}{Style.RESET_ALL}")
|
||||
|
||||
def monitor_proc(proc, name):
|
||||
while not self.stop_flag.is_set() and proc and proc.poll() is None:
|
||||
line = proc.stderr.readline()
|
||||
if line and name == "push" and not self.pushing and "frame=" in line:
|
||||
self.pushing = True
|
||||
self.logger.info(f"{Fore.GREEN}[{self.room_name}] 成功推流到 YouTube{Style.RESET_ALL}")
|
||||
|
||||
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():
|
||||
if self.push_proc.poll() is not None:
|
||||
self.logger.warning(f"{Fore.YELLOW}[{self.room_name}] 推流进程退出{Style.RESET_ALL}")
|
||||
self.stop_flag.set()
|
||||
break
|
||||
if self.enable_recording and self.record_proc and self.record_proc.poll() is not None:
|
||||
self.logger.warning(f"{Fore.YELLOW}[{self.room_name}] 录制进程退出,重启中...{Style.RESET_ALL}")
|
||||
self._terminate_procs()
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"{Fore.RED}[{self.room_name}] FFmpeg 错误: {e}{Style.RESET_ALL}")
|
||||
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 = f"{Fore.GREEN}推流中{Style.RESET_ALL}" if self.pushing else f"{Fore.YELLOW}启动中{Style.RESET_ALL}"
|
||||
sys.stdout.write(f"\r[{self.room_name}] {status} | 时间: {m:02d}:{s:02d} | 码率: {self.bitrate}k")
|
||||
sys.stdout.flush()
|
||||
time.sleep(1)
|
||||
|
||||
def stop(self):
|
||||
self.logger.info(f"{Fore.YELLOW}[{self.room_name}] 停止推流和录制{Style.RESET_ALL}")
|
||||
self.stop_flag.set()
|
||||
self._terminate_procs()
|
||||
|
||||
# ----------------- 其他函数保持不变 -----------------
|
||||
async def get_douyin_real_url(record_url: str, index: int, total: int, record_quality="原画", proxy_addr=None, cookies='') -> Tuple[Optional[str], Optional[str]]:
|
||||
def update_status(status: str, nickname: str = "未知主播"):
|
||||
display_url = record_url[:40] + "..." if len(record_url) > 40 else record_url
|
||||
display_nickname = nickname[:20] + "..." if len(nickname) > 20 else nickname
|
||||
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [{display_nickname}] [{status}]")
|
||||
sys.stdout.flush()
|
||||
|
||||
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"
|
||||
}
|
||||
session_args = {"headers": headers}
|
||||
if proxy_addr:
|
||||
session_args["proxy"] = proxy_addr
|
||||
|
||||
update_status(f"{Fore.YELLOW}检测中...{Style.RESET_ALL}")
|
||||
|
||||
async with aiohttp.ClientSession(**session_args) as session:
|
||||
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():
|
||||
update_status(f"{Fore.RED}失败: 验证码{Style.RESET_ALL}")
|
||||
return None, None
|
||||
|
||||
json_data = await (spider.get_douyin_stream_data if 'v.douyin.com' not in record_url and '/user/' not in record_url
|
||||
else spider.get_douyin_app_stream_data)(url=record_url, proxy_addr=proxy_addr, cookies=cookies)
|
||||
|
||||
if not json_data or not isinstance(json_data, dict):
|
||||
update_status(f"{Fore.RED}失败: 无流{Style.RESET_ALL}")
|
||||
return None, None
|
||||
|
||||
# 尝试多种字段获取主播名
|
||||
nickname = (
|
||||
json_data.get('room_title') or
|
||||
json_data.get('title') or
|
||||
json_data.get('user', {}).get('nickname') or
|
||||
json_data.get('owner', {}).get('nickname') or
|
||||
json_data.get('stream', {}).get('owner', {}).get('nickname') or
|
||||
"未知主播"
|
||||
)
|
||||
update_status(f"{Fore.YELLOW}解析中...{Style.RESET_ALL}", nickname)
|
||||
|
||||
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"):
|
||||
update_status(f"{Fore.GREEN}成功{Style.RESET_ALL}", nickname)
|
||||
return port_info.get("record_url"), nickname
|
||||
update_status(f"{Fore.RED}失败: 无效流{Style.RESET_ALL}", nickname)
|
||||
return None, None
|
||||
except Exception:
|
||||
update_status(f"{Fore.RED}失败: 错误{Style.RESET_ALL}")
|
||||
return None, None
|
||||
|
||||
def start_douyin_to_youtube(record_url: str, youtube_key: str, real_url: Optional[str] = None, nickname: Optional[str] = None,
|
||||
proxy_addr=None, cookies='', bitrate=5000, enable_recording=False):
|
||||
if real_url is None or nickname is None:
|
||||
real_url, nickname = asyncio.run(get_douyin_real_url(record_url, 0, 0, proxy_addr=proxy_addr, cookies=cookies))
|
||||
if not real_url:
|
||||
logger.error(f"{Fore.RED}无法获取 {record_url[:50]}... 的流{Style.RESET_ALL}")
|
||||
return None
|
||||
logger.info(f"{Fore.GREEN}推流自: {real_url[:50]}... [{nickname}]{Style.RESET_ALL}")
|
||||
streamer = DouyinToYouTubeStreamer(bitrate=bitrate, enable_recording=enable_recording)
|
||||
streamer.start_stream(real_url, youtube_key, room_name=nickname)
|
||||
return streamer
|
||||
|
||||
def load_config():
|
||||
config = configparser.ConfigParser()
|
||||
config.read("config.ini", encoding="utf-8")
|
||||
return config.get("youtube", "key", fallback=None)
|
||||
|
||||
def load_list(last_mtime=0):
|
||||
try:
|
||||
mtime = os.path.getmtime("list.ini")
|
||||
if mtime <= last_mtime:
|
||||
return None, last_mtime
|
||||
with open("list.ini", "r", encoding="utf-8") as f:
|
||||
urls = [line.strip() for line in f if line.strip() and not line.startswith("#")]
|
||||
logger.info(f"{Fore.CYAN}加载 {len(urls)} 个唯一 URL{Style.RESET_ALL}")
|
||||
return urls, mtime
|
||||
except FileNotFoundError:
|
||||
logger.error(f"{Fore.RED}未找到 list.ini 文件{Style.RESET_ALL}")
|
||||
return [], last_mtime
|
||||
|
||||
if __name__ == "__main__":
|
||||
youtube_key = load_config()
|
||||
if not youtube_key:
|
||||
logger.error(f"{Fore.RED}config.ini 中无 YouTube Key{Style.RESET_ALL}")
|
||||
sys.exit(1)
|
||||
|
||||
streamer_instance = None
|
||||
last_mtime = 0
|
||||
urls = []
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
if streamer_instance:
|
||||
streamer_instance.stop()
|
||||
sys.stdout.write("\r" + " " * 80 + "\r")
|
||||
sys.stdout.flush()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
logger.info(f"{Fore.GREEN}启动抖音直播检测{Style.RESET_ALL}")
|
||||
while True:
|
||||
urls_result, new_mtime = load_list(last_mtime)
|
||||
if urls_result is not None:
|
||||
urls = urls_result
|
||||
last_mtime = new_mtime
|
||||
|
||||
if not streamer_instance or (hasattr(streamer_instance, 'stop_flag') and streamer_instance.stop_flag.is_set()):
|
||||
if streamer_instance:
|
||||
logger.info(f"{Fore.YELLOW}直播中断,重新遍历 URL 列表{Style.RESET_ALL}")
|
||||
sys.stdout.write("\r" + " " * 80 + "\r")
|
||||
sys.stdout.flush()
|
||||
time.sleep(5) # 中断后短暂延迟
|
||||
streamer_instance = None
|
||||
if not urls:
|
||||
sys.stdout.write(f"\r{Fore.YELLOW}无URL列表,10秒后重试...{Style.RESET_ALL}")
|
||||
sys.stdout.flush()
|
||||
time.sleep(10)
|
||||
continue
|
||||
|
||||
for i, url in enumerate(urls, 1):
|
||||
real_url, nickname = asyncio.run(get_douyin_real_url(url, i, len(urls)))
|
||||
sys.stdout.write("\r" + " " * 80 + "\r")
|
||||
sys.stdout.flush()
|
||||
if real_url and nickname:
|
||||
logger.info(f"{Fore.GREEN}发现直播: {url[:50]}... [{nickname}]{Style.RESET_ALL}")
|
||||
streamer_instance = start_douyin_to_youtube(url, youtube_key, real_url=real_url, nickname=nickname)
|
||||
break
|
||||
|
||||
if not streamer_instance:
|
||||
sys.stdout.write(f"\r{Fore.YELLOW}未找到直播,60秒后重试...{Style.RESET_ALL}")
|
||||
sys.stdout.flush()
|
||||
time.sleep(60)
|
||||
else:
|
||||
time.sleep(10)
|
||||
510
backup/back15
Normal file
510
backup/back15
Normal file
@@ -0,0 +1,510 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Douyin → YouTube 极致稳定推流器 (async 版)
|
||||
- 特性:
|
||||
* 全异步 asyncio 架构,单线程高并发 I/O;
|
||||
* 彩色/文件双日志,结构化前缀、滚动保存;
|
||||
* FFMPEG 自恢复与指数退避 + 抖动;
|
||||
* HLS/HTTP 强化:-reconnect-* 自动断线重连,低延迟参数;
|
||||
* 可选本地录制(独立进程,意外退出自动重启);
|
||||
* 优雅退出:SIGINT/SIGTERM,确保子进程回收;
|
||||
* list.ini 热加载(mtime 变更即时生效);
|
||||
* 健康状态心跳(码率/时长/是否已推上帧);
|
||||
* 配置集中化:config.ini + 环境变量覆盖;
|
||||
|
||||
依赖:仅标准库 + colorama + aiohttp
|
||||
保留你现有 spider/stream 模块调用约定。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
import logging
|
||||
import configparser
|
||||
from typing import Optional, Tuple, List
|
||||
|
||||
import aiohttp
|
||||
from colorama import init as color_init, Fore, Style
|
||||
|
||||
# ====== 第三方模块日志降噪(尽量提前) ======
|
||||
for noisy in ("aiohttp", "aiohttp.client", "httpcore", "urllib3", "asyncio"):
|
||||
logging.getLogger(noisy).setLevel(logging.CRITICAL)
|
||||
|
||||
# 你本地模块(按需降噪)
|
||||
import spider # 保持与现有工程兼容
|
||||
import stream # 保持与现有工程兼容
|
||||
if hasattr(spider, 'logger'):
|
||||
spider.logger.setLevel(logging.CRITICAL)
|
||||
if hasattr(stream, 'logger'):
|
||||
stream.logger.setLevel(logging.CRITICAL)
|
||||
|
||||
# ================= 日志系统 =================
|
||||
color_init(autoreset=True)
|
||||
LOG_DIR = os.environ.get("LOG_DIR", "logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
LOG_FILE = os.path.join(LOG_DIR, "app.log")
|
||||
|
||||
class ColorFormatter(logging.Formatter):
|
||||
COLORS = {
|
||||
logging.DEBUG: Fore.BLUE,
|
||||
logging.INFO: Fore.CYAN,
|
||||
logging.WARNING: Fore.YELLOW,
|
||||
logging.ERROR: Fore.RED,
|
||||
logging.CRITICAL: Fore.RED + Style.BRIGHT,
|
||||
}
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
msg = super().format(record)
|
||||
return f"{self.COLORS.get(record.levelno, '')}{msg}{Style.RESET_ALL}"
|
||||
|
||||
def build_logger() -> logging.Logger:
|
||||
from logging.handlers import RotatingFileHandler
|
||||
logger = logging.getLogger("main")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# 控制台:INFO+
|
||||
ch = logging.StreamHandler(sys.stdout)
|
||||
ch.setLevel(logging.INFO)
|
||||
ch.setFormatter(ColorFormatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%H:%M:%S"))
|
||||
|
||||
# 文件:DEBUG+
|
||||
fh = RotatingFileHandler(LOG_FILE, maxBytes=5*1024*1024, backupCount=5, encoding="utf-8")
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
# 防重复添加
|
||||
if not logger.handlers:
|
||||
logger.addHandler(ch)
|
||||
logger.addHandler(fh)
|
||||
return logger
|
||||
|
||||
logger = build_logger()
|
||||
|
||||
# ================= 配置中心 =================
|
||||
@dataclasses.dataclass
|
||||
class Settings:
|
||||
youtube_key: Optional[str] = None
|
||||
bitrate_k: int = 5000
|
||||
audio_bitrate_k: int = 128
|
||||
resolution: str = "720x1280"
|
||||
fps: int = 30
|
||||
gop: int = 60
|
||||
enable_recording: bool = False
|
||||
list_path: str = "list.ini"
|
||||
cookies: str = "" # 原始字符串或 "k=v;..."
|
||||
proxy: Optional[str] = None # eg. "http://127.0.0.1:7890"
|
||||
record_dir: str = "records"
|
||||
|
||||
# 退避与探测间隔(秒)
|
||||
no_live_retry_base: float = 10.0
|
||||
no_live_retry_cap: float = 120.0
|
||||
|
||||
# FFMPEG 超时/网络参数
|
||||
rw_timeout_ms: int = 10_000_000
|
||||
reconnect: bool = True
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> "Settings":
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read("config.ini", encoding="utf-8")
|
||||
|
||||
def g(section: str, key: str, fallback=None):
|
||||
return cfg.get(section, key, fallback=fallback) if cfg.has_option(section, key) else fallback
|
||||
|
||||
s = cls(
|
||||
youtube_key=os.environ.get("YOUTUBE_KEY") or (g("youtube", "key") or None),
|
||||
bitrate_k=int(os.environ.get("VIDEO_BITRATE_K", g("youtube", "bitrate", fallback=5000))),
|
||||
audio_bitrate_k=int(os.environ.get("AUDIO_BITRATE_K", g("youtube", "audio_bitrate", fallback=128))),
|
||||
resolution=os.environ.get("VIDEO_RES", g("youtube", "resolution", fallback="720x1280")),
|
||||
fps=int(os.environ.get("FPS", g("youtube", "fps", fallback=30))),
|
||||
gop=int(os.environ.get("GOP", g("youtube", "gop", fallback=60))),
|
||||
enable_recording=(os.environ.get("ENABLE_RECORDING") or g("record", "enable", fallback="false")).lower() in {"1","true","yes","on"},
|
||||
list_path=os.environ.get("LIST_PATH", g("general", "list", fallback="list.ini")),
|
||||
cookies=os.environ.get("COOKIES", g("douyin", "cookies", fallback="")),
|
||||
proxy=os.environ.get("PROXY", g("network", "proxy", fallback=None)),
|
||||
record_dir=os.environ.get("RECORD_DIR", g("record", "dir", fallback="records")),
|
||||
no_live_retry_base=float(os.environ.get("NO_LIVE_BASE", g("backoff", "base", fallback=10.0))),
|
||||
no_live_retry_cap=float(os.environ.get("NO_LIVE_CAP", g("backoff", "cap", fallback=120.0))),
|
||||
rw_timeout_ms=int(os.environ.get("RW_TIMEOUT_MS", g("ffmpeg", "rw_timeout_ms", fallback=10_000_000))),
|
||||
reconnect=(os.environ.get("FF_RECONNECT", g("ffmpeg", "reconnect", fallback="true")).lower() in {"1","true","yes","on"}),
|
||||
)
|
||||
os.makedirs(s.record_dir, exist_ok=True)
|
||||
return s
|
||||
|
||||
SETTINGS = Settings.load()
|
||||
if not SETTINGS.youtube_key:
|
||||
logger.error(Fore.RED + "config.ini 中无 YouTube Key,或未设置环境变量 YOUTUBE_KEY" + Style.RESET_ALL)
|
||||
sys.exit(1)
|
||||
|
||||
# ============== 工具函数 ==============
|
||||
_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_RAW = "Referer: https://www.douyin.com/\r\nOrigin: https://www.douyin.com\r\n"
|
||||
|
||||
async def async_sleep(sec: float):
|
||||
try:
|
||||
await asyncio.sleep(sec)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# ============== Douyin 解析 ==============
|
||||
async def get_douyin_real_url(record_url: str, index: int, total: int,
|
||||
record_quality: str = "原画",
|
||||
proxy_addr: Optional[str] = None,
|
||||
cookies: str = "") -> Tuple[Optional[str], Optional[str]]:
|
||||
"""解析抖音真实流 URL 与昵称。"""
|
||||
def clip(s: str, n: int) -> str:
|
||||
return (s[:n] + "...") if len(s) > n else s
|
||||
|
||||
display_url = clip(record_url, 40)
|
||||
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [解析中...]")
|
||||
sys.stdout.flush()
|
||||
|
||||
headers = {
|
||||
"User-Agent": _USER_AGENT,
|
||||
"Referer": "https://www.douyin.com/",
|
||||
"Origin": "https://www.douyin.com",
|
||||
}
|
||||
|
||||
session_args = {"headers": headers}
|
||||
if proxy_addr:
|
||||
session_args["proxy"] = proxy_addr
|
||||
|
||||
async with aiohttp.ClientSession(**session_args) as session:
|
||||
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():
|
||||
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [{Fore.RED}失败: 验证码{Style.RESET_ALL}]")
|
||||
sys.stdout.flush()
|
||||
return None, None
|
||||
|
||||
# 选择解析函数(与原工程保持一致)
|
||||
fn = spider.get_douyin_stream_data if ('v.douyin.com' not in record_url and '/user/' not in record_url) else spider.get_douyin_app_stream_data
|
||||
json_data = await fn(url=record_url, proxy_addr=proxy_addr, cookies=cookies)
|
||||
if not json_data or not isinstance(json_data, dict):
|
||||
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [{Fore.RED}失败: 无流{Style.RESET_ALL}]")
|
||||
sys.stdout.flush()
|
||||
return None, None
|
||||
|
||||
nickname = (
|
||||
json_data.get('room_title') or
|
||||
json_data.get('title') or
|
||||
json_data.get('user', {}).get('nickname') or
|
||||
json_data.get('owner', {}).get('nickname') or
|
||||
json_data.get('stream', {}).get('owner', {}).get('nickname') or
|
||||
"未知主播"
|
||||
)
|
||||
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [{Fore.YELLOW}{nickname}{Style.RESET_ALL}] [拉流...]")
|
||||
sys.stdout.flush()
|
||||
|
||||
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"):
|
||||
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [{Fore.GREEN}{nickname}{Style.RESET_ALL}] [成功]\n")
|
||||
sys.stdout.flush()
|
||||
return port_info.get("record_url"), nickname
|
||||
|
||||
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [{Fore.RED}{nickname}{Style.RESET_ALL}] [无效流]\n")
|
||||
sys.stdout.flush()
|
||||
return None, None
|
||||
except Exception as e:
|
||||
logger.debug(f"get_douyin_real_url error: {e!r}")
|
||||
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [{Fore.RED}错误{Style.RESET_ALL}]\n")
|
||||
sys.stdout.flush()
|
||||
return None, None
|
||||
|
||||
# ============== 推流器 ==============
|
||||
class AsyncStreamer:
|
||||
def __init__(self,
|
||||
real_url: str,
|
||||
youtube_key: str,
|
||||
|
||||
room_name: Optional[str] = None,
|
||||
bitrate_k: int = SETTINGS.bitrate_k,
|
||||
audio_bitrate_k: int = SETTINGS.audio_bitrate_k,
|
||||
resolution: str = SETTINGS.resolution,
|
||||
fps: int = SETTINGS.fps,
|
||||
gop: int = SETTINGS.gop,
|
||||
enable_recording: bool = SETTINGS.enable_recording,
|
||||
) -> None:
|
||||
self.real_url = real_url
|
||||
self.youtube_key = youtube_key
|
||||
self.room = room_name or f"抖音_{str(uuid.uuid4())[:8]}"
|
||||
self.bitrate_k = bitrate_k
|
||||
self.audio_bitrate_k = audio_bitrate_k
|
||||
self.resolution = resolution
|
||||
self.fps = fps
|
||||
self.gop = gop
|
||||
self.enable_recording = enable_recording
|
||||
|
||||
self.push_proc: Optional[asyncio.subprocess.Process] = None
|
||||
self.rec_proc: Optional[asyncio.subprocess.Process] = None
|
||||
self.pushing: bool = False
|
||||
self.start_ts: float = 0.0
|
||||
self._monitor_task: Optional[asyncio.Task] = None
|
||||
self._stderr_tasks: List[asyncio.Task] = []
|
||||
self._stop = asyncio.Event()
|
||||
self.logger = logging.getLogger(f"Streamer[{self.room}]")
|
||||
|
||||
# ---------- 构建命令 ----------
|
||||
def _push_cmd(self) -> List[str]:
|
||||
flags = [
|
||||
"ffmpeg", "-y",
|
||||
"-rw_timeout", str(SETTINGS.rw_timeout_ms),
|
||||
"-user_agent", _USER_AGENT,
|
||||
"-headers", _HEADERS_RAW,
|
||||
"-protocol_whitelist", "file,pipe,http,https,tcp,tls,udp,rtp,crypto",
|
||||
"-fflags", "+discardcorrupt+genpts",
|
||||
"-flags", "low_delay",
|
||||
]
|
||||
if SETTINGS.reconnect:
|
||||
flags += [
|
||||
"-reconnect", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_on_network_error", "1",
|
||||
"-reconnect_delay_max", "10",
|
||||
]
|
||||
flags += [
|
||||
"-i", self.real_url,
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
|
||||
"-b:v", f"{self.bitrate_k}k",
|
||||
"-maxrate", f"{self.bitrate_k}k",
|
||||
"-bufsize", f"{self.bitrate_k*2}k",
|
||||
"-r", str(self.fps),
|
||||
"-g", str(self.gop),
|
||||
"-s", self.resolution,
|
||||
"-c:a", "aac", "-b:a", f"{self.audio_bitrate_k}k", "-ar", "44100", "-ac", "2",
|
||||
"-f", "flv", f"rtmp://a.rtmp.youtube.com/live2/{self.youtube_key}",
|
||||
]
|
||||
return flags
|
||||
|
||||
def _rec_cmd(self) -> Tuple[List[str], str]:
|
||||
base = f"{self.room}_{int(time.time())}.mp4"
|
||||
out = os.path.join(SETTINGS.record_dir, base).replace("\\", "/")
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-rw_timeout", str(SETTINGS.rw_timeout_ms),
|
||||
"-user_agent", _USER_AGENT,
|
||||
"-headers", _HEADERS_RAW,
|
||||
"-protocol_whitelist", "file,pipe,http,https,tcp,tls,udp,rtp,crypto",
|
||||
"-fflags", "+discardcorrupt+genpts",
|
||||
"-flags", "low_delay",
|
||||
"-i", self.real_url,
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
|
||||
"-c:a", "aac", "-b:a", f"{self.audio_bitrate_k}k", "-ar", "44100", "-ac", "2",
|
||||
"-movflags", "+faststart",
|
||||
out,
|
||||
]
|
||||
return cmd, out
|
||||
|
||||
# ---------- 进程与监控 ----------
|
||||
async def _spawn(self, cmd: List[str]) -> asyncio.subprocess.Process:
|
||||
# Windows 上隐藏新控制台窗口
|
||||
creationflags = 0
|
||||
if os.name == 'nt':
|
||||
creationflags = 0x08000000 # CREATE_NO_WINDOW
|
||||
return await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
|
||||
async def _read_stderr(self, proc: asyncio.subprocess.Process, kind: str):
|
||||
assert proc.stderr is not None
|
||||
while not self._stop.is_set():
|
||||
line = await proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
txt = line.decode(errors='ignore').strip()
|
||||
if kind == "push" and (not self.pushing) and "frame=" in txt:
|
||||
self.pushing = True
|
||||
self.logger.info(Fore.GREEN + "成功推流到 YouTube" + Style.RESET_ALL)
|
||||
self.logger.debug(f"{kind}> {txt}")
|
||||
|
||||
async def _monitor(self):
|
||||
while not self._stop.is_set():
|
||||
uptime = int(time.time() - self.start_ts) if self.start_ts else 0
|
||||
m, s = divmod(uptime, 60)
|
||||
status = f"{Fore.GREEN}推流中{Style.RESET_ALL}" if self.pushing else f"{Fore.YELLOW}启动中{Style.RESET_ALL}"
|
||||
sys.stdout.write(f"\r[{self.room}] {status} | 时间: {m:02d}:{s:02d} | 码率: {self.bitrate_k}k")
|
||||
sys.stdout.flush()
|
||||
await async_sleep(1)
|
||||
|
||||
async def run(self):
|
||||
self.logger.info(Fore.CYAN + f"启动推流到 YouTube" + Style.RESET_ALL)
|
||||
self.start_ts = time.time()
|
||||
self.pushing = False
|
||||
|
||||
# 启动推流
|
||||
self.push_proc = await self._spawn(self._push_cmd())
|
||||
self.logger.info(Fore.CYAN + f"推流进程 PID: {self.push_proc.pid}" + Style.RESET_ALL)
|
||||
self._stderr_tasks.append(asyncio.create_task(self._read_stderr(self.push_proc, "push")))
|
||||
|
||||
# 启动录制(可选)
|
||||
if self.enable_recording:
|
||||
rec_cmd, out = self._rec_cmd()
|
||||
self.rec_proc = await self._spawn(rec_cmd)
|
||||
self.logger.info(Fore.CYAN + f"录制到: {out}" + Style.RESET_ALL)
|
||||
self._stderr_tasks.append(asyncio.create_task(self._read_stderr(self.rec_proc, "record")))
|
||||
|
||||
# 监控任务
|
||||
self._monitor_task = asyncio.create_task(self._monitor())
|
||||
|
||||
# 进程生命周期
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
# 推流意外退出 → 结束
|
||||
if self.push_proc and (self.push_proc.returncode is not None):
|
||||
self.logger.warning(Fore.YELLOW + f"推流进程退出: {self.push_proc.returncode}" + Style.RESET_ALL)
|
||||
await self.stop()
|
||||
break
|
||||
# 录制退出 → 重启录制
|
||||
if self.enable_recording and self.rec_proc and (self.rec_proc.returncode is not None):
|
||||
self.logger.warning(Fore.YELLOW + "录制进程退出,重启中..." + Style.RESET_ALL)
|
||||
rec_cmd, _ = self._rec_cmd()
|
||||
self.rec_proc = await self._spawn(rec_cmd)
|
||||
self._stderr_tasks.append(asyncio.create_task(self._read_stderr(self.rec_proc, "record")))
|
||||
await async_sleep(1)
|
||||
finally:
|
||||
await self.stop()
|
||||
|
||||
async def stop(self):
|
||||
if self._stop.is_set():
|
||||
return
|
||||
self._stop.set()
|
||||
for t in self._stderr_tasks:
|
||||
t.cancel()
|
||||
if self._monitor_task:
|
||||
self._monitor_task.cancel()
|
||||
|
||||
async def _terminate(proc: Optional[asyncio.subprocess.Process]):
|
||||
if not proc:
|
||||
return
|
||||
if proc.returncode is None:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=2)
|
||||
except asyncio.TimeoutError:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(proc.wait(), timeout=2)
|
||||
await _terminate(self.push_proc)
|
||||
await _terminate(self.rec_proc)
|
||||
self.push_proc = None
|
||||
self.rec_proc = None
|
||||
# 清理一行状态输出
|
||||
sys.stdout.write("\r" + " " * 100 + "\r")
|
||||
sys.stdout.flush()
|
||||
self.logger.info(Fore.YELLOW + "已停止推流/录制" + Style.RESET_ALL)
|
||||
|
||||
# ============== 主循环 ==============
|
||||
async def load_list_if_changed(path: str, last_mtime: float) -> Tuple[Optional[List[str]], float]:
|
||||
try:
|
||||
m = os.path.getmtime(path)
|
||||
if m <= last_mtime:
|
||||
return None, last_mtime
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
urls = [ln.strip() for ln in f if ln.strip() and not ln.strip().startswith('#')]
|
||||
logger.info(Fore.CYAN + f"加载 {len(urls)} 个唯一 URL" + Style.RESET_ALL)
|
||||
return urls, m
|
||||
except FileNotFoundError:
|
||||
logger.error(Fore.RED + f"未找到 {path} 文件" + Style.RESET_ALL)
|
||||
return [], last_mtime
|
||||
|
||||
async def iter_until_live(urls: List[str]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
for i, url in enumerate(urls, 1):
|
||||
real_url, nickname = await get_douyin_real_url(url, i, len(urls), proxy_addr=SETTINGS.proxy, cookies=SETTINGS.cookies)
|
||||
# 清一行
|
||||
sys.stdout.write("\r" + " " * 100 + "\r")
|
||||
sys.stdout.flush()
|
||||
if real_url and nickname:
|
||||
logger.info(Fore.GREEN + f"发现直播: {url[:50]}... [{nickname}]" + Style.RESET_ALL)
|
||||
return url, real_url, nickname
|
||||
return None, None, None
|
||||
|
||||
async def main():
|
||||
# 信号处理
|
||||
stop_evt = asyncio.Event()
|
||||
def _sig_handler(*_):
|
||||
stop_evt.set()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
with contextlib.suppress(NotImplementedError):
|
||||
asyncio.get_running_loop().add_signal_handler(sig, _sig_handler)
|
||||
|
||||
logger.info(Fore.GREEN + "启动抖音直播检测" + Style.RESET_ALL)
|
||||
|
||||
last_mtime = 0.0
|
||||
urls: List[str] = []
|
||||
streamer: Optional[AsyncStreamer] = None
|
||||
|
||||
backoff = SETTINGS.no_live_retry_base
|
||||
|
||||
while not stop_evt.is_set():
|
||||
urls_new, new_mtime = await load_list_if_changed(SETTINGS.list_path, last_mtime)
|
||||
if urls_new is not None:
|
||||
urls = urls_new
|
||||
last_mtime = new_mtime
|
||||
|
||||
# 若没有在推流或已停止 → 尝试寻找新的直播
|
||||
if (streamer is None) or streamer._stop.is_set():
|
||||
if streamer and streamer._stop.is_set():
|
||||
logger.info(Fore.YELLOW + "直播中断,准备重新遍历 URL 列表" + Style.RESET_ALL)
|
||||
await async_sleep(5)
|
||||
streamer = None
|
||||
|
||||
if not urls:
|
||||
sys.stdout.write(f"\r{Fore.YELLOW}无URL列表,{int(backoff)}秒后重试...{Style.RESET_ALL}")
|
||||
sys.stdout.flush()
|
||||
await async_sleep(backoff)
|
||||
backoff = min(backoff * 1.7 + (0.5 if backoff < 30 else 1.0), SETTINGS.no_live_retry_cap)
|
||||
continue
|
||||
|
||||
raw_url, real_url, nickname = await iter_until_live(urls)
|
||||
if not real_url:
|
||||
sys.stdout.write(f"\r{Fore.YELLOW}未找到直播,{int(backoff)}秒后重试...{Style.RESET_ALL}")
|
||||
sys.stdout.flush()
|
||||
await async_sleep(backoff)
|
||||
backoff = min(backoff * 1.7 + (0.5 if backoff < 30 else 1.0), SETTINGS.no_live_retry_cap)
|
||||
continue
|
||||
|
||||
backoff = SETTINGS.no_live_retry_base # 命中直播 → 重置退避
|
||||
logger.info(Fore.GREEN + f"推流自: {real_url[:50]}... [{nickname}]" + Style.RESET_ALL)
|
||||
streamer = AsyncStreamer(
|
||||
real_url=real_url,
|
||||
youtube_key=SETTINGS.youtube_key,
|
||||
room_name=nickname,
|
||||
bitrate_k=SETTINGS.bitrate_k,
|
||||
audio_bitrate_k=SETTINGS.audio_bitrate_k,
|
||||
resolution=SETTINGS.resolution,
|
||||
fps=SETTINGS.fps,
|
||||
gop=SETTINGS.gop,
|
||||
enable_recording=SETTINGS.enable_recording,
|
||||
)
|
||||
asyncio.create_task(streamer.run())
|
||||
else:
|
||||
# 正在推流 → 间隔检查
|
||||
await async_sleep(10)
|
||||
|
||||
# 退出
|
||||
if streamer:
|
||||
await streamer.stop()
|
||||
logger.info(Fore.YELLOW + "进程已退出" + Style.RESET_ALL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
202
backup/back3
Normal file
202
backup/back3
Normal file
@@ -0,0 +1,202 @@
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
import uuid
|
||||
import queue
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
import shutil
|
||||
import spider
|
||||
import stream
|
||||
|
||||
class AdaptiveHLS2YouTube:
|
||||
def __init__(self, max_bitrate=5000, min_bitrate=3000, audio_bitrate=192,
|
||||
delay_sec=40, segment_time=2, check_interval=5):
|
||||
self.processes = {}
|
||||
self.stop_flag = threading.Event()
|
||||
self.delay_sec = delay_sec
|
||||
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.current_bitrate = max_bitrate
|
||||
|
||||
def start_stream(self, real_url, youtube_key, room_name=None):
|
||||
room_name = room_name or f"抖音_{str(uuid.uuid4())[:8]}"
|
||||
print(f"[{room_name}] 启动推流,目标 YouTube Key: {youtube_key}")
|
||||
threading.Thread(target=self._pull_thread, args=(real_url,), daemon=True).start()
|
||||
threading.Thread(target=self._push_thread, args=(youtube_key, room_name), daemon=True).start()
|
||||
threading.Thread(target=self._abr_thread, daemon=True).start()
|
||||
|
||||
def _pull_thread(self, real_url):
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-rw_timeout", "50000000",
|
||||
"-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 = [
|
||||
"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:
|
||||
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
self.processes[room_name] = proc
|
||||
|
||||
def read_stderr():
|
||||
while proc.poll() is None:
|
||||
line = proc.stderr.readline()
|
||||
if line:
|
||||
print(f"[{room_name} FFmpeg]: {line.decode(errors='ignore').strip()}")
|
||||
threading.Thread(target=read_stderr, daemon=True).start()
|
||||
|
||||
while not self.stop_flag.is_set():
|
||||
try:
|
||||
data = self.buffer_queue.get(timeout=1)
|
||||
proc.stdin.write(data)
|
||||
proc.stdin.flush()
|
||||
except queue.Empty:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[{room_name}] 推流异常: {e}")
|
||||
|
||||
def _abr_thread(self):
|
||||
while not self.stop_flag.is_set():
|
||||
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):
|
||||
print("停止所有推流...")
|
||||
self.stop_flag.set()
|
||||
for proc in self.processes.values():
|
||||
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:
|
||||
print(f"获取真实直播流失败: {e}")
|
||||
return
|
||||
if not real_url:
|
||||
print("获取真实直播流失败")
|
||||
return
|
||||
print(f"实际直播流: {real_url}")
|
||||
hls2yt = AdaptiveHLS2YouTube(delay_sec=delay_sec)
|
||||
hls2yt.start_stream(real_url, youtube_key, room_name)
|
||||
return hls2yt
|
||||
|
||||
|
||||
# ----------------- 控制台监控面板 -----------------
|
||||
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__":
|
||||
DOUYIN_URL = "https://live.douyin.com/364595817295"
|
||||
YOUTUBE_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
|
||||
hls_instance = start_douyin_to_youtube(DOUYIN_URL, YOUTUBE_KEY, delay_sec=40)
|
||||
if not hls_instance:
|
||||
print("推流实例创建失败,程序退出")
|
||||
sys.exit(1)
|
||||
|
||||
threading.Thread(target=monitor_panel_thread, args=(hls_instance,), daemon=True).start()
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
if hls_instance:
|
||||
hls_instance.stop_all()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
print("程序已启动,按 Ctrl+C 停止...")
|
||||
while True:
|
||||
time.sleep(60)
|
||||
208
backup/back4
Normal file
208
backup/back4
Normal file
@@ -0,0 +1,208 @@
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
import uuid
|
||||
import queue
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
import shutil
|
||||
import spider
|
||||
import stream
|
||||
|
||||
class AdaptiveHLS2YouTube:
|
||||
def __init__(self, max_bitrate=5000, min_bitrate=3000, audio_bitrate=192,
|
||||
delay_sec=40, segment_time=2, check_interval=5):
|
||||
self.processes = {}
|
||||
self.stop_flag = threading.Event()
|
||||
self.delay_sec = delay_sec
|
||||
self.segment_time = segment_time
|
||||
self.check_interval = check_interval
|
||||
self.buffer_queue = queue.Queue(maxsize=500) # 增大队列,防止阻塞
|
||||
self.max_bitrate = max_bitrate
|
||||
self.min_bitrate = min_bitrate
|
||||
self.audio_bitrate = audio_bitrate
|
||||
self.current_bitrate = max_bitrate
|
||||
|
||||
def start_stream(self, real_url, youtube_key, room_name=None):
|
||||
room_name = room_name or f"抖音_{str(uuid.uuid4())[:8]}"
|
||||
print(f"[{room_name}] 启动推流,目标 YouTube Key: {youtube_key}")
|
||||
threading.Thread(target=self._pull_thread, args=(real_url,), daemon=True).start()
|
||||
threading.Thread(target=self._push_thread, args=(youtube_key, room_name), daemon=True).start()
|
||||
threading.Thread(target=self._abr_thread, daemon=True).start()
|
||||
|
||||
def _pull_thread(self, real_url):
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-rw_timeout", "50000000",
|
||||
"-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*2000) # 大块读取,减少空跑
|
||||
if not data:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
try:
|
||||
self.buffer_queue.put_nowait(data) # 非阻塞写入队列
|
||||
except queue.Full:
|
||||
continue # 队列满直接丢掉,保证不卡
|
||||
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 = [
|
||||
"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:
|
||||
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
self.processes[room_name] = proc
|
||||
|
||||
def read_stderr():
|
||||
while proc.poll() is None:
|
||||
line = proc.stderr.readline()
|
||||
if line:
|
||||
print(f"[{room_name} FFmpeg]: {line.decode(errors='ignore').strip()}")
|
||||
threading.Thread(target=read_stderr, daemon=True).start()
|
||||
|
||||
while not self.stop_flag.is_set():
|
||||
try:
|
||||
data = self.buffer_queue.get(timeout=0.5)
|
||||
try:
|
||||
proc.stdin.write(data)
|
||||
proc.stdin.flush()
|
||||
except (BrokenPipeError, OSError):
|
||||
break # 管道断开,退出推流循环
|
||||
except queue.Empty:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[{room_name}] 推流异常: {e}")
|
||||
|
||||
def _abr_thread(self):
|
||||
while not self.stop_flag.is_set():
|
||||
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(max(self.check_interval, 10)) # 降低 ABR 更新频率,避免频繁干扰
|
||||
|
||||
def stop_all(self):
|
||||
print("停止所有推流...")
|
||||
self.stop_flag.set()
|
||||
for proc in self.processes.values():
|
||||
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:
|
||||
print(f"获取真实直播流失败: {e}")
|
||||
return
|
||||
if not real_url:
|
||||
print("获取真实直播流失败")
|
||||
return
|
||||
print(f"实际直播流: {real_url}")
|
||||
hls2yt = AdaptiveHLS2YouTube(delay_sec=delay_sec)
|
||||
hls2yt.start_stream(real_url, youtube_key, room_name)
|
||||
return hls2yt
|
||||
|
||||
|
||||
# ----------------- 控制台监控面板 -----------------
|
||||
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__":
|
||||
DOUYIN_URL = "https://live.douyin.com/125742860435"
|
||||
YOUTUBE_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
|
||||
hls_instance = start_douyin_to_youtube(DOUYIN_URL, YOUTUBE_KEY, delay_sec=40)
|
||||
if not hls_instance:
|
||||
print("推流实例创建失败,程序退出")
|
||||
sys.exit(1)
|
||||
|
||||
threading.Thread(target=monitor_panel_thread, args=(hls_instance,), daemon=True).start()
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
if hls_instance:
|
||||
hls_instance.stop_all()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
print("程序已启动,按 Ctrl+C 停止...")
|
||||
while True:
|
||||
time.sleep(60)
|
||||
220
backup/back5
Normal file
220
backup/back5
Normal file
@@ -0,0 +1,220 @@
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
import uuid
|
||||
import queue
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
import shutil
|
||||
import spider
|
||||
import stream
|
||||
|
||||
|
||||
class AdaptiveHLS2YouTube:
|
||||
def __init__(self, max_bitrate=5000, min_bitrate=3000, audio_bitrate=192,
|
||||
delay_sec=40, segment_time=2, check_interval=5):
|
||||
self.processes = {}
|
||||
self.stop_flag = threading.Event()
|
||||
self.delay_sec = delay_sec
|
||||
self.segment_time = segment_time
|
||||
self.check_interval = check_interval
|
||||
self.buffer_queue = queue.Queue(maxsize=800) # 队列加大
|
||||
self.max_bitrate = max_bitrate
|
||||
self.min_bitrate = min_bitrate
|
||||
self.audio_bitrate = audio_bitrate
|
||||
self.current_bitrate = max_bitrate
|
||||
|
||||
def start_stream(self, real_url, youtube_key, room_name=None):
|
||||
room_name = room_name or f"抖音_{str(uuid.uuid4())[:8]}"
|
||||
print(f"[{room_name}] 启动推流,目标 YouTube Key: {youtube_key}")
|
||||
threading.Thread(target=self._pull_thread, args=(real_url,), daemon=True).start()
|
||||
threading.Thread(target=self._push_thread, args=(youtube_key, room_name), daemon=True).start()
|
||||
threading.Thread(target=self._abr_thread, daemon=True).start()
|
||||
|
||||
def _pull_thread(self, real_url):
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-rw_timeout", "50000000",
|
||||
"-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.PIPE)
|
||||
print("[拉流] FFmpeg 启动成功")
|
||||
|
||||
def read_stderr():
|
||||
while proc.poll() is None:
|
||||
line = proc.stderr.readline()
|
||||
if line:
|
||||
print(f"[拉流FFmpeg]: {line.decode(errors='ignore').strip()}")
|
||||
threading.Thread(target=read_stderr, daemon=True).start()
|
||||
|
||||
while not self.stop_flag.is_set():
|
||||
data = proc.stdout.read(188 * 5000) # 提升吞吐
|
||||
if not data:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
try:
|
||||
self.buffer_queue.put_nowait(data)
|
||||
except queue.Full:
|
||||
continue
|
||||
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 = [
|
||||
"ffmpeg",
|
||||
"-re",
|
||||
"-f", "mpegts",
|
||||
"-i", "-",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
"-tune", "zerolatency",
|
||||
"-x264-params", "keyint=60:scenecut=0",
|
||||
"-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",
|
||||
"-strict", "-2",
|
||||
"-f", "flv",
|
||||
"-rtmp_live", "live",
|
||||
f"rtmp://a.rtmp.youtube.com/live2/{youtube_key}"
|
||||
]
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
self.processes[room_name] = proc
|
||||
|
||||
def read_stderr():
|
||||
while proc.poll() is None:
|
||||
line = proc.stderr.readline()
|
||||
if line:
|
||||
print(f"[{room_name} FFmpeg]: {line.decode(errors='ignore').strip()}")
|
||||
threading.Thread(target=read_stderr, daemon=True).start()
|
||||
|
||||
while not self.stop_flag.is_set():
|
||||
try:
|
||||
data = self.buffer_queue.get(timeout=0.5)
|
||||
try:
|
||||
proc.stdin.write(data)
|
||||
proc.stdin.flush()
|
||||
except (BrokenPipeError, OSError):
|
||||
print(f"[{room_name}] 推流管道断开,准备重启")
|
||||
break
|
||||
except queue.Empty:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[{room_name}] 推流异常: {e}")
|
||||
|
||||
def _abr_thread(self):
|
||||
while not self.stop_flag.is_set():
|
||||
queue_len = self.buffer_queue.qsize()
|
||||
if queue_len > 200:
|
||||
new_bitrate = max(self.min_bitrate, self.current_bitrate - 500)
|
||||
elif queue_len < 20:
|
||||
new_bitrate = min(self.max_bitrate, self.current_bitrate + 500)
|
||||
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(max(self.check_interval, 10))
|
||||
|
||||
def stop_all(self):
|
||||
print("停止所有推流...")
|
||||
self.stop_flag.set()
|
||||
for proc in self.processes.values():
|
||||
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:
|
||||
print(f"获取真实直播流失败: {e}")
|
||||
return
|
||||
if not real_url:
|
||||
print("获取真实直播流失败")
|
||||
return
|
||||
print(f"实际直播流: {real_url}")
|
||||
hls2yt = AdaptiveHLS2YouTube(delay_sec=delay_sec)
|
||||
hls2yt.start_stream(real_url, youtube_key, room_name)
|
||||
return hls2yt
|
||||
|
||||
|
||||
# ----------------- 控制台监控面板 -----------------
|
||||
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 = sum(1 for proc in hls_instance.processes.values() if proc and proc.poll() is None)
|
||||
|
||||
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__":
|
||||
DOUYIN_URL = "https://live.douyin.com/592179817312"
|
||||
YOUTUBE_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
|
||||
hls_instance = start_douyin_to_youtube(DOUYIN_URL, YOUTUBE_KEY, delay_sec=40)
|
||||
if not hls_instance:
|
||||
print("推流实例创建失败,程序退出")
|
||||
sys.exit(1)
|
||||
|
||||
threading.Thread(target=monitor_panel_thread, args=(hls_instance,), daemon=True).start()
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
if hls_instance:
|
||||
hls_instance.stop_all()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
print("程序已启动,按 Ctrl+C 停止...")
|
||||
while True:
|
||||
time.sleep(60)
|
||||
209
backup/back6
Normal file
209
backup/back6
Normal file
@@ -0,0 +1,209 @@
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
import uuid
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
import shutil
|
||||
import logging
|
||||
import spider
|
||||
import stream
|
||||
|
||||
# ----------------- Logging Setup -----------------
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
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.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.semaphore = threading.Semaphore(1)
|
||||
self.pushing = False # 是否正在推流
|
||||
|
||||
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_ffmpeg_loop, args=(real_url, youtube_key), daemon=True).start()
|
||||
threading.Thread(target=self._monitor_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"
|
||||
|
||||
cmd = [
|
||||
'ffmpeg', '-y', '-v', 'error', '-hide_banner',
|
||||
'-rw_timeout', '10000000',
|
||||
'-analyzeduration', '1000000',
|
||||
'-probesize', '5000000',
|
||||
'-thread_queue_size', '512',
|
||||
'-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',
|
||||
'-avioflags', 'direct',
|
||||
'-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}'
|
||||
]
|
||||
return cmd
|
||||
|
||||
def _run_ffmpeg_loop(self, real_url, youtube_key):
|
||||
while not self.stop_flag.is_set():
|
||||
try:
|
||||
with self.semaphore:
|
||||
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():
|
||||
try:
|
||||
for line in iter(self.proc.stderr.readline, b''):
|
||||
if self.stop_flag.is_set():
|
||||
break
|
||||
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}")
|
||||
|
||||
stderr_thread = threading.Thread(target=read_stderr, daemon=True)
|
||||
stderr_thread.start()
|
||||
|
||||
self.proc.wait()
|
||||
if not self.stop_flag.is_set():
|
||||
logging.warning(f"[{self.room_name}] FFmpeg 退出 (返回码: {self.proc.returncode}),1秒后重启...")
|
||||
self.pushing = False
|
||||
except Exception as 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=1)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.kill()
|
||||
self.proc = None
|
||||
if not self.stop_flag.is_set():
|
||||
time.sleep(1)
|
||||
|
||||
def _monitor_thread(self):
|
||||
term_width, _ = shutil.get_terminal_size()
|
||||
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)
|
||||
|
||||
def stop(self):
|
||||
logging.info(f"[{self.room_name}] 停止推流...")
|
||||
self.stop_flag.set()
|
||||
if self.proc and self.proc.poll() is None:
|
||||
self.proc.terminate()
|
||||
try:
|
||||
self.proc.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.kill()
|
||||
|
||||
|
||||
# ----------------- 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__":
|
||||
DOUYIN_URL = "https://live.douyin.com/112633436109"
|
||||
YOUTUBE_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
|
||||
hls_instance = start_douyin_to_youtube(DOUYIN_URL, YOUTUBE_KEY)
|
||||
if not hls_instance:
|
||||
logging.error("推流实例创建失败,程序退出")
|
||||
sys.exit(1)
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
if hls_instance:
|
||||
hls_instance.stop()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
logging.info("程序已启动,低延迟推流中,按 Ctrl+C 停止...")
|
||||
while True:
|
||||
time.sleep(60)
|
||||
257
backup/back7
Normal file
257
backup/back7
Normal file
@@ -0,0 +1,257 @@
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
import uuid
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
import logging
|
||||
import platform
|
||||
import spider
|
||||
import stream
|
||||
|
||||
# ----------------- Logging Setup -----------------
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S"
|
||||
)
|
||||
|
||||
IS_WINDOWS = platform.system() == "Windows"
|
||||
|
||||
# ----------------- Douyin to YouTube Streamer -----------------
|
||||
class DouyinToYouTubeStreamer:
|
||||
def __init__(self, bitrate=5000, audio_bitrate=128, resolution='720x1280',
|
||||
fps=30, gop=60, max_minutes=8):
|
||||
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
|
||||
|
||||
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),
|
||||
'-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.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
# ---- 启动本地录制 ----
|
||||
record_cmd = self._build_record_cmd(real_url)
|
||||
self.record_proc = subprocess.Popen(
|
||||
record_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
logging.info(f"[{self.room_name}] 本地录制开始: {self.out_file}")
|
||||
logging.info(f"[{self.room_name}] 推流进程 PID: {self.push_proc.pid}")
|
||||
|
||||
# ---- 监控 stderr ----
|
||||
def monitor_proc(proc, name):
|
||||
try:
|
||||
for line in iter(proc.stderr.readline, b''):
|
||||
if self.stop_flag.is_set():
|
||||
break
|
||||
if line:
|
||||
decoded = line.decode('utf-8', errors='ignore').strip()
|
||||
if name == "push" and not self.pushing and "frame=" in decoded and "fps=" in decoded:
|
||||
self.pushing = True
|
||||
logging.info(f"[{self.room_name}] 推流到 YouTube 成功")
|
||||
logging.debug(f"[{self.room_name} {name}]: {decoded}")
|
||||
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()
|
||||
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 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 or self.record_proc.poll() is not None:
|
||||
logging.warning(f"[{self.room_name}] FFmpeg 进程退出,1秒后重启...")
|
||||
break
|
||||
time.sleep(2)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[{self.room_name}] FFmpeg异常: {e}")
|
||||
finally:
|
||||
self._terminate_procs()
|
||||
self._finalize_recording()
|
||||
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(2)
|
||||
|
||||
# ----------------- 停止 -----------------
|
||||
def stop(self):
|
||||
logging.info(f"[{self.room_name}] 停止推流和录制...")
|
||||
self.stop_flag.set()
|
||||
self._terminate_procs()
|
||||
self._finalize_recording()
|
||||
|
||||
def _finalize_recording(self):
|
||||
if self.out_file and os.path.exists(self.out_file):
|
||||
try:
|
||||
# 根据系统发送信号完成封装
|
||||
if self.record_proc and self.record_proc.poll() is None:
|
||||
sig = signal.CTRL_BREAK_EVENT if IS_WINDOWS else signal.SIGINT
|
||||
self.record_proc.send_signal(sig)
|
||||
try:
|
||||
self.record_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.record_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 重命名文件带时长
|
||||
elapsed = int(time.time() - self.start_time) if self.start_time else 0
|
||||
suffix = f"_{elapsed // 60}min" if elapsed >= 60 else f"_{elapsed}s"
|
||||
new_name = self.out_file.replace(".mp4", f"{suffix}.mp4")
|
||||
try:
|
||||
os.rename(self.out_file, new_name)
|
||||
except FileExistsError:
|
||||
new_name = self.out_file.replace(".mp4", f"_{uuid.uuid4().hex[:4]}.mp4")
|
||||
os.rename(self.out_file, new_name)
|
||||
size_mb = os.path.getsize(new_name) / (1024 * 1024)
|
||||
logging.info(f"[{self.room_name}] 本地录制已结束: {new_name} | 大小: {size_mb:.2f} MB")
|
||||
|
||||
# ----------------- 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__":
|
||||
DOUYIN_URL = "https://live.douyin.com/321245312734"
|
||||
YOUTUBE_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
|
||||
streamer_instance = start_douyin_to_youtube(DOUYIN_URL, YOUTUBE_KEY)
|
||||
if not streamer_instance:
|
||||
logging.error("推流实例创建失败,程序退出")
|
||||
sys.exit(1)
|
||||
|
||||
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("程序已启动,低延迟推流中,按 Ctrl+C 停止...")
|
||||
|
||||
while True:
|
||||
time.sleep(60)
|
||||
287
backup/back8
Normal file
287
backup/back8
Normal file
@@ -0,0 +1,287 @@
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import asyncio
|
||||
import uuid
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
import logging
|
||||
import platform
|
||||
import spider
|
||||
import stream
|
||||
import aiohttp
|
||||
|
||||
# ----------------- Logging Setup -----------------
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S"
|
||||
)
|
||||
|
||||
IS_WINDOWS = platform.system() == "Windows"
|
||||
|
||||
# ----------------- Douyin to YouTube Streamer -----------------
|
||||
class DouyinToYouTubeStreamer:
|
||||
def __init__(self, bitrate=5000, audio_bitrate=128, resolution='720x1280',
|
||||
fps=30, gop=60, max_minutes=8):
|
||||
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
|
||||
|
||||
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),
|
||||
'-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.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
# ---- 启动本地录制 ----
|
||||
record_cmd = self._build_record_cmd(real_url)
|
||||
self.record_proc = subprocess.Popen(
|
||||
record_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
logging.info(f"[{self.room_name}] 本地录制开始: {self.out_file}")
|
||||
logging.info(f"[{self.room_name}] 推流进程 PID: {self.push_proc.pid}")
|
||||
|
||||
# ---- 监控 stderr ----
|
||||
def monitor_proc(proc, name):
|
||||
try:
|
||||
for line in iter(proc.stderr.readline, b''):
|
||||
if self.stop_flag.is_set():
|
||||
break
|
||||
if line:
|
||||
decoded = line.decode('utf-8', errors='ignore').strip()
|
||||
if name == "push" and not self.pushing and "frame=" in decoded and "fps=" in decoded:
|
||||
self.pushing = True
|
||||
logging.info(f"[{self.room_name}] 推流到 YouTube 成功")
|
||||
logging.debug(f"[{self.room_name} {name}]: {decoded}")
|
||||
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()
|
||||
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 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 or self.record_proc.poll() is not None:
|
||||
logging.warning(f"[{self.room_name}] FFmpeg 进程退出,1秒后重启...")
|
||||
break
|
||||
time.sleep(2)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[{self.room_name}] FFmpeg异常: {e}")
|
||||
finally:
|
||||
self._terminate_procs()
|
||||
self._finalize_recording()
|
||||
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(2)
|
||||
|
||||
# ----------------- 停止 -----------------
|
||||
def stop(self):
|
||||
logging.info(f"[{self.room_name}] 停止推流和录制...")
|
||||
self.stop_flag.set()
|
||||
self._terminate_procs()
|
||||
self._finalize_recording()
|
||||
|
||||
def _finalize_recording(self):
|
||||
if self.out_file and os.path.exists(self.out_file):
|
||||
try:
|
||||
if self.record_proc and self.record_proc.poll() is None:
|
||||
sig = signal.CTRL_BREAK_EVENT if IS_WINDOWS else signal.SIGINT
|
||||
self.record_proc.send_signal(sig)
|
||||
try:
|
||||
self.record_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.record_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elapsed = int(time.time() - self.start_time) if self.start_time else 0
|
||||
suffix = f"_{elapsed // 60}min" if elapsed >= 60 else f"_{elapsed}s"
|
||||
new_name = self.out_file.replace(".mp4", f"{suffix}.mp4")
|
||||
try:
|
||||
os.rename(self.out_file, new_name)
|
||||
except FileExistsError:
|
||||
new_name = self.out_file.replace(".mp4", f"_{uuid.uuid4().hex[:4]}.mp4")
|
||||
os.rename(self.out_file, new_name)
|
||||
size_mb = os.path.getsize(new_name) / (1024 * 1024)
|
||||
logging.info(f"[{self.room_name}] 本地录制已结束: {new_name} | 大小: {size_mb:.2f} MB")
|
||||
|
||||
# ----------------- 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(3):
|
||||
try:
|
||||
async with session.get(record_url, cookies=cookies) as resp:
|
||||
text = await resp.text()
|
||||
if not text or "captcha" in text.lower():
|
||||
logging.warning(f"第{attempt+1}次尝试获取流失败,返回内容为空或验证码,重试...")
|
||||
await asyncio.sleep(1)
|
||||
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)
|
||||
|
||||
port_info = await stream.get_douyin_stream_url(
|
||||
json_data, record_quality, proxy_addr)
|
||||
if port_info and port_info.get("record_url"):
|
||||
return port_info.get("record_url")
|
||||
else:
|
||||
logging.warning(f"第{attempt+1}次尝试解析失败,port_info为空")
|
||||
await asyncio.sleep(1)
|
||||
except Exception as e:
|
||||
logging.warning(f"第{attempt+1}次尝试抓取抖音流异常: {e}")
|
||||
await asyncio.sleep(1)
|
||||
logging.error("多次尝试后仍获取抖音真实流失败")
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.error(f"初始化 aiohttp session 失败: {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=proxy_addr, cookies=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__":
|
||||
DOUYIN_URL = "https://live.douyin.com/321245312734"
|
||||
YOUTUBE_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
|
||||
streamer_instance = start_douyin_to_youtube(DOUYIN_URL, YOUTUBE_KEY)
|
||||
if not streamer_instance:
|
||||
logging.error("推流实例创建失败,程序退出")
|
||||
sys.exit(1)
|
||||
|
||||
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("程序已启动,低延迟推流中,按 Ctrl+C 停止...")
|
||||
while True:
|
||||
time.sleep(60)
|
||||
275
backup/back9
Normal file
275
backup/back9
Normal file
@@ -0,0 +1,275 @@
|
||||
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 模块
|
||||
|
||||
# ----------------- Logging Setup -----------------
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S"
|
||||
)
|
||||
|
||||
IS_WINDOWS = platform.system() == "Windows"
|
||||
|
||||
# ----------------- 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 # None 表示无限制
|
||||
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}")
|
||||
|
||||
# ---- monitor stderr 阻塞读取(Windows兼容) ----
|
||||
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}] 推流进程退出,5秒后重启...")
|
||||
time.sleep(5)
|
||||
break
|
||||
if self.enable_recording and self.record_proc and self.record_proc.poll() is not None:
|
||||
logging.warning(f"[{self.room_name}] 录制进程退出,5秒后重启...")
|
||||
time.sleep(5)
|
||||
break
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
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(5)
|
||||
|
||||
# ----------------- 停止 -----------------
|
||||
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(3):
|
||||
try:
|
||||
async with session.get(record_url, cookies=cookies) as resp:
|
||||
text = await resp.text()
|
||||
if not text or "captcha" in text.lower():
|
||||
logging.warning(f"第{attempt+1}次尝试获取流失败,返回内容为空或验证码,重试...")
|
||||
await asyncio.sleep(1)
|
||||
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)
|
||||
|
||||
port_info = await stream.get_douyin_stream_url(
|
||||
json_data, record_quality, proxy_addr)
|
||||
if port_info and port_info.get("record_url"):
|
||||
return port_info.get("record_url")
|
||||
else:
|
||||
logging.warning(f"第{attempt+1}次尝试解析失败,port_info为空")
|
||||
await asyncio.sleep(1)
|
||||
except Exception as e:
|
||||
logging.warning(f"第{attempt+1}次尝试抓取抖音流异常: {e}")
|
||||
await asyncio.sleep(1)
|
||||
logging.error("多次尝试后仍获取抖音真实流失败")
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.error(f"初始化 aiohttp session 失败: {e}")
|
||||
return None
|
||||
|
||||
def start_douyin_to_youtube(record_url, youtube_key, room_name=None,
|
||||
proxy_addr=None, cookies='', bitrate=5000, enable_recording=False):
|
||||
real_url = asyncio.run(get_douyin_real_url(record_url, proxy_addr=proxy_addr, cookies=cookies))
|
||||
if not real_url:
|
||||
logging.error("获取真实直播流失败")
|
||||
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
|
||||
|
||||
|
||||
# ----------------- Main Program -----------------
|
||||
if __name__ == "__main__":
|
||||
DOUYIN_URL = "https://live.douyin.com/851742623054"
|
||||
YOUTUBE_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
|
||||
# enable_recording=True 可开启本地录制
|
||||
streamer_instance = start_douyin_to_youtube(DOUYIN_URL, YOUTUBE_KEY, enable_recording=False)
|
||||
if not streamer_instance:
|
||||
logging.error("推流实例创建失败,程序退出")
|
||||
sys.exit(1)
|
||||
|
||||
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("程序已启动,低延迟推流中,按 Ctrl+C 停止...")
|
||||
while True:
|
||||
time.sleep(60)
|
||||
33
config.ini
Normal file
33
config.ini
Normal file
@@ -0,0 +1,33 @@
|
||||
|
||||
[youtube]
|
||||
key = qxvb-r47b-r5ju-6ud3-6k7z
|
||||
#r99j-dbcu-tesz-atkp-39mt
|
||||
|
||||
|
||||
bitrate = 5000
|
||||
audio_bitrate = 128
|
||||
resolution = 720x1280
|
||||
fps = 30
|
||||
gop = 60
|
||||
|
||||
[general]
|
||||
list = list.ini
|
||||
health_port = 8080
|
||||
|
||||
[douyin]
|
||||
cookies =
|
||||
|
||||
[network]
|
||||
proxy =
|
||||
|
||||
[record]
|
||||
enable = false
|
||||
dir = records
|
||||
|
||||
[backoff]
|
||||
base = 10
|
||||
cap = 120
|
||||
|
||||
[ffmpeg]
|
||||
rw_timeout_ms = 10000000
|
||||
reconnect = true
|
||||
8
config/URL_config.ini
Normal file
8
config/URL_config.ini
Normal file
@@ -0,0 +1,8 @@
|
||||
https://live.douyin.com/210443559964,主播: 小小诺
|
||||
https://live.douyin.com/525514386431,主播: 阿闯烤肉夹馍
|
||||
https://live.douyin.com/8687122573,主播: 小彤炒粉
|
||||
https://live.douyin.com/642534242822,主播: (小王_)煎饼果子
|
||||
https://live.douyin.com/589037028237,主播: 娜姐辉哥在奋斗
|
||||
https://live.douyin.com/743565594721,主播: 汪汪汪小妞
|
||||
https://live.douyin.com/249578288248,主播: 思思努力版
|
||||
https://live.douyin.com/153648759493,主播: 晨晨_烤冷面
|
||||
129
config/config.ini
Normal file
129
config/config.ini
Normal file
@@ -0,0 +1,129 @@
|
||||
[录制设置]
|
||||
language(zh_cn/en) = en
|
||||
是否跳过代理检测(是/否) = 否
|
||||
直播保存路径(不填则默认) =
|
||||
保存文件夹是否以作者区分 = 是
|
||||
保存文件夹是否以时间区分 = 否
|
||||
保存文件夹是否以标题区分 = 否
|
||||
保存文件名是否包含标题 = 否
|
||||
是否去除名称中的表情符号 = 是
|
||||
视频保存格式ts|mkv|flv|mp4|mp3音频|m4a音频 = mp4
|
||||
原画|超清|高清|标清|流畅 = 原画
|
||||
是否使用代理ip(是/否) = 是
|
||||
代理地址 =
|
||||
同一时间访问网络的线程数 = 20
|
||||
循环时间(秒) = 300
|
||||
排队读取网址时间(秒) = 0
|
||||
是否显示循环秒数 = 否
|
||||
是否显示直播源地址 = 是
|
||||
分段录制是否开启 = 是
|
||||
是否强制启用https录制 = 否
|
||||
录制空间剩余阈值(gb) = 1.0
|
||||
视频分段时间(秒) = 480
|
||||
录制完成后自动转为mp4格式 = 是
|
||||
mp4格式重新编码为h264 = 否
|
||||
追加格式后删除原文件 = 是
|
||||
生成时间字幕文件 = 否
|
||||
是否录制完成后执行自定义脚本 = 否
|
||||
自定义脚本执行命令 =
|
||||
使用代理录制的平台(逗号分隔) = tiktok, sooplive, pandalive, winktv, flextv, popkontv, twitch, liveme, showroom, chzzk, shopee, shp, youtu
|
||||
额外使用代理录制的平台(逗号分隔) =
|
||||
|
||||
[推送配置]
|
||||
直播状态推送渠道 =
|
||||
钉钉推送接口链接 =
|
||||
微信推送接口链接 =
|
||||
bark推送接口链接 =
|
||||
bark推送中断级别 = active
|
||||
bark推送铃声 =
|
||||
钉钉通知@对象(填手机号) =
|
||||
钉钉通知@全体(是/否) = 否
|
||||
tgapi令牌 =
|
||||
tg聊天id(个人或者群组id) =
|
||||
smtp邮件服务器 =
|
||||
是否使用smtp服务ssl加密(是/否) =
|
||||
smtp邮件服务器端口 =
|
||||
邮箱登录账号 =
|
||||
发件人密码(授权码) =
|
||||
发件人邮箱 =
|
||||
发件人显示昵称 =
|
||||
收件人邮箱 =
|
||||
ntfy推送地址 = https://ntfy.sh/xxxx
|
||||
ntfy推送标签 = tada
|
||||
ntfy推送邮箱 =
|
||||
pushplus推送token =
|
||||
自定义推送标题 =
|
||||
自定义开播推送内容 =
|
||||
自定义关播推送内容 =
|
||||
只推送通知不录制(是/否) = 否
|
||||
直播推送检测频率(秒) = 1800
|
||||
开播推送开启(是/否) = 是
|
||||
关播推送开启(是/否) = 否
|
||||
|
||||
[Cookie]
|
||||
抖音cookie = ttwid=1%7CB1qls3GdnZhUov9o2NxOMxxYS2ff6OSvEWbv0ytbES4%7C1680522049%7C280d802d6d478e3e78d0c807f7c487e7ffec0ae4e5fdd6a0fe74c3c6af149511; my_rd=1; passport_csrf_token=3ab34460fa656183fccfb904b16ff742; passport_csrf_token_default=3ab34460fa656183fccfb904b16ff742; d_ticket=9f562383ac0547d0b561904513229d76c9c21; n_mh=hvnJEQ4Q5eiH74-84kTFUyv4VK8xtSrpRZG1AhCeFNI; store-region=cn-fj; store-region-src=uid; LOGIN_STATUS=1; __security_server_data_status=1; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; pwa2=%223%7C0%7C3%7C0%22; download_guide=%223%2F20230729%2F0%22; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.6%7D; strategyABtestKey=%221690824679.923%22; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A1536%2C%5C%22screen_height%5C%22%3A864%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A8%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A150%7D%22; VIDEO_FILTER_MEMO_SELECT=%7B%22expireTime%22%3A1691443863751%2C%22type%22%3Anull%7D; home_can_add_dy_2_desktop=%221%22; __live_version__=%221.1.1.2169%22; device_web_cpu_core=8; device_web_memory_size=8; xgplayer_user_id=346045893336; csrf_session_id=2e00356b5cd8544d17a0e66484946f28; odin_tt=724eb4dd23bc6ffaed9a1571ac4c757ef597768a70c75fef695b95845b7ffcd8b1524278c2ac31c2587996d058e03414595f0a4e856c53bd0d5e5f56dc6d82e24004dc77773e6b83ced6f80f1bb70627; __ac_nonce=064caded4009deafd8b89; __ac_signature=_02B4Z6wo00f01HLUuwwAAIDBh6tRkVLvBQBy9L-AAHiHf7; ttcid=2e9619ebbb8449eaa3d5a42d8ce88ec835; webcast_leading_last_show_time=1691016922379; webcast_leading_total_show_times=1; webcast_local_quality=sd; live_can_add_dy_2_desktop=%221%22; msToken=1JDHnVPw_9yTvzIrwb7cQj8dCMNOoesXbA_IooV8cezcOdpe4pzusZE7NB7tZn9TBXPr0ylxmv-KMs5rqbNUBHP4P7VBFUu0ZAht_BEylqrLpzgt3y5ne_38hXDOX8o=; msToken=jV_yeN1IQKUd9PlNtpL7k5vthGKcHo0dEh_QPUQhr8G3cuYv-Jbb4NnIxGDmhVOkZOCSihNpA2kvYtHiTW25XNNX_yrsv5FN8O6zm3qmCIXcEe0LywLn7oBO2gITEeg=; tt_scid=mYfqpfbDjqXrIGJuQ7q-DlQJfUSG51qG.KUdzztuGP83OjuVLXnQHjsz-BRHRJu4e986
|
||||
快手cookie =
|
||||
tiktok_cookie =
|
||||
虎牙cookie =
|
||||
斗鱼cookie =
|
||||
yy_cookie =
|
||||
b站cookie =
|
||||
小红书cookie =
|
||||
bigo_cookie =
|
||||
blued_cookie =
|
||||
sooplive_cookie =
|
||||
netease_cookie =
|
||||
千度热播_cookie =
|
||||
pandatv_cookie =
|
||||
猫耳fm_cookie =
|
||||
winktv_cookie =
|
||||
flextv_cookie =
|
||||
look_cookie =
|
||||
twitcasting_cookie =
|
||||
baidu_cookie =
|
||||
weibo_cookie =
|
||||
kugou_cookie =
|
||||
twitch_cookie =
|
||||
liveme_cookie =
|
||||
huajiao_cookie =
|
||||
liuxing_cookie =
|
||||
showroom_cookie =
|
||||
acfun_cookie =
|
||||
changliao_cookie =
|
||||
yinbo_cookie =
|
||||
yingke_cookie =
|
||||
zhihu_cookie =
|
||||
chzzk_cookie =
|
||||
haixiu_cookie =
|
||||
vvxqiu_cookie =
|
||||
17live_cookie =
|
||||
langlive_cookie =
|
||||
pplive_cookie =
|
||||
6room_cookie =
|
||||
lehaitv_cookie =
|
||||
huamao_cookie =
|
||||
shopee_cookie =
|
||||
youtube_cookie =
|
||||
taobao_cookie =
|
||||
jd_cookie =
|
||||
faceit_cookie =
|
||||
migu_cookie =
|
||||
lianjie_cookie =
|
||||
laixiu_cookie =
|
||||
picarto_cookie =
|
||||
|
||||
[Authorization]
|
||||
popkontv_token =
|
||||
|
||||
[账号密码]
|
||||
sooplive账号 =
|
||||
sooplive密码 =
|
||||
flextv账号 =
|
||||
flextv密码 =
|
||||
popkontv账号 =
|
||||
partner_code = P-00001
|
||||
popkontv密码 =
|
||||
twitcasting账号类型 = normal
|
||||
twitcasting账号 =
|
||||
twitcasting密码 =
|
||||
|
||||
9
config/url.txt
Normal file
9
config/url.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
https://live.douyin.com/270711345461,主播: 茄茄
|
||||
https://live.douyin.com/957925266814,主播: 卓力君
|
||||
https://live.douyin.com/292625589966,主播: 思了个思(游戏版)
|
||||
https://live.douyin.com/52070571116,主播: 兔然不困了
|
||||
https://live.douyin.com/913137650417,主播: 发千金
|
||||
https://live.douyin.com/905966903795,主播: 总被男人骗
|
||||
https://live.douyin.com/97282309368,主播: 会跳舞的饭
|
||||
https://live.douyin.com/640697071840,主播: 煎饼先生
|
||||
https://live.douyin.com/167254117139,主播: 乔妮妮
|
||||
1
credentials.json
Normal file
1
credentials.json
Normal file
@@ -0,0 +1 @@
|
||||
{"installed":{"client_id":"305371487651-kdukd8mtfue07agrmb0qf9psi8eooe6b.apps.googleusercontent.com","project_id":"live-473104","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["http://localhost"]}}
|
||||
@@ -1,71 +0,0 @@
|
||||
2025-09-13 09:42:21,441 [INFO] httpx: HTTP Request: GET https://live.douyin.com/848698179845 "HTTP/2 200 OK"
|
||||
2025-09-13 09:42:22,592 [INFO] httpx: HTTP Request: HEAD http://pull-hls-l13.douyincdn.com/stage/stream-117958962069897656_or4.m3u8?arch_hrchy=w1&exp_hrchy=w1&expire=1758332541&major_anchor_level=common&sign=5d71c5252347afa2a3b465f133ed58f2&t_id=037-20250913094221234B680EF32A4439DD87-GEORFp&codec=h264 "HTTP/1.1 200 OK"
|
||||
2025-09-13 09:42:22,597 [INFO] __main__: 实际直播流: http://pull-hls-l13.douyincdn.com/stage/stream-117958962069897656_or4.m3u8?arch_hrchy=w1&exp_hrchy=w1&expire=1758332541&major_anchor_level=common&sign=5d71c5252347afa2a3b465f133ed58f2&t_id=037-20250913094221234B680EF32A4439DD87-GEORFp&codec=h264
|
||||
2025-09-13 09:42:22,602 [INFO] __main__: [抖音_7dbe64a7] 启动推流,目标 YouTube Key: qxvb-r47b-r5ju-6ud3-6k7z
|
||||
2025-09-13 09:42:22,604 [INFO] __main__: [抖音_7dbe64a7] 实际直播流: http://pull-hls-l13.douyincdn.com/stage/stream-117958962069897656_or4.m3u8?arch_hrchy=w1&exp_hrchy=w1&expire=1758332541&major_anchor_level=common&sign=5d71c5252347afa2a3b465f133ed58f2&t_id=037-20250913094221234B680EF32A4439DD87-GEORFp&codec=h264
|
||||
2025-09-13 09:42:25,655 [INFO] __main__: [抖音_7dbe64a7] 拉流进程启动,PID: 7728
|
||||
2025-09-13 09:42:40,372 [INFO] __main__: [抖音_7dbe64a7] 推流进程启动,PID: 19568
|
||||
2025-09-13 09:42:44,609 [INFO] __main__: [抖音_7dbe64a7] [ABR] 调整码率: 6000k -> 5500k (缓冲比例: 12.80)
|
||||
2025-09-13 09:42:49,613 [INFO] __main__: [抖音_7dbe64a7] [ABR] 调整码率: 5500k -> 5000k (缓冲比例: 13.13)
|
||||
2025-09-13 09:42:54,616 [INFO] __main__: [抖音_7dbe64a7] [ABR] 调整码率: 5000k -> 4500k (缓冲比例: 11.67)
|
||||
2025-09-13 09:42:59,619 [INFO] __main__: [抖音_7dbe64a7] [ABR] 调整码率: 4500k -> 4000k (缓冲比例: 12.93)
|
||||
2025-09-13 09:43:04,620 [INFO] __main__: [抖音_7dbe64a7] [ABR] 调整码率: 4000k -> 3500k (缓冲比例: 13.33)
|
||||
2025-09-13 09:43:09,622 [INFO] __main__: [抖音_7dbe64a7] [ABR] 调整码率: 3500k -> 3000k (缓冲比例: 13.33)
|
||||
2025-09-13 09:44:04,081 [ERROR] __main__: [抖音_7dbe64a7] 推流写入异常: [Errno 22] Invalid argument
|
||||
2025-09-13 09:44:04,583 [ERROR] __main__: [抖音_7dbe64a7] 推流写入异常: [Errno 22] Invalid argument
|
||||
2025-09-13 09:44:05,085 [ERROR] __main__: [抖音_7dbe64a7] 推流写入异常: [Errno 22] Invalid argument
|
||||
2025-09-13 09:44:05,586 [ERROR] __main__: [抖音_7dbe64a7] 推流写入异常: [Errno 22] Invalid argument
|
||||
2025-09-13 09:44:06,088 [ERROR] __main__: [抖音_7dbe64a7] 推流写入异常: [Errno 22] Invalid argument
|
||||
2025-09-13 09:44:06,589 [ERROR] __main__: [抖音_7dbe64a7] 推流写入异常: [Errno 22] Invalid argument
|
||||
2025-09-13 09:44:07,091 [ERROR] __main__: [抖音_7dbe64a7] 推流写入异常: [Errno 22] Invalid argument
|
||||
2025-09-13 09:44:07,593 [ERROR] __main__: [抖音_7dbe64a7] 推流写入异常: [Errno 22] Invalid argument
|
||||
2025-09-13 09:44:08,095 [ERROR] __main__: [抖音_7dbe64a7] 推流写入异常: [Errno 22] Invalid argument
|
||||
2025-09-13 09:44:08,597 [ERROR] __main__: [抖音_7dbe64a7] 推流写入异常: [Errno 22] Invalid argument
|
||||
2025-09-13 09:44:09,098 [ERROR] __main__: [抖音_7dbe64a7] 推流写入异常: [Errno 22] Invalid argument
|
||||
2025-09-13 09:44:09,602 [ERROR] __main__: [抖音_7dbe64a7] 推流写入异常: [Errno 22] Invalid argument
|
||||
2025-09-13 09:44:10,107 [ERROR] __main__: [抖音_7dbe64a7] 推流写入异常: [Errno 22] Invalid argument
|
||||
2025-09-13 09:44:10,615 [ERROR] __main__: [抖音_7dbe64a7] 推流写入异常: [Errno 22] Invalid argument
|
||||
2025-09-13 09:44:11,166 [ERROR] __main__: [抖音_7dbe64a7] 推流写入异常: [Errno 22] Invalid argument
|
||||
2025-09-13 09:44:11,725 [INFO] __main__: 接收到信号 2,停止推流
|
||||
2025-09-13 09:44:11,726 [INFO] __main__: 停止所有推流...
|
||||
2025-09-13 09:44:11,738 [INFO] __main__: 拉流进程已停止
|
||||
2025-09-13 09:46:22,098 [INFO] httpx: HTTP Request: GET https://live.douyin.com/848698179845 "HTTP/2 200 OK"
|
||||
2025-09-13 09:46:24,076 [INFO] httpx: HTTP Request: HEAD http://pull-hls-l13.douyincdn.com/stage/stream-117958962069897656_or4.m3u8?arch_hrchy=w1&exp_hrchy=w1&expire=1758332782&major_anchor_level=common&sign=4c8db99e7a0c26a419faf2a1e34eae74&t_id=037-20250913094621681A1B914C390282BA5E-Rxo2Sa&codec=h264 "HTTP/1.1 200 OK"
|
||||
2025-09-13 09:46:24,081 [INFO] __main__: 实际直播流: http://pull-hls-l13.douyincdn.com/stage/stream-117958962069897656_or4.m3u8?arch_hrchy=w1&exp_hrchy=w1&expire=1758332782&major_anchor_level=common&sign=4c8db99e7a0c26a419faf2a1e34eae74&t_id=037-20250913094621681A1B914C390282BA5E-Rxo2Sa&codec=h264
|
||||
2025-09-13 09:46:24,083 [INFO] __main__: [抖音_ea368365] 启动推流,目标 YouTube Key: qxvb-r47b-r5ju-6ud3-6k7z
|
||||
2025-09-13 09:46:24,084 [INFO] __main__: [抖音_ea368365] 实际直播流: http://pull-hls-l13.douyincdn.com/stage/stream-117958962069897656_or4.m3u8?arch_hrchy=w1&exp_hrchy=w1&expire=1758332782&major_anchor_level=common&sign=4c8db99e7a0c26a419faf2a1e34eae74&t_id=037-20250913094621681A1B914C390282BA5E-Rxo2Sa&codec=h264
|
||||
2025-09-13 09:46:26,500 [INFO] __main__: [抖音_ea368365] 拉流进程启动,PID: 9880
|
||||
2025-09-13 09:46:29,093 [WARNING] __main__: [抖音_ea368365] 初始缓冲不足,推流延迟启动
|
||||
2025-09-13 09:46:39,096 [INFO] __main__: [抖音_ea368365] [ABR] 调整码率: 6000k -> 5800k (缓冲比例: 13.60)
|
||||
2025-09-13 09:46:49,098 [INFO] __main__: [抖音_ea368365] [ABR] 调整码率: 5800k -> 5600k (缓冲比例: 19.27)
|
||||
2025-09-13 09:46:59,100 [INFO] __main__: [抖音_ea368365] [ABR] 调整码率: 5600k -> 5400k (缓冲比例: 27.73)
|
||||
2025-09-13 09:47:09,102 [INFO] __main__: [抖音_ea368365] [ABR] 调整码率: 5400k -> 5200k (缓冲比例: 33.30)
|
||||
2025-09-13 09:47:19,104 [INFO] __main__: [抖音_ea368365] [ABR] 调整码率: 5200k -> 5000k (缓冲比例: 33.33)
|
||||
2025-09-13 09:47:29,105 [INFO] __main__: [抖音_ea368365] [ABR] 调整码率: 5000k -> 4800k (缓冲比例: 33.33)
|
||||
2025-09-13 09:47:39,107 [INFO] __main__: [抖音_ea368365] [ABR] 调整码率: 4800k -> 4600k (缓冲比例: 33.33)
|
||||
2025-09-13 09:47:49,109 [INFO] __main__: [抖音_ea368365] [ABR] 调整码率: 4600k -> 4400k (缓冲比例: 33.33)
|
||||
2025-09-13 09:47:59,112 [INFO] __main__: [抖音_ea368365] [ABR] 调整码率: 4400k -> 4200k (缓冲比例: 33.33)
|
||||
2025-09-13 09:48:09,115 [INFO] __main__: [抖音_ea368365] [ABR] 调整码率: 4200k -> 4000k (缓冲比例: 33.33)
|
||||
2025-09-13 09:48:19,120 [INFO] __main__: [抖音_ea368365] [ABR] 调整码率: 4000k -> 3800k (缓冲比例: 33.33)
|
||||
2025-09-13 09:48:29,123 [INFO] __main__: [抖音_ea368365] [ABR] 调整码率: 3800k -> 3600k (缓冲比例: 33.33)
|
||||
2025-09-13 09:48:39,127 [INFO] __main__: [抖音_ea368365] [ABR] 调整码率: 3600k -> 3400k (缓冲比例: 33.33)
|
||||
2025-09-13 09:48:49,129 [INFO] __main__: [抖音_ea368365] [ABR] 调整码率: 3400k -> 3200k (缓冲比例: 33.33)
|
||||
2025-09-13 09:48:59,132 [INFO] __main__: [抖音_ea368365] [ABR] 调整码率: 3200k -> 3000k (缓冲比例: 33.33)
|
||||
2025-09-13 09:49:35,107 [INFO] __main__: 接收到信号 2,停止推流
|
||||
2025-09-13 09:49:35,108 [INFO] __main__: 停止所有推流...
|
||||
2025-09-13 09:49:35,119 [INFO] __main__: 拉流进程已停止
|
||||
2025-09-13 09:49:39,251 [INFO] httpx: HTTP Request: GET https://live.douyin.com/848698179845 "HTTP/2 200 OK"
|
||||
2025-09-13 09:49:40,464 [INFO] httpx: HTTP Request: HEAD http://pull-hls-l13.douyincdn.com/stage/stream-117958962069897656_or4.m3u8?arch_hrchy=w1&exp_hrchy=w1&expire=1758332979&major_anchor_level=common&sign=1b74463ce878bc0174a578edcfa719c1&t_id=037-202509130949385D15825682C4A5F8F47C-iIt4V6&codec=h264 "HTTP/1.1 200 OK"
|
||||
2025-09-13 09:49:40,469 [INFO] __main__: 实际直播流: http://pull-hls-l13.douyincdn.com/stage/stream-117958962069897656_or4.m3u8?arch_hrchy=w1&exp_hrchy=w1&expire=1758332979&major_anchor_level=common&sign=1b74463ce878bc0174a578edcfa719c1&t_id=037-202509130949385D15825682C4A5F8F47C-iIt4V6&codec=h264
|
||||
2025-09-13 09:49:40,470 [INFO] __main__: [抖音_d46e59a8] 启动推流,目标 YouTube Key: qxvb-r47b-r5ju-6ud3-6k7z
|
||||
2025-09-13 09:49:40,470 [INFO] __main__: [抖音_d46e59a8] 实际直播流: http://pull-hls-l13.douyincdn.com/stage/stream-117958962069897656_or4.m3u8?arch_hrchy=w1&exp_hrchy=w1&expire=1758332979&major_anchor_level=common&sign=1b74463ce878bc0174a578edcfa719c1&t_id=037-202509130949385D15825682C4A5F8F47C-iIt4V6&codec=h264
|
||||
2025-09-13 09:49:42,555 [INFO] __main__: [抖音_d46e59a8] 拉流进程启动,PID: 20372
|
||||
2025-09-13 09:49:50,472 [WARNING] __main__: [抖音_d46e59a8] 初始缓冲不足(队列大小: 0),推流延迟启动
|
||||
2025-09-13 09:50:00,475 [INFO] __main__: [抖音_d46e59a8] [ABR] 调整码率: 6000k -> 5900k (缓冲比例: 7.03)
|
||||
2025-09-13 09:50:10,476 [INFO] __main__: [抖音_d46e59a8] [ABR] 调整码率: 5900k -> 5800k (缓冲比例: 18.30)
|
||||
2025-09-13 09:50:20,478 [INFO] __main__: [抖音_d46e59a8] [ABR] 调整码率: 5800k -> 5700k (缓冲比例: 23.90)
|
||||
2025-09-13 09:50:30,480 [INFO] __main__: [抖音_d46e59a8] [ABR] 调整码率: 5700k -> 5600k (缓冲比例: 30.57)
|
||||
2025-09-13 09:50:40,481 [INFO] __main__: [抖音_d46e59a8] [ABR] 调整码率: 5600k -> 5500k (缓冲比例: 33.33)
|
||||
2025-09-13 09:50:42,864 [INFO] __main__: 接收到信号 2,停止推流
|
||||
2025-09-13 09:50:42,866 [INFO] __main__: 停止所有推流...
|
||||
2025-09-13 09:50:42,877 [INFO] __main__: 拉流进程已停止
|
||||
26
list.ini
Normal file
26
list.ini
Normal file
@@ -0,0 +1,26 @@
|
||||
https://live.douyin.com/43665279648
|
||||
https://live.douyin.com/72212955083
|
||||
https://live.douyin.com/621118100231
|
||||
https://live.douyin.com/347310028364
|
||||
https://live.douyin.com/280422307519
|
||||
https://live.douyin.com/651293616593
|
||||
https://live.douyin.com/114111936890
|
||||
https://live.douyin.com/122383435921
|
||||
https://live.douyin.com/92876979328
|
||||
https://live.douyin.com/136996589868
|
||||
https://live.douyin.com/517715534931
|
||||
https://live.douyin.com/460525712926
|
||||
https://live.douyin.com/456576000931
|
||||
https://live.douyin.com/745606325880
|
||||
https://live.douyin.com/749687541944
|
||||
https://live.douyin.com/675343045974
|
||||
https://live.douyin.com/673565298571
|
||||
https://live.douyin.com/835571459859
|
||||
https://live.douyin.com/152358755212
|
||||
https://live.douyin.com/990825651731
|
||||
https://live.douyin.com/591442402624
|
||||
https://live.douyin.com/967381081829
|
||||
https://live.douyin.com/15652099787
|
||||
https://live.douyin.com/78012762575
|
||||
https://live.douyin.com/80248655914
|
||||
|
||||
681
live.py
681
live.py
@@ -1,201 +1,510 @@
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Douyin → YouTube 极致稳定推流器 (async 版)
|
||||
- 特性:
|
||||
* 全异步 asyncio 架构,单线程高并发 I/O;
|
||||
* 彩色/文件双日志,结构化前缀、滚动保存;
|
||||
* FFMPEG 自恢复与指数退避 + 抖动;
|
||||
* HLS/HTTP 强化:-reconnect-* 自动断线重连,低延迟参数;
|
||||
* 可选本地录制(独立进程,意外退出自动重启);
|
||||
* 优雅退出:SIGINT/SIGTERM,确保子进程回收;
|
||||
* list.ini 热加载(mtime 变更即时生效);
|
||||
* 健康状态心跳(码率/时长/是否已推上帧);
|
||||
* 配置集中化:config.ini + 环境变量覆盖;
|
||||
|
||||
依赖:仅标准库 + colorama + aiohttp
|
||||
保留你现有 spider/stream 模块调用约定。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import uuid
|
||||
import queue
|
||||
import time
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import shutil
|
||||
import spider
|
||||
import stream
|
||||
import time
|
||||
import uuid
|
||||
import logging
|
||||
import configparser
|
||||
from typing import Optional, Tuple, List
|
||||
|
||||
class AdaptiveHLS2YouTube:
|
||||
def __init__(self, max_bitrate=2500, min_bitrate=1500, audio_bitrate=128,
|
||||
delay_sec=30, segment_time=2, check_interval=5):
|
||||
self.processes = {}
|
||||
self.stop_flag = threading.Event()
|
||||
self.delay_sec = delay_sec
|
||||
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.current_bitrate = max_bitrate
|
||||
import aiohttp
|
||||
from colorama import init as color_init, Fore, Style
|
||||
|
||||
def start_stream(self, real_url, youtube_key, room_name=None):
|
||||
room_name = room_name or f"抖音_{str(uuid.uuid4())[:8]}"
|
||||
print(f"[{room_name}] 启动推流,目标 YouTube Key: {youtube_key}")
|
||||
threading.Thread(target=self._pull_thread, args=(real_url,), daemon=True).start()
|
||||
threading.Thread(target=self._push_thread, args=(youtube_key, room_name), daemon=True).start()
|
||||
threading.Thread(target=self._abr_thread, daemon=True).start()
|
||||
# ====== 第三方模块日志降噪(尽量提前) ======
|
||||
for noisy in ("aiohttp", "aiohttp.client", "httpcore", "urllib3", "asyncio"):
|
||||
logging.getLogger(noisy).setLevel(logging.CRITICAL)
|
||||
|
||||
def _pull_thread(self, real_url):
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-rw_timeout", "50000000",
|
||||
"-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",
|
||||
"-"
|
||||
]
|
||||
# 你本地模块(按需降噪)
|
||||
import spider # 保持与现有工程兼容
|
||||
import stream # 保持与现有工程兼容
|
||||
if hasattr(spider, 'logger'):
|
||||
spider.logger.setLevel(logging.CRITICAL)
|
||||
if hasattr(stream, 'logger'):
|
||||
stream.logger.setLevel(logging.CRITICAL)
|
||||
|
||||
# ================= 日志系统 =================
|
||||
color_init(autoreset=True)
|
||||
LOG_DIR = os.environ.get("LOG_DIR", "logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
LOG_FILE = os.path.join(LOG_DIR, "app.log")
|
||||
|
||||
class ColorFormatter(logging.Formatter):
|
||||
COLORS = {
|
||||
logging.DEBUG: Fore.BLUE,
|
||||
logging.INFO: Fore.CYAN,
|
||||
logging.WARNING: Fore.YELLOW,
|
||||
logging.ERROR: Fore.RED,
|
||||
logging.CRITICAL: Fore.RED + Style.BRIGHT,
|
||||
}
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
msg = super().format(record)
|
||||
return f"{self.COLORS.get(record.levelno, '')}{msg}{Style.RESET_ALL}"
|
||||
|
||||
def build_logger() -> logging.Logger:
|
||||
from logging.handlers import RotatingFileHandler
|
||||
logger = logging.getLogger("main")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# 控制台:INFO+
|
||||
ch = logging.StreamHandler(sys.stdout)
|
||||
ch.setLevel(logging.INFO)
|
||||
ch.setFormatter(ColorFormatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%H:%M:%S"))
|
||||
|
||||
# 文件:DEBUG+
|
||||
fh = RotatingFileHandler(LOG_FILE, maxBytes=5*1024*1024, backupCount=5, encoding="utf-8")
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
# 防重复添加
|
||||
if not logger.handlers:
|
||||
logger.addHandler(ch)
|
||||
logger.addHandler(fh)
|
||||
return logger
|
||||
|
||||
logger = build_logger()
|
||||
|
||||
# ================= 配置中心 =================
|
||||
@dataclasses.dataclass
|
||||
class Settings:
|
||||
youtube_key: Optional[str] = None
|
||||
bitrate_k: int = 5000
|
||||
audio_bitrate_k: int = 128
|
||||
resolution: str = "720x1280"
|
||||
fps: int = 30
|
||||
gop: int = 60
|
||||
enable_recording: bool = False
|
||||
list_path: str = "list.ini"
|
||||
cookies: str = "" # 原始字符串或 "k=v;..."
|
||||
proxy: Optional[str] = None # eg. "http://127.0.0.1:7890"
|
||||
record_dir: str = "records"
|
||||
|
||||
# 退避与探测间隔(秒)
|
||||
no_live_retry_base: float = 10.0
|
||||
no_live_retry_cap: float = 120.0
|
||||
|
||||
# FFMPEG 超时/网络参数
|
||||
rw_timeout_ms: int = 10_000_000
|
||||
reconnect: bool = True
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> "Settings":
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read("config.ini", encoding="utf-8")
|
||||
|
||||
def g(section: str, key: str, fallback=None):
|
||||
return cfg.get(section, key, fallback=fallback) if cfg.has_option(section, key) else fallback
|
||||
|
||||
s = cls(
|
||||
youtube_key=os.environ.get("YOUTUBE_KEY") or (g("youtube", "key") or None),
|
||||
bitrate_k=int(os.environ.get("VIDEO_BITRATE_K", g("youtube", "bitrate", fallback=5000))),
|
||||
audio_bitrate_k=int(os.environ.get("AUDIO_BITRATE_K", g("youtube", "audio_bitrate", fallback=128))),
|
||||
resolution=os.environ.get("VIDEO_RES", g("youtube", "resolution", fallback="720x1280")),
|
||||
fps=int(os.environ.get("FPS", g("youtube", "fps", fallback=30))),
|
||||
gop=int(os.environ.get("GOP", g("youtube", "gop", fallback=60))),
|
||||
enable_recording=(os.environ.get("ENABLE_RECORDING") or g("record", "enable", fallback="false")).lower() in {"1","true","yes","on"},
|
||||
list_path=os.environ.get("LIST_PATH", g("general", "list", fallback="list.ini")),
|
||||
cookies=os.environ.get("COOKIES", g("douyin", "cookies", fallback="")),
|
||||
proxy=os.environ.get("PROXY", g("network", "proxy", fallback=None)),
|
||||
record_dir=os.environ.get("RECORD_DIR", g("record", "dir", fallback="records")),
|
||||
no_live_retry_base=float(os.environ.get("NO_LIVE_BASE", g("backoff", "base", fallback=10.0))),
|
||||
no_live_retry_cap=float(os.environ.get("NO_LIVE_CAP", g("backoff", "cap", fallback=120.0))),
|
||||
rw_timeout_ms=int(os.environ.get("RW_TIMEOUT_MS", g("ffmpeg", "rw_timeout_ms", fallback=10_000_000))),
|
||||
reconnect=(os.environ.get("FF_RECONNECT", g("ffmpeg", "reconnect", fallback="true")).lower() in {"1","true","yes","on"}),
|
||||
)
|
||||
os.makedirs(s.record_dir, exist_ok=True)
|
||||
return s
|
||||
|
||||
SETTINGS = Settings.load()
|
||||
if not SETTINGS.youtube_key:
|
||||
logger.error(Fore.RED + "config.ini 中无 YouTube Key,或未设置环境变量 YOUTUBE_KEY" + Style.RESET_ALL)
|
||||
sys.exit(1)
|
||||
|
||||
# ============== 工具函数 ==============
|
||||
_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_RAW = "Referer: https://www.douyin.com/\r\nOrigin: https://www.douyin.com\r\n"
|
||||
|
||||
async def async_sleep(sec: float):
|
||||
try:
|
||||
await asyncio.sleep(sec)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# ============== Douyin 解析 ==============
|
||||
async def get_douyin_real_url(record_url: str, index: int, total: int,
|
||||
record_quality: str = "原画",
|
||||
proxy_addr: Optional[str] = None,
|
||||
cookies: str = "") -> Tuple[Optional[str], Optional[str]]:
|
||||
"""解析抖音真实流 URL 与昵称。"""
|
||||
def clip(s: str, n: int) -> str:
|
||||
return (s[:n] + "...") if len(s) > n else s
|
||||
|
||||
display_url = clip(record_url, 40)
|
||||
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [解析中...]")
|
||||
sys.stdout.flush()
|
||||
|
||||
headers = {
|
||||
"User-Agent": _USER_AGENT,
|
||||
"Referer": "https://www.douyin.com/",
|
||||
"Origin": "https://www.douyin.com",
|
||||
}
|
||||
|
||||
session_args = {"headers": headers}
|
||||
if proxy_addr:
|
||||
session_args["proxy"] = proxy_addr
|
||||
|
||||
async with aiohttp.ClientSession(**session_args) as session:
|
||||
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*50)
|
||||
if not data:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
self.buffer_queue.put(data)
|
||||
except Exception as e:
|
||||
print(f"拉流异常: {e}")
|
||||
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():
|
||||
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [{Fore.RED}失败: 验证码{Style.RESET_ALL}]")
|
||||
sys.stdout.flush()
|
||||
return None, None
|
||||
|
||||
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)
|
||||
# 选择解析函数(与原工程保持一致)
|
||||
fn = spider.get_douyin_stream_data if ('v.douyin.com' not in record_url and '/user/' not in record_url) else spider.get_douyin_app_stream_data
|
||||
json_data = await fn(url=record_url, proxy_addr=proxy_addr, cookies=cookies)
|
||||
if not json_data or not isinstance(json_data, dict):
|
||||
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [{Fore.RED}失败: 无流{Style.RESET_ALL}]")
|
||||
sys.stdout.flush()
|
||||
return None, None
|
||||
|
||||
nickname = (
|
||||
json_data.get('room_title') or
|
||||
json_data.get('title') or
|
||||
json_data.get('user', {}).get('nickname') or
|
||||
json_data.get('owner', {}).get('nickname') or
|
||||
json_data.get('stream', {}).get('owner', {}).get('nickname') or
|
||||
"未知主播"
|
||||
)
|
||||
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [{Fore.YELLOW}{nickname}{Style.RESET_ALL}] [拉流...]")
|
||||
sys.stdout.flush()
|
||||
|
||||
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"):
|
||||
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [{Fore.GREEN}{nickname}{Style.RESET_ALL}] [成功]\n")
|
||||
sys.stdout.flush()
|
||||
return port_info.get("record_url"), nickname
|
||||
|
||||
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [{Fore.RED}{nickname}{Style.RESET_ALL}] [无效流]\n")
|
||||
sys.stdout.flush()
|
||||
return None, None
|
||||
except Exception as e:
|
||||
logger.debug(f"get_douyin_real_url error: {e!r}")
|
||||
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [{Fore.RED}错误{Style.RESET_ALL}]\n")
|
||||
sys.stdout.flush()
|
||||
return None, None
|
||||
|
||||
# ============== 推流器 ==============
|
||||
class AsyncStreamer:
|
||||
def __init__(self,
|
||||
real_url: str,
|
||||
youtube_key: str,
|
||||
|
||||
room_name: Optional[str] = None,
|
||||
bitrate_k: int = SETTINGS.bitrate_k,
|
||||
audio_bitrate_k: int = SETTINGS.audio_bitrate_k,
|
||||
resolution: str = SETTINGS.resolution,
|
||||
fps: int = SETTINGS.fps,
|
||||
gop: int = SETTINGS.gop,
|
||||
enable_recording: bool = SETTINGS.enable_recording,
|
||||
) -> None:
|
||||
self.real_url = real_url
|
||||
self.youtube_key = youtube_key
|
||||
self.room = room_name or f"抖音_{str(uuid.uuid4())[:8]}"
|
||||
self.bitrate_k = bitrate_k
|
||||
self.audio_bitrate_k = audio_bitrate_k
|
||||
self.resolution = resolution
|
||||
self.fps = fps
|
||||
self.gop = gop
|
||||
self.enable_recording = enable_recording
|
||||
|
||||
self.push_proc: Optional[asyncio.subprocess.Process] = None
|
||||
self.rec_proc: Optional[asyncio.subprocess.Process] = None
|
||||
self.pushing: bool = False
|
||||
self.start_ts: float = 0.0
|
||||
self._monitor_task: Optional[asyncio.Task] = None
|
||||
self._stderr_tasks: List[asyncio.Task] = []
|
||||
self._stop = asyncio.Event()
|
||||
self.logger = logging.getLogger(f"Streamer[{self.room}]")
|
||||
|
||||
# ---------- 构建命令 ----------
|
||||
def _push_cmd(self) -> List[str]:
|
||||
flags = [
|
||||
"ffmpeg", "-y",
|
||||
"-rw_timeout", str(SETTINGS.rw_timeout_ms),
|
||||
"-user_agent", _USER_AGENT,
|
||||
"-headers", _HEADERS_RAW,
|
||||
"-protocol_whitelist", "file,pipe,http,https,tcp,tls,udp,rtp,crypto",
|
||||
"-fflags", "+discardcorrupt+genpts",
|
||||
"-flags", "low_delay",
|
||||
]
|
||||
if SETTINGS.reconnect:
|
||||
flags += [
|
||||
"-reconnect", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_on_network_error", "1",
|
||||
"-reconnect_delay_max", "10",
|
||||
]
|
||||
flags += [
|
||||
"-i", self.real_url,
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
|
||||
"-b:v", f"{self.bitrate_k}k",
|
||||
"-maxrate", f"{self.bitrate_k}k",
|
||||
"-bufsize", f"{self.bitrate_k*2}k",
|
||||
"-r", str(self.fps),
|
||||
"-g", str(self.gop),
|
||||
"-s", self.resolution,
|
||||
"-c:a", "aac", "-b:a", f"{self.audio_bitrate_k}k", "-ar", "44100", "-ac", "2",
|
||||
"-f", "flv", f"rtmp://a.rtmp.youtube.com/live2/{self.youtube_key}",
|
||||
]
|
||||
return flags
|
||||
|
||||
def _rec_cmd(self) -> Tuple[List[str], str]:
|
||||
base = f"{self.room}_{int(time.time())}.mp4"
|
||||
out = os.path.join(SETTINGS.record_dir, base).replace("\\", "/")
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-rw_timeout", str(SETTINGS.rw_timeout_ms),
|
||||
"-user_agent", _USER_AGENT,
|
||||
"-headers", _HEADERS_RAW,
|
||||
"-protocol_whitelist", "file,pipe,http,https,tcp,tls,udp,rtp,crypto",
|
||||
"-fflags", "+discardcorrupt+genpts",
|
||||
"-flags", "low_delay",
|
||||
"-i", self.real_url,
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
|
||||
"-c:a", "aac", "-b:a", f"{self.audio_bitrate_k}k", "-ar", "44100", "-ac", "2",
|
||||
"-movflags", "+faststart",
|
||||
out,
|
||||
]
|
||||
return cmd, out
|
||||
|
||||
# ---------- 进程与监控 ----------
|
||||
async def _spawn(self, cmd: List[str]) -> asyncio.subprocess.Process:
|
||||
# Windows 上隐藏新控制台窗口
|
||||
creationflags = 0
|
||||
if os.name == 'nt':
|
||||
creationflags = 0x08000000 # CREATE_NO_WINDOW
|
||||
return await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
|
||||
async def _read_stderr(self, proc: asyncio.subprocess.Process, kind: str):
|
||||
assert proc.stderr is not None
|
||||
while not self._stop.is_set():
|
||||
line = await proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
txt = line.decode(errors='ignore').strip()
|
||||
if kind == "push" and (not self.pushing) and "frame=" in txt:
|
||||
self.pushing = True
|
||||
self.logger.info(Fore.GREEN + "成功推流到 YouTube" + Style.RESET_ALL)
|
||||
self.logger.debug(f"{kind}> {txt}")
|
||||
|
||||
async def _monitor(self):
|
||||
while not self._stop.is_set():
|
||||
uptime = int(time.time() - self.start_ts) if self.start_ts else 0
|
||||
m, s = divmod(uptime, 60)
|
||||
status = f"{Fore.GREEN}推流中{Style.RESET_ALL}" if self.pushing else f"{Fore.YELLOW}启动中{Style.RESET_ALL}"
|
||||
sys.stdout.write(f"\r[{self.room}] {status} | 时间: {m:02d}:{s:02d} | 码率: {self.bitrate_k}k")
|
||||
sys.stdout.flush()
|
||||
await async_sleep(1)
|
||||
|
||||
async def run(self):
|
||||
self.logger.info(Fore.CYAN + f"启动推流到 YouTube" + Style.RESET_ALL)
|
||||
self.start_ts = time.time()
|
||||
self.pushing = False
|
||||
|
||||
# 启动推流
|
||||
self.push_proc = await self._spawn(self._push_cmd())
|
||||
self.logger.info(Fore.CYAN + f"推流进程 PID: {self.push_proc.pid}" + Style.RESET_ALL)
|
||||
self._stderr_tasks.append(asyncio.create_task(self._read_stderr(self.push_proc, "push")))
|
||||
|
||||
# 启动录制(可选)
|
||||
if self.enable_recording:
|
||||
rec_cmd, out = self._rec_cmd()
|
||||
self.rec_proc = await self._spawn(rec_cmd)
|
||||
self.logger.info(Fore.CYAN + f"录制到: {out}" + Style.RESET_ALL)
|
||||
self._stderr_tasks.append(asyncio.create_task(self._read_stderr(self.rec_proc, "record")))
|
||||
|
||||
# 监控任务
|
||||
self._monitor_task = asyncio.create_task(self._monitor())
|
||||
|
||||
# 进程生命周期
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
# 推流意外退出 → 结束
|
||||
if self.push_proc and (self.push_proc.returncode is not None):
|
||||
self.logger.warning(Fore.YELLOW + f"推流进程退出: {self.push_proc.returncode}" + Style.RESET_ALL)
|
||||
await self.stop()
|
||||
break
|
||||
# 录制退出 → 重启录制
|
||||
if self.enable_recording and self.rec_proc and (self.rec_proc.returncode is not None):
|
||||
self.logger.warning(Fore.YELLOW + "录制进程退出,重启中..." + Style.RESET_ALL)
|
||||
rec_cmd, _ = self._rec_cmd()
|
||||
self.rec_proc = await self._spawn(rec_cmd)
|
||||
self._stderr_tasks.append(asyncio.create_task(self._read_stderr(self.rec_proc, "record")))
|
||||
await async_sleep(1)
|
||||
finally:
|
||||
await self.stop()
|
||||
|
||||
async def stop(self):
|
||||
if self._stop.is_set():
|
||||
return
|
||||
self._stop.set()
|
||||
for t in self._stderr_tasks:
|
||||
t.cancel()
|
||||
if self._monitor_task:
|
||||
self._monitor_task.cancel()
|
||||
|
||||
async def _terminate(proc: Optional[asyncio.subprocess.Process]):
|
||||
if not proc:
|
||||
return
|
||||
if proc.returncode is None:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=2)
|
||||
except asyncio.TimeoutError:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(proc.wait(), timeout=2)
|
||||
await _terminate(self.push_proc)
|
||||
await _terminate(self.rec_proc)
|
||||
self.push_proc = None
|
||||
self.rec_proc = None
|
||||
# 清理一行状态输出
|
||||
sys.stdout.write("\r" + " " * 100 + "\r")
|
||||
sys.stdout.flush()
|
||||
self.logger.info(Fore.YELLOW + "已停止推流/录制" + Style.RESET_ALL)
|
||||
|
||||
# ============== 主循环 ==============
|
||||
async def load_list_if_changed(path: str, last_mtime: float) -> Tuple[Optional[List[str]], float]:
|
||||
try:
|
||||
m = os.path.getmtime(path)
|
||||
if m <= last_mtime:
|
||||
return None, last_mtime
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
urls = [ln.strip() for ln in f if ln.strip() and not ln.strip().startswith('#')]
|
||||
logger.info(Fore.CYAN + f"加载 {len(urls)} 个唯一 URL" + Style.RESET_ALL)
|
||||
return urls, m
|
||||
except FileNotFoundError:
|
||||
logger.error(Fore.RED + f"未找到 {path} 文件" + Style.RESET_ALL)
|
||||
return [], last_mtime
|
||||
|
||||
async def iter_until_live(urls: List[str]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
for i, url in enumerate(urls, 1):
|
||||
real_url, nickname = await get_douyin_real_url(url, i, len(urls), proxy_addr=SETTINGS.proxy, cookies=SETTINGS.cookies)
|
||||
# 清一行
|
||||
sys.stdout.write("\r" + " " * 100 + "\r")
|
||||
sys.stdout.flush()
|
||||
if real_url and nickname:
|
||||
logger.info(Fore.GREEN + f"发现直播: {url[:50]}... [{nickname}]" + Style.RESET_ALL)
|
||||
return url, real_url, nickname
|
||||
return None, None, None
|
||||
|
||||
async def main():
|
||||
# 信号处理
|
||||
stop_evt = asyncio.Event()
|
||||
def _sig_handler(*_):
|
||||
stop_evt.set()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
with contextlib.suppress(NotImplementedError):
|
||||
asyncio.get_running_loop().add_signal_handler(sig, _sig_handler)
|
||||
|
||||
logger.info(Fore.GREEN + "启动抖音直播检测" + Style.RESET_ALL)
|
||||
|
||||
last_mtime = 0.0
|
||||
urls: List[str] = []
|
||||
streamer: Optional[AsyncStreamer] = None
|
||||
|
||||
backoff = SETTINGS.no_live_retry_base
|
||||
|
||||
while not stop_evt.is_set():
|
||||
urls_new, new_mtime = await load_list_if_changed(SETTINGS.list_path, last_mtime)
|
||||
if urls_new is not None:
|
||||
urls = urls_new
|
||||
last_mtime = new_mtime
|
||||
|
||||
# 若没有在推流或已停止 → 尝试寻找新的直播
|
||||
if (streamer is None) or streamer._stop.is_set():
|
||||
if streamer and streamer._stop.is_set():
|
||||
logger.info(Fore.YELLOW + "直播中断,准备重新遍历 URL 列表" + Style.RESET_ALL)
|
||||
await async_sleep(5)
|
||||
streamer = None
|
||||
|
||||
if not urls:
|
||||
sys.stdout.write(f"\r{Fore.YELLOW}无URL列表,{int(backoff)}秒后重试...{Style.RESET_ALL}")
|
||||
sys.stdout.flush()
|
||||
await async_sleep(backoff)
|
||||
backoff = min(backoff * 1.7 + (0.5 if backoff < 30 else 1.0), SETTINGS.no_live_retry_cap)
|
||||
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*2}k",
|
||||
"-r", "30",
|
||||
"-g", "60",
|
||||
"-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:
|
||||
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
self.processes[room_name] = proc
|
||||
raw_url, real_url, nickname = await iter_until_live(urls)
|
||||
if not real_url:
|
||||
sys.stdout.write(f"\r{Fore.YELLOW}未找到直播,{int(backoff)}秒后重试...{Style.RESET_ALL}")
|
||||
sys.stdout.flush()
|
||||
await async_sleep(backoff)
|
||||
backoff = min(backoff * 1.7 + (0.5 if backoff < 30 else 1.0), SETTINGS.no_live_retry_cap)
|
||||
continue
|
||||
|
||||
def read_stderr():
|
||||
while proc.poll() is None:
|
||||
line = proc.stderr.readline()
|
||||
if line:
|
||||
print(f"[{room_name} FFmpeg]: {line.decode(errors='ignore').strip()}")
|
||||
threading.Thread(target=read_stderr, daemon=True).start()
|
||||
backoff = SETTINGS.no_live_retry_base # 命中直播 → 重置退避
|
||||
logger.info(Fore.GREEN + f"推流自: {real_url[:50]}... [{nickname}]" + Style.RESET_ALL)
|
||||
streamer = AsyncStreamer(
|
||||
real_url=real_url,
|
||||
youtube_key=SETTINGS.youtube_key,
|
||||
room_name=nickname,
|
||||
bitrate_k=SETTINGS.bitrate_k,
|
||||
audio_bitrate_k=SETTINGS.audio_bitrate_k,
|
||||
resolution=SETTINGS.resolution,
|
||||
fps=SETTINGS.fps,
|
||||
gop=SETTINGS.gop,
|
||||
enable_recording=SETTINGS.enable_recording,
|
||||
)
|
||||
asyncio.create_task(streamer.run())
|
||||
else:
|
||||
# 正在推流 → 间隔检查
|
||||
await async_sleep(10)
|
||||
|
||||
while not self.stop_flag.is_set():
|
||||
if not self.buffer_queue.empty():
|
||||
data = self.buffer_queue.get()
|
||||
proc.stdin.write(data)
|
||||
proc.stdin.flush()
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
except Exception as e:
|
||||
print(f"[{room_name}] 推流异常: {e}")
|
||||
|
||||
def _abr_thread(self):
|
||||
while not self.stop_flag.is_set():
|
||||
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):
|
||||
print("停止所有推流...")
|
||||
self.stop_flag.set()
|
||||
for proc in self.processes.values():
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
# 退出
|
||||
if streamer:
|
||||
await streamer.stop()
|
||||
logger.info(Fore.YELLOW + "进程已退出" + Style.RESET_ALL)
|
||||
|
||||
|
||||
# ----------------- 抖音解析逻辑 -----------------
|
||||
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=30):
|
||||
try:
|
||||
real_url = asyncio.run(get_douyin_real_url(record_url))
|
||||
except Exception as e:
|
||||
print(f"获取真实直播流失败: {e}")
|
||||
return
|
||||
if not real_url:
|
||||
print("获取真实直播流失败")
|
||||
return
|
||||
print(f"实际直播流: {real_url}")
|
||||
hls2yt = AdaptiveHLS2YouTube(delay_sec=delay_sec)
|
||||
hls2yt.start_stream(real_url, youtube_key, room_name)
|
||||
return hls2yt
|
||||
|
||||
|
||||
# ----------------- 控制台监控面板 -----------------
|
||||
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__":
|
||||
DOUYIN_URL = "https://live.douyin.com/848698179845"
|
||||
YOUTUBE_KEY = "qxvb-r47b-r5ju-6ud3-6k7z"
|
||||
|
||||
hls_instance = start_douyin_to_youtube(DOUYIN_URL, YOUTUBE_KEY, delay_sec=30)
|
||||
if not hls_instance:
|
||||
print("推流实例创建失败,程序退出")
|
||||
sys.exit(1)
|
||||
|
||||
threading.Thread(target=monitor_panel_thread, args=(hls_instance,), daemon=True).start()
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
if hls_instance:
|
||||
hls_instance.stop_all()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
print("程序已启动,按 Ctrl+C 停止...")
|
||||
while True:
|
||||
time.sleep(60)
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
4191
logs/app.log
Normal file
4191
logs/app.log
Normal file
File diff suppressed because one or more lines are too long
21647
logs/app.log.1
Normal file
21647
logs/app.log.1
Normal file
File diff suppressed because one or more lines are too long
21685
logs/app.log.2
Normal file
21685
logs/app.log.2
Normal file
File diff suppressed because one or more lines are too long
21722
logs/app.log.3
Normal file
21722
logs/app.log.3
Normal file
File diff suppressed because one or more lines are too long
21793
logs/app.log.4
Normal file
21793
logs/app.log.4
Normal file
File diff suppressed because one or more lines are too long
21645
logs/app.log.5
Normal file
21645
logs/app.log.5
Normal file
File diff suppressed because one or more lines are too long
15
requirements.txt
Normal file
15
requirements.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
requests>=2.31.0
|
||||
loguru>=0.7.3
|
||||
pycryptodome>=3.20.0
|
||||
distro>=1.9.0
|
||||
tqdm>=4.67.1
|
||||
httpx[http2]>=0.28.1
|
||||
PyExecJS>=1.5.1
|
||||
rich
|
||||
pyttsx3
|
||||
watchdog
|
||||
celery
|
||||
redis
|
||||
python-ntfy
|
||||
aiohttp
|
||||
typer>=0.12.5
|
||||
66
test.py
Normal file
66
test.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
def get_git_objects(repo_path="."):
|
||||
"""获取 Git 仓库中所有对象的 hash 和大小"""
|
||||
pack_dir = os.path.join(repo_path, ".git", "objects", "pack")
|
||||
idx_files = [f for f in os.listdir(pack_dir) if f.endswith(".idx")]
|
||||
|
||||
objects = []
|
||||
for idx in idx_files:
|
||||
idx_path = os.path.join(pack_dir, idx)
|
||||
try:
|
||||
# 运行 git verify-pack
|
||||
result = subprocess.check_output(
|
||||
["git", "verify-pack", "-v", idx_path],
|
||||
cwd=repo_path,
|
||||
text=True,
|
||||
errors="ignore"
|
||||
)
|
||||
for line in result.splitlines():
|
||||
parts = line.split()
|
||||
# 只处理包含 "blob" 的行(文件对象)
|
||||
if len(parts) >= 5 and parts[1] == "blob":
|
||||
sha = parts[0]
|
||||
size = int(parts[2]) # uncompressed size
|
||||
objects.append((sha, size))
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error reading {idx_path}: {e}")
|
||||
return objects
|
||||
|
||||
def map_objects_to_files(repo_path="."):
|
||||
"""把对象 hash 映射到文件路径"""
|
||||
result = subprocess.check_output(
|
||||
["git", "rev-list", "--objects", "--all"],
|
||||
cwd=repo_path,
|
||||
text=True,
|
||||
errors="ignore"
|
||||
)
|
||||
mapping = {}
|
||||
for line in result.splitlines():
|
||||
parts = line.split(" ", 1)
|
||||
if len(parts) == 2:
|
||||
sha, path = parts
|
||||
mapping[sha] = path
|
||||
return mapping
|
||||
|
||||
def main():
|
||||
repo_path = "." # 当前目录
|
||||
objects = get_git_objects(repo_path)
|
||||
mapping = map_objects_to_files(repo_path)
|
||||
|
||||
if not objects:
|
||||
print("⚠️ 没找到大对象,请确认仓库里有 pack 文件。")
|
||||
return
|
||||
|
||||
# 按大小排序,取前 20 个
|
||||
objects.sort(key=lambda x: x[1], reverse=True)
|
||||
top_n = objects[:20]
|
||||
|
||||
print("Git 仓库里最大的 20 个文件:\n")
|
||||
for sha, size in top_n:
|
||||
path = mapping.get(sha, "(已删除或不可见文件)")
|
||||
print(f"{size/1024:.2f} KB\t{sha}\t{path}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
verify.txt
Normal file
0
verify.txt
Normal file
Reference in New Issue
Block a user