Files
Live-streaming/app/main.py
T

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())