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:
lgooseandClaude Bot committed 2026-08-21 01:10:42 +02:00
1 parent 6e61892c04
commit c2f6d265c1
3 files changed
+235

No files matched your search

+31
View File
@@ -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)
+52
View File
@@ -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)
+152
View File
@@ -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),
}