diff options
| author | salmonik <raisersoft@proton.me> | 2026-04-17 15:36:12 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-04-17 15:36:12 +0200 |
| commit | d135265798da8c2698f363237f4cb74b682f3f9e (patch) | |
| tree | bca914d297e74a6188478a773ba7333c10fe4a34 /cogs/music/play_commands.py | |
| parent | Merge branch 'main' of https://codeberg.org/salmonik/senator-sperma (diff) | |
| download | senator-sperma-d135265798da8c2698f363237f4cb74b682f3f9e.tar.gz senator-sperma-d135265798da8c2698f363237f4cb74b682f3f9e.zip | |
make voice great again
Signed-off-by: salmonik <raisersoft@proton.me>
Diffstat (limited to 'cogs/music/play_commands.py')
| -rw-r--r-- | cogs/music/play_commands.py | 184 |
1 files changed, 96 insertions, 88 deletions
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index a06c636..a24169f 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py | |||
| @@ -3,88 +3,102 @@ | |||
| 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io | 3 | # Copyright (C) 2026 Salmonik @ salmonik@tuta.io |
| 4 | # https://codeberg.org/salmonik/senator-sperma | 4 | # https://codeberg.org/salmonik/senator-sperma |
| 5 | 5 | ||
| 6 | import logging | ||
| 6 | import os | 7 | import os |
| 7 | import urllib.parse | 8 | import urllib.parse |
| 8 | import discord | ||
| 9 | from typing import List, Tuple, Callable, Awaitable | ||
| 10 | from os import PathLike | 9 | from os import PathLike |
| 10 | from typing import Awaitable, Callable, List, Tuple | ||
| 11 | |||
| 12 | import discord | ||
| 13 | from discord.commands import OptionChoice | ||
| 14 | from discord.ext import commands | ||
| 11 | 15 | ||
| 12 | from cogs.music import audio_playback, media_processing | 16 | from cogs.music import audio_playback, media_processing |
| 13 | from cogs.utils import file_search as search_utils | 17 | from cogs.utils import file_search as search_utils |
| 14 | from discord.ext import commands | ||
| 15 | from discord.commands import OptionChoice | ||
| 16 | import logging | ||
| 17 | 18 | ||
| 18 | logger = logging.getLogger(__name__) | 19 | logger = logging.getLogger(__name__) |
| 19 | 20 | ||
| 21 | |||
| 20 | class PlayCommands(commands.Cog): | 22 | class PlayCommands(commands.Cog): |
| 21 | """Discord slash commands for music playback.""" | 23 | """Discord slash commands for music playback.""" |
| 22 | 24 | ||
| 23 | def __init__(self, bot): | 25 | def __init__(self, bot): |
| 24 | self.bot = bot | 26 | self.bot = bot |
| 25 | self.temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp") | 27 | self.temp_dir = os.path.join( |
| 28 | os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp" | ||
| 29 | ) | ||
| 26 | self.path_mapping = {} # Maps display names to full paths | 30 | self.path_mapping = {} # Maps display names to full paths |
| 27 | 31 | ||
| 28 | @staticmethod | 32 | @staticmethod |
| 29 | async def _create_search_embed(candidates: List[str]) -> Tuple[discord.Embed, discord.ui.View]: | 33 | async def _create_search_embed( |
| 34 | candidates: List[str], | ||
| 35 | ) -> Tuple[discord.Embed, discord.ui.View]: | ||
| 30 | """Create an embed and view for search results.""" | 36 | """Create an embed and view for search results.""" |
| 31 | embed = discord.Embed( | 37 | embed = discord.Embed( |
| 32 | title="Wyniki wyszukiwania", | 38 | title="Wyniki wyszukiwania", |
| 33 | description="Wybierz plik do odtworzenia:", | 39 | description="Wybierz plik do odtworzenia:", |
| 34 | color=discord.Color.blue() | 40 | color=discord.Color.blue(), |
| 35 | ) | 41 | ) |
| 36 | 42 | ||
| 37 | view = discord.ui.View(disable_on_timeout=True) | 43 | view = discord.ui.View(disable_on_timeout=True) |
| 38 | 44 | ||
| 39 | for i, candidate in enumerate(candidates, start=1): | 45 | for i, candidate in enumerate(candidates, start=1): |
| 40 | filename = candidate.split('/')[-1].split('?')[0] | 46 | filename = candidate.split("/")[-1].split("?")[0] |
| 41 | display_name = os.path.splitext(filename)[0] | 47 | display_name = os.path.splitext(filename)[0] |
| 42 | embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False) | 48 | embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False) |
| 43 | 49 | ||
| 44 | return embed, view | 50 | return embed, view |
| 45 | 51 | ||
| 46 | async def _handle_search_results( | 52 | async def _handle_search_results( |
| 47 | self, | 53 | self, |
| 48 | ctx: discord.ApplicationContext, | 54 | ctx: discord.ApplicationContext, |
| 49 | candidates: List[str], | 55 | candidates: List[str], |
| 50 | play_callback: Callable[[discord.Interaction, str], Awaitable[None]] | 56 | play_callback: Callable[[discord.Interaction, str], Awaitable[None]], |
| 51 | ) -> None: | 57 | ) -> None: |
| 52 | """Handle the display of search results and button interactions.""" | 58 | """Handle the display of search results and button interactions.""" |
| 53 | if not candidates: | 59 | if not candidates: |
| 54 | await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True) | 60 | await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True) |
| 55 | return | 61 | return |
| 56 | 62 | ||
| 57 | embed, view = await self._create_search_embed(candidates) | 63 | embed, view = await self._create_search_embed(candidates) |
| 58 | 64 | ||
| 59 | # Add buttons for each candidate | 65 | # Add buttons for each candidate |
| 60 | for i, candidate in enumerate(candidates, start=1): | 66 | for i, candidate in enumerate(candidates, start=1): |
| 61 | button = discord.ui.Button(label=f"Play {i}") | 67 | button = discord.ui.Button(label=f"Play {i}") |
| 62 | 68 | ||
| 63 | async def button_callback(interaction: discord.Interaction, candidate_path=candidate): | 69 | async def button_callback( |
| 70 | interaction: discord.Interaction, candidate_path=candidate | ||
| 71 | ): | ||
| 64 | await interaction.response.defer(ephemeral=True) | 72 | await interaction.response.defer(ephemeral=True) |
| 65 | await ctx.interaction.delete_original_response() | 73 | await ctx.interaction.delete_original_response() |
| 66 | try: | 74 | try: |
| 67 | await play_callback(interaction, candidate_path) | 75 | await play_callback(interaction, candidate_path) |
| 68 | except Exception as e: | 76 | except Exception as e: |
| 69 | logger.error(f"Error in play callback: {str(e)}", exc_info=True) | 77 | logger.error(f"Error in play callback: {str(e)}", exc_info=True) |
| 70 | await interaction.followup.send(f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True) | 78 | await interaction.followup.send( |
| 71 | 79 | f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True | |
| 80 | ) | ||
| 81 | |||
| 72 | button.callback = button_callback | 82 | button.callback = button_callback |
| 73 | view.add_item(button) | 83 | view.add_item(button) |
| 74 | 84 | ||
| 75 | await ctx.respond(embed=embed, view=view, ephemeral=True) | 85 | await ctx.respond(embed=embed, view=view, ephemeral=True) |
| 76 | 86 | ||
| 77 | async def _search_autocomplete(self, ctx: discord.AutocompleteContext) -> List[OptionChoice]: | 87 | async def _search_autocomplete( |
| 88 | self, ctx: discord.AutocompleteContext | ||
| 89 | ) -> List[OptionChoice]: | ||
| 78 | """Autocomplete handler for search queries.""" | 90 | """Autocomplete handler for search queries.""" |
| 79 | query = ctx.value.lower() | 91 | query = ctx.value.lower() |
| 80 | try: | 92 | try: |
| 81 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete_audio(query, max_results=25) | 93 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete_audio( |
| 94 | query, max_results=25 | ||
| 95 | ) | ||
| 82 | choices = [] | 96 | choices = [] |
| 83 | self.path_mapping = {} # Reset the mapping for each new query | 97 | self.path_mapping = {} # Reset the mapping for each new query |
| 84 | 98 | ||
| 85 | for url in results: | 99 | for url in results: |
| 86 | filename = url.split('/')[-1] | 100 | filename = url.split("/")[-1] |
| 87 | filename = filename.split('?')[0] # Remove query params if any | 101 | filename = filename.split("?")[0] # Remove query params if any |
| 88 | filename = urllib.parse.unquote(filename) # Decode URL encoding | 102 | filename = urllib.parse.unquote(filename) # Decode URL encoding |
| 89 | display = os.path.splitext(filename)[0] # Remove extension | 103 | display = os.path.splitext(filename)[0] # Remove extension |
| 90 | 104 | ||
| @@ -93,7 +107,7 @@ class PlayCommands(commands.Cog): | |||
| 93 | self.path_mapping[display] = url | 107 | self.path_mapping[display] = url |
| 94 | 108 | ||
| 95 | if len(display) > 90: | 109 | if len(display) > 90: |
| 96 | display = display[:87] + '...' | 110 | display = display[:87] + "..." |
| 97 | 111 | ||
| 98 | choices.append(OptionChoice(display[:100], display[:100])) | 112 | choices.append(OptionChoice(display[:100], display[:100])) |
| 99 | 113 | ||
| @@ -106,25 +120,12 @@ class PlayCommands(commands.Cog): | |||
| 106 | logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) | 120 | logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) |
| 107 | return [] | 121 | return [] |
| 108 | 122 | ||
| 109 | async def _validate_play_inputs(self, file: str, stream: str, ctx: discord.ApplicationContext) -> bool: | 123 | async def _handle_compressed_file_play( |
| 110 | """Validate input parameters for play command.""" | 124 | self, ctx: discord.ApplicationContext, url: str |
| 111 | if not file and not stream: | 125 | ) -> None: |
| 112 | await ctx.respond("Musisz podać plik do odtworzenia lub adres streamu.", ephemeral=True) | ||
| 113 | return False | ||
| 114 | |||
| 115 | if file and stream: | ||
| 116 | await ctx.respond("Możesz podać tylko plik LUB stream, nie oba naraz.", ephemeral=True) | ||
| 117 | return False | ||
| 118 | |||
| 119 | return True | ||
| 120 | |||
| 121 | async def _handle_stream_play(self, ctx: discord.ApplicationContext, stream: str) -> None: | ||
| 122 | """Handle internet stream playback.""" | ||
| 123 | await audio_playback.AudioPlaybackManager.play(ctx, stream) | ||
| 124 | |||
| 125 | async def _handle_compressed_file_play(self, ctx: discord.ApplicationContext, url: str) -> None: | ||
| 126 | """Download from copyparty, compress, then play locally.""" | 126 | """Download from copyparty, compress, then play locally.""" |
| 127 | import aiohttp | 127 | import aiohttp |
| 128 | |||
| 128 | local_path = None | 129 | local_path = None |
| 129 | compressed_path = None | 130 | compressed_path = None |
| 130 | try: | 131 | try: |
| @@ -133,21 +134,25 @@ class PlayCommands(commands.Cog): | |||
| 133 | if response.status != 200: | 134 | if response.status != 200: |
| 134 | await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) | 135 | await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) |
| 135 | return | 136 | return |
| 136 | 137 | ||
| 137 | filename = url.split('/')[-1].split('?')[0] | 138 | filename = url.split("/")[-1].split("?")[0] |
| 138 | filename = urllib.parse.unquote(filename) | 139 | filename = urllib.parse.unquote(filename) |
| 139 | local_path = os.path.join(self.temp_dir, filename) | 140 | local_path = os.path.join(self.temp_dir, filename) |
| 140 | os.makedirs(self.temp_dir, exist_ok=True) | 141 | os.makedirs(self.temp_dir, exist_ok=True) |
| 141 | 142 | ||
| 142 | with open(local_path, 'wb') as f: | 143 | with open(local_path, "wb") as f: |
| 143 | async for chunk in response.content.iter_chunked(8192): | 144 | async for chunk in response.content.iter_chunked(8192): |
| 144 | f.write(chunk) | 145 | f.write(chunk) |
| 145 | 146 | ||
| 146 | compressed_path = await media_processing.AudioCompressor.compress(ctx, local_path) | 147 | compressed_path = await media_processing.AudioCompressor.compress( |
| 148 | ctx, local_path | ||
| 149 | ) | ||
| 147 | if not compressed_path: | 150 | if not compressed_path: |
| 148 | return | 151 | return |
| 149 | 152 | ||
| 150 | await audio_playback.AudioPlaybackManager.play(ctx, compressed_path, delete_after_play=True) | 153 | await audio_playback.AudioPlaybackManager.play( |
| 154 | ctx, compressed_path, delete_after_play=True | ||
| 155 | ) | ||
| 151 | except Exception as e: | 156 | except Exception as e: |
| 152 | logger.error(f"Error in compressed playback: {str(e)}", exc_info=True) | 157 | logger.error(f"Error in compressed playback: {str(e)}", exc_info=True) |
| 153 | await ctx.respond(f"Błąd: {str(e)}", ephemeral=True) | 158 | await ctx.respond(f"Błąd: {str(e)}", ephemeral=True) |
| @@ -158,10 +163,12 @@ class PlayCommands(commands.Cog): | |||
| 158 | except Exception: | 163 | except Exception: |
| 159 | pass | 164 | pass |
| 160 | 165 | ||
| 161 | async def _handle_local_file_play(self, ctx: discord.ApplicationContext, file: str, compressed: bool) -> None: | 166 | async def _handle_local_file_play( |
| 167 | self, ctx: discord.ApplicationContext, file: str, compressed: bool | ||
| 168 | ) -> None: | ||
| 162 | """Handle file playback from copyparty URL.""" | 169 | """Handle file playback from copyparty URL.""" |
| 163 | url = self.path_mapping.get(file, file) | 170 | url = self.path_mapping.get(file, file) |
| 164 | 171 | ||
| 165 | if compressed: | 172 | if compressed: |
| 166 | await self._handle_compressed_file_play(ctx, url) | 173 | await self._handle_compressed_file_play(ctx, url) |
| 167 | else: | 174 | else: |
| @@ -169,46 +176,47 @@ class PlayCommands(commands.Cog): | |||
| 169 | 176 | ||
| 170 | @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") | 177 | @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") |
| 171 | async def play( | 178 | async def play( |
| 172 | self, | 179 | self, |
| 173 | ctx: discord.ApplicationContext, | 180 | ctx: discord.ApplicationContext, |
| 174 | file: str = discord.Option( | 181 | melodia: str = discord.Option( |
| 175 | str, | 182 | str, |
| 176 | "wyszukaj plik z serwera", | 183 | "odtwórz plik z serwera bądź strumienia", |
| 177 | autocomplete=_search_autocomplete, | 184 | autocomplete=_search_autocomplete, |
| 178 | required=False | 185 | required=False, |
| 179 | ), | 186 | ), |
| 180 | stream: str = discord.Option( | 187 | file: discord.Attachment = discord.Option( |
| 181 | str, | 188 | discord.Attachment, "plik do zagrania", required=False |
| 182 | "odtwórz stream internetowy (e.g. radio)", | 189 | ), |
| 183 | required=False | 190 | compressed: bool = discord.Option( |
| 184 | ), | 191 | bool, "Czy skompresować dźwięk?", default=False |
| 185 | compressed: bool = discord.Option( | 192 | ), |
| 186 | bool, | ||
| 187 | "Czy skompresować dźwięk?", | ||
| 188 | default=False | ||
| 189 | ) | ||
| 190 | ): | 193 | ): |
| 191 | """Odtwarza wybrany plik dźwiękowy lub stream internetowy.""" | 194 | """Odtwarza wybrany plik dźwiękowy lub stream internetowy.""" |
| 192 | await ctx.defer() | 195 | await ctx.defer() |
| 193 | 196 | ||
| 194 | if not await self._validate_play_inputs(file, stream, ctx): | 197 | if melodia and file: |
| 198 | await ctx.respond( | ||
| 199 | "Możesz podać plik z serwera LUB upload, nie oba naraz.", ephemeral=True | ||
| 200 | ) | ||
| 195 | return | 201 | return |
| 196 | |||
| 197 | try: | ||
| 198 | if stream: | ||
| 199 | await self._handle_stream_play(ctx, stream) | ||
| 200 | else: | ||
| 201 | await self._handle_local_file_play(ctx, file, compressed) | ||
| 202 | except Exception as e: | ||
| 203 | logger.error(f"Error playing: {str(e)}", exc_info=True) | ||
| 204 | await ctx.respond(f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True) | ||
| 205 | 202 | ||
| 206 | @discord.slash_command(name="grajcuj_plik", description="grajcuj plik") | 203 | if not melodia and not file: |
| 207 | async def play_file(self, ctx: discord.ApplicationContext, attachment: discord.Attachment = discord.Option(discord.Attachment, "co zagrać??", required=True)): | 204 | await ctx.respond("Musisz podać plik do odtworzenia.", ephemeral=True) |
| 208 | await ctx.defer() | 205 | return |
| 209 | file_path: PathLike[str] | str = os.path.join(self.temp_dir, attachment.filename) | 206 | |
| 210 | await attachment.save(file_path) | 207 | if file: |
| 211 | await audio_playback.AudioPlaybackManager.play(ctx, file_path) | 208 | file_path: PathLike[str] | str = os.path.join(self.temp_dir, file.filename) |
| 209 | await file.save(file_path) | ||
| 210 | await audio_playback.AudioPlaybackManager.play(ctx, file_path) | ||
| 211 | else: | ||
| 212 | try: | ||
| 213 | await self._handle_local_file_play(ctx, melodia, compressed) | ||
| 214 | except Exception as e: | ||
| 215 | logger.error(f"Error playing: {str(e)}", exc_info=True) | ||
| 216 | await ctx.respond( | ||
| 217 | f"Wystąpił błąd podczas odtworzenia: {str(e)}", ephemeral=True | ||
| 218 | ) | ||
| 219 | |||
| 212 | 220 | ||
| 213 | def setup(bot): | 221 | def setup(bot): |
| 214 | bot.add_cog(PlayCommands(bot)) | 222 | bot.add_cog(PlayCommands(bot)) |
