45 Commits
main ... dev

Author SHA1 Message Date
eric
f59234a601 's' 2025-09-26 01:59:18 +08:00
eric
6ed686932f ''s 2025-09-26 00:18:58 +08:00
eric
6d3f50e1e3 's' 2025-09-25 11:52:19 +08:00
eric
8f496f2109 's' 2025-09-24 14:55:54 +08:00
eric
a49143093c '无人直播' 2025-09-24 14:07:48 +08:00
eric
fedc756642 's' 2025-09-19 13:24:32 +08:00
eric
2f379f2559 's' 2025-09-18 23:43:39 +08:00
eric
a0a86b76c0 's' 2025-09-17 01:19:52 +08:00
eric
8c91808c7d 's' 2025-09-17 01:16:09 +08:00
eric
89a96faf11 's' 2025-09-16 21:45:03 +08:00
eric
8395418a12 's 2025-09-16 21:29:47 +08:00
eric
ea12e8bfaa 's' 2025-09-16 21:10:36 +08:00
eric
94f7939b48 's' 2025-09-16 19:28:48 +08:00
eric
fc2da4ebfb 's' 2025-09-16 19:04:38 +08:00
eric
da736cf51b 's' 2025-09-16 18:38:03 +08:00
eric
3c09c7bd76 's' 2025-09-16 18:34:35 +08:00
eric
a9ca6e16a9 's' 2025-09-16 18:33:41 +08:00
eric
f03b747943 's' 2025-09-16 18:32:51 +08:00
eric
0504c104d3 's' 2025-09-16 18:31:01 +08:00
eric
687294ce5d 's' 2025-09-16 18:23:19 +08:00
eric
696e00a144 's' 2025-09-16 17:02:16 +08:00
eric
2c4b0547f3 's' 2025-09-16 00:53:07 +08:00
eric
e32896165b 's' 2025-09-16 00:33:59 +08:00
eric
f637e0b6c3 'back6' 2025-09-15 22:11:16 +08:00
eric
09648e67d3 's' 2025-09-15 22:10:08 +08:00
eric
cd6d39210e 's' 2025-09-15 16:21:28 +08:00
eric
2b5f9a9b90 's' 2025-09-15 11:58:48 +08:00
eric
682350c804 's' 2025-09-14 08:42:57 +08:00
eric
eab967aabe 's' 2025-09-13 22:02:15 +08:00
eric
2a977b1b7e 's' 2025-09-13 21:15:31 +08:00
eric
a6db843ea0 's' 2025-09-13 21:11:59 +08:00
eric
88d54db72d '' 2025-09-13 19:52:51 +08:00
eric
5bf88b1417 'clash设置抖音直连' 2025-09-13 15:02:30 +08:00
eric
6223b39b9e 's' 2025-09-13 14:44:56 +08:00
eric
4a89940e5e 's' 2025-09-13 13:29:01 +08:00
eric
24e55f36b1 's' 2025-09-13 13:02:36 +08:00
eric
2c609a7bb2 s 2025-09-13 04:45:53 +00:00
eric
e9e018db08 s 2025-09-13 04:38:21 +00:00
eric
752f11e9a6 's' 2025-09-13 12:26:59 +08:00
eric
6bbf547ab2 '5000码率' 2025-09-13 10:49:03 +08:00
eric
f3e8192b62 's'' 2025-09-13 10:21:42 +08:00
eric
aa26e03ca1 's'' 2025-09-13 10:15:00 +08:00
eric
e49b8b6865 ' 2025-09-13 08:49:26 +08:00
eric
77561952c2 '' 2025-09-13 08:21:39 +08:00
eric
b272565386 's' 2025-09-13 08:13:47 +08:00
33 changed files with 117326 additions and 258 deletions

3
.gitignore vendored
View File

@@ -1,2 +1,5 @@
cache/
__pycache__/
*.pyc
records/
*.ts

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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

View File

202
backup/back3 Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]}}

View File

@@ -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
View 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
View File

@@ -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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

21645
logs/app.log.5 Normal file

File diff suppressed because one or more lines are too long

15
requirements.txt Normal file
View 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
View 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
View File