refactor(web): rozgłaszanie stanu po WebSockecie
- ConnectionManager - rejestr aktywnych połączeń (bez zmian poza typami).
- StatusService - budowanie obrazu stanu bota. Endpoint get_status miał
126 linii i składał odpowiedź w dwóch wariantach; teraz jest to zestaw
metod budujących poszczególne fragmenty payloadu.
- StatusBroadcaster - rozgłaszanie na żądanie oraz pętla cykliczna, wraz
z zatrzymywaniem zadania przy wyłączaniu aplikacji. Wcześniej zadanie
było tworzone w on_event("startup") i nigdy nie anulowane.
Wariant odpowiedzi dla bota bez serwerów zwraca teraz komplet kluczy
(dochodzi queue_index: -1), więc panel dostaje zawsze ten sam kształt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6e61892c04
commit
c2f6d265c1
3 files changed
+235
No files matched your search
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
}
|
||||
Reference in new issue
Block a user