200 lines
6.5 KiB
Python
200 lines
6.5 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from aiohttp import web
|
|
|
|
from app.auth import load_tokens
|
|
from app.chat_manager import ChatManager
|
|
from app.chat_models import ChatConfig
|
|
from app.config import create_example_config, get_config_file, load_chat_settings
|
|
from app.providers.gsmtc import run_gsmtc_provider
|
|
from app.state import AppState
|
|
from app.webserver import make_app
|
|
|
|
|
|
def _configure_asyncio() -> None:
|
|
"""
|
|
Windows: avoid noisy Proactor transport errors on abrupt socket closes and
|
|
improve compatibility by using the selector event loop policy.
|
|
"""
|
|
if os.name == "nt":
|
|
try:
|
|
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) # type: ignore[attr-defined]
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _install_loop_exception_handler(loop: asyncio.AbstractEventLoop) -> None:
|
|
def handler(_loop: asyncio.AbstractEventLoop, context: dict) -> None:
|
|
exc = context.get("exception")
|
|
# Ignore common noisy Windows disconnect error (browser/tab closes etc.)
|
|
if isinstance(exc, ConnectionResetError) and getattr(exc, "winerror", None) == 10054:
|
|
return
|
|
_loop.default_exception_handler(context)
|
|
|
|
loop.set_exception_handler(handler)
|
|
|
|
|
|
def _load_chat_config_from_settings(state: AppState) -> None:
|
|
"""Load saved chat settings into state."""
|
|
settings = load_chat_settings()
|
|
if settings:
|
|
state.chat_config = ChatConfig(
|
|
twitch_channel=settings.get("twitch_channel", ""),
|
|
youtube_video_id=settings.get("youtube_video_id", ""),
|
|
max_messages=settings.get("max_messages", 50),
|
|
show_timestamps=settings.get("show_timestamps", True),
|
|
show_badges=settings.get("show_badges", True),
|
|
show_platform_icons=settings.get("show_platform_icons", True),
|
|
unified_view=settings.get("unified_view", True),
|
|
enable_ffz=settings.get("enable_ffz", True),
|
|
enable_bttv=settings.get("enable_bttv", True),
|
|
enable_7tv=settings.get("enable_7tv", True),
|
|
)
|
|
print(f"Loaded chat settings: twitch={settings.get('twitch_channel', '')}, youtube={settings.get('youtube_video_id', '')}")
|
|
|
|
|
|
async def _run_server(host: str, port: int, state: AppState) -> None:
|
|
# Create example config if it doesn't exist
|
|
create_example_config()
|
|
|
|
# Load saved tokens
|
|
await load_tokens(state)
|
|
|
|
# Load saved chat settings
|
|
_load_chat_config_from_settings(state)
|
|
|
|
app = make_app(state)
|
|
runner = web.AppRunner(app)
|
|
await runner.setup()
|
|
site = web.TCPSite(runner, host, port)
|
|
await site.start()
|
|
|
|
# Start providers
|
|
asyncio.create_task(run_gsmtc_provider(state))
|
|
|
|
# Start chat manager (if configured)
|
|
chat_manager = ChatManager(state)
|
|
state.chat_manager = chat_manager # Store reference for config changes
|
|
await chat_manager.start()
|
|
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(3600)
|
|
finally:
|
|
await chat_manager.stop()
|
|
|
|
|
|
def run_forever(host: str = "127.0.0.1", port: int = 8765) -> None:
|
|
"""
|
|
Blocking entrypoint for console usage.
|
|
"""
|
|
_configure_asyncio()
|
|
loop = asyncio.new_event_loop()
|
|
_install_loop_exception_handler(loop)
|
|
asyncio.set_event_loop(loop)
|
|
state = AppState()
|
|
try:
|
|
loop.run_until_complete(_run_server(host, port, state))
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
loop.stop()
|
|
loop.close()
|
|
|
|
|
|
class ServerController:
|
|
"""
|
|
Starts/stops the asyncio server on a background thread (for tray UI).
|
|
"""
|
|
|
|
def __init__(self, host: str = "127.0.0.1", port: int = 8765) -> None:
|
|
self.host = host
|
|
self.port = port
|
|
self._thread: Optional[threading.Thread] = None
|
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
self._stop_evt = threading.Event()
|
|
|
|
def is_running(self) -> bool:
|
|
return self._thread is not None and self._thread.is_alive()
|
|
|
|
def start(self) -> None:
|
|
if self.is_running():
|
|
return
|
|
self._stop_evt.clear()
|
|
|
|
def _thread_main() -> None:
|
|
_configure_asyncio()
|
|
loop = asyncio.new_event_loop()
|
|
_install_loop_exception_handler(loop)
|
|
self._loop = loop
|
|
asyncio.set_event_loop(loop)
|
|
|
|
state = AppState()
|
|
|
|
async def runner() -> None:
|
|
# Create example config if it doesn't exist
|
|
create_example_config()
|
|
|
|
# Load saved tokens
|
|
await load_tokens(state)
|
|
|
|
# Load saved chat settings
|
|
_load_chat_config_from_settings(state)
|
|
|
|
app = make_app(state)
|
|
runner = web.AppRunner(app)
|
|
await runner.setup()
|
|
site = web.TCPSite(runner, self.host, self.port)
|
|
await site.start()
|
|
provider_task = asyncio.create_task(run_gsmtc_provider(state))
|
|
|
|
# Start chat manager
|
|
chat_manager = ChatManager(state)
|
|
state.chat_manager = chat_manager # Store reference for config changes
|
|
await chat_manager.start()
|
|
|
|
try:
|
|
while not self._stop_evt.is_set():
|
|
await asyncio.sleep(0.2)
|
|
finally:
|
|
await chat_manager.stop()
|
|
provider_task.cancel()
|
|
# CancelledError may derive from BaseException depending on Python version;
|
|
# suppress it so Stop doesn't spam a traceback.
|
|
with contextlib.suppress(BaseException):
|
|
await provider_task
|
|
await runner.cleanup()
|
|
|
|
import contextlib
|
|
|
|
try:
|
|
loop.run_until_complete(runner())
|
|
except BaseException:
|
|
# Avoid noisy background-thread tracebacks on intentional shutdown.
|
|
pass
|
|
finally:
|
|
try:
|
|
loop.stop()
|
|
finally:
|
|
loop.close()
|
|
|
|
self._thread = threading.Thread(target=_thread_main, name="WidgetServer", daemon=True)
|
|
self._thread.start()
|
|
|
|
def stop(self) -> None:
|
|
if not self.is_running():
|
|
return
|
|
self._stop_evt.set()
|
|
if self._thread:
|
|
# Wait for cleanup so the port is released before a subsequent start().
|
|
self._thread.join()
|
|
self._thread = None
|
|
self._loop = None
|
|
|
|
|