261 lines
8.3 KiB
Python
261 lines
8.3 KiB
Python
"""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())
|