Move web and bot packages to a consolidated ui package
This commit is contained in:
33 files changed
+44
-43
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Komunikacja użytkownika z odtwarzaczem"""
|
||||
@@ -0,0 +1 @@
|
||||
"""Warstwa Discorda: klient bota, cogi i komendy ukośnikowe."""
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from zyrarbot.audio.track_factory import TrackFactory
|
||||
from zyrarbot.ui.bot.cogs.music_cog import MusicCog
|
||||
from zyrarbot.music.music_player import MusicPlayer
|
||||
from zyrarbot.settings import Settings
|
||||
from zyrarbot.storage.uploads_repository import UploadsRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MusicBot(commands.Bot):
|
||||
"""Klient Discorda rejestrujący cog muzyczny i synchronizujący komendy.
|
||||
|
||||
``player`` przypisuje kompozycja aplikacji tuż po utworzeniu klienta -
|
||||
odtwarzacz potrzebuje bota, a bot odtwarzacza, więc pętlę domykamy tutaj.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings, tracks: TrackFactory, uploads: UploadsRepository):
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
intents.voice_states = True
|
||||
super().__init__(command_prefix="!", intents=intents)
|
||||
|
||||
self.settings = settings
|
||||
self.tracks = tracks
|
||||
self.uploads = uploads
|
||||
self.player: MusicPlayer | None = None
|
||||
|
||||
async def setup_hook(self) -> None:
|
||||
if self.player is None:
|
||||
raise RuntimeError("MusicPlayer musi zostać przypisany przed startem bota")
|
||||
|
||||
await self.add_cog(MusicCog(self, self.player, self.tracks, self.uploads, self.settings))
|
||||
|
||||
if self.settings.guild_id:
|
||||
guild = discord.Object(id=self.settings.guild_id)
|
||||
self.tree.copy_global_to(guild=guild)
|
||||
await self.tree.sync(guild=guild)
|
||||
logger.info("Zsynchronizowano komendy z serwerem %s", self.settings.guild_id)
|
||||
else:
|
||||
await self.tree.sync()
|
||||
logger.info("Zsynchronizowano komendy globalnie")
|
||||
|
||||
async def on_ready(self) -> None:
|
||||
if self.user:
|
||||
logger.info("Zalogowano jako %s (%s)", self.user.name, self.user.id)
|
||||
@@ -0,0 +1 @@
|
||||
"""Cogi rejestrowane w kliencie Discorda."""
|
||||
@@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
|
||||
from zyrarbot.audio.track_factory import TrackFactory
|
||||
from zyrarbot.music.loop_mode import LoopMode
|
||||
from zyrarbot.music.music_player import MusicPlayer
|
||||
from zyrarbot.settings import Settings
|
||||
from zyrarbot.storage.uploads_repository import UploadsRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
QUEUE_PREVIEW_LIMIT = 30
|
||||
HISTORY_PREVIEW_LIMIT = 15
|
||||
AUTOCOMPLETE_LIMIT = 25
|
||||
|
||||
|
||||
class MusicCog(commands.Cog, name="MusicBot"):
|
||||
"""Komendy ukośnikowe sterujące odtwarzaczem. Cała logika żyje w ``MusicPlayer``."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bot: commands.Bot,
|
||||
player: MusicPlayer,
|
||||
tracks: TrackFactory,
|
||||
uploads: UploadsRepository,
|
||||
settings: Settings,
|
||||
):
|
||||
self.bot = bot
|
||||
self.player = player
|
||||
self._tracks = tracks
|
||||
self._uploads = uploads
|
||||
self._settings = settings
|
||||
|
||||
# --- Zdarzenia ----------------------------------------------------------
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_voice_state_update(
|
||||
self,
|
||||
member: discord.Member,
|
||||
before: discord.VoiceState,
|
||||
after: discord.VoiceState,
|
||||
) -> None:
|
||||
is_bot_leaving = (
|
||||
self.bot.user
|
||||
and member.id == self.bot.user.id
|
||||
and before.channel is not None
|
||||
and after.channel is None
|
||||
)
|
||||
if is_bot_leaving:
|
||||
self.player.clear(before.channel.guild.id)
|
||||
await self.player.trigger_state_change()
|
||||
|
||||
# --- Pomocnicze ---------------------------------------------------------
|
||||
|
||||
async def _ensure_voice(self, interaction: discord.Interaction) -> bool:
|
||||
if not interaction.user.voice:
|
||||
await interaction.response.send_message(
|
||||
"Nie jesteś połączony z kanałem głosowym.", ephemeral=True
|
||||
)
|
||||
return False
|
||||
|
||||
voice_client = interaction.guild.voice_client
|
||||
if voice_client is None:
|
||||
await interaction.user.voice.channel.connect()
|
||||
elif voice_client.channel != interaction.user.voice.channel:
|
||||
await interaction.response.send_message(
|
||||
"Jestem już połączony z innym kanałem głosowym.", ephemeral=True
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _enqueue_and_reply(
|
||||
self, interaction: discord.Interaction, track, *, prefix: str = ""
|
||||
) -> None:
|
||||
result = await self.player.enqueue(interaction.guild.id, track, channel=interaction.channel)
|
||||
title = discord.utils.escape_markdown(track.title or "Nieznany tytuł")
|
||||
action = "Dodano do kolejki" if result["status"] == "queued" else "Teraz odtwarzane"
|
||||
await interaction.followup.send(f"{action}{prefix}: {title}")
|
||||
|
||||
# --- Odtwarzanie --------------------------------------------------------
|
||||
|
||||
@app_commands.command(name="play", description="Odtwórz utwór z YouTube")
|
||||
@app_commands.describe(query="Link YouTube lub zapytanie wyszukiwania")
|
||||
async def play(self, interaction: discord.Interaction, query: str) -> None:
|
||||
if not await self._ensure_voice(interaction):
|
||||
return
|
||||
await interaction.response.defer()
|
||||
try:
|
||||
track = await self._tracks.resolve(query, loop=self.bot.loop)
|
||||
await self._enqueue_and_reply(interaction, track)
|
||||
except Exception as error: # noqa: BLE001 - komunikat trafia do użytkownika
|
||||
logger.error("Komenda /play nie powiodła się: %s", error)
|
||||
await interaction.followup.send(f"Wystąpił błąd: {error}", ephemeral=True)
|
||||
|
||||
async def _uploads_autocomplete(
|
||||
self, interaction: discord.Interaction, current: str
|
||||
) -> list[app_commands.Choice[str]]:
|
||||
names = self._uploads.search(current, limit=AUTOCOMPLETE_LIMIT)
|
||||
return [app_commands.Choice(name=name, value=name) for name in names]
|
||||
|
||||
@app_commands.command(name="playfile", description="Odtwórz plik przesłany na serwer")
|
||||
@app_commands.describe(filename="Wybierz plik z listy przesłanych")
|
||||
@app_commands.autocomplete(filename=_uploads_autocomplete)
|
||||
async def playfile(self, interaction: discord.Interaction, filename: str) -> None:
|
||||
if not await self._ensure_voice(interaction):
|
||||
return
|
||||
await interaction.response.defer()
|
||||
|
||||
if not self._uploads.exists(filename):
|
||||
await interaction.followup.send("Nie znaleziono pliku na serwerze.", ephemeral=True)
|
||||
return
|
||||
|
||||
try:
|
||||
track = await self._tracks.from_file(self._uploads.resolve(filename))
|
||||
await self._enqueue_and_reply(interaction, track)
|
||||
except Exception as error: # noqa: BLE001
|
||||
logger.error("Komenda /playfile nie powiodła się: %s", error)
|
||||
await interaction.followup.send(f"Wystąpił błąd: {error}", ephemeral=True)
|
||||
|
||||
# --- Kolejka i historia -------------------------------------------------
|
||||
|
||||
@app_commands.command(name="queue", description="Pokaż aktualną kolejkę")
|
||||
async def show_queue(self, interaction: discord.Interaction) -> None:
|
||||
state = self.player.state(interaction.guild.id)
|
||||
if not state.queue:
|
||||
await interaction.response.send_message("Kolejka jest pusta.", ephemeral=True)
|
||||
return
|
||||
|
||||
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)
|
||||
lines.append(
|
||||
f"▶️ **{title}** (obecnie odtwarzane - ostatnie odtworzenie) [🔁 {plays}]\n---"
|
||||
)
|
||||
|
||||
for position, track in enumerate(state.queue):
|
||||
is_current = position == state.index
|
||||
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)}]")
|
||||
|
||||
preview = "\n".join(lines[:QUEUE_PREVIEW_LIMIT])
|
||||
if len(lines) > QUEUE_PREVIEW_LIMIT:
|
||||
preview += f"\n... i {len(lines) - QUEUE_PREVIEW_LIMIT} więcej."
|
||||
await interaction.response.send_message(f"Aktualna kolejka:\n{preview}")
|
||||
|
||||
async def _history_autocomplete(
|
||||
self, interaction: discord.Interaction, current: str
|
||||
) -> list[app_commands.Choice[str]]:
|
||||
entries = self.player.history(interaction.guild.id)
|
||||
matches = [entry for entry in entries if current.lower() in entry.title.lower()]
|
||||
return [
|
||||
app_commands.Choice(name=entry.title[:100], value=entry.id)
|
||||
for entry in matches[:AUTOCOMPLETE_LIMIT]
|
||||
]
|
||||
|
||||
@app_commands.command(
|
||||
name="history", description="Pokaż historię ostatnio odtwarzanych utworów"
|
||||
)
|
||||
async def show_history(self, interaction: discord.Interaction) -> None:
|
||||
entries = self.player.history(interaction.guild.id)
|
||||
if not entries:
|
||||
await interaction.response.send_message(
|
||||
"Historia odtwarzania jest pusta.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
listing = "\n".join(
|
||||
f"{position + 1}. {discord.utils.escape_markdown(entry.title)}"
|
||||
for position, entry in enumerate(entries[:HISTORY_PREVIEW_LIMIT])
|
||||
)
|
||||
await interaction.response.send_message(
|
||||
f"📜 Historia odtwarzania (ostatnie {HISTORY_PREVIEW_LIMIT}):\n{listing}"
|
||||
)
|
||||
|
||||
@app_commands.command(name="replay", description="Odtwórz ponownie utwór z historii")
|
||||
@app_commands.describe(track_id="Wybierz utwór z historii odtwarzania")
|
||||
@app_commands.autocomplete(track_id=_history_autocomplete)
|
||||
async def replay(self, interaction: discord.Interaction, track_id: str) -> None:
|
||||
if not await self._ensure_voice(interaction):
|
||||
return
|
||||
await interaction.response.defer()
|
||||
|
||||
guild_id = interaction.guild.id
|
||||
entries = self.player.history(guild_id)
|
||||
entry = self.player.find_history_entry(guild_id, track_id)
|
||||
if entry is None and track_id.isdigit():
|
||||
position = int(track_id) - 1
|
||||
if 0 <= position < len(entries):
|
||||
entry = entries[position]
|
||||
|
||||
if entry is None:
|
||||
await interaction.followup.send("Nie znaleziono utworu w historii.", ephemeral=True)
|
||||
return
|
||||
|
||||
try:
|
||||
if entry.is_local:
|
||||
filepath = entry.replay_source or ""
|
||||
if not self._uploads.exists(filepath):
|
||||
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
|
||||
)
|
||||
else:
|
||||
track = await self._tracks.from_url(entry.url or "", loop=self.bot.loop)
|
||||
await self._enqueue_and_reply(interaction, track, prefix=" z historii")
|
||||
except Exception as error: # noqa: BLE001
|
||||
logger.error("Komenda /replay nie powiodła się: %s", error)
|
||||
await interaction.followup.send(f"Wystąpił błąd: {error}", ephemeral=True)
|
||||
|
||||
# --- Sterowanie ---------------------------------------------------------
|
||||
|
||||
@app_commands.command(name="skip", description="Pomiń aktualny utwór")
|
||||
async def skip(self, interaction: discord.Interaction) -> None:
|
||||
guild_id = interaction.guild.id
|
||||
state = self.player.state(guild_id)
|
||||
if state.loop_mode.loops_track:
|
||||
state.loop_mode = LoopMode.OFF
|
||||
|
||||
if not self.player.is_active(guild_id):
|
||||
await interaction.response.send_message("Nic nie jest odtwarzane.", ephemeral=True)
|
||||
return
|
||||
|
||||
interaction.guild.voice_client.stop()
|
||||
await self.player.trigger_state_change()
|
||||
await interaction.response.send_message("Pominięto aktualny utwór.")
|
||||
|
||||
@app_commands.command(name="pause", description="Wstrzymaj odtwarzanie")
|
||||
async def pause(self, interaction: discord.Interaction) -> None:
|
||||
voice_client = interaction.guild.voice_client
|
||||
if not voice_client or not voice_client.is_playing():
|
||||
await interaction.response.send_message("Nic nie jest odtwarzane.", ephemeral=True)
|
||||
return
|
||||
voice_client.pause()
|
||||
await self.player.trigger_state_change()
|
||||
await interaction.response.send_message("Odtwarzanie wstrzymane.")
|
||||
|
||||
@app_commands.command(name="resume", description="Wznów odtwarzanie")
|
||||
async def resume(self, interaction: discord.Interaction) -> None:
|
||||
voice_client = interaction.guild.voice_client
|
||||
if not voice_client or not voice_client.is_paused():
|
||||
await interaction.response.send_message(
|
||||
"Odtwarzanie nie jest wstrzymane.", ephemeral=True
|
||||
)
|
||||
return
|
||||
voice_client.resume()
|
||||
await self.player.trigger_state_change()
|
||||
await interaction.response.send_message("Odtwarzanie wznowione.")
|
||||
|
||||
@app_commands.command(name="stop", description="Zatrzymaj odtwarzanie i wyczyść kolejkę")
|
||||
async def stop(self, interaction: discord.Interaction) -> None:
|
||||
guild_id = interaction.guild.id
|
||||
voice_client = interaction.guild.voice_client
|
||||
if voice_client and voice_client.is_playing():
|
||||
voice_client.stop()
|
||||
self.player.clear(guild_id)
|
||||
await self.player.trigger_state_change()
|
||||
await interaction.response.send_message("Zatrzymano odtwarzanie i wyczyszczono kolejkę.")
|
||||
|
||||
@app_commands.command(name="volume", description="Zmień głośność odtwarzacza")
|
||||
@app_commands.describe(volume="Poziom głośności (1-100)")
|
||||
async def volume(self, interaction: discord.Interaction, volume: int) -> None:
|
||||
if interaction.guild.voice_client is None:
|
||||
await interaction.response.send_message(
|
||||
"Nie połączono z kanałem głosowym.", ephemeral=True
|
||||
)
|
||||
return
|
||||
if not 0 < volume <= 100:
|
||||
await interaction.response.send_message(
|
||||
"Wprowadź wartość z przedziału od 1 do 100.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
self.player.set_volume(interaction.guild.id, volume / 100)
|
||||
await self.player.trigger_state_change()
|
||||
await interaction.response.send_message(f"Zmieniono głośność na {volume}%")
|
||||
|
||||
@app_commands.command(name="nowplaying", description="Pokaż aktualnie odtwarzany utwór")
|
||||
async def now_playing(self, interaction: discord.Interaction) -> None:
|
||||
current = self.player.state(interaction.guild.id).current
|
||||
if not current:
|
||||
await interaction.response.send_message(
|
||||
"Nic nie jest obecnie odtwarzane.", ephemeral=True
|
||||
)
|
||||
return
|
||||
title = discord.utils.escape_markdown(current.title or "")
|
||||
await interaction.response.send_message(f"Teraz odtwarzane: {title}")
|
||||
|
||||
@app_commands.command(name="leave", description="Odłącz bota od kanału głosowego")
|
||||
async def leave(self, interaction: discord.Interaction) -> None:
|
||||
if interaction.guild.voice_client is None:
|
||||
await interaction.response.send_message(
|
||||
"Nie jestem na kanale głosowym.", ephemeral=True
|
||||
)
|
||||
return
|
||||
await interaction.guild.voice_client.disconnect()
|
||||
self.player.clear(interaction.guild.id)
|
||||
await interaction.response.send_message("Rozłączono z kanału głosowego.")
|
||||
|
||||
# --- Zapętlanie i limity odtworzeń --------------------------------------
|
||||
|
||||
@app_commands.command(
|
||||
name="loop",
|
||||
description="Zmień tryb zapętlania (Wyłączone -> Cała kolejka -> Jeden utwór)",
|
||||
)
|
||||
async def loop_command(self, interaction: discord.Interaction) -> None:
|
||||
mode = self.player.cycle_loop_mode(interaction.guild.id)
|
||||
await self.player.trigger_state_change()
|
||||
await interaction.response.send_message(mode.label)
|
||||
|
||||
@app_commands.command(
|
||||
name="loopplaylist",
|
||||
description="Włącz/wyłącz zapętlanie całej kolejki (tryb playlisty)",
|
||||
)
|
||||
async def loopplaylist_command(self, interaction: discord.Interaction) -> None:
|
||||
guild_id = interaction.guild.id
|
||||
enabled = not self.player.state(guild_id).loop_mode.loops_queue
|
||||
self.player.set_loop_mode(guild_id, LoopMode.QUEUE if enabled else LoopMode.OFF)
|
||||
await self.player.trigger_state_change()
|
||||
|
||||
description = (
|
||||
"włączone (utwory po zakończeniu wracają na koniec kolejki)" if enabled else "wyłączone"
|
||||
)
|
||||
await interaction.response.send_message(f"🔁 Zapętlanie całej kolejki: **{description}**.")
|
||||
|
||||
@app_commands.command(
|
||||
name="defaultplays",
|
||||
description="Ustaw domyślną liczbę odtworzeń dla nowych utworów w kolejce",
|
||||
)
|
||||
@app_commands.describe(count="Domyślna liczba odtworzeń (1-999)")
|
||||
async def default_plays_command(self, interaction: discord.Interaction, count: int) -> None:
|
||||
if not self._settings.is_valid_plays(count):
|
||||
await interaction.response.send_message(
|
||||
"❌ Liczba odtworzeń musi być z przedziału 1-999.", ephemeral=True
|
||||
)
|
||||
return
|
||||
self.player.set_default_plays(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}**."
|
||||
)
|
||||
|
||||
@app_commands.command(
|
||||
name="trackplays", description="Zmień liczbę odtworzeń dla utworu w kolejce"
|
||||
)
|
||||
@app_commands.describe(index="Pozycja w kolejce (od 1)", count="Nowa liczba odtworzeń (1-999)")
|
||||
async def track_plays_command(
|
||||
self, interaction: discord.Interaction, index: int, count: int
|
||||
) -> None:
|
||||
queue = self.player.state(interaction.guild.id).queue
|
||||
if not 1 <= index <= len(queue):
|
||||
await interaction.response.send_message(
|
||||
"❌ Nieprawidłowy numer utworu w kolejce.", ephemeral=True
|
||||
)
|
||||
return
|
||||
if not self._settings.is_valid_plays(count):
|
||||
await interaction.response.send_message(
|
||||
"❌ Liczba odtworzeń musi być z przedziału 1-999.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
track = queue[index - 1]
|
||||
track.plays_left = count
|
||||
await self.player.trigger_state_change()
|
||||
title = discord.utils.escape_markdown(track.title or "")
|
||||
await interaction.response.send_message(
|
||||
f"✅ Ustawiono liczbę odtworzeń dla utworu **{title}** na **{count}**."
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
|
||||
class GuildResolver:
|
||||
"""Ustala kontekst serwera dla panelu webowego, który nie zna interakcji Discorda."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self._bot = bot
|
||||
|
||||
def active_guild(self) -> discord.Guild | None:
|
||||
"""Serwer z aktywnym połączeniem głosowym, a w razie jego braku - pierwszy znany."""
|
||||
if self._bot.voice_clients:
|
||||
return self._bot.voice_clients[0].guild
|
||||
return self._bot.guilds[0] if self._bot.guilds else None
|
||||
|
||||
def guilds(self) -> list[discord.Guild]:
|
||||
return list(self._bot.guilds)
|
||||
|
||||
def channel(self, channel_id: int) -> discord.abc.GuildChannel | None:
|
||||
return self._bot.get_channel(channel_id)
|
||||
|
||||
def voice_client(self, guild_id: int) -> discord.VoiceClient | None:
|
||||
return discord.utils.get(self._bot.voice_clients, guild__id=guild_id)
|
||||
@@ -0,0 +1 @@
|
||||
"""Panel webowy: FastAPI, WebSocket i modele żądań."""
|
||||
@@ -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,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import discord
|
||||
from fastapi import HTTPException, Request, WebSocket
|
||||
|
||||
from zyrarbot.audio.track_factory import TrackFactory
|
||||
from zyrarbot.ui.bot.guild_resolver import GuildResolver
|
||||
from zyrarbot.music.music_player import MusicPlayer
|
||||
from zyrarbot.settings import Settings
|
||||
from zyrarbot.storage.guild_config_repository import GuildConfigRepository
|
||||
from zyrarbot.storage.uploads_repository import UploadsRepository
|
||||
from zyrarbot.ui.web.connection_manager import ConnectionManager
|
||||
from zyrarbot.ui.web.status_broadcaster import StatusBroadcaster
|
||||
from zyrarbot.ui.web.status_service import StatusService
|
||||
|
||||
NO_GUILD_DETAIL = "Bot has no active server context"
|
||||
|
||||
|
||||
def get_player(request: Request) -> MusicPlayer:
|
||||
return request.app.state.player
|
||||
|
||||
|
||||
def get_guilds(request: Request) -> GuildResolver:
|
||||
return request.app.state.guilds
|
||||
|
||||
|
||||
def get_uploads(request: Request) -> UploadsRepository:
|
||||
return request.app.state.uploads
|
||||
|
||||
|
||||
def get_tracks(request: Request) -> TrackFactory:
|
||||
return request.app.state.tracks
|
||||
|
||||
|
||||
def get_settings(request: Request) -> Settings:
|
||||
return request.app.state.settings
|
||||
|
||||
|
||||
def get_config(request: Request) -> GuildConfigRepository:
|
||||
return request.app.state.config
|
||||
|
||||
|
||||
def get_status_service(request: Request) -> StatusService:
|
||||
return request.app.state.status_service
|
||||
|
||||
|
||||
def get_broadcaster(request: Request) -> StatusBroadcaster:
|
||||
return request.app.state.broadcaster
|
||||
|
||||
|
||||
def get_connections(websocket: WebSocket) -> ConnectionManager:
|
||||
return websocket.app.state.connections
|
||||
|
||||
|
||||
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()
|
||||
if guild is None:
|
||||
raise HTTPException(status_code=400, detail=NO_GUILD_DETAIL)
|
||||
return guild
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from zyrarbot.audio.track_factory import TrackFactory
|
||||
from zyrarbot.ui.bot.guild_resolver import GuildResolver
|
||||
from zyrarbot.music.music_player import MusicPlayer
|
||||
from zyrarbot.settings import Settings
|
||||
from zyrarbot.storage.guild_config_repository import GuildConfigRepository
|
||||
from zyrarbot.storage.uploads_repository import UploadsRepository
|
||||
from zyrarbot.ui.web.connection_manager import ConnectionManager
|
||||
from zyrarbot.ui.web.routers import (
|
||||
playback_router,
|
||||
queue_router,
|
||||
settings_router,
|
||||
status_router,
|
||||
uploads_router,
|
||||
voice_router,
|
||||
websocket_router,
|
||||
)
|
||||
from zyrarbot.ui.web.status_broadcaster import StatusBroadcaster
|
||||
from zyrarbot.ui.web.status_service import StatusService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ROUTERS = (
|
||||
status_router.router,
|
||||
playback_router.router,
|
||||
queue_router.router,
|
||||
voice_router.router,
|
||||
uploads_router.router,
|
||||
settings_router.router,
|
||||
websocket_router.router,
|
||||
)
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
settings: Settings,
|
||||
player: MusicPlayer,
|
||||
guilds: GuildResolver,
|
||||
tracks: TrackFactory,
|
||||
uploads: UploadsRepository,
|
||||
config: GuildConfigRepository,
|
||||
) -> FastAPI:
|
||||
"""Buduje aplikację FastAPI i wstrzykuje zależności przez ``app.state``."""
|
||||
connections = ConnectionManager()
|
||||
status_service = StatusService(player, guilds)
|
||||
broadcaster = StatusBroadcaster(status_service, connections)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
player.on_state_change = broadcaster.broadcast
|
||||
broadcaster.start()
|
||||
logger.info("Panel webowy gotowy, rozgłaszanie stanu uruchomione")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
player.on_state_change = None
|
||||
await broadcaster.stop()
|
||||
|
||||
app = FastAPI(title="ŻyrarBOT Queue Controller", lifespan=lifespan)
|
||||
|
||||
app.state.settings = settings
|
||||
app.state.player = player
|
||||
app.state.guilds = guilds
|
||||
app.state.tracks = tracks
|
||||
app.state.uploads = uploads
|
||||
app.state.config = config
|
||||
app.state.connections = connections
|
||||
app.state.status_service = status_service
|
||||
app.state.broadcaster = broadcaster
|
||||
|
||||
for router in ROUTERS:
|
||||
app.include_router(router)
|
||||
|
||||
# Pliki panelu montujemy na końcu, aby nie przesłoniły tras API.
|
||||
os.makedirs(settings.static_dir, exist_ok=True)
|
||||
app.mount("/", StaticFiles(directory=settings.static_dir, html=True), name="static")
|
||||
|
||||
return app
|
||||
@@ -0,0 +1 @@
|
||||
"""Routery FastAPI pogrupowane według obszaru odpowiedzialności."""
|
||||
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import discord
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from zyrarbot.audio.track_factory import TrackFactory
|
||||
from zyrarbot.music.loop_mode import LoopMode
|
||||
from zyrarbot.music.music_player import MusicPlayer
|
||||
from zyrarbot.storage.uploads_repository import UploadsRepository
|
||||
from zyrarbot.ui.web.dependencies import (
|
||||
get_active_guild,
|
||||
get_broadcaster,
|
||||
get_player,
|
||||
get_tracks,
|
||||
get_uploads,
|
||||
)
|
||||
from zyrarbot.ui.web.schemas.loop_request import LoopRequest
|
||||
from zyrarbot.ui.web.schemas.play_file_request import PlayFileRequest
|
||||
from zyrarbot.ui.web.schemas.play_request import PlayRequest
|
||||
from zyrarbot.ui.web.schemas.volume_request import VolumeRequest
|
||||
from zyrarbot.ui.web.status_broadcaster import StatusBroadcaster
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["playback"])
|
||||
|
||||
|
||||
def _require_voice(player: MusicPlayer, guild: discord.Guild) -> None:
|
||||
if player.voice_client(guild.id) is None:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Bot is not connected to a voice channel on this server"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/play")
|
||||
async def play(
|
||||
request: PlayRequest,
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
tracks: TrackFactory = Depends(get_tracks),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, Any]:
|
||||
_require_voice(player, guild)
|
||||
try:
|
||||
track = await tracks.resolve(request.query)
|
||||
result = await player.enqueue(guild.id, track, from_web=True)
|
||||
except Exception as error:
|
||||
logger.error("Nie udało się dodać utworu z panelu: %s", error)
|
||||
raise HTTPException(status_code=500, detail=str(error)) from error
|
||||
|
||||
await broadcaster.broadcast()
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/play_file")
|
||||
async def play_file(
|
||||
request: PlayFileRequest,
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
tracks: TrackFactory = Depends(get_tracks),
|
||||
uploads: UploadsRepository = Depends(get_uploads),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, Any]:
|
||||
_require_voice(player, guild)
|
||||
if not uploads.exists(request.filename):
|
||||
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))
|
||||
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)
|
||||
raise HTTPException(status_code=500, detail=str(error)) from error
|
||||
|
||||
await broadcaster.broadcast()
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/pause")
|
||||
async def pause(
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, str]:
|
||||
voice_client = player.voice_client(guild.id)
|
||||
if not voice_client or not voice_client.is_playing():
|
||||
raise HTTPException(status_code=400, detail="Bot is not playing on this server")
|
||||
voice_client.pause()
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "paused"}
|
||||
|
||||
|
||||
@router.post("/resume")
|
||||
async def resume(
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, str]:
|
||||
voice_client = player.voice_client(guild.id)
|
||||
if not voice_client or not voice_client.is_paused():
|
||||
raise HTTPException(status_code=400, detail="Bot is not paused on this server")
|
||||
voice_client.resume()
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "resumed"}
|
||||
|
||||
|
||||
@router.post("/skip")
|
||||
async def skip(
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, str]:
|
||||
state = player.state(guild.id)
|
||||
if state.loop_mode.loops_track:
|
||||
state.loop_mode = LoopMode.OFF
|
||||
|
||||
if not player.is_active(guild.id):
|
||||
raise HTTPException(status_code=400, detail="Bot is not playing anything on this server")
|
||||
|
||||
player.voice_client(guild.id).stop()
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "skipped"}
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
async def stop(
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, str]:
|
||||
if player.is_active(guild.id):
|
||||
player.voice_client(guild.id).stop()
|
||||
player.clear(guild.id)
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "stopped"}
|
||||
|
||||
|
||||
@router.post("/volume")
|
||||
async def set_volume(
|
||||
request: VolumeRequest,
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, Any]:
|
||||
if not 0 <= request.volume <= 100:
|
||||
raise HTTPException(status_code=400, detail="Volume must be between 0 and 100")
|
||||
player.set_volume(guild.id, request.volume / 100)
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "success", "volume": request.volume}
|
||||
|
||||
|
||||
@router.post("/loop")
|
||||
async def set_loop(
|
||||
request: LoopRequest,
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, Any]:
|
||||
player.set_loop_mode(guild.id, LoopMode.TRACK if request.loop else LoopMode.OFF)
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "success", "loop": request.loop}
|
||||
|
||||
|
||||
@router.post("/loop_playlist")
|
||||
async def toggle_loop_playlist(
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, Any]:
|
||||
enabled = not player.state(guild.id).loop_mode.loops_queue
|
||||
player.set_loop_mode(guild.id, LoopMode.QUEUE if enabled else LoopMode.OFF)
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "success", "loop_playlist": enabled}
|
||||
|
||||
|
||||
@router.post("/loop_cycle")
|
||||
async def cycle_loop(
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, Any]:
|
||||
mode = player.cycle_loop_mode(guild.id)
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "success", "mode": mode.value}
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.ui.web.dependencies import (
|
||||
get_active_guild,
|
||||
get_broadcaster,
|
||||
get_player,
|
||||
get_settings,
|
||||
)
|
||||
from zyrarbot.ui.web.schemas.jump_request import JumpRequest
|
||||
from zyrarbot.ui.web.schemas.remove_request import RemoveRequest
|
||||
from zyrarbot.ui.web.schemas.reorder_request import ReorderRequest
|
||||
from zyrarbot.ui.web.schemas.track_plays_request import TrackPlaysRequest
|
||||
from zyrarbot.ui.web.status_broadcaster import StatusBroadcaster
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["queue"])
|
||||
|
||||
|
||||
@router.post("/jump")
|
||||
async def jump(
|
||||
request: JumpRequest,
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, str]:
|
||||
queue = player.state(guild.id).queue
|
||||
if not 0 <= request.index < len(queue):
|
||||
raise HTTPException(status_code=400, detail="Invalid track index")
|
||||
|
||||
await player.jump(guild.id, request.index)
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "jumped"}
|
||||
|
||||
|
||||
@router.post("/remove")
|
||||
async def remove(
|
||||
request: RemoveRequest,
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, str]:
|
||||
if not await player.remove(guild.id, request.id):
|
||||
raise HTTPException(status_code=404, detail="Track not found in queue")
|
||||
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
@router.post("/reorder")
|
||||
async def reorder(
|
||||
request: ReorderRequest,
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, str]:
|
||||
player.reorder(guild.id, request.queue_ids)
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.post("/queue/plays")
|
||||
async def set_track_plays(
|
||||
request: TrackPlaysRequest,
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
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.plays):
|
||||
raise HTTPException(status_code=400, detail="Invalid plays value")
|
||||
|
||||
track = player.state(guild.id).find(request.id)
|
||||
if track is None:
|
||||
raise HTTPException(status_code=404, detail="Track not found in queue")
|
||||
|
||||
track.plays_left = request.plays
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "success", "id": request.id, "plays": request.plays}
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import discord
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from zyrarbot.ui.bot.guild_resolver import GuildResolver
|
||||
from zyrarbot.music.music_player import MusicPlayer
|
||||
from zyrarbot.settings import Settings
|
||||
from zyrarbot.storage.guild_config_repository import GuildConfigRepository
|
||||
from zyrarbot.ui.web.dependencies import (
|
||||
get_broadcaster,
|
||||
get_config,
|
||||
get_guilds,
|
||||
get_player,
|
||||
get_settings,
|
||||
)
|
||||
from zyrarbot.ui.web.schemas.default_plays_request import DefaultPlaysRequest
|
||||
from zyrarbot.ui.web.schemas.text_channel_request import TextChannelRequest
|
||||
from zyrarbot.ui.web.status_broadcaster import StatusBroadcaster
|
||||
|
||||
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}
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from zyrarbot.ui.web.dependencies import get_status_service
|
||||
from zyrarbot.ui.web.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()
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
|
||||
from zyrarbot.storage.uploads_repository import UploadsRepository
|
||||
from zyrarbot.ui.web.dependencies import 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()]}
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload(
|
||||
file: UploadFile = File(...),
|
||||
uploads: UploadsRepository = Depends(get_uploads),
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
filename = uploads.save(file.filename or "plik", file.file)
|
||||
except OSError as error:
|
||||
raise HTTPException(status_code=500, detail=f"Błąd zapisu pliku: {error}") from error
|
||||
return {"status": "success", "filename": filename}
|
||||
|
||||
|
||||
@router.delete("/uploads/{filename}")
|
||||
async def delete_upload(
|
||||
filename: str,
|
||||
uploads: UploadsRepository = Depends(get_uploads),
|
||||
) -> dict[str, str]:
|
||||
if not uploads.exists(filename):
|
||||
raise HTTPException(status_code=404, detail="Plik nie istnieje")
|
||||
try:
|
||||
uploads.delete(filename)
|
||||
except OSError as error:
|
||||
raise HTTPException(status_code=500, detail=str(error)) from error
|
||||
return {"status": "deleted"}
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import discord
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from zyrarbot.ui.bot.guild_resolver import GuildResolver
|
||||
from zyrarbot.music.music_player import MusicPlayer
|
||||
from zyrarbot.music.notification_channel_resolver import NotificationChannelResolver
|
||||
from zyrarbot.ui.web.dependencies import (
|
||||
get_active_guild,
|
||||
get_broadcaster,
|
||||
get_guilds,
|
||||
get_player,
|
||||
)
|
||||
from zyrarbot.ui.web.schemas.connect_request import ConnectRequest
|
||||
from zyrarbot.ui.web.status_broadcaster import StatusBroadcaster
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["voice"])
|
||||
|
||||
|
||||
@router.post("/connect")
|
||||
async def connect(
|
||||
request: ConnectRequest,
|
||||
guilds: GuildResolver = Depends(get_guilds),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
channel_id = int(request.channel_id)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail="Invalid channel ID") from error
|
||||
|
||||
channel = guilds.channel(channel_id)
|
||||
if not isinstance(channel, discord.VoiceChannel):
|
||||
raise HTTPException(status_code=404, detail="Voice channel not found")
|
||||
|
||||
guild_id = channel.guild.id
|
||||
# Rozłączamy wyłącznie na tym samym serwerze - inne serwery grają dalej.
|
||||
existing = guilds.voice_client(guild_id)
|
||||
if existing:
|
||||
await existing.disconnect()
|
||||
|
||||
await channel.connect()
|
||||
|
||||
if not player.notification_channel(guild_id):
|
||||
player.set_notification_channel(
|
||||
guild_id, NotificationChannelResolver.first_writable(channel.guild)
|
||||
)
|
||||
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "connected", "channel": channel.name}
|
||||
|
||||
|
||||
@router.post("/disconnect")
|
||||
async def disconnect(
|
||||
guild: discord.Guild = Depends(get_active_guild),
|
||||
player: MusicPlayer = Depends(get_player),
|
||||
broadcaster: StatusBroadcaster = Depends(get_broadcaster),
|
||||
) -> dict[str, str]:
|
||||
voice_client = player.voice_client(guild.id)
|
||||
if not voice_client:
|
||||
raise HTTPException(status_code=400, detail="Bot not connected to voice on this server")
|
||||
|
||||
await voice_client.disconnect()
|
||||
player.clear(guild.id)
|
||||
await broadcaster.broadcast()
|
||||
return {"status": "disconnected"}
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
|
||||
|
||||
from zyrarbot.ui.web.connection_manager import ConnectionManager
|
||||
from zyrarbot.ui.web.dependencies import get_connections, get_socket_status_service
|
||||
from zyrarbot.ui.web.status_service import StatusService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
connections: ConnectionManager = Depends(get_connections),
|
||||
status: StatusService = Depends(get_socket_status_service),
|
||||
) -> None:
|
||||
await connections.connect(websocket)
|
||||
try:
|
||||
await websocket.send_json(status.snapshot())
|
||||
while True:
|
||||
# Klient nic nie wysyła - czekamy wyłącznie na rozłączenie.
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
connections.disconnect(websocket)
|
||||
except Exception as error: # noqa: BLE001
|
||||
logger.error("Błąd połączenia WebSocket: %s", error)
|
||||
connections.disconnect(websocket)
|
||||
@@ -0,0 +1 @@
|
||||
"""Modele żądań przyjmowanych przez API - jeden model na plik."""
|
||||
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ConnectRequest(BaseModel):
|
||||
"""Podłączenie bota do kanału głosowego."""
|
||||
|
||||
channel_id: str
|
||||
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DefaultPlaysRequest(BaseModel):
|
||||
"""Domyślna liczba odtworzeń przypisywana nowym utworom."""
|
||||
|
||||
default_plays: int
|
||||
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class JumpRequest(BaseModel):
|
||||
"""Przeskok do wskazanej pozycji w kolejce."""
|
||||
|
||||
index: int
|
||||
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LoopRequest(BaseModel):
|
||||
"""Włączenie lub wyłączenie zapętlania pojedynczego utworu."""
|
||||
|
||||
loop: bool
|
||||
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PlayFileRequest(BaseModel):
|
||||
"""Dodanie do kolejki pliku z serwerowej bazy plików."""
|
||||
|
||||
filename: str
|
||||
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PlayRequest(BaseModel):
|
||||
"""Dodanie utworu do kolejki na podstawie linku lub frazy wyszukiwania."""
|
||||
|
||||
query: str
|
||||
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RemoveRequest(BaseModel):
|
||||
"""Usunięcie utworu z kolejki po jego identyfikatorze."""
|
||||
|
||||
id: str
|
||||
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ReorderRequest(BaseModel):
|
||||
"""Nowa kolejność utworów wyrażona listą identyfikatorów."""
|
||||
|
||||
queue_ids: list[str]
|
||||
@@ -0,0 +1,8 @@
|
||||
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
|
||||
@@ -0,0 +1,8 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TrackPlaysRequest(BaseModel):
|
||||
"""Zmiana pozostałej liczby odtworzeń konkretnego utworu w kolejce."""
|
||||
|
||||
id: str
|
||||
plays: int
|
||||
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class VolumeRequest(BaseModel):
|
||||
"""Zmiana głośności odtwarzacza w skali 0-100."""
|
||||
|
||||
volume: int
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
from zyrarbot.ui.web.connection_manager import ConnectionManager
|
||||
from zyrarbot.ui.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()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._task
|
||||
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,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import discord
|
||||
|
||||
from zyrarbot.audio.ytdl_source import YTDLSource
|
||||
from zyrarbot.ui.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