refactor: isolate application state per Discord server
This commit is contained in:
30 files changed
+330
-471
No files matched your search
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
@@ -9,23 +10,25 @@ class ConnectionManager:
|
||||
"""Rejestr aktywnych połączeń WebSocket panelu webowego."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._connections: list[WebSocket] = []
|
||||
self._connections: dict[WebSocket, int | None] = {}
|
||||
|
||||
@property
|
||||
def has_listeners(self) -> bool:
|
||||
return bool(self._connections)
|
||||
|
||||
async def connect(self, websocket: WebSocket) -> None:
|
||||
async def connect(self, websocket: WebSocket, guild_id: int | None = None) -> None:
|
||||
await websocket.accept()
|
||||
self._connections.append(websocket)
|
||||
self._connections[websocket] = guild_id
|
||||
|
||||
def disconnect(self, websocket: WebSocket) -> None:
|
||||
if websocket in self._connections:
|
||||
self._connections.remove(websocket)
|
||||
self._connections.pop(websocket, None)
|
||||
|
||||
async def broadcast(self, message: dict[str, Any]) -> None:
|
||||
for connection in list(self._connections):
|
||||
async def broadcast(
|
||||
self, message: dict[str, Any] | Callable[[int | None], dict[str, Any]]
|
||||
) -> None:
|
||||
for connection, guild_id in list(self._connections.items()):
|
||||
try:
|
||||
await connection.send_json(message)
|
||||
payload = message(guild_id) if callable(message) else message
|
||||
await connection.send_json(payload)
|
||||
except Exception: # noqa: BLE001 - zerwane połączenie po prostu usuwamy
|
||||
self.disconnect(connection)
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import discord
|
||||
from fastapi import HTTPException, Request, WebSocket
|
||||
from fastapi import HTTPException, Query, Request, WebSocket
|
||||
|
||||
from zyrarbot.audio.track_factory import TrackFactory
|
||||
from zyrarbot.music.music_player import MusicPlayer
|
||||
@@ -56,9 +56,12 @@ def get_socket_status_service(websocket: WebSocket) -> StatusService:
|
||||
return websocket.app.state.status_service
|
||||
|
||||
|
||||
def get_active_guild(request: Request) -> discord.Guild:
|
||||
"""Serwer, którym steruje panel; brak kontekstu kończy się błędem 400."""
|
||||
guild = get_guilds(request).active_guild()
|
||||
def get_active_guild(request: Request, guild_id: int | None = Query(default=None)) -> discord.Guild:
|
||||
"""Zwraca serwer wskazany przez panel lub pierwszy znany serwer."""
|
||||
guilds = get_guilds(request)
|
||||
guild = next((item for item in guilds.guilds() if item.id == guild_id), None)
|
||||
if guild_id is None:
|
||||
guild = guilds.active_guild()
|
||||
if guild is None:
|
||||
raise HTTPException(status_code=400, detail=NO_GUILD_DETAIL)
|
||||
raise HTTPException(status_code=404, detail="Unknown Discord server")
|
||||
return guild
|
||||
@@ -11,7 +11,6 @@ from zyrarbot.ui.api.connection_manager import ConnectionManager
|
||||
from zyrarbot.ui.api.routers import (
|
||||
playback_router,
|
||||
queue_router,
|
||||
settings_router,
|
||||
status_router,
|
||||
uploads_router,
|
||||
voice_router,
|
||||
@@ -27,7 +26,6 @@ ROUTERS = (
|
||||
queue_router.router,
|
||||
voice_router.router,
|
||||
uploads_router.router,
|
||||
settings_router.router,
|
||||
websocket_router.router,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DefaultPlaysRequest(BaseModel):
|
||||
"""Domyślna liczba odtworzeń przypisywana nowym utworom."""
|
||||
|
||||
default_plays: int
|
||||
@@ -1,8 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TextChannelRequest(BaseModel):
|
||||
"""Ustawienie kanału powiadomień; puste ``channel_id`` czyści ustawienie."""
|
||||
|
||||
channel_id: str
|
||||
guild_id: str | None = None
|
||||
@@ -65,13 +65,13 @@ async def play_file(
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, Any]:
|
||||
_require_voice(player, guild)
|
||||
if not uploads.exists(request.filename):
|
||||
if not uploads.exists(request.filename, guild.id):
|
||||
raise HTTPException(status_code=404, detail="Plik nie istnieje na serwerze")
|
||||
|
||||
try:
|
||||
# Odświeżenie mtime utrzymuje plik na szczycie listy ostatnio używanych.
|
||||
uploads.touch(request.filename)
|
||||
track = await tracks.from_file(uploads.resolve(request.filename))
|
||||
uploads.touch(request.filename, guild.id)
|
||||
track = await tracks.from_file(uploads.resolve(request.filename, guild.id))
|
||||
result = await player.enqueue(guild.id, track, from_web=True)
|
||||
except Exception as error:
|
||||
logger.error("Nie udało się odtworzyć pliku z panelu: %s", error)
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import discord
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from zyrarbot.music.music_player import MusicPlayer
|
||||
from zyrarbot.settings import Settings
|
||||
from zyrarbot.storage.guild_config_repository import GuildConfigRepository
|
||||
from zyrarbot.ui.api.dependencies import (
|
||||
get_broadcaster,
|
||||
get_config,
|
||||
get_guilds,
|
||||
get_player,
|
||||
get_settings,
|
||||
)
|
||||
from zyrarbot.ui.api.requests.default_plays_request import DefaultPlaysRequest
|
||||
from zyrarbot.ui.api.requests.text_channel_request import TextChannelRequest
|
||||
from zyrarbot.ui.api.status_broadcaster import StatusBroadcaster
|
||||
from zyrarbot.ui.bot.guild_resolver import GuildResolver
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
@router.post("/text_channel")
|
||||
async def set_text_channel(
|
||||
request: TextChannelRequest,
|
||||
guilds: GuildResolver = Depends(get_guilds),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
config: GuildConfigRepository = Depends(get_config),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, Any]:
|
||||
if request.channel_id in ("", "0"):
|
||||
if not request.guild_id:
|
||||
raise HTTPException(status_code=400, detail="guild_id required to reset channel")
|
||||
guild_id = int(request.guild_id)
|
||||
player.set_notification_channel(guild_id, None)
|
||||
config.set_notification_channel_id(guild_id, None)
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "success", "channel_id": None, "guild_id": str(guild_id)}
|
||||
|
||||
channel = guilds.channel(int(request.channel_id))
|
||||
if not isinstance(channel, discord.TextChannel):
|
||||
raise HTTPException(status_code=404, detail="Text channel not found")
|
||||
|
||||
guild_id = channel.guild.id
|
||||
if request.guild_id and int(request.guild_id) != guild_id:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Channel does not belong to the specified guild"
|
||||
)
|
||||
|
||||
player.set_notification_channel(guild_id, channel)
|
||||
config.set_notification_channel_id(guild_id, channel.id)
|
||||
|
||||
try:
|
||||
await channel.send(
|
||||
"🔔 Ten kanał został ustawiony jako główny kanał powiadomień dla ŻyrarBOT "
|
||||
f"na serwerze **{channel.guild.name}** z panelu webowego."
|
||||
)
|
||||
except discord.DiscordException as error:
|
||||
logger.error("Nie udało się wysłać wiadomości testowej: %s", error)
|
||||
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "success", "channel_id": request.channel_id, "guild_id": str(guild_id)}
|
||||
|
||||
|
||||
@router.post("/default_plays")
|
||||
async def set_default_plays(
|
||||
request: DefaultPlaysRequest,
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
settings: Settings = Depends(get_settings),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, Any]:
|
||||
if not settings.is_valid_plays(request.default_plays):
|
||||
raise HTTPException(status_code=400, detail="Invalid default plays value")
|
||||
player.set_default_plays(request.default_plays)
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "success", "default_plays": request.default_plays}
|
||||
@@ -2,18 +2,22 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import discord
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from zyrarbot.music.music_player import MusicPlayer
|
||||
from zyrarbot.ui.api.dependencies import get_player, get_status_service
|
||||
from zyrarbot.ui.api.dependencies import get_active_guild, get_player, get_status_service
|
||||
from zyrarbot.ui.api.status_service import StatusService
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["status"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status(status: StatusService = Depends(get_status_service)) -> dict[str, Any]:
|
||||
return status.snapshot()
|
||||
async def get_status(
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
status: StatusService = Depends(get_status_service),
|
||||
) -> dict[str, Any]:
|
||||
return status.snapshot(guild.id)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
|
||||
@@ -2,26 +2,31 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import discord
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
|
||||
from zyrarbot.storage.uploads_repository import UploadsRepository
|
||||
from zyrarbot.ui.api.dependencies import get_uploads
|
||||
from zyrarbot.ui.api.dependencies import get_active_guild, get_uploads
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["uploads"])
|
||||
|
||||
|
||||
@router.get("/uploads")
|
||||
async def list_uploads(uploads: UploadsRepository = Depends(get_uploads)) -> dict[str, Any]:
|
||||
return {"files": [item.to_dict() for item in uploads.list()]}
|
||||
async def list_uploads(
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
uploads: UploadsRepository = Depends(get_uploads),
|
||||
) -> dict[str, Any]:
|
||||
return {"files": [item.to_dict() for item in uploads.list(guild.id)]}
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload(
|
||||
file: UploadFile = File(...),
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
uploads: UploadsRepository = Depends(get_uploads),
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
filename = uploads.save(file.filename or "plik", file.file)
|
||||
filename = uploads.save(file.filename or "plik", file.file, guild.id)
|
||||
except OSError as error:
|
||||
raise HTTPException(status_code=500, detail=f"Błąd zapisu pliku: {error}") from error
|
||||
return {"status": "success", "filename": filename}
|
||||
@@ -30,12 +35,13 @@ async def upload(
|
||||
@router.delete("/uploads/{filename}")
|
||||
async def delete_upload(
|
||||
filename: str,
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
uploads: UploadsRepository = Depends(get_uploads),
|
||||
) -> dict[str, str]:
|
||||
if not uploads.exists(filename):
|
||||
if not uploads.exists(filename, guild.id):
|
||||
raise HTTPException(status_code=404, detail="Plik nie istnieje")
|
||||
try:
|
||||
uploads.delete(filename)
|
||||
uploads.delete(filename, guild.id)
|
||||
except OSError as error:
|
||||
raise HTTPException(status_code=500, detail=str(error)) from error
|
||||
return {"status": "deleted"}
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
|
||||
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
from zyrarbot.ui.api.connection_manager import ConnectionManager
|
||||
from zyrarbot.ui.api.dependencies import get_connections, get_socket_status_service
|
||||
@@ -18,10 +18,11 @@ async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
connections: ConnectionManager = Depends(get_connections),
|
||||
status: StatusService = Depends(get_socket_status_service),
|
||||
guild_id: int | None = Query(default=None),
|
||||
) -> None:
|
||||
await connections.connect(websocket)
|
||||
await connections.connect(websocket, guild_id)
|
||||
try:
|
||||
await websocket.send_json(status.snapshot())
|
||||
await websocket.send_json(status.snapshot(guild_id))
|
||||
while True:
|
||||
# Klient nic nie wysyła - czekamy wyłącznie na rozłączenie.
|
||||
await websocket.receive_text()
|
||||
|
||||
@@ -24,7 +24,7 @@ class StatusBroadcaster:
|
||||
if not self._connections.has_listeners:
|
||||
return
|
||||
try:
|
||||
await self._connections.broadcast(self._status.snapshot())
|
||||
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)
|
||||
|
||||
|
||||
@@ -19,14 +19,19 @@ class StatusService:
|
||||
self._player = player
|
||||
self._guilds = guilds
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
def snapshot(self, guild_id: int | None = None) -> dict[str, Any]:
|
||||
guild = (
|
||||
next((item for item in self._guilds.guilds() if item.id == guild_id), None)
|
||||
if guild_id is not None
|
||||
else self._guilds.active_guild()
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"guilds": self._guilds_payload(),
|
||||
"voice_channels": self._voice_channels_payload(),
|
||||
"default_plays": self._player.default_plays,
|
||||
"voice_channels": self._voice_channels_payload(guild),
|
||||
"default_plays": self._player.guild_default_plays(guild.id)
|
||||
if guild
|
||||
else self._player.default_plays,
|
||||
}
|
||||
|
||||
guild = self._guilds.active_guild()
|
||||
if guild is None:
|
||||
payload.update(self._idle_payload())
|
||||
return payload
|
||||
@@ -63,8 +68,8 @@ class StatusService:
|
||||
"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],
|
||||
"current": self._current_payload(state.current, guild.id),
|
||||
"queue": [self._track_payload(track, guild.id) for track in state.queue],
|
||||
"queue_index": state.index,
|
||||
"active_voice_channel": self._active_voice_payload(voice_client),
|
||||
"text_channels": [
|
||||
@@ -79,7 +84,7 @@ class StatusService:
|
||||
],
|
||||
}
|
||||
|
||||
def _current_payload(self, track: YTDLSource | None) -> dict[str, Any] | None:
|
||||
def _current_payload(self, track: YTDLSource | None, guild_id: int) -> dict[str, Any] | None:
|
||||
if track is None:
|
||||
return None
|
||||
return {
|
||||
@@ -88,15 +93,15 @@ class StatusService:
|
||||
"id": track.id,
|
||||
"duration": track.duration,
|
||||
"elapsed": round(track.elapsed, 1),
|
||||
"plays_left": self._player.plays_left(track),
|
||||
"plays_left": self._player.plays_left(track, guild_id),
|
||||
}
|
||||
|
||||
def _track_payload(self, track: YTDLSource) -> dict[str, Any]:
|
||||
def _track_payload(self, track: YTDLSource, guild_id: int) -> dict[str, Any]:
|
||||
return {
|
||||
"title": track.title,
|
||||
"url": track.display_url,
|
||||
"id": track.id,
|
||||
"plays_left": self._player.plays_left(track),
|
||||
"plays_left": self._player.plays_left(track, guild_id),
|
||||
}
|
||||
|
||||
def _active_voice_payload(
|
||||
@@ -112,7 +117,7 @@ class StatusService:
|
||||
"guild_id": str(channel.guild.id),
|
||||
}
|
||||
|
||||
def _voice_channels_payload(self) -> list[dict[str, Any]]:
|
||||
def _voice_channels_payload(self, selected_guild: discord.Guild | None) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"guild_name": guild.name,
|
||||
@@ -121,6 +126,7 @@ class StatusService:
|
||||
"channel_id": str(channel.id),
|
||||
}
|
||||
for guild in self._guilds.guilds()
|
||||
if selected_guild is None or guild.id == selected_guild.id
|
||||
for channel in guild.voice_channels
|
||||
]
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ class PlaybackCog(MusicCogBase, name="Playback"):
|
||||
async def _uploads_autocomplete(
|
||||
self, interaction: discord.Interaction, current: str
|
||||
) -> list[app_commands.Choice[str]]:
|
||||
names = self._uploads.search(current, limit=AUTOCOMPLETE_LIMIT)
|
||||
names = self._uploads.search(
|
||||
current, limit=AUTOCOMPLETE_LIMIT, guild_id=interaction.guild.id
|
||||
)
|
||||
return [app_commands.Choice(name=name, value=name) for name in names]
|
||||
|
||||
async def _history_autocomplete(
|
||||
@@ -52,7 +54,12 @@ class PlaybackCog(MusicCogBase, name="Playback"):
|
||||
return
|
||||
await interaction.response.defer()
|
||||
try:
|
||||
track = await self._tracks.resolve(query, loop=self.bot.loop)
|
||||
if self._uploads.exists(query, interaction.guild.id):
|
||||
track = await self._tracks.from_file(
|
||||
self._uploads.resolve(query, interaction.guild.id)
|
||||
)
|
||||
else:
|
||||
track = await self._tracks.resolve(query, loop=self.bot.loop)
|
||||
await self._enqueue_and_reply(interaction, track)
|
||||
except Exception as error: # noqa: BLE001
|
||||
logger.error("Komenda /play nie powiodła się: %s", error)
|
||||
@@ -65,11 +72,13 @@ class PlaybackCog(MusicCogBase, name="Playback"):
|
||||
if not await self._ensure_voice(interaction):
|
||||
return
|
||||
await interaction.response.defer()
|
||||
if not self._uploads.exists(filename):
|
||||
if not self._uploads.exists(filename, interaction.guild.id):
|
||||
await interaction.followup.send("Nie znaleziono pliku na serwerze.", ephemeral=True)
|
||||
return
|
||||
try:
|
||||
track = await self._tracks.from_file(self._uploads.resolve(filename))
|
||||
track = await self._tracks.from_file(
|
||||
self._uploads.resolve(filename, interaction.guild.id)
|
||||
)
|
||||
await self._enqueue_and_reply(interaction, track)
|
||||
except Exception as error: # noqa: BLE001
|
||||
logger.error("Komenda /playfile nie powiodła się: %s", error)
|
||||
@@ -95,13 +104,13 @@ class PlaybackCog(MusicCogBase, name="Playback"):
|
||||
try:
|
||||
if entry.is_local:
|
||||
filepath = entry.replay_source or ""
|
||||
if not self._uploads.exists(filepath):
|
||||
if not self._uploads.exists(filepath, guild_id):
|
||||
await interaction.followup.send(
|
||||
"Plik lokalny z historii już nie istnieje na serwerze.", ephemeral=True
|
||||
)
|
||||
return
|
||||
track = await self._tracks.from_file(
|
||||
self._uploads.resolve(filepath), title=entry.title
|
||||
self._uploads.resolve(filepath, guild_id), title=entry.title
|
||||
)
|
||||
else:
|
||||
track = await self._tracks.from_url(entry.url or "", loop=self.bot.loop)
|
||||
|
||||
@@ -29,7 +29,7 @@ class QueueCog(MusicCogBase, name="Queue"):
|
||||
lines: list[str] = []
|
||||
if state.current and not 0 <= state.index < len(state.queue):
|
||||
title = discord.utils.escape_markdown(state.current.title or "")
|
||||
plays = self.player.plays_left(state.current)
|
||||
plays = self.player.plays_left(state.current, interaction.guild.id)
|
||||
lines.append(
|
||||
f"▶️ **{title}** (obecnie odtwarzane - ostatnie odtworzenie) [🔁 {plays}]\n---"
|
||||
)
|
||||
@@ -38,7 +38,8 @@ class QueueCog(MusicCogBase, name="Queue"):
|
||||
prefix = "▶️ **" if is_current else f"{position + 1}. "
|
||||
suffix = "** (obecnie odtwarzane)" if is_current else ""
|
||||
title = discord.utils.escape_markdown(track.title or "")
|
||||
lines.append(f"{prefix}{title}{suffix} [🔁 {self.player.plays_left(track)}]")
|
||||
plays = self.player.plays_left(track, interaction.guild.id)
|
||||
lines.append(f"{prefix}{title}{suffix} [🔁 {plays}]")
|
||||
preview = "\n".join(lines[:QUEUE_PREVIEW_LIMIT])
|
||||
if len(lines) > QUEUE_PREVIEW_LIMIT:
|
||||
preview += f"\n... i {len(lines) - QUEUE_PREVIEW_LIMIT} więcej."
|
||||
|
||||
@@ -49,7 +49,7 @@ class SettingsCog(MusicCogBase, name="Settings"):
|
||||
"❌ Liczba odtworzeń musi być z przedziału 1-999.", ephemeral=True
|
||||
)
|
||||
return
|
||||
self.player.set_default_plays(count)
|
||||
self.player.set_default_plays(interaction.guild.id, count)
|
||||
await self.player.trigger_state_change()
|
||||
await interaction.response.send_message(
|
||||
f"✅ Domyślna liczba odtworzeń dla nowych utworów została ustawiona na: **{count}**."
|
||||
|
||||
Reference in new issue
Block a user