diff --git a/back/mult9 b/back/mult9 deleted file mode 100644 index e69de29..0000000 diff --git a/main.py b/main.py index b6a70f7..07507b0 100644 --- a/main.py +++ b/main.py @@ -37,6 +37,7 @@ def _enable_vt_on_windows(): if os.name != "nt": return try: + import msvcrt # noqa: F401 import ctypes kernel32 = ctypes.windll.kernel32 handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE = -11 @@ -62,16 +63,16 @@ class ColorLog: WHITE = "\033[97m" ICONS = { - "INFO": "green_circle", - "WARN": "yellow_circle", - "ERROR": "red_circle", - "DEBUG": "white_circle", - "OK": "check_mark", - "SWAP": "repeat", - "LIVE": "satellite", - "OFF": "no_entry", - "FFMPEG":"movie_camera", - "WATCH": "eyes", + "INFO": "🟢", + "WARN": "🟡", + "ERROR": "🔴", + "DEBUG": "⚪", + "OK": "✅", + "SWAP": "🔁", + "LIVE": "📡", + "OFF": "📴", + "FFMPEG":"🎬", + "WATCH": "👀", } COLORS = { @@ -181,8 +182,11 @@ def _build_youtube_rtmp_url(key: str, secure: bool, ingest: str | None): host = f"{node}.rtmps.youtube.com" if secure else f"{node}.rtmp.youtube.com" return f"{scheme}://{host}/live2/{key}" -# ======================= QSV 优化:build_youtube_output_for_key ======================= def build_youtube_output_for_key(key: str, resolution: Optional[str]) -> Tuple[str, list, list]: + """ + !!!此函数放在文件靠前位置,确保任何地方调用都不再出现 NameError。 + 与旧版保持一致;仅对异常进行更稳妥处理与注释增强。 + """ if not key: logger.error("错误: youtube.ini 未配置 key/keys") sys.exit(1) @@ -198,51 +202,73 @@ def build_youtube_output_for_key(key: str, resolution: Optional[str]) -> Tuple[s out_url = _build_youtube_rtmp_url(key, secure, ingest) - # === QSV 硬件编码(N5105 超低 CPU)=== - # v_args = [ - # "-c:v", "h264_qsv", - # "-preset", "veryfast", - # "-global_quality", "22", - # "-b:v", f"{bv}k", - # "-maxrate", f"{bv}k", - # "-bufsize", f"{max(bv*2, 10000)}k", - # "-g", str(gop//2), - # "-bf", "0", - # "-pix_fmt", "nv12", - # "-look_ahead", "0", - # "-look_ahead_depth", "0", - # "-vprofile", "high", - # "-level", "4.1", - # ] v_args = [ - "-c:v", "h264_qsv", - "-preset", "veryfast", - "-global_quality", "23", # 22→23 再降一点画质换性能 - "-b:v", "3500k", # 你原 5000k → 降码率更省 - "-maxrate", "3500k", - "-bufsize", "7000k", - "-g", "60", - "-bf", "0", - "-pix_fmt", "nv12", - "-look_ahead", "0", + "-c:v", "libx264", + "-preset", "veryfast", + "-tune", "zerolatency", + "-profile:v", "high", + "-level:v", "4.2", + "-pix_fmt", "yuv420p", + "-r", str(fps), + "-g", str(gop), + "-b:v", f"{bv}k", + "-maxrate", f"{bv}k", + "-bufsize", f"{max(2*bv, 4000)}k", + ] + + # v_args = [ + # "-hwaccel", "qsv", # 启用硬件加速解码 + # "-c:v", "h264_qsv", # 使用 QSV H.264 解码 + # "-i", input_file, # 输入文件/流 + # "-c:v", "h264_qsv", # 使用 QSV H.264 编码 + # "-b:v", f"{bv}k", # 目标码率 + # "-maxrate", f"{bv}k", # 最大码率 + # "-bufsize", f"{max(2*bv, 4000)}k", # 缓冲区 + # "-r", str(fps), # 帧率 + # "-g", str(gop), # GOP 长度 + # "-pix_fmt", "nv12", # QSV 支持的像素格式 + # "-profile:v", "high", # High Profile + # "-level:v", "4.1", # Level 4.1,N5105 支持 + # "-f", "flv", # 输出协议(YouTube/RTMP) + # ] + + # v_args = [ + # "-c:v", "libx264", # CPU 编码 H.264 + # "-preset", "superfast", # 性能优先,速度快,CPU 占用低 + # "-tune", "zerolatency", # 低延迟模式,适合直播 + # "-profile:v", "baseline", # baseline 比 high 更轻量,N5105 CPU 更适合 + # "-level:v", "3.1", # 与 baseline 匹配,低复杂度 + # "-pix_fmt", "yuv420p", # 抖音/YouTube 通用 + # "-r", str(fps), # 帧率保持原始 + # "-g", str(gop), # GOP 与帧率匹配,避免过多 I 帧 + # "-b:v", f"{bv}k", # 目标码率 + # "-maxrate", f"{bv}k", + # "-bufsize", f"{max(2*bv, 2000)}k", # 缓冲,CPU轻量化 + # "-tune", "fastdecode", # 优化解码速度,直播客户端更流畅 + # "-x264opts", "no-mbtree:threads=4" # 禁用 mbtree 减少 CPU,固定线程数 + # ] + + a_args = [ + "-c:a", "aac", + "-b:a", f"{ba}k", + "-ar", "44100", + "-ac", "2", ] if res_str: try: w, h = map(int, res_str.lower().split("x")) w, h = _round_res(w, h) - v_args = ["-vf", f"scale={w}:{h}:flags=lanczos"] + v_args + vf = ( + f"scale={w}:{h}:force_original_aspect_ratio=decrease," + f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2," + f"crop={w}:{h}," + f"format=yuv420p" + ) + v_args = ["-vf", vf] + v_args except Exception: logger.warning(f"resolution '{res_str}' 非法,忽略") - # a_args = [ - # "-c:a", "aac", - # "-b:a", f"{ba}k", - # "-ar", "44100", - # "-ac", "2", - # ] - a_args = ["-c:a", "copy"] # 关键!不重编码音频 - return out_url, v_args, a_args # =============== 基础环境 =============== @@ -347,6 +373,9 @@ def is_flv_preferred_platform(link: str) -> bool: return any(i in link for i in ["douyin", "tiktok"]) def select_source_url(link: str, stream_info: dict) -> Optional[str]: + """ + 选源优先级保持不变:抖音/TikTok 优先 FLV(且避免 H265),否则 record_url/m3u8/flv。 + """ if not stream_info: return None if is_flv_preferred_platform(link): @@ -365,6 +394,10 @@ def select_source_url(link: str, stream_info: dict) -> Optional[str]: # =============== 辅助:探测播放 URL 是否可用(用于误判纠正) =============== def _open_with_proxy(url: str, timeout: float = 5.0): + """ + 使用 urllib 在当前代理环境下打开 URL。 + 仅抓取少量字节用于“活跃性探测”,避免大流量。 + """ handlers = [] if proxy_addr: handlers.append(urllib.request.ProxyHandler({"http": proxy_addr, "https": proxy_addr})) @@ -380,6 +413,13 @@ def _open_with_proxy(url: str, timeout: float = 5.0): return opener.open(req, timeout=timeout) def probe_stream_url(real_url: Optional[str], timeout: float = 5.0) -> bool: + """ + 轻量级“探活”: + - m3u8: 取前若干字节并检查 "#EXTM3U" + - flv: 取前3字节是否 "FLV" + - 其它:只要 200 且有数据即可视为“活着” + 失败不抛异常,返回 False + """ if not real_url: return False try: @@ -387,29 +427,36 @@ def probe_stream_url(real_url: Optional[str], timeout: float = 5.0) -> bool: code = getattr(resp, "status", None) or getattr(resp, "code", None) if code and int(code) >= 400: return False - head = resp.read(256) + head = resp.read(256) # 少量字节足够判定格式 u = real_url.lower() if ".m3u8" in u: return b"#EXTM3U" in head.upper() if ".flv" in u: return head.startswith(b"FLV") + # 其它类型:有数据即认为仍在 return bool(head) except Exception: return False # =============== 解析与选源(抖音/TikTok/自定义) =============== +# 修复点①:增强 fetch_port_info —— 重试 + 指数退避(不改签名/调用处) +import random, json, contextlib, urllib.request, urllib.error, re, time + async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict: + """抖音/TikTok 直播解析(带防风控与 JSON fallback)""" proxy = proxy_addr delay = 1.2 for attempt in range(1, 4): try: if "douyin.com" in url: + # ======= 原始接口 ======= if 'v.douyin.com' not in url and '/user/' not in url: j = await spider.get_douyin_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) else: j = await spider.get_douyin_app_stream_data(url=url, proxy_addr=proxy, cookies=dy_cookie) port_info = await stream.get_douyin_stream_url(j, record_quality_code, proxy) + # ======= fallback:接口空时解析 HTML 中 JSON ======= if not port_info.get("is_live"): handlers = [] if proxy: @@ -427,6 +474,7 @@ async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict: with contextlib.closing(opener.open(req, timeout=6)) as resp: html = resp.read(512*1024).decode("utf-8", errors="ignore") + # 解析 roomStore JSON m = re.search(r'"roomStore"\s*:\s*({.*?})\s*,\s*"userStore"', html) if m: try: @@ -440,7 +488,7 @@ async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict: port_info = {"anchor_name": "JSONFallback", "is_live": True} if flv: port_info["flv_url"] = flv else: port_info["m3u8_url"] = m3u8 - CLOG.warn("JSON fallback 成功解析流地址", worker=None) + CLOG.warn("⚠️ JSON fallback 成功解析流地址", worker=None) except Exception as e: CLOG.debug(f"JSON fallback 解析失败: {e}") @@ -476,46 +524,116 @@ async def fetch_port_info(url: str, record_quality_code: str = "OD") -> dict: return {"anchor_name": "", "is_live": False} -# ======================= 精简版 launch_ffmpeg(去冗余参数) ======================= +# =============== 启动/停止 ffmpeg =============== def launch_ffmpeg(input_url: str, out_url: str, extra_headers: Optional[str], v_args: list, a_args: list, worker_name: str) -> subprocess.Popen: + user_agent = ("Mozilla/5.0 (Linux; Android 11; SM-G973U) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/87.0.4280.141 Mobile Safari/537.36") + # 输入侧容错/重连/探测 base = [ - "ffmpeg", "-nostdin", "-hide_banner", "-stats", "-stats_period", "60", - "-rw_timeout", "15000000", "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_delay_max", "30", - "-user_agent", "Mozilla/5.0", "-protocol_whitelist", "file,http,https,tcp,tls,crypto,udp,rtmp,rtp,httpproxy", - "-thread_queue_size", "2048", "-analyzeduration", "5000000", "-probesize", "5000000", - ] - if extra_headers: base += ["-headers", extra_headers] - if proxy_addr: base = ["ffmpeg", "-http_proxy", proxy_addr] + base[1:] - base += ["-re", "-i", input_url] - base += ["-fflags", "+genpts+igndts", "-avoid_negative_ts", "make_zero"] - base += v_args + a_args - base += ["-f", "flv", "-flvflags", "no_duration_filesize", out_url] + "ffmpeg", "-nostdin", "-hide_banner", + "-stats", + "-stats_period", "60", + "-progress", "pipe:1", + "-loglevel", "info", + # 输入容错 & 自动重连 + "-rw_timeout", "15000000", + "-timeout", "10000000", + "-reconnect", "1", + "-reconnect_streamed", "1", + "-reconnect_at_eof", "1", + "-reconnect_delay_max", "30", + + # UA/协议白名单 + "-user_agent", user_agent, + "-protocol_whitelist", "file,http,https,tcp,tls,crypto,udp,rtmp,rtp,httpproxy", + + # 缓冲 & 探测 + "-thread_queue_size", "4096", + "-analyzeduration", "10000000", + "-probesize", "10000000", + ] + + # 平台 headers(referer/origin)要在 -i 之前 + if extra_headers: + base += ["-headers", extra_headers] + + # 代理:http_proxy/https_proxy 环境变量(同时保留 -http_proxy 兼容) env = os.environ.copy() if proxy_addr: env["http_proxy"] = proxy_addr env["https_proxy"] = proxy_addr + base = ["ffmpeg", "-http_proxy", proxy_addr] + base[1:] - creationflags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0 - p = subprocess.Popen(base, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=0, env=env, creationflags=creationflags) + # 输入 + base += ["-re", "-i", input_url] + + # 通用容错 + 时间戳修复(优化版) + base += [ + "-fflags", "+genpts+igndts+flush_packets", + "-use_wallclock_as_timestamps", "1", + "-avioflags", "direct", + "-rtbufsize", "100M", + "-err_detect", "ignore_err", + "-avoid_negative_ts", "make_zero", + ] + + # 视频/音频编码参数(使用 build_youtube_output 的结果) + v_args = v_args or [] + a_args = a_args or [] + base += v_args + a_args + + # 输出(flv 到 YT,更宽容) + base += ["-f", "flv", "-flvflags", "no_duration_filesize", out_url] + + cmd_str = " ".join(base) + # 控制台+文件日志 + CLOG.live(f"FFmpeg CMD({worker_name}): {cmd_str if len(cmd_str) < 500 else cmd_str[:500] + ' ...'}", worker=worker_name) + logger.info(f"{worker_name} 启动推流:{input_url} -> {out_url}") + + creationflags = 0 + if os.name == "nt": + creationflags = subprocess.CREATE_NEW_PROCESS_GROUP + + p = subprocess.Popen( + base, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=0, + env=env, + creationflags=creationflags + ) p.last_out_ts = time.time() - with ALL_LOCK: ALL_PROCS.add(p) + # ---- 注册进程,便于 Ctrl+C 统一清理 ---- + with ALL_LOCK: + ALL_PROCS.add(p) def _reader(): try: for line in iter(p.stdout.readline, b''): - if not line: break + if not line: + break text = line.decode(errors="ignore").strip() p.last_out_ts = time.time() - if any(k in text for k in ["bitrate=", "frame=", "speed="]): - CLOG.info(f"[统计] {text}", worker=worker_name) - except: pass + + # 只保留关键统计/错误(前缀带 Worker) + if ("bitrate=" in text) or ("frame=" in text) or ("speed=" in text) or ("libx264" in text): + CLOG.info(f"[ffmpeg统计] {text}", worker=worker_name) + if "Server disconnected" in text: + CLOG.error(f"[输出异常] {text}", worker=worker_name) + elif ("not supported" in text) or ("Invalid" in text) or ("invalid" in text): + CLOG.error(f"[转码异常] {text}", worker=worker_name) + except Exception: + pass + threading.Thread(target=_reader, daemon=True).start() return p @@ -534,6 +652,7 @@ def stop_ffmpeg(p: Optional[subprocess.Popen]): with contextlib.suppress(Exception): p.kill() finally: + # ---- 从全局集合移除 ---- with ALL_LOCK: ALL_PROCS.discard(p) @@ -563,8 +682,8 @@ def detect_platform(url: str) -> str: class LogState: def __init__(self): self.lock = threading.Lock() - self.url_status: Dict[str, str] = {} - self.no_source_worker: Dict[str, bool] = {} + self.url_status: Dict[str, str] = {} # url -> "unknown/offline/online/streaming" + self.no_source_worker: Dict[str, bool] = {} # worker_name -> idle printed flag def set_status(self, url: str, status: str, text: Optional[str] = None, prefix: str = ""): with self.lock: @@ -588,6 +707,10 @@ LOGSTATE = LogState() # =============== URL 协调器(url 级独占,保持原顺序) =============== class UrlCoordinator: + """ + - urls 顺序分配 + - in_use_urls: url 级别独占(防止同一 url 被不同 worker 同时占用) + """ def __init__(self, path: str): self.path = path self.urls: List[str] = parse_urls_from_file(path) @@ -607,6 +730,7 @@ class UrlCoordinator: logger.warning(f"URL 列表热更新失败:{e}") def acquire_next_live(self, worker_key: str, quality_code: str = "OD") -> Optional[Tuple[str, dict]]: + """尝试遍历一轮 URL 表,找到首个在播的;否则返回 None。""" with self.lock: total = len(self.urls) if total == 0: @@ -617,10 +741,12 @@ class UrlCoordinator: with self.lock: url = self.urls[self.idx] self.idx = (self.idx + 1) % total + if url in self.in_use_urls: tried += 1 continue + # 锁外解析(避免阻塞) port = asyncio.run(fetch_port_info(url, quality_code)) anchor = port.get('anchor_name', '') if not port.get("anchor_name"): @@ -632,6 +758,7 @@ class UrlCoordinator: tried += 1 continue + # 回到锁内占用 with self.lock: if url in self.in_use_urls: tried += 1 @@ -666,28 +793,35 @@ class StreamWorker(threading.Thread): self.current_anchor = "" self.record_quality_code = "OD" - self.watchdog_grace = 120 - self.watchdog_silence = 600 - self.refresh_real_url_every = 300 - self.check_interval_live = 8 - self.scan_interval_when_idle = 2 + # 与原逻辑一致的参数(稳定优先) + self.watchdog_grace = 120 # 推流启动后宽限期 + self.watchdog_silence = 600 # 10 分钟无输出视为卡死 + self.refresh_real_url_every = 300 # 5 分钟刷新一次真实地址(token 可能过期) + self.check_interval_live = 8 # 在播时的检测间隔 + self.scan_interval_when_idle = 2 # 空闲时的扫描间隔 self._last_progress_print = -9999 self._stop_event = threading.Event() self.start_time = time.time() + # 修复点②:新增下播防抖状态(连续多次 is_live=False 才判定) self._offline_count = 0 self._last_live_ts = time.time() + # 软重启冷却(避免频繁重启) self._last_soft_restart_ts = 0.0 self._soft_restart_min_interval = 90.0 def stop(self): + """收到停止请求时,尽快停掉当前 ffmpeg。""" self._stop_event.set() with contextlib.suppress(Exception): stop_ffmpeg(self.current_proc) def _soft_restart_ffmpeg(self, reason: str): + """ + 尝试“软重启”ffmpeg:仅重启进程,不释放 URL 占用,优先保持连续性。 + """ now = time.time() if now - self._last_soft_restart_ts < self._soft_restart_min_interval: CLOG.warn(f"跳过软重启(冷却中): {reason}", worker=self.worker_name) @@ -702,6 +836,7 @@ class StreamWorker(threading.Thread): out_url, v_args, a_args = build_youtube_output_for_key(self.key, self.resolution) headers = headers_for_platform(self.current_platform, self.current_url or "") if not self.current_real_url: + # 尝试重新解析一遍 new_port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) if self.current_url else {} self.current_real_url = select_source_url(self.current_url or "", new_port) if not self.current_real_url: @@ -721,6 +856,7 @@ class StreamWorker(threading.Thread): try: self.coordinator.refresh_if_changed() + # ========== 已在推流:健康检测 ========== if self.current_proc and self.current_url: retcode = self.current_proc.poll() if retcode is not None: @@ -731,6 +867,7 @@ class StreamWorker(threading.Thread): LOGSTATE.clear_idle_flag(self.worker_name) continue + # 检测是否仍在直播(加入“防抖 + 交叉探活”) still_live = True is_live_raw = True try: @@ -738,14 +875,16 @@ class StreamWorker(threading.Thread): is_live_raw = bool(port.get("is_live")) except Exception as e: CLOG.warn(f"检测当前源异常:{e}", worker=self.worker_name) - is_live_raw = True + is_live_raw = True # 网络/接口异常默认不下播 if not is_live_raw: + # 交叉探活:若当前 real_url 还能探测成功,认为仍在播 alive_by_probe = probe_stream_url(self.current_real_url) if alive_by_probe: CLOG.warn("API 返回下播但探活成功,视为仍在播。", worker=self.worker_name) is_live_raw = True + # 下播防抖:连续 N 次失败 且 距最后一次活跃已超过 M 秒 if is_live_raw: self._offline_count = 0 self._last_live_ts = time.time() @@ -759,10 +898,11 @@ class StreamWorker(threading.Thread): CLOG.warn(f"疑似下播 {self._offline_count}/3 次,继续观望", worker=self.worker_name) still_live = True else: - still_live = False + still_live = False # 多次确认才下播 if still_live: elapsed = int(time.time() - self.start_time) + # 每 60s 打印一次“正在转播” if elapsed - self._last_progress_print >= 60: h, m = divmod(elapsed, 3600) m, s = divmod(m, 60) @@ -770,6 +910,7 @@ class StreamWorker(threading.Thread): CLOG.live(f"正在转播({dur_str}):{self.current_platform} | {self.current_anchor}", worker=self.worker_name) self._last_progress_print = elapsed + # 定期刷新真实播放地址(token) try: if elapsed >= self.refresh_real_url_every and (elapsed % self.refresh_real_url_every) < self.check_interval_live: new_port = asyncio.run(fetch_port_info(self.current_url, self.record_quality_code)) @@ -788,6 +929,7 @@ class StreamWorker(threading.Thread): except Exception as e: CLOG.warn(f"刷新播放地址失败:{e}", worker=self.worker_name) + # 看门狗:长时间无输出 -> 先软重启,如果软重启失败再释放 silence = time.time() - getattr(self.current_proc, "last_out_ts", 0) if elapsed >= self.watchdog_grace and silence > self.watchdog_silence: CLOG.warn("超过10分钟未见输出,尝试软重启…", worker=self.worker_name) @@ -811,11 +953,12 @@ class StreamWorker(threading.Thread): LOGSTATE.clear_idle_flag(self.worker_name) continue + # ========== 未在推流:尝试分配新源 ========== got = self.coordinator.acquire_next_live(self.key, self.record_quality_code) if not got: LOGSTATE.mark_idle_once(self.worker_name) time.sleep(self.scan_interval_when_idle) - + continue LOGSTATE.clear_idle_flag(self.worker_name) url, port = got @@ -826,6 +969,7 @@ class StreamWorker(threading.Thread): time.sleep(1) continue + # 新源上线前:探活(避免刚拿到 URL 就空推) if not probe_stream_url(real_url): CLOG.warn(f"播放 URL 探活失败(可能刚开播或接口延迟),暂缓此源:{url}", worker=self.worker_name) self.coordinator.release(url, self.key) @@ -847,6 +991,7 @@ class StreamWorker(threading.Thread): CLOG.ok(f"开始转播:{self.current_platform} | {self.current_anchor} | {url}", worker=self.worker_name) except Exception as e: + # 关键:任何内部异常不让线程退出 CLOG.error(f"内部异常:{e}", worker=self.worker_name) time.sleep(2) continue @@ -871,19 +1016,22 @@ def check_ffmpeg_existence() -> bool: try: result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True) if result.returncode == 0: - print(result.stdout.splitlines()[0]) + print(result.stdout.splitlines()[0]) # 贴合旧风格:打印版本第一行 except Exception: pass finally: return bool(check_ffmpeg()) +# ---- 统一优雅退出 ---- def graceful_shutdown(signum=None, frame=None): if not SHUTDOWN_EVENT.is_set(): print("\n收到中断,正在停止所有 worker 与 ffmpeg …") SHUTDOWN_EVENT.set() + # 先发 stop 给所有 worker(会内部 stop 当前 ffmpeg) for w in list(ALL_WORKERS): with contextlib.suppress(Exception): w.stop() + # 再兜底停止全局登记的 ffmpeg 子进程 with ALL_LOCK: procs = list(ALL_PROCS) for p in procs: @@ -903,6 +1051,7 @@ def main(): logger.error("URL_config.ini 中没有可用 URL。") sys.exit(1) + # keys(并行路数 = keys 数量);兼容单 key keys = _yt_get_list("youtube", "keys") if not keys: single_key = _yt_get("youtube", "key", fallback="", cast=str) @@ -912,10 +1061,12 @@ def main(): logger.error("youtube.ini 没有配置 [youtube].keys 或 key") sys.exit(1) - per_res = _yt_get_list("youtube", "resolutions") + # 可选:每路分辨率 + per_res = _yt_get_list("youtube", "resolutions") # 如:1920x1080,1080x1920 while per_res and len(per_res) < len(keys): per_res.append(per_res[-1]) + # 打印 ffmpeg 版本(贴合旧风格) try: vline = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True).stdout.splitlines()[0] print(vline) @@ -924,11 +1075,13 @@ def main(): coordinator = UrlCoordinator(url_config_file) + # 信号处理(Ctrl+C / kill) with contextlib.suppress(Exception): signal.signal(signal.SIGINT, graceful_shutdown) with contextlib.suppress(Exception): signal.signal(signal.SIGTERM, graceful_shutdown) + # 启动多路(Worker 前缀区分) workers: List[StreamWorker] = [] for i, key in enumerate(keys, start=1): res_for_this = per_res[i-1] if per_res else None @@ -949,4 +1102,4 @@ def main(): print("全部 worker 已退出。") if __name__ == "__main__": - main() \ No newline at end of file + main()