streamer-widgets/app/main.py

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