''s
This commit is contained in:
342
backup/back14
Normal file
342
backup/back14
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
import aiohttp
|
||||||
|
import spider # 您的 spider 模块
|
||||||
|
import stream # 您的 stream 模块
|
||||||
|
import configparser
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from colorama import init, Fore, Style
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
|
# ----------------- 日志设置 -----------------
|
||||||
|
# 初始化 colorama
|
||||||
|
init(autoreset=True)
|
||||||
|
|
||||||
|
# 创建 logs 目录
|
||||||
|
LOG_DIR = "logs"
|
||||||
|
os.makedirs(LOG_DIR, exist_ok=True)
|
||||||
|
LOG_FILE = os.path.join(LOG_DIR, "app.log")
|
||||||
|
|
||||||
|
# 自定义彩色日志 Formatter
|
||||||
|
class ColorFormatter(logging.Formatter):
|
||||||
|
COLORS = {
|
||||||
|
logging.DEBUG: Fore.BLUE,
|
||||||
|
logging.INFO: Fore.CYAN,
|
||||||
|
logging.WARNING: Fore.YELLOW,
|
||||||
|
logging.ERROR: Fore.RED,
|
||||||
|
logging.CRITICAL: Fore.RED + Style.BRIGHT,
|
||||||
|
}
|
||||||
|
|
||||||
|
def format(self, record):
|
||||||
|
color = self.COLORS.get(record.levelno, "")
|
||||||
|
message = super().format(record)
|
||||||
|
return f"{color}{message}{Style.RESET_ALL}"
|
||||||
|
|
||||||
|
# 控制台日志(INFO 以上,带颜色)
|
||||||
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
console_handler.setLevel(logging.INFO)
|
||||||
|
console_formatter = ColorFormatter("%(asctime)s [%(levelname)s] %(message)s", "%H:%M:%S")
|
||||||
|
console_handler.setFormatter(console_formatter)
|
||||||
|
|
||||||
|
# 文件日志(DEBUG 以上,自动滚动,5MB*5份)
|
||||||
|
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=5*1024*1024, backupCount=5, encoding="utf-8")
|
||||||
|
file_handler.setLevel(logging.DEBUG)
|
||||||
|
file_formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S")
|
||||||
|
file_handler.setFormatter(file_formatter)
|
||||||
|
|
||||||
|
# 根 logger
|
||||||
|
logging.basicConfig(level=logging.DEBUG, handlers=[console_handler, file_handler])
|
||||||
|
logger = logging.getLogger("main")
|
||||||
|
|
||||||
|
# 抑制不必要的三方日志
|
||||||
|
logging.getLogger("aiohttp").setLevel(logging.CRITICAL)
|
||||||
|
logging.getLogger("aiohttp.client").setLevel(logging.CRITICAL)
|
||||||
|
logging.getLogger("httpcore").setLevel(logging.CRITICAL)
|
||||||
|
logging.getLogger("urllib3").setLevel(logging.CRITICAL)
|
||||||
|
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
|
||||||
|
if hasattr(spider, 'logger'):
|
||||||
|
spider.logger.setLevel(logging.CRITICAL)
|
||||||
|
if hasattr(stream, 'logger'):
|
||||||
|
stream.logger.setLevel(logging.CRITICAL)
|
||||||
|
|
||||||
|
# ----------------- 主逻辑 -----------------
|
||||||
|
class DouyinToYouTubeStreamer:
|
||||||
|
def __init__(self, bitrate=5000, audio_bitrate=128, resolution='720x1280', fps=30, gop=60, enable_recording=False):
|
||||||
|
self.push_proc = None
|
||||||
|
self.record_proc = None
|
||||||
|
self.stop_flag = threading.Event()
|
||||||
|
self.bitrate = bitrate
|
||||||
|
self.audio_bitrate = audio_bitrate
|
||||||
|
self.resolution = resolution
|
||||||
|
self.fps = fps
|
||||||
|
self.gop = gop
|
||||||
|
self.start_time = None
|
||||||
|
self.room_name = None
|
||||||
|
self.pushing = False
|
||||||
|
self.out_file = None
|
||||||
|
self.enable_recording = enable_recording
|
||||||
|
os.makedirs("records", exist_ok=True)
|
||||||
|
self.logger = logging.getLogger(self.__class__.__name__)
|
||||||
|
|
||||||
|
def start_stream(self, real_url, youtube_key, room_name=None):
|
||||||
|
self.room_name = room_name or f"抖音_{str(uuid.uuid4())[:8]}"
|
||||||
|
self.logger.info(f"{Fore.GREEN}[{self.room_name}] 启动推流到 YouTube{Style.RESET_ALL}")
|
||||||
|
threading.Thread(target=self._run_stream_loop, args=(real_url, youtube_key), daemon=True).start()
|
||||||
|
threading.Thread(target=self._monitor_thread, daemon=True).start()
|
||||||
|
|
||||||
|
def _build_push_cmd(self, real_url, youtube_key):
|
||||||
|
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
|
||||||
|
headers = "Referer: https://www.douyin.com/\r\nOrigin: https://www.douyin.com\r\n"
|
||||||
|
return [
|
||||||
|
'ffmpeg', '-y',
|
||||||
|
'-rw_timeout', '10000000',
|
||||||
|
'-user_agent', user_agent,
|
||||||
|
'-headers', headers,
|
||||||
|
'-protocol_whitelist', 'file,pipe,http,https,tcp,tls,udp,rtp,crypto',
|
||||||
|
'-fflags', '+discardcorrupt+genpts',
|
||||||
|
'-flags', 'low_delay',
|
||||||
|
'-i', real_url,
|
||||||
|
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
|
||||||
|
'-b:v', f'{self.bitrate}k',
|
||||||
|
'-maxrate', f'{self.bitrate}k',
|
||||||
|
'-bufsize', f'{self.bitrate * 2}k',
|
||||||
|
'-r', str(self.fps),
|
||||||
|
'-g', str(self.gop),
|
||||||
|
'-s', self.resolution,
|
||||||
|
'-c:a', 'aac', '-b:a', f'{self.audio_bitrate}k', '-ar', '44100', '-ac', '2',
|
||||||
|
'-f', 'flv',
|
||||||
|
f'rtmp://a.rtmp.youtube.com/live2/{youtube_key}'
|
||||||
|
]
|
||||||
|
|
||||||
|
def _build_record_cmd(self, real_url):
|
||||||
|
base_name = f"{self.room_name}_{int(time.time())}.mp4"
|
||||||
|
self.out_file = os.path.join("records", base_name).replace("\\", "/")
|
||||||
|
return [
|
||||||
|
'ffmpeg', '-y',
|
||||||
|
'-i', real_url,
|
||||||
|
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
|
||||||
|
'-c:a', 'aac', '-b:a', f'{self.audio_bitrate}k', '-ar', '44100', '-ac', '2',
|
||||||
|
'-movflags', '+faststart',
|
||||||
|
self.out_file
|
||||||
|
]
|
||||||
|
|
||||||
|
def _run_stream_loop(self, real_url, youtube_key):
|
||||||
|
while not self.stop_flag.is_set():
|
||||||
|
try:
|
||||||
|
self.start_time = time.time()
|
||||||
|
self.pushing = False
|
||||||
|
push_cmd = self._build_push_cmd(real_url, youtube_key)
|
||||||
|
self.push_proc = subprocess.Popen(
|
||||||
|
push_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
||||||
|
text=True, bufsize=1
|
||||||
|
)
|
||||||
|
self.logger.info(f"{Fore.CYAN}[{self.room_name}] 推流进程 PID: {self.push_proc.pid}{Style.RESET_ALL}")
|
||||||
|
|
||||||
|
if self.enable_recording:
|
||||||
|
record_cmd = self._build_record_cmd(real_url)
|
||||||
|
self.record_proc = subprocess.Popen(
|
||||||
|
record_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
||||||
|
text=True, bufsize=1
|
||||||
|
)
|
||||||
|
self.logger.info(f"{Fore.CYAN}[{self.room_name}] 录制到: {self.out_file}{Style.RESET_ALL}")
|
||||||
|
|
||||||
|
def monitor_proc(proc, name):
|
||||||
|
while not self.stop_flag.is_set() and proc and proc.poll() is None:
|
||||||
|
line = proc.stderr.readline()
|
||||||
|
if line and name == "push" and not self.pushing and "frame=" in line:
|
||||||
|
self.pushing = True
|
||||||
|
self.logger.info(f"{Fore.GREEN}[{self.room_name}] 成功推流到 YouTube{Style.RESET_ALL}")
|
||||||
|
|
||||||
|
threading.Thread(target=monitor_proc, args=(self.push_proc, "push"), daemon=True).start()
|
||||||
|
if self.enable_recording:
|
||||||
|
threading.Thread(target=monitor_proc, args=(self.record_proc, "record"), daemon=True).start()
|
||||||
|
|
||||||
|
while not self.stop_flag.is_set():
|
||||||
|
if self.push_proc.poll() is not None:
|
||||||
|
self.logger.warning(f"{Fore.YELLOW}[{self.room_name}] 推流进程退出{Style.RESET_ALL}")
|
||||||
|
self.stop_flag.set()
|
||||||
|
break
|
||||||
|
if self.enable_recording and self.record_proc and self.record_proc.poll() is not None:
|
||||||
|
self.logger.warning(f"{Fore.YELLOW}[{self.room_name}] 录制进程退出,重启中...{Style.RESET_ALL}")
|
||||||
|
self._terminate_procs()
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"{Fore.RED}[{self.room_name}] FFmpeg 错误: {e}{Style.RESET_ALL}")
|
||||||
|
finally:
|
||||||
|
self._terminate_procs()
|
||||||
|
if not self.stop_flag.is_set():
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def _terminate_procs(self):
|
||||||
|
for proc in [self.push_proc, self.record_proc]:
|
||||||
|
if proc and proc.poll() is None:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=2)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
self.push_proc, self.record_proc = None, None
|
||||||
|
|
||||||
|
def _monitor_thread(self):
|
||||||
|
while not self.stop_flag.is_set():
|
||||||
|
uptime = int(time.time() - self.start_time) if self.start_time else 0
|
||||||
|
m, s = divmod(uptime, 60)
|
||||||
|
status = f"{Fore.GREEN}推流中{Style.RESET_ALL}" if self.pushing else f"{Fore.YELLOW}启动中{Style.RESET_ALL}"
|
||||||
|
sys.stdout.write(f"\r[{self.room_name}] {status} | 时间: {m:02d}:{s:02d} | 码率: {self.bitrate}k")
|
||||||
|
sys.stdout.flush()
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.logger.info(f"{Fore.YELLOW}[{self.room_name}] 停止推流和录制{Style.RESET_ALL}")
|
||||||
|
self.stop_flag.set()
|
||||||
|
self._terminate_procs()
|
||||||
|
|
||||||
|
# ----------------- 其他函数保持不变 -----------------
|
||||||
|
async def get_douyin_real_url(record_url: str, index: int, total: int, record_quality="原画", proxy_addr=None, cookies='') -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
def update_status(status: str, nickname: str = "未知主播"):
|
||||||
|
display_url = record_url[:40] + "..." if len(record_url) > 40 else record_url
|
||||||
|
display_nickname = nickname[:20] + "..." if len(nickname) > 20 else nickname
|
||||||
|
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [{display_nickname}] [{status}]")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
||||||
|
"Referer": "https://www.douyin.com/",
|
||||||
|
"Origin": "https://www.douyin.com"
|
||||||
|
}
|
||||||
|
session_args = {"headers": headers}
|
||||||
|
if proxy_addr:
|
||||||
|
session_args["proxy"] = proxy_addr
|
||||||
|
|
||||||
|
update_status(f"{Fore.YELLOW}检测中...{Style.RESET_ALL}")
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(**session_args) as session:
|
||||||
|
try:
|
||||||
|
async with session.get(record_url, cookies=cookies, timeout=10) as resp:
|
||||||
|
text = await resp.text()
|
||||||
|
if not text.strip() or "captcha" in text.lower():
|
||||||
|
update_status(f"{Fore.RED}失败: 验证码{Style.RESET_ALL}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
json_data = await (spider.get_douyin_stream_data if 'v.douyin.com' not in record_url and '/user/' not in record_url
|
||||||
|
else spider.get_douyin_app_stream_data)(url=record_url, proxy_addr=proxy_addr, cookies=cookies)
|
||||||
|
|
||||||
|
if not json_data or not isinstance(json_data, dict):
|
||||||
|
update_status(f"{Fore.RED}失败: 无流{Style.RESET_ALL}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# 尝试多种字段获取主播名
|
||||||
|
nickname = (
|
||||||
|
json_data.get('room_title') or
|
||||||
|
json_data.get('title') or
|
||||||
|
json_data.get('user', {}).get('nickname') or
|
||||||
|
json_data.get('owner', {}).get('nickname') or
|
||||||
|
json_data.get('stream', {}).get('owner', {}).get('nickname') or
|
||||||
|
"未知主播"
|
||||||
|
)
|
||||||
|
update_status(f"{Fore.YELLOW}解析中...{Style.RESET_ALL}", nickname)
|
||||||
|
|
||||||
|
port_info = await stream.get_douyin_stream_url(json_data, record_quality, proxy_addr)
|
||||||
|
if port_info and isinstance(port_info, dict) and port_info.get("record_url"):
|
||||||
|
update_status(f"{Fore.GREEN}成功{Style.RESET_ALL}", nickname)
|
||||||
|
return port_info.get("record_url"), nickname
|
||||||
|
update_status(f"{Fore.RED}失败: 无效流{Style.RESET_ALL}", nickname)
|
||||||
|
return None, None
|
||||||
|
except Exception:
|
||||||
|
update_status(f"{Fore.RED}失败: 错误{Style.RESET_ALL}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def start_douyin_to_youtube(record_url: str, youtube_key: str, real_url: Optional[str] = None, nickname: Optional[str] = None,
|
||||||
|
proxy_addr=None, cookies='', bitrate=5000, enable_recording=False):
|
||||||
|
if real_url is None or nickname is None:
|
||||||
|
real_url, nickname = asyncio.run(get_douyin_real_url(record_url, 0, 0, proxy_addr=proxy_addr, cookies=cookies))
|
||||||
|
if not real_url:
|
||||||
|
logger.error(f"{Fore.RED}无法获取 {record_url[:50]}... 的流{Style.RESET_ALL}")
|
||||||
|
return None
|
||||||
|
logger.info(f"{Fore.GREEN}推流自: {real_url[:50]}... [{nickname}]{Style.RESET_ALL}")
|
||||||
|
streamer = DouyinToYouTubeStreamer(bitrate=bitrate, enable_recording=enable_recording)
|
||||||
|
streamer.start_stream(real_url, youtube_key, room_name=nickname)
|
||||||
|
return streamer
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read("config.ini", encoding="utf-8")
|
||||||
|
return config.get("youtube", "key", fallback=None)
|
||||||
|
|
||||||
|
def load_list(last_mtime=0):
|
||||||
|
try:
|
||||||
|
mtime = os.path.getmtime("list.ini")
|
||||||
|
if mtime <= last_mtime:
|
||||||
|
return None, last_mtime
|
||||||
|
with open("list.ini", "r", encoding="utf-8") as f:
|
||||||
|
urls = [line.strip() for line in f if line.strip() and not line.startswith("#")]
|
||||||
|
logger.info(f"{Fore.CYAN}加载 {len(urls)} 个唯一 URL{Style.RESET_ALL}")
|
||||||
|
return urls, mtime
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error(f"{Fore.RED}未找到 list.ini 文件{Style.RESET_ALL}")
|
||||||
|
return [], last_mtime
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
youtube_key = load_config()
|
||||||
|
if not youtube_key:
|
||||||
|
logger.error(f"{Fore.RED}config.ini 中无 YouTube Key{Style.RESET_ALL}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
streamer_instance = None
|
||||||
|
last_mtime = 0
|
||||||
|
urls = []
|
||||||
|
|
||||||
|
def signal_handler(sig, frame):
|
||||||
|
if streamer_instance:
|
||||||
|
streamer_instance.stop()
|
||||||
|
sys.stdout.write("\r" + " " * 80 + "\r")
|
||||||
|
sys.stdout.flush()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
|
||||||
|
logger.info(f"{Fore.GREEN}启动抖音直播检测{Style.RESET_ALL}")
|
||||||
|
while True:
|
||||||
|
urls_result, new_mtime = load_list(last_mtime)
|
||||||
|
if urls_result is not None:
|
||||||
|
urls = urls_result
|
||||||
|
last_mtime = new_mtime
|
||||||
|
|
||||||
|
if not streamer_instance or (hasattr(streamer_instance, 'stop_flag') and streamer_instance.stop_flag.is_set()):
|
||||||
|
if streamer_instance:
|
||||||
|
logger.info(f"{Fore.YELLOW}直播中断,重新遍历 URL 列表{Style.RESET_ALL}")
|
||||||
|
sys.stdout.write("\r" + " " * 80 + "\r")
|
||||||
|
sys.stdout.flush()
|
||||||
|
time.sleep(5) # 中断后短暂延迟
|
||||||
|
streamer_instance = None
|
||||||
|
if not urls:
|
||||||
|
sys.stdout.write(f"\r{Fore.YELLOW}无URL列表,10秒后重试...{Style.RESET_ALL}")
|
||||||
|
sys.stdout.flush()
|
||||||
|
time.sleep(10)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for i, url in enumerate(urls, 1):
|
||||||
|
real_url, nickname = asyncio.run(get_douyin_real_url(url, i, len(urls)))
|
||||||
|
sys.stdout.write("\r" + " " * 80 + "\r")
|
||||||
|
sys.stdout.flush()
|
||||||
|
if real_url and nickname:
|
||||||
|
logger.info(f"{Fore.GREEN}发现直播: {url[:50]}... [{nickname}]{Style.RESET_ALL}")
|
||||||
|
streamer_instance = start_douyin_to_youtube(url, youtube_key, real_url=real_url, nickname=nickname)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not streamer_instance:
|
||||||
|
sys.stdout.write(f"\r{Fore.YELLOW}未找到直播,60秒后重试...{Style.RESET_ALL}")
|
||||||
|
sys.stdout.flush()
|
||||||
|
time.sleep(60)
|
||||||
|
else:
|
||||||
|
time.sleep(10)
|
||||||
26
config.ini
26
config.ini
@@ -1,2 +1,28 @@
|
|||||||
[youtube]
|
[youtube]
|
||||||
key = qxvb-r47b-r5ju-6ud3-6k7z
|
key = qxvb-r47b-r5ju-6ud3-6k7z
|
||||||
|
bitrate = 5000
|
||||||
|
audio_bitrate = 128
|
||||||
|
resolution = 720x1280
|
||||||
|
fps = 30
|
||||||
|
gop = 60
|
||||||
|
|
||||||
|
[general]
|
||||||
|
list = list.ini
|
||||||
|
|
||||||
|
[douyin]
|
||||||
|
cookies =
|
||||||
|
|
||||||
|
[network]
|
||||||
|
proxy =
|
||||||
|
|
||||||
|
[record]
|
||||||
|
enable = false
|
||||||
|
dir = records
|
||||||
|
|
||||||
|
[backoff]
|
||||||
|
base = 10
|
||||||
|
cap = 120
|
||||||
|
|
||||||
|
[ffmpeg]
|
||||||
|
rw_timeout_ms = 10000000
|
||||||
|
reconnect = true
|
||||||
|
|||||||
1
list.ini
1
list.ini
@@ -1,5 +1,4 @@
|
|||||||
https://live.douyin.com/43665279648
|
https://live.douyin.com/43665279648
|
||||||
https://live.douyin.com/359511214397
|
|
||||||
https://live.douyin.com/72212955083
|
https://live.douyin.com/72212955083
|
||||||
https://live.douyin.com/621118100231
|
https://live.douyin.com/621118100231
|
||||||
https://live.douyin.com/347310028364
|
https://live.douyin.com/347310028364
|
||||||
|
|||||||
710
live.py
710
live.py
@@ -1,30 +1,56 @@
|
|||||||
import os
|
#!/usr/bin/env python3
|
||||||
import subprocess
|
# -*- coding: utf-8 -*-
|
||||||
import threading
|
"""
|
||||||
|
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 asyncio
|
||||||
import uuid
|
import contextlib
|
||||||
import time
|
import dataclasses
|
||||||
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
import aiohttp
|
|
||||||
import spider # 您的 spider 模块
|
|
||||||
import stream # 您的 stream 模块
|
|
||||||
import configparser
|
import configparser
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple, List
|
||||||
from colorama import init, Fore, Style
|
|
||||||
from logging.handlers import RotatingFileHandler
|
|
||||||
|
|
||||||
# ----------------- 日志设置 -----------------
|
import aiohttp
|
||||||
# 初始化 colorama
|
from colorama import init as color_init, Fore, Style
|
||||||
init(autoreset=True)
|
|
||||||
|
|
||||||
# 创建 logs 目录
|
# ====== 第三方模块日志降噪(尽量提前) ======
|
||||||
LOG_DIR = "logs"
|
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)
|
os.makedirs(LOG_DIR, exist_ok=True)
|
||||||
LOG_FILE = os.path.join(LOG_DIR, "app.log")
|
LOG_FILE = os.path.join(LOG_DIR, "app.log")
|
||||||
|
|
||||||
# 自定义彩色日志 Formatter
|
|
||||||
class ColorFormatter(logging.Formatter):
|
class ColorFormatter(logging.Formatter):
|
||||||
COLORS = {
|
COLORS = {
|
||||||
logging.DEBUG: Fore.BLUE,
|
logging.DEBUG: Fore.BLUE,
|
||||||
@@ -33,310 +59,452 @@ class ColorFormatter(logging.Formatter):
|
|||||||
logging.ERROR: Fore.RED,
|
logging.ERROR: Fore.RED,
|
||||||
logging.CRITICAL: Fore.RED + Style.BRIGHT,
|
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 format(self, record):
|
def build_logger() -> logging.Logger:
|
||||||
color = self.COLORS.get(record.levelno, "")
|
from logging.handlers import RotatingFileHandler
|
||||||
message = super().format(record)
|
logger = logging.getLogger("main")
|
||||||
return f"{color}{message}{Style.RESET_ALL}"
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
# 控制台日志(INFO 以上,带颜色)
|
# 控制台:INFO+
|
||||||
console_handler = logging.StreamHandler(sys.stdout)
|
ch = logging.StreamHandler(sys.stdout)
|
||||||
console_handler.setLevel(logging.INFO)
|
ch.setLevel(logging.INFO)
|
||||||
console_formatter = ColorFormatter("%(asctime)s [%(levelname)s] %(message)s", "%H:%M:%S")
|
ch.setFormatter(ColorFormatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%H:%M:%S"))
|
||||||
console_handler.setFormatter(console_formatter)
|
|
||||||
|
|
||||||
# 文件日志(DEBUG 以上,自动滚动,5MB*5份)
|
# 文件:DEBUG+
|
||||||
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=5*1024*1024, backupCount=5, encoding="utf-8")
|
fh = RotatingFileHandler(LOG_FILE, maxBytes=5*1024*1024, backupCount=5, encoding="utf-8")
|
||||||
file_handler.setLevel(logging.DEBUG)
|
fh.setLevel(logging.DEBUG)
|
||||||
file_formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S")
|
fh.setFormatter(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])
|
if not logger.handlers:
|
||||||
logger = logging.getLogger("main")
|
logger.addHandler(ch)
|
||||||
|
logger.addHandler(fh)
|
||||||
|
return logger
|
||||||
|
|
||||||
# 抑制不必要的三方日志
|
logger = build_logger()
|
||||||
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:
|
@dataclasses.dataclass
|
||||||
def __init__(self, bitrate=5000, audio_bitrate=128, resolution='720x1280', fps=30, gop=60, enable_recording=False):
|
class Settings:
|
||||||
self.push_proc = None
|
youtube_key: Optional[str] = None
|
||||||
self.record_proc = None
|
bitrate_k: int = 5000
|
||||||
self.stop_flag = threading.Event()
|
audio_bitrate_k: int = 128
|
||||||
self.bitrate = bitrate
|
resolution: str = "720x1280"
|
||||||
self.audio_bitrate = audio_bitrate
|
fps: int = 30
|
||||||
self.resolution = resolution
|
gop: int = 60
|
||||||
self.fps = fps
|
enable_recording: bool = False
|
||||||
self.gop = gop
|
list_path: str = "list.ini"
|
||||||
self.start_time = None
|
cookies: str = "" # 原始字符串或 "k=v;..."
|
||||||
self.room_name = None
|
proxy: Optional[str] = None # eg. "http://127.0.0.1:7890"
|
||||||
self.pushing = False
|
record_dir: str = "records"
|
||||||
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]}"
|
no_live_retry_base: float = 10.0
|
||||||
self.logger.info(f"{Fore.GREEN}[{self.room_name}] 启动推流到 YouTube{Style.RESET_ALL}")
|
no_live_retry_cap: float = 120.0
|
||||||
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):
|
# FFMPEG 超时/网络参数
|
||||||
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"
|
rw_timeout_ms: int = 10_000_000
|
||||||
headers = "Referer: https://www.douyin.com/\r\nOrigin: https://www.douyin.com\r\n"
|
reconnect: bool = True
|
||||||
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):
|
@classmethod
|
||||||
base_name = f"{self.room_name}_{int(time.time())}.mp4"
|
def load(cls) -> "Settings":
|
||||||
self.out_file = os.path.join("records", base_name).replace("\\", "/")
|
cfg = configparser.ConfigParser()
|
||||||
return [
|
cfg.read("config.ini", encoding="utf-8")
|
||||||
'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):
|
def g(section: str, key: str, fallback=None):
|
||||||
while not self.stop_flag.is_set():
|
return cfg.get(section, key, fallback=fallback) if cfg.has_option(section, key) else fallback
|
||||||
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:
|
s = cls(
|
||||||
record_cmd = self._build_record_cmd(real_url)
|
youtube_key=os.environ.get("YOUTUBE_KEY") or (g("youtube", "key") or None),
|
||||||
self.record_proc = subprocess.Popen(
|
bitrate_k=int(os.environ.get("VIDEO_BITRATE_K", g("youtube", "bitrate", fallback=5000))),
|
||||||
record_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
audio_bitrate_k=int(os.environ.get("AUDIO_BITRATE_K", g("youtube", "audio_bitrate", fallback=128))),
|
||||||
text=True, bufsize=1
|
resolution=os.environ.get("VIDEO_RES", g("youtube", "resolution", fallback="720x1280")),
|
||||||
)
|
fps=int(os.environ.get("FPS", g("youtube", "fps", fallback=30))),
|
||||||
self.logger.info(f"{Fore.CYAN}[{self.room_name}] 录制到: {self.out_file}{Style.RESET_ALL}")
|
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
|
||||||
|
|
||||||
def monitor_proc(proc, name):
|
SETTINGS = Settings.load()
|
||||||
while not self.stop_flag.is_set() and proc and proc.poll() is None:
|
if not SETTINGS.youtube_key:
|
||||||
line = proc.stderr.readline()
|
logger.error(Fore.RED + "config.ini 中无 YouTube Key,或未设置环境变量 YOUTUBE_KEY" + Style.RESET_ALL)
|
||||||
if line and name == "push" and not self.pushing and "frame=" in line:
|
sys.exit(1)
|
||||||
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:
|
_USER_AGENT = (
|
||||||
threading.Thread(target=monitor_proc, args=(self.record_proc, "record"), daemon=True).start()
|
"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"
|
||||||
|
|
||||||
while not self.stop_flag.is_set():
|
async def async_sleep(sec: float):
|
||||||
if self.push_proc.poll() is not None:
|
try:
|
||||||
self.logger.warning(f"{Fore.YELLOW}[{self.room_name}] 推流进程退出{Style.RESET_ALL}")
|
await asyncio.sleep(sec)
|
||||||
self.stop_flag.set()
|
except asyncio.CancelledError:
|
||||||
break
|
pass
|
||||||
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:
|
# ============== Douyin 解析 ==============
|
||||||
self.logger.error(f"{Fore.RED}[{self.room_name}] FFmpeg 错误: {e}{Style.RESET_ALL}")
|
async def get_douyin_real_url(record_url: str, index: int, total: int,
|
||||||
finally:
|
record_quality: str = "原画",
|
||||||
self._terminate_procs()
|
proxy_addr: Optional[str] = None,
|
||||||
if not self.stop_flag.is_set():
|
cookies: str = "") -> Tuple[Optional[str], Optional[str]]:
|
||||||
time.sleep(1)
|
"""解析抖音真实流 URL 与昵称。"""
|
||||||
|
def clip(s: str, n: int) -> str:
|
||||||
|
return (s[:n] + "...") if len(s) > n else s
|
||||||
|
|
||||||
def _terminate_procs(self):
|
display_url = clip(record_url, 40)
|
||||||
for proc in [self.push_proc, self.record_proc]:
|
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [解析中...]")
|
||||||
if proc and proc.poll() is None:
|
sys.stdout.flush()
|
||||||
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 = {
|
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",
|
"User-Agent": _USER_AGENT,
|
||||||
"Referer": "https://www.douyin.com/",
|
"Referer": "https://www.douyin.com/",
|
||||||
"Origin": "https://www.douyin.com"
|
"Origin": "https://www.douyin.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
session_args = {"headers": headers}
|
session_args = {"headers": headers}
|
||||||
if proxy_addr:
|
if proxy_addr:
|
||||||
session_args["proxy"] = proxy_addr
|
session_args["proxy"] = proxy_addr
|
||||||
|
|
||||||
update_status(f"{Fore.YELLOW}检测中...{Style.RESET_ALL}")
|
|
||||||
|
|
||||||
async with aiohttp.ClientSession(**session_args) as session:
|
async with aiohttp.ClientSession(**session_args) as session:
|
||||||
try:
|
try:
|
||||||
async with session.get(record_url, cookies=cookies, timeout=10) as resp:
|
async with session.get(record_url, cookies=cookies, timeout=10) as resp:
|
||||||
text = await resp.text()
|
text = await resp.text()
|
||||||
if not text.strip() or "captcha" in text.lower():
|
if not text.strip() or "captcha" in text.lower():
|
||||||
update_status(f"{Fore.RED}失败: 验证码{Style.RESET_ALL}")
|
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [{Fore.RED}失败: 验证码{Style.RESET_ALL}]")
|
||||||
|
sys.stdout.flush()
|
||||||
return None, None
|
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)
|
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):
|
if not json_data or not isinstance(json_data, dict):
|
||||||
update_status(f"{Fore.RED}失败: 无流{Style.RESET_ALL}")
|
sys.stdout.write(f"\r{index}/{total} 检查: {display_url} [{Fore.RED}失败: 无流{Style.RESET_ALL}]")
|
||||||
return None, None
|
sys.stdout.flush()
|
||||||
|
|
||||||
# 尝试多种字段获取主播名
|
|
||||||
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
|
return None, None
|
||||||
except Exception:
|
|
||||||
update_status(f"{Fore.RED}失败: 错误{Style.RESET_ALL}")
|
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
|
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):
|
class AsyncStreamer:
|
||||||
if real_url is None or nickname is None:
|
def __init__(self,
|
||||||
real_url, nickname = asyncio.run(get_douyin_real_url(record_url, 0, 0, proxy_addr=proxy_addr, cookies=cookies))
|
real_url: str,
|
||||||
if not real_url:
|
youtube_key: str,
|
||||||
logger.error(f"{Fore.RED}无法获取 {record_url[:50]}... 的流{Style.RESET_ALL}")
|
|
||||||
return None
|
room_name: Optional[str] = None,
|
||||||
logger.info(f"{Fore.GREEN}推流自: {real_url[:50]}... [{nickname}]{Style.RESET_ALL}")
|
bitrate_k: int = SETTINGS.bitrate_k,
|
||||||
streamer = DouyinToYouTubeStreamer(bitrate=bitrate, enable_recording=enable_recording)
|
audio_bitrate_k: int = SETTINGS.audio_bitrate_k,
|
||||||
streamer.start_stream(real_url, youtube_key, room_name=nickname)
|
resolution: str = SETTINGS.resolution,
|
||||||
return streamer
|
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
|
||||||
|
|
||||||
def load_config():
|
self.push_proc: Optional[asyncio.subprocess.Process] = None
|
||||||
config = configparser.ConfigParser()
|
self.rec_proc: Optional[asyncio.subprocess.Process] = None
|
||||||
config.read("config.ini", encoding="utf-8")
|
self.pushing: bool = False
|
||||||
return config.get("youtube", "key", fallback=None)
|
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 load_list(last_mtime=0):
|
# ---------- 构建命令 ----------
|
||||||
|
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:
|
try:
|
||||||
mtime = os.path.getmtime("list.ini")
|
m = os.path.getmtime(path)
|
||||||
if mtime <= last_mtime:
|
if m <= last_mtime:
|
||||||
return None, last_mtime
|
return None, last_mtime
|
||||||
with open("list.ini", "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
urls = [line.strip() for line in f if line.strip() and not line.startswith("#")]
|
urls = [ln.strip() for ln in f if ln.strip() and not ln.strip().startswith('#')]
|
||||||
logger.info(f"{Fore.CYAN}加载 {len(urls)} 个唯一 URL{Style.RESET_ALL}")
|
logger.info(Fore.CYAN + f"加载 {len(urls)} 个唯一 URL" + Style.RESET_ALL)
|
||||||
return urls, mtime
|
return urls, m
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.error(f"{Fore.RED}未找到 list.ini 文件{Style.RESET_ALL}")
|
logger.error(Fore.RED + f"未找到 {path} 文件" + Style.RESET_ALL)
|
||||||
return [], last_mtime
|
return [], last_mtime
|
||||||
|
|
||||||
if __name__ == "__main__":
|
async def iter_until_live(urls: List[str]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||||
youtube_key = load_config()
|
for i, url in enumerate(urls, 1):
|
||||||
if not youtube_key:
|
real_url, nickname = await get_douyin_real_url(url, i, len(urls), proxy_addr=SETTINGS.proxy, cookies=SETTINGS.cookies)
|
||||||
logger.error(f"{Fore.RED}config.ini 中无 YouTube Key{Style.RESET_ALL}")
|
# 清一行
|
||||||
sys.exit(1)
|
sys.stdout.write("\r" + " " * 100 + "\r")
|
||||||
|
|
||||||
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.stdout.flush()
|
||||||
sys.exit(0)
|
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
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
async def main():
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
# 信号处理
|
||||||
|
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(f"{Fore.GREEN}启动抖音直播检测{Style.RESET_ALL}")
|
logger.info(Fore.GREEN + "启动抖音直播检测" + Style.RESET_ALL)
|
||||||
while True:
|
|
||||||
urls_result, new_mtime = load_list(last_mtime)
|
last_mtime = 0.0
|
||||||
if urls_result is not None:
|
urls: List[str] = []
|
||||||
urls = urls_result
|
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
|
last_mtime = new_mtime
|
||||||
|
|
||||||
if not streamer_instance or (hasattr(streamer_instance, 'stop_flag') and streamer_instance.stop_flag.is_set()):
|
# 若没有在推流或已停止 → 尝试寻找新的直播
|
||||||
if streamer_instance:
|
if (streamer is None) or streamer._stop.is_set():
|
||||||
logger.info(f"{Fore.YELLOW}直播中断,重新遍历 URL 列表{Style.RESET_ALL}")
|
if streamer and streamer._stop.is_set():
|
||||||
sys.stdout.write("\r" + " " * 80 + "\r")
|
logger.info(Fore.YELLOW + "直播中断,准备重新遍历 URL 列表" + Style.RESET_ALL)
|
||||||
sys.stdout.flush()
|
await async_sleep(5)
|
||||||
time.sleep(5) # 中断后短暂延迟
|
streamer = None
|
||||||
streamer_instance = None
|
|
||||||
if not urls:
|
if not urls:
|
||||||
sys.stdout.write(f"\r{Fore.YELLOW}无URL列表,10秒后重试...{Style.RESET_ALL}")
|
sys.stdout.write(f"\r{Fore.YELLOW}无URL列表,{int(backoff)}秒后重试...{Style.RESET_ALL}")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
time.sleep(10)
|
await async_sleep(backoff)
|
||||||
|
backoff = min(backoff * 1.7 + (0.5 if backoff < 30 else 1.0), SETTINGS.no_live_retry_cap)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for i, url in enumerate(urls, 1):
|
raw_url, real_url, nickname = await iter_until_live(urls)
|
||||||
real_url, nickname = asyncio.run(get_douyin_real_url(url, i, len(urls)))
|
if not real_url:
|
||||||
sys.stdout.write("\r" + " " * 80 + "\r")
|
sys.stdout.write(f"\r{Fore.YELLOW}未找到直播,{int(backoff)}秒后重试...{Style.RESET_ALL}")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
if real_url and nickname:
|
await async_sleep(backoff)
|
||||||
logger.info(f"{Fore.GREEN}发现直播: {url[:50]}... [{nickname}]{Style.RESET_ALL}")
|
backoff = min(backoff * 1.7 + (0.5 if backoff < 30 else 1.0), SETTINGS.no_live_retry_cap)
|
||||||
streamer_instance = start_douyin_to_youtube(url, youtube_key, real_url=real_url, nickname=nickname)
|
continue
|
||||||
break
|
|
||||||
|
|
||||||
if not streamer_instance:
|
backoff = SETTINGS.no_live_retry_base # 命中直播 → 重置退避
|
||||||
sys.stdout.write(f"\r{Fore.YELLOW}未找到直播,60秒后重试...{Style.RESET_ALL}")
|
logger.info(Fore.GREEN + f"推流自: {real_url[:50]}... [{nickname}]" + Style.RESET_ALL)
|
||||||
sys.stdout.flush()
|
streamer = AsyncStreamer(
|
||||||
time.sleep(60)
|
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:
|
else:
|
||||||
time.sleep(10)
|
# 正在推流 → 间隔检查
|
||||||
|
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
|
||||||
|
|||||||
19816
logs/app.log
19816
logs/app.log
File diff suppressed because one or more lines are too long
43332
logs/app.log.1
43332
logs/app.log.1
File diff suppressed because one or more lines are too long
43407
logs/app.log.2
43407
logs/app.log.2
File diff suppressed because one or more lines are too long
43515
logs/app.log.3
43515
logs/app.log.3
File diff suppressed because one or more lines are too long
43434
logs/app.log.4
43434
logs/app.log.4
File diff suppressed because one or more lines are too long
43362
logs/app.log.5
43362
logs/app.log.5
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user