From e2099a76b3bc8e253ecd3b9f5a6465fd014ef90e Mon Sep 17 00:00:00 2001 From: Marcin Macierzanka Date: Fri, 21 Aug 2026 17:52:56 +0200 Subject: [PATCH] feat: add interactive Discord queue and history actions --- README.md | 3 ++ zyrarbot/ui/bot/cogs/playback_cog.py | 80 ++++++++++++++++++++++++++++ zyrarbot/ui/bot/cogs/queue_cog.py | 39 ++++++-------- 3 files changed, 99 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index bfcb674..a4447e8 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,15 @@ indeksów ręcznie. | `/player` | Publiczny panel odtwarzacza z przyciskami sterowania | | `/play query` | Dodaje utwór, link lub zapytanie wyszukiwania | | `/playfile filename` | Odtwarza plik z biblioteki serwera | +| `/upload file` | Przesyła i waliduje plik audio/wideo do biblioteki | +| `/deletefile filename` | Usuwa wybrany plik z biblioteki | | `/replay track_id` | Ponownie odtwarza utwór z historii | | `/queue` | Pokazuje kolejkę w embedzie | | `/history` | Pokazuje historię odtwarzania | | `/restorequeue` | Przywraca ostatnią zapisaną kolejkę | | `/jump track_id` | Rozpoczyna wybrany utwór z kolejki | | `/remove track_id` | Usuwa wybrany utwór z kolejki | +| `/move track_id before_id` | Przenosi utwór przed wybrany utwór docelowy | | `/trackplays track_id count` | Ustawia liczbę odtworzeń utworu | | `/pause`, `/resume` | Wstrzymuje lub wznawia odtwarzanie | | `/skip` | Pomija bieżący utwór | diff --git a/zyrarbot/ui/bot/cogs/playback_cog.py b/zyrarbot/ui/bot/cogs/playback_cog.py index 1a9b669..38bfaf0 100644 --- a/zyrarbot/ui/bot/cogs/playback_cog.py +++ b/zyrarbot/ui/bot/cogs/playback_cog.py @@ -1,11 +1,14 @@ from __future__ import annotations import logging +import os +import tempfile import discord from discord import app_commands from discord.ext import commands +from zyrarbot.audio.media_probe import MediaProbe from zyrarbot.audio.track_factory import TrackFactory from zyrarbot.music.music_player import MusicPlayer from zyrarbot.storage.uploads_repository import UploadsRepository @@ -13,6 +16,29 @@ from zyrarbot.ui.bot.cogs.common import MusicCogBase logger = logging.getLogger(__name__) AUTOCOMPLETE_LIMIT = 25 +HISTORY_PREVIEW_LIMIT = 25 + + +class HistoryView(discord.ui.View): + def __init__(self, cog: PlaybackCog, guild_id: int): + super().__init__(timeout=900) + self.cog = cog + entries = cog.player.history(guild_id)[:HISTORY_PREVIEW_LIMIT] + options = [ + discord.SelectOption(label=entry.title[:100], value=entry.id) for entry in entries + ] + if options: + self.add_item(HistorySelect(options)) + + +class HistorySelect(discord.ui.Select): + def __init__(self, options: list[discord.SelectOption]): + super().__init__(placeholder="Wybierz utwór do ponownego odtworzenia", options=options) + + async def callback(self, interaction: discord.Interaction) -> None: + view = self.view + if isinstance(view, HistoryView): + await view.cog.replay_entry(interaction, self.values[0]) class PlaybackCog(MusicCogBase, name="Playback"): @@ -37,6 +63,36 @@ class PlaybackCog(MusicCogBase, name="Playback"): ) return [app_commands.Choice(name=name, value=name) for name in names] + @app_commands.command(name="upload", description="Prześlij plik audio lub wideo do biblioteki") + @app_commands.describe(file="Plik multimedialny") + async def upload(self, interaction: discord.Interaction, file: discord.Attachment) -> None: + filename = file.filename or "" + if not self._uploads.is_supported_filename(filename): + await interaction.response.send_message( + "❌ Obsługiwane są tylko pliki audio i wideo.", ephemeral=True + ) + return + suffix = os.path.splitext(filename)[1] + with tempfile.NamedTemporaryFile(suffix=suffix) as temporary: + await file.save(temporary.name) + if not await MediaProbe().is_media(temporary.name): + await interaction.response.send_message( + "❌ Przesłany plik nie jest prawidłowym plikiem multimedialnym.", ephemeral=True + ) + return + with open(temporary.name, "rb") as source: + saved = self._uploads.save(filename, source, interaction.guild.id) + await interaction.response.send_message(f"✅ Dodano plik **{saved}** do biblioteki.") + + @app_commands.command(name="deletefile", description="Usuń plik z biblioteki serwera") + @app_commands.describe(filename="Plik z biblioteki") + @app_commands.autocomplete(filename=_uploads_autocomplete) + async def delete_file(self, interaction: discord.Interaction, filename: str) -> None: + if not self._uploads.delete(filename, interaction.guild.id): + await interaction.response.send_message("Nie znaleziono pliku.", ephemeral=True) + return + await interaction.response.send_message(f"✅ Usunięto plik **{filename}**.") + async def _history_autocomplete( self, interaction: discord.Interaction, current: str ) -> list[app_commands.Choice[str]]: @@ -47,6 +103,27 @@ class PlaybackCog(MusicCogBase, name="Playback"): for entry in matches[:AUTOCOMPLETE_LIMIT] ] + @app_commands.command(name="history", description="Pokaż historię z wyborem utworu do replayu") + async def 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}. {discord.utils.escape_markdown(entry.title)}" + for position, entry in enumerate(entries[:HISTORY_PREVIEW_LIMIT], start=1) + ) + await interaction.response.send_message( + embed=discord.Embed( + title="📜 Historia odtwarzania", + description=listing, + color=discord.Color.gold(), + ), + view=HistoryView(self, interaction.guild.id), + ) + @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: @@ -88,6 +165,9 @@ class PlaybackCog(MusicCogBase, name="Playback"): @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: + await self.replay_entry(interaction, track_id) + + async def replay_entry(self, interaction: discord.Interaction, track_id: str) -> None: if not await self._ensure_voice(interaction): return await interaction.response.defer() diff --git a/zyrarbot/ui/bot/cogs/queue_cog.py b/zyrarbot/ui/bot/cogs/queue_cog.py index c6632dd..8b28727 100644 --- a/zyrarbot/ui/bot/cogs/queue_cog.py +++ b/zyrarbot/ui/bot/cogs/queue_cog.py @@ -9,7 +9,6 @@ from zyrarbot.settings import Settings from zyrarbot.ui.bot.cogs.common import MusicCogBase QUEUE_PREVIEW_LIMIT = 30 -HISTORY_PREVIEW_LIMIT = 15 AUTOCOMPLETE_LIMIT = 25 @@ -74,28 +73,6 @@ class QueueCog(MusicCogBase, name="Queue"): 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]) - ) - embed = discord.Embed( - title="📜 Historia odtwarzania", - description=listing, - color=discord.Color.gold(), - ) - embed.set_footer(text=f"Ostatnie {min(len(entries), HISTORY_PREVIEW_LIMIT)} utworów") - await interaction.response.send_message(embed=embed) - @app_commands.command(name="restorequeue", description="Przywróć ostatnią zapisaną kolejkę") async def restore_queue(self, interaction: discord.Interaction) -> None: count = await self.player.restore_queue(interaction.guild.id) @@ -132,6 +109,22 @@ class QueueCog(MusicCogBase, name="Queue"): await self.player.trigger_state_change() await interaction.response.send_message("▶️ Rozpoczęto wybrany utwór.") + @app_commands.command(name="move", description="Przenieś utwór przed inny utwór") + @app_commands.describe(track_id="Utwór do przeniesienia", before_id="Utwór docelowy") + @app_commands.autocomplete(track_id=_queue_autocomplete, before_id=_queue_autocomplete) + async def move(self, interaction: discord.Interaction, track_id: str, before_id: str) -> None: + state = self.player.state(interaction.guild.id) + if track_id == before_id or not state.find(track_id) or not state.find(before_id): + await interaction.response.send_message( + "❌ Wybierz dwa różne utwory z kolejki.", ephemeral=True + ) + return + ids = [track.id for track in state.queue if track.id != track_id] + ids.insert(ids.index(before_id), track_id) + self.player.reorder(interaction.guild.id, ids) + await self.player.trigger_state_change() + await interaction.response.send_message("✅ Zmieniono kolejność kolejki.") + @app_commands.command( name="trackplays", description="Zmień liczbę odtworzeń dla utworu w kolejce" )