"""Unified launcher for source and frozen builds.""" from __future__ import annotations import argparse import asyncio import ctypes import os import socket import subprocess import sys from pathlib import Path APP_DIR = Path(__file__).resolve().parent if str(APP_DIR) not in sys.path: sys.path.insert(0, str(APP_DIR)) from core.runtime_paths import APP_ROOT, DATA_DIR, ensure_runtime_dirs ERROR_ALREADY_EXISTS = 183 MAIN_INSTANCE_MUTEX = "Local\\BetterGI_LiveStreaming_Main_5191" class SingleInstanceLock: """Windows named mutex used by the queue-producing main process only.""" def __init__(self, name: str = MAIN_INSTANCE_MUTEX): self.name = name self._handle = None def acquire(self) -> bool: if os.name != "nt": return True kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) handle = kernel32.CreateMutexW(None, False, self.name) last_error = ctypes.get_last_error() if not handle: raise ctypes.WinError(last_error) self._handle = handle if last_error == ERROR_ALREADY_EXISTS: self.close() return False return True def close(self): if self._handle is None or os.name != "nt": return ctypes.WinDLL("kernel32", use_last_error=True).CloseHandle(self._handle) self._handle = None class _JOBOBJECT_IO_COUNTERS(ctypes.Structure): _fields_ = [ ("ReadOperationCount", ctypes.c_ulonglong), ("WriteOperationCount", ctypes.c_ulonglong), ("OtherOperationCount", ctypes.c_ulonglong), ("ReadTransferCount", ctypes.c_ulonglong), ("WriteTransferCount", ctypes.c_ulonglong), ("OtherTransferCount", ctypes.c_ulonglong), ] class _JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure): _fields_ = [ ("PerProcessUserTimeLimit", ctypes.c_longlong), ("PerJobUserTimeLimit", ctypes.c_longlong), ("LimitFlags", ctypes.c_ulong), ("MinimumWorkingSetSize", ctypes.c_size_t), ("MaximumWorkingSetSize", ctypes.c_size_t), ("ActiveProcessLimit", ctypes.c_ulong), ("Affinity", ctypes.c_size_t), ("PriorityClass", ctypes.c_ulong), ("SchedulingClass", ctypes.c_ulong), ] class _JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure): _fields_ = [ ("BasicLimitInformation", _JOBOBJECT_BASIC_LIMIT_INFORMATION), ("IoInfo", _JOBOBJECT_IO_COUNTERS), ("ProcessMemoryLimit", ctypes.c_size_t), ("JobMemoryLimit", ctypes.c_size_t), ("PeakProcessMemoryUsed", ctypes.c_size_t), ("PeakJobMemoryUsed", ctypes.c_size_t), ] class WindowsJob: """Kill spawned music/TTS processes automatically when the launcher exits.""" JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9 def __init__(self): self._handle = None if os.name != "nt": return kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) handle = kernel32.CreateJobObjectW(None, None) if not handle: raise ctypes.WinError(ctypes.get_last_error()) info = _JOBOBJECT_EXTENDED_LIMIT_INFORMATION() info.BasicLimitInformation.LimitFlags = self.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE ok = kernel32.SetInformationJobObject( handle, self.JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, ctypes.byref(info), ctypes.sizeof(info), ) if not ok: error = ctypes.get_last_error() kernel32.CloseHandle(handle) raise ctypes.WinError(error) self._handle = handle def assign(self, process: subprocess.Popen): if self._handle is None or os.name != "nt": return kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) if not kernel32.AssignProcessToJobObject(self._handle, process._handle): raise ctypes.WinError(ctypes.get_last_error()) def close(self): if self._handle is None or os.name != "nt": return ctypes.WinDLL("kernel32", use_last_error=True).CloseHandle(self._handle) self._handle = None def _is_frozen() -> bool: return bool(getattr(sys, "frozen", False)) def _role_command(role: str, port: int, host: str) -> list[str]: if _is_frozen(): return [sys.executable, "--role", role, "--port", str(port), "--host", host] return [sys.executable, str(Path(__file__).resolve()), "--role", role, "--port", str(port), "--host", host] def _assert_port_available(host: str, port: int): probe_host = "0.0.0.0" if host in {"", "::"} else host family = socket.AF_INET6 if ":" in probe_host else socket.AF_INET with socket.socket(family, socket.SOCK_STREAM) as sock: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0) sock.bind((probe_host, port)) def _spawn_role(role: str, port: int, host: str, *, visible: bool = False) -> subprocess.Popen: creationflags = 0 if os.name == "nt": creationflags = 0x00000010 if visible else 0x08000000 # CREATE_NEW_CONSOLE / CREATE_NO_WINDOW return subprocess.Popen( _role_command(role, port, host), cwd=APP_ROOT, stdout=None if visible else subprocess.DEVNULL, stderr=None if visible else subprocess.DEVNULL, creationflags=creationflags, ) async def _run_queue(host: str, port: int): import danmu_queue await danmu_queue.main(host=host, port=port) async def _run_music(port: int): import music_monitor await music_monitor.run_monitor(port) def _run_tts_monitor(): import tts_monitor sys.argv = [ sys.argv[0], "--state-file", str(DATA_DIR / "tts_state.json"), ] tts_monitor.main() def _stop_children(children: list[subprocess.Popen], timeout: float = 5.0): for child in children: if child.poll() is None: child.terminate() for child in children: if child.poll() is not None: continue try: child.wait(timeout=timeout) except subprocess.TimeoutExpired: child.kill() child.wait(timeout=timeout) async def _run_all(host: str, port: int): children: list[subprocess.Popen] = [] job = WindowsJob() queue_task: asyncio.Task | None = None try: # Refuse stale/conflicting listeners before creating any helper process. _assert_port_available(host, port) queue_task = asyncio.create_task(_run_queue(host, port), name="queue-main") children.append(_spawn_role("music", port, host, visible=False)) children.append(_spawn_role("tts", port, host, visible=True)) for child in children: job.assign(child) await queue_task except Exception: if queue_task is not None and not queue_task.done(): queue_task.cancel() await asyncio.gather(queue_task, return_exceptions=True) raise finally: _stop_children(children) job.close() def main(): ensure_runtime_dirs() parser = argparse.ArgumentParser(description="BetterGI 直播联动统一入口") parser.add_argument("--role", choices=["all", "queue", "music", "tts"], default="all") parser.add_argument("--port", type=int, default=8086) parser.add_argument("--host", default="0.0.0.0", help="Web service bind address") args = parser.parse_args() instance_lock = None if args.role in {"all", "queue"}: instance_lock = SingleInstanceLock() if not instance_lock.acquire(): print("直播系统已经在运行,本次重复启动已拒绝。") return 2 try: if args.role == "tts": _run_tts_monitor() return 0 if args.role == "music": asyncio.run(_run_music(args.port)) return 0 if args.role == "queue": asyncio.run(_run_queue(args.host, args.port)) return 0 asyncio.run(_run_all(args.host, args.port)) return 0 except OSError as exc: if getattr(exc, "winerror", None) == 10048 or getattr(exc, "errno", None) in {48, 98, 10048}: print(f"直播端口 {args.port} 已被占用,服务未启动,也未创建辅助进程。") return 3 raise finally: if instance_lock is not None: instance_lock.close() if __name__ == "__main__": raise SystemExit(main())