diff --git a/backup/back10 b/backup/back10 new file mode 100644 index 0000000..e05c967 --- /dev/null +++ b/backup/back10 @@ -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) diff --git a/backup/back11 b/backup/back11 new file mode 100644 index 0000000..536b208 --- /dev/null +++ b/backup/back11 @@ -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) diff --git a/config.ini b/config.ini index 31625fa..fe7d873 100644 --- a/config.ini +++ b/config.ini @@ -1 +1,2 @@ -qxvb-r47b-r5ju-6ud3-6k7z \ No newline at end of file +[youtube] +key = ue78-1c3e-mr9g-14mz-9r4z diff --git a/credentials.json b/credentials.json new file mode 100644 index 0000000..b90c227 --- /dev/null +++ b/credentials.json @@ -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"]}} \ No newline at end of file diff --git a/list b/list deleted file mode 100644 index 05dda69..0000000 --- a/list +++ /dev/null @@ -1,12 +0,0 @@ -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/472140253414 -https://live.douyin.com/700846653732 -https://live.douyin.com/81849868631 - -https://live.douyin.com/642534242822 -https://live.douyin.com/469980190666 \ No newline at end of file diff --git a/list.ini b/list.ini new file mode 100644 index 0000000..dff08a4 --- /dev/null +++ b/list.ini @@ -0,0 +1,27 @@ +https://live.douyin.com/767599969296 +https://live.douyin.com/456576000931 +https://live.douyin.com/745606325880 +https://live.douyin.com/511878781385 +https://live.douyin.com/416879949175 +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/483160615952 +https://live.douyin.com/673565298571 +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/472140253414 +https://live.douyin.com/700846653732 +https://live.douyin.com/81849868631 +https://live.douyin.com/690114366322 +https://live.douyin.com/642534242822 +https://live.douyin.com/469980190666 +https://live.douyin.com/388066418744 +https://live.douyin.com/122383435921 diff --git a/live.py b/live.py index b59db22..9cfb1fc 100644 --- a/live.py +++ b/live.py @@ -12,7 +12,8 @@ import aiohttp import spider # 你的 spider 模块 import stream # 你的 stream 模块 import configparser -from typing import Any +from typing import Any, Optional + # ----------------- Logging Setup ----------------- logging.basicConfig( level=logging.INFO, @@ -20,10 +21,6 @@ logging.basicConfig( datefmt="%H:%M:%S" ) - - - - # ----------------- Douyin to YouTube Streamer ----------------- class DouyinToYouTubeStreamer: def __init__(self, bitrate=5000, audio_bitrate=128, resolution='720x1280', @@ -40,20 +37,18 @@ class DouyinToYouTubeStreamer: self.room_name = None self.pushing = False self.out_file = None - self.max_minutes = max_minutes # 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() + 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") @@ -83,7 +78,6 @@ class DouyinToYouTubeStreamer: ] 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("\\", "/") @@ -102,14 +96,12 @@ class DouyinToYouTubeStreamer: ] 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, @@ -117,7 +109,6 @@ class DouyinToYouTubeStreamer: ) 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( @@ -126,7 +117,6 @@ class DouyinToYouTubeStreamer: ) 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: @@ -143,7 +133,6 @@ class DouyinToYouTubeStreamer: 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: @@ -152,12 +141,13 @@ class DouyinToYouTubeStreamer: break if self.push_proc.poll() is not None: - logging.warning(f"[{self.room_name}] 推流进程退出,1秒后重启...") - time.sleep(1) + 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秒后重启...") - time.sleep(1) + self._terminate_procs() break time.sleep(1) @@ -179,7 +169,6 @@ class DouyinToYouTubeStreamer: 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 @@ -188,7 +177,6 @@ class DouyinToYouTubeStreamer: 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() @@ -211,14 +199,15 @@ async def get_douyin_real_url(record_url, record_quality="原画", proxy_addr=No try: async with aiohttp.ClientSession(**session_args) as session: - for attempt in range(3): + for attempt in range(5): # 增加重试次数到5次 try: - async with session.get(record_url, cookies=cookies) as resp: + async with session.get(record_url, cookies=cookies, timeout=10) as resp: text = await resp.text() - if not text or "captcha" in text.lower(): + if not text.strip() or "captcha" in text.lower(): logging.warning(f"第{attempt+1}次尝试获取流失败,返回内容为空或验证码,重试...") - await asyncio.sleep(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) @@ -226,27 +215,42 @@ async def get_douyin_real_url(record_url, record_quality="原画", proxy_addr=No 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 port_info.get("record_url"): + + 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为空") - await asyncio.sleep(1) + 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(1) - logging.error("多次尝试后仍获取抖音真实流失败") + 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, room_name=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): - real_url = asyncio.run(get_douyin_real_url(record_url, proxy_addr=proxy_addr, cookies=cookies)) + 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("获取真实直播流失败") + logging.error(f"获取真实直播流失败: {record_url}") return None logging.info(f"实际直播流: {real_url}") streamer = DouyinToYouTubeStreamer(bitrate=bitrate, enable_recording=enable_recording, max_minutes=None) @@ -254,17 +258,53 @@ def start_douyin_to_youtube(record_url, youtube_key, room_name=None, 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__": - DOUYIN_URL = "https://live.douyin.com/969965492267" - 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("推流实例创建失败,程序退出") + 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() @@ -273,6 +313,11 @@ if __name__ == "__main__": signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) - logging.info("程序已启动,低延迟推流中,按 Ctrl+C 停止...") + logging.info("程序已启动,自动检测抖音直播...") while True: - time.sleep(60) + 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) \ No newline at end of file