first commit
This commit is contained in:
+260
@@ -0,0 +1,260 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user