From d31747e80bc9051d94d7bd0b4cbc3464f265217f Mon Sep 17 00:00:00 2001 From: Marcin Macierzanka Date: Fri, 21 Aug 2026 13:11:50 +0200 Subject: [PATCH] refactor: split music cog by responsibility --- zyrarbot/ui/bot/client.py | 21 +- zyrarbot/ui/bot/cogs/common.py | 41 +++ zyrarbot/ui/bot/cogs/control_cog.py | 77 ++++++ zyrarbot/ui/bot/cogs/music_cog.py | 378 --------------------------- zyrarbot/ui/bot/cogs/playback_cog.py | 111 ++++++++ zyrarbot/ui/bot/cogs/queue_cog.py | 95 +++++++ zyrarbot/ui/bot/cogs/settings_cog.py | 49 ++++ zyrarbot/ui/bot/cogs/voice_cog.py | 36 +++ 8 files changed, 428 insertions(+), 380 deletions(-) create mode 100644 zyrarbot/ui/bot/cogs/common.py create mode 100644 zyrarbot/ui/bot/cogs/control_cog.py delete mode 100644 zyrarbot/ui/bot/cogs/music_cog.py create mode 100644 zyrarbot/ui/bot/cogs/playback_cog.py create mode 100644 zyrarbot/ui/bot/cogs/queue_cog.py create mode 100644 zyrarbot/ui/bot/cogs/settings_cog.py create mode 100644 zyrarbot/ui/bot/cogs/voice_cog.py diff --git a/zyrarbot/ui/bot/client.py b/zyrarbot/ui/bot/client.py index 7f45b9e..59ae783 100644 --- a/zyrarbot/ui/bot/client.py +++ b/zyrarbot/ui/bot/client.py @@ -6,10 +6,14 @@ 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 +from zyrarbot.ui.bot.cogs.control_cog import ControlCog +from zyrarbot.ui.bot.cogs.playback_cog import PlaybackCog +from zyrarbot.ui.bot.cogs.queue_cog import QueueCog +from zyrarbot.ui.bot.cogs.settings_cog import SettingsCog +from zyrarbot.ui.bot.cogs.voice_cog import VoiceCog logger = logging.getLogger(__name__) @@ -32,11 +36,24 @@ class MusicBot(commands.Bot): self.uploads = uploads self.player: MusicPlayer | None = None + def _create_music_cogs(self) -> tuple[commands.Cog, ...]: + if self.player is None: + raise RuntimeError("MusicPlayer musi zostać przypisany przed utworzeniem Cogów") + + return ( + PlaybackCog(self, self.player, self.tracks, self.uploads), + QueueCog(self, self.player, self.settings), + ControlCog(self, self.player), + VoiceCog(self, self.player), + SettingsCog(self, self.player, self.settings), + ) + 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)) + for cog in self._create_music_cogs(): + await self.add_cog(cog) if self.settings.guild_id: guild = discord.Object(id=self.settings.guild_id) diff --git a/zyrarbot/ui/bot/cogs/common.py b/zyrarbot/ui/bot/cogs/common.py new file mode 100644 index 0000000..ff21d52 --- /dev/null +++ b/zyrarbot/ui/bot/cogs/common.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import discord +from discord.ext import commands + +from zyrarbot.music.music_player import MusicPlayer + + +class MusicCogBase(commands.Cog): + """Wspólne zależności i pomocnicze operacje dla muzycznych Cogów.""" + + def __init__(self, bot: commands.Bot, player: MusicPlayer): + self.bot = bot + self.player = player + + 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}") diff --git a/zyrarbot/ui/bot/cogs/control_cog.py b/zyrarbot/ui/bot/cogs/control_cog.py new file mode 100644 index 0000000..0f5ceaf --- /dev/null +++ b/zyrarbot/ui/bot/cogs/control_cog.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from discord import app_commands +import discord + +from zyrarbot.music.loop_mode import LoopMode +from zyrarbot.music.music_player import MusicPlayer +from zyrarbot.ui.bot.cogs.common import MusicCogBase + + +class ControlCog(MusicCogBase, name="Control"): + """Komendy bezpośredniego sterowania odtwarzaniem.""" + + @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}") diff --git a/zyrarbot/ui/bot/cogs/music_cog.py b/zyrarbot/ui/bot/cogs/music_cog.py deleted file mode 100644 index 4f77a7c..0000000 --- a/zyrarbot/ui/bot/cogs/music_cog.py +++ /dev/null @@ -1,378 +0,0 @@ -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}**." - ) diff --git a/zyrarbot/ui/bot/cogs/playback_cog.py b/zyrarbot/ui/bot/cogs/playback_cog.py new file mode 100644 index 0000000..500bc8c --- /dev/null +++ b/zyrarbot/ui/bot/cogs/playback_cog.py @@ -0,0 +1,111 @@ +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.music_player import MusicPlayer +from zyrarbot.storage.uploads_repository import UploadsRepository +from zyrarbot.ui.bot.cogs.common import MusicCogBase + +logger = logging.getLogger(__name__) +AUTOCOMPLETE_LIMIT = 25 + + +class PlaybackCog(MusicCogBase, name="Playback"): + """Komendy dodawania i ponownego odtwarzania utworów.""" + + def __init__( + self, + bot: commands.Bot, + player: MusicPlayer, + tracks: TrackFactory, + uploads: UploadsRepository, + ): + super().__init__(bot, player) + self._tracks = tracks + self._uploads = uploads + + 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] + + 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="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 + logger.error("Komenda /play nie powiodła się: %s", error) + await interaction.followup.send(f"Wystąpił błąd: {error}", ephemeral=True) + + @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) + + @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) diff --git a/zyrarbot/ui/bot/cogs/queue_cog.py b/zyrarbot/ui/bot/cogs/queue_cog.py new file mode 100644 index 0000000..f4a64c0 --- /dev/null +++ b/zyrarbot/ui/bot/cogs/queue_cog.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import discord +from discord import app_commands +from discord.ext import commands + +from zyrarbot.music.music_player import MusicPlayer +from zyrarbot.settings import Settings +from zyrarbot.ui.bot.cogs.common import MusicCogBase + +QUEUE_PREVIEW_LIMIT = 30 +HISTORY_PREVIEW_LIMIT = 15 +AUTOCOMPLETE_LIMIT = 25 + + +class QueueCog(MusicCogBase, name="Queue"): + """Komendy podglądu kolejki i historii odtwarzania.""" + + def __init__(self, bot: commands.Bot, player: MusicPlayer, settings: Settings): + super().__init__(bot, player) + self._settings = settings + + @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="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}**." + ) diff --git a/zyrarbot/ui/bot/cogs/settings_cog.py b/zyrarbot/ui/bot/cogs/settings_cog.py new file mode 100644 index 0000000..cd6ed57 --- /dev/null +++ b/zyrarbot/ui/bot/cogs/settings_cog.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import discord +from discord import app_commands +from discord.ext import commands + +from zyrarbot.music.loop_mode import LoopMode +from zyrarbot.music.music_player import MusicPlayer +from zyrarbot.settings import Settings +from zyrarbot.ui.bot.cogs.common import MusicCogBase + + +class SettingsCog(MusicCogBase, name="Settings"): + """Komendy zmieniające ustawienia odtwarzacza.""" + + def __init__(self, bot: commands.Bot, player: MusicPlayer, settings: Settings): + super().__init__(bot, player) + self._settings = settings + + @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}**." + ) diff --git a/zyrarbot/ui/bot/cogs/voice_cog.py b/zyrarbot/ui/bot/cogs/voice_cog.py new file mode 100644 index 0000000..1ecf8d3 --- /dev/null +++ b/zyrarbot/ui/bot/cogs/voice_cog.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import discord +from discord.ext import commands + +from zyrarbot.ui.bot.cogs.common import MusicCogBase + + +class VoiceCog(MusicCogBase, name="Voice"): + """Obsługa zdarzeń i rozłączania z kanałów głosowych.""" + + @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() + + @discord.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.")