diff --git a/zyrarbot/web/connection_manager.py b/zyrarbot/web/connection_manager.py new file mode 100644 index 0000000..8fafb1a --- /dev/null +++ b/zyrarbot/web/connection_manager.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import WebSocket + + +class ConnectionManager: + """Rejestr aktywnych połączeń WebSocket panelu webowego.""" + + def __init__(self) -> None: + self._connections: list[WebSocket] = [] + + @property + def has_listeners(self) -> bool: + return bool(self._connections) + + async def connect(self, websocket: WebSocket) -> None: + await websocket.accept() + self._connections.append(websocket) + + def disconnect(self, websocket: WebSocket) -> None: + if websocket in self._connections: + self._connections.remove(websocket) + + async def broadcast(self, message: dict[str, Any]) -> None: + for connection in list(self._connections): + try: + await connection.send_json(message) + except Exception: # noqa: BLE001 - zerwane połączenie po prostu usuwamy + self.disconnect(connection) diff --git a/zyrarbot/web/status_broadcaster.py b/zyrarbot/web/status_broadcaster.py new file mode 100644 index 0000000..8c33729 --- /dev/null +++ b/zyrarbot/web/status_broadcaster.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import asyncio +import logging + +from zyrarbot.web.connection_manager import ConnectionManager +from zyrarbot.web.status_service import StatusService + +logger = logging.getLogger(__name__) + +BROADCAST_INTERVAL_SECONDS = 1.0 + + +class StatusBroadcaster: + """Rozgłasza stan bota po WebSocketach - zarówno cyklicznie, jak i na żądanie.""" + + def __init__(self, status: StatusService, connections: ConnectionManager): + self._status = status + self._connections = connections + self._task: asyncio.Task[None] | None = None + + async def broadcast(self) -> None: + if not self._connections.has_listeners: + return + try: + await self._connections.broadcast(self._status.snapshot()) + except Exception as error: # noqa: BLE001 - rozgłaszanie nie może ubić pętli + logger.error("Błąd rozgłaszania stanu: %s", error) + + def start(self) -> None: + if self._task is None or self._task.done(): + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + if self._task is None: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _loop(self) -> None: + while True: + try: + await asyncio.sleep(BROADCAST_INTERVAL_SECONDS) + await self.broadcast() + except asyncio.CancelledError: + break + except Exception as error: # noqa: BLE001 + logger.error("Błąd pętli rozgłaszania stanu: %s", error) diff --git a/zyrarbot/web/status_service.py b/zyrarbot/web/status_service.py new file mode 100644 index 0000000..1333626 --- /dev/null +++ b/zyrarbot/web/status_service.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from typing import Any + +import discord + +from zyrarbot.audio.ytdl_source import YTDLSource +from zyrarbot.bot.guild_resolver import GuildResolver +from zyrarbot.music.music_player import MusicPlayer +from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver + +HISTORY_PAYLOAD_LIMIT = 20 + + +class StatusService: + """Składa pełny obraz stanu bota wysyłany do panelu przez REST i WebSocket.""" + + def __init__(self, player: MusicPlayer, guilds: GuildResolver): + self._player = player + self._guilds = guilds + + def snapshot(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "guilds": self._guilds_payload(), + "voice_channels": self._voice_channels_payload(), + "default_plays": self._player.default_plays, + } + + guild = self._guilds.active_guild() + if guild is None: + payload.update(self._idle_payload()) + return payload + + payload.update(self._guild_payload(guild)) + return payload + + # --- Fragmenty odpowiedzi ---------------------------------------------- + + def _idle_payload(self) -> dict[str, Any]: + return { + "playing": False, + "paused": False, + "loop_single": False, + "loop_playlist": False, + "volume": 100, + "current": None, + "queue": [], + "queue_index": -1, + "history": [], + "active_voice_channel": None, + "text_channels": [], + "active_text_channel_id": None, + } + + def _guild_payload(self, guild: discord.Guild) -> dict[str, Any]: + state = self._player.state(guild.id) + voice_client = self._guilds.voice_client(guild.id) + notification_channel = self._player.notification_channel(guild.id) + + return { + "playing": bool(voice_client and voice_client.is_playing()), + "paused": bool(voice_client and voice_client.is_paused()), + "loop_single": state.loop_mode.loops_track, + "loop_playlist": state.loop_mode.loops_queue, + "volume": int(state.volume * 100), + "current": self._current_payload(state.current), + "queue": [self._track_payload(track) for track in state.queue], + "queue_index": state.index, + "active_voice_channel": self._active_voice_payload(voice_client), + "text_channels": [ + self._channel_payload(guild, channel) + for channel in NotificationChannelResolver.writable_channels(guild) + ], + "active_text_channel_id": ( + str(notification_channel.id) if notification_channel else None + ), + "history": [ + entry.to_dict() + for entry in self._player.history(guild.id)[:HISTORY_PAYLOAD_LIMIT] + ], + } + + def _current_payload(self, track: YTDLSource | None) -> dict[str, Any] | None: + if track is None: + return None + return { + "title": track.title, + "url": track.display_url, + "id": track.id, + "duration": track.duration, + "elapsed": round(track.elapsed, 1), + "plays_left": self._player.plays_left(track), + } + + def _track_payload(self, track: YTDLSource) -> dict[str, Any]: + return { + "title": track.title, + "url": track.display_url, + "id": track.id, + "plays_left": self._player.plays_left(track), + } + + def _active_voice_payload( + self, voice_client: discord.VoiceClient | None + ) -> dict[str, Any] | None: + if not voice_client or not voice_client.channel: + return None + channel = voice_client.channel + return { + "channel_name": channel.name, + "channel_id": str(channel.id), + "guild_name": channel.guild.name, + "guild_id": str(channel.guild.id), + } + + def _voice_channels_payload(self) -> list[dict[str, Any]]: + return [ + { + "guild_name": guild.name, + "guild_id": str(guild.id), + "channel_name": channel.name, + "channel_id": str(channel.id), + } + for guild in self._guilds.guilds() + for channel in guild.voice_channels + ] + + def _guilds_payload(self) -> list[dict[str, Any]]: + payload = [] + for guild in self._guilds.guilds(): + active = self._player.notification_channel(guild.id) + payload.append( + { + "guild_id": str(guild.id), + "guild_name": guild.name, + "text_channels": [ + {"channel_id": str(channel.id), "channel_name": channel.name} + for channel in NotificationChannelResolver.writable_channels(guild) + ], + "active_notification_channel_id": str(active.id) if active else None, + } + ) + return payload + + @staticmethod + def _channel_payload(guild: discord.Guild, channel: discord.TextChannel) -> dict[str, Any]: + return { + "guild_name": guild.name, + "guild_id": str(guild.id), + "channel_name": channel.name, + "channel_id": str(channel.id), + }