This commit is contained in:
eric
2025-11-05 04:46:18 +08:00
parent db04131a9c
commit 6d7f3b874f
2 changed files with 233 additions and 80 deletions

313
main.py
View File

@@ -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.1N5105 支持
# "-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",
]
# 平台 headersreferer/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()
main()