218 lines
8.3 KiB
Python
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
|