Files
zyrarbot/zyrarbot/application.py
T

218 lines
8.3 KiB
Python

from __future__ import annotations
import asyncio
import logging
import os
import signal
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from zyrarbot.audio.media_probe import MediaProbe
from zyrarbot.audio.track_factory import TrackFactory
from zyrarbot.music.music_player import MusicPlayer
from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver
from zyrarbot.music.track_embed_factory import TrackEmbedFactory
from zyrarbot.settings import Settings
from zyrarbot.storage.guild_config_repository import GuildConfigRepository
from zyrarbot.storage.history_repository import HistoryRepository
from zyrarbot.storage.json_file_store import JsonFileStore
from zyrarbot.storage.queue_repository import QueueRepository
from zyrarbot.storage.uploads_repository import UploadsRepository
from zyrarbot.ui.api.factory import mount_api
from zyrarbot.ui.bot.client import MusicBot
from zyrarbot.ui.bot.guild_resolver import GuildResolver
logger = logging.getLogger(__name__)
class Application:
"""Korzeń kompozycji: składa wszystkie zależności i prowadzi bota razem z API."""
def __init__(self, settings: Settings | None = None):
self.settings = settings or Settings.from_env()
self.config = GuildConfigRepository(
JsonFileStore(self.settings.config_file),
fallback_plays=self.settings.default_plays,
)
self.history = HistoryRepository(
JsonFileStore(self.settings.history_file), limit=self.settings.history_limit
)
self.uploads = UploadsRepository(self.settings.uploads_dir)
self.queues = QueueRepository(os.path.join(self.settings.config_dir, "queues"))
self.tracks = TrackFactory(self.uploads, MediaProbe())
self._server: uvicorn.Server | None = None
self.bot = MusicBot(
self.settings,
self.tracks,
self.uploads,
self.config,
self.history,
restart_callback=self.schedule_restart,
)
self.guilds = GuildResolver(self.bot)
self.player = MusicPlayer(
bot=self.bot,
track_factory=self.tracks,
history=self.history,
config=self.config,
channels=NotificationChannelResolver(self.bot, self.config),
embeds=TrackEmbedFactory(),
queues=self.queues,
)
self.bot.player = self.player
self.web = FastAPI(title="ŻyrarBOT")
self.web.state.restart = self.schedule_restart
broadcaster = mount_api(
self.web,
settings=self.settings,
player=self.player,
guilds=self.guilds,
tracks=self.tracks,
uploads=self.uploads,
config=self.config,
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
self.player.on_state_change = broadcaster.broadcast
broadcaster.start()
logger.info("Panel webowy gotowy, rozgłaszanie stanu uruchomione")
try:
yield
finally:
self.player.on_state_change = None
await broadcaster.stop()
if not self.bot.is_closed():
await self.bot.close()
self.web.router.lifespan_context = lifespan
os.makedirs(self.settings.static_dir, exist_ok=True)
@self.web.get("/icon.jpeg", include_in_schema=False)
async def favicon() -> FileResponse:
"""Serve the dashboard icon before the SPA guild fallback route."""
return FileResponse(os.path.join(self.settings.static_dir, "icon.jpeg"))
@self.web.get("/{guild_route}", include_in_schema=False)
async def guild_panel(guild_route: str) -> FileResponse:
"""Serve the SPA for a direct link to a guild view."""
return FileResponse(os.path.join(self.settings.static_dir, "index.html"))
self.web.mount(
"/", StaticFiles(directory=self.settings.static_dir, html=True), name="static"
)
def schedule_restart(self, delay: float = 0.5) -> None:
"""Zaplanuj restart bota (zakończenie procesu Pythona)."""
asyncio.create_task(self._restart_worker(delay))
async def _restart_worker(self, delay: float) -> None:
logger.info("Zaplanowano restart aplikacji za %.1f s...", delay)
await asyncio.sleep(delay)
logger.info("Kończenie pracy aplikacji w celu restartu...")
try:
if not self.bot.is_closed():
await self.bot.close()
except Exception as error: # noqa: BLE001
logger.error("Błąd podczas zamykania bota: %s", error)
if self._server is not None:
self._server.should_exit = True
# Bezpiecznik: jeśli proces nie zakończy się w ciągu 2.0 s, wymuś wyjście
await asyncio.sleep(2.0)
logger.warning("Wymuszanie zakończenia procesu (os._exit)")
os._exit(0)
async def run(self) -> None:
"""Startuje serwer web, a następnie bota; panel działa też bez Discorda."""
config = uvicorn.Config(
self.web,
host=self.settings.web_host,
port=self.settings.web_port,
log_level="info",
)
server = uvicorn.Server(config)
self._server = server
loop = asyncio.get_running_loop()
def _handle_shutdown_signal() -> None:
logger.info("Odebrano sygnał zakończenia, zamykanie serwera i bota...")
server.should_exit = True
if not self.bot.is_closed():
asyncio.create_task(self.bot.close())
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, _handle_shutdown_signal)
except (NotImplementedError, RuntimeError):
pass
async with self.bot:
server_task = asyncio.create_task(server.serve(), name="web-server")
async def run_bot() -> None:
if not self.settings.discord_token:
raise RuntimeError("Brak DISCORD_TOKEN w środowisku")
await self.bot.start(self.settings.discord_token)
bot_task = asyncio.create_task(run_bot(), name="discord-bot")
try:
done, pending = await asyncio.wait(
[server_task, bot_task],
return_when=asyncio.FIRST_COMPLETED,
)
if server_task in done:
# Serwer webowy zakończył działanie (np. przez sygnał wyjścia lub restart)
logger.info("Serwer webowy zakończył pracę, zamykanie bota Discord...")
if not self.bot.is_closed():
await self.bot.close()
try:
await bot_task
except (asyncio.CancelledError, Exception) as error:
logger.debug("Zakończenie zadania bota: %s", error)
elif bot_task in done:
# Bot Discord zakończył działanie (błąd startu lub wyjątek)
try:
bot_task.result()
except Exception as error: # noqa: BLE001
logger.error("Bot Discord zakończył pracę z błędem: %s", error)
logger.info("Serwer webowy działa dalej...")
await self._wait_for_shutdown(server)
except asyncio.CancelledError:
pass
finally:
server.should_exit = True
if not self.bot.is_closed():
await self.bot.close()
if not server_task.done():
await server_task
if not bot_task.done():
bot_task.cancel()
try:
await bot_task
except (asyncio.CancelledError, Exception):
pass
@staticmethod
async def _wait_for_shutdown(server: uvicorn.Server) -> None:
while not server.should_exit:
try:
await asyncio.sleep(1)
except asyncio.CancelledError:
break