diff options
| author | salmonik <raisersoft@proton.me> | 2026-03-23 17:43:36 +0100 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-03-23 17:43:36 +0100 |
| commit | 456842d6f1c36d5a04cb4f8821e641be80e077f9 (patch) | |
| tree | 5235263de6ed0a25cc21576bb2f301b94d9d6f1e /cogs/music | |
| parent | triggers (diff) | |
| download | senator-sperma-456842d6f1c36d5a04cb4f8821e641be80e077f9.tar.gz senator-sperma-456842d6f1c36d5a04cb4f8821e641be80e077f9.zip | |
play music using the server
Diffstat (limited to 'cogs/music')
| -rw-r--r-- | cogs/music/play_commands.py | 166 |
1 files changed, 84 insertions, 82 deletions
diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index e3ca4c1..23808d3 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py | |||
| @@ -4,8 +4,8 @@ | |||
| 4 | # https://codeberg.org/salmonik/senator-sperma | 4 | # https://codeberg.org/salmonik/senator-sperma |
| 5 | 5 | ||
| 6 | import os | 6 | import os |
| 7 | import urllib.parse | ||
| 7 | import discord | 8 | import discord |
| 8 | import mutagen | ||
| 9 | from typing import List, Tuple, Callable, Awaitable | 9 | from typing import List, Tuple, Callable, Awaitable |
| 10 | from os import PathLike | 10 | from os import PathLike |
| 11 | 11 | ||
| @@ -37,20 +37,8 @@ class PlayCommands(commands.Cog): | |||
| 37 | view = discord.ui.View(disable_on_timeout=True) | 37 | view = discord.ui.View(disable_on_timeout=True) |
| 38 | 38 | ||
| 39 | for i, candidate in enumerate(candidates, start=1): | 39 | for i, candidate in enumerate(candidates, start=1): |
| 40 | filename = os.path.basename(candidate) | 40 | filename = candidate.split('/')[-1].split('?')[0] |
| 41 | title = None | 41 | display_name = os.path.splitext(filename)[0] |
| 42 | |||
| 43 | # Try to get metadata if it's an audio file | ||
| 44 | if os.path.splitext(filename)[1].lower() in ['.mp3', '.wav', '.opus']: | ||
| 45 | try: | ||
| 46 | metadata = mutagen.File(candidate) | ||
| 47 | if metadata: | ||
| 48 | title = metadata.get('title', None) | ||
| 49 | except Exception as e: | ||
| 50 | logger.debug(f"Could not read metadata for {filename}: {str(e)}") | ||
| 51 | |||
| 52 | # Add field with title if available, otherwise use filename | ||
| 53 | display_name = title if title else filename | ||
| 54 | embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False) | 42 | embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False) |
| 55 | 43 | ||
| 56 | return embed, view | 44 | return embed, view |
| @@ -86,69 +74,46 @@ class PlayCommands(commands.Cog): | |||
| 86 | 74 | ||
| 87 | await ctx.respond(embed=embed, view=view, ephemeral=True) | 75 | await ctx.respond(embed=embed, view=view, ephemeral=True) |
| 88 | 76 | ||
| 89 | def _get_display_name_from_metadata(self, path: str, filename: str) -> str: | ||
| 90 | """Extract display name from audio file metadata.""" | ||
| 91 | try: | ||
| 92 | if os.path.splitext(path)[1].lower() in ['.mp3', '.wav', '.opus', '.m4a']: | ||
| 93 | metadata = mutagen.File(path, easy=True) | ||
| 94 | |||
| 95 | title = metadata.get('title', [filename])[0] | ||
| 96 | artist = metadata.get('artist', [''])[0] | ||
| 97 | album_artist = metadata.get('albumartist', [artist])[0] | ||
| 98 | |||
| 99 | if album_artist and title: | ||
| 100 | return f"{album_artist} - {title}" | ||
| 101 | elif artist and title: | ||
| 102 | return f"{artist} - {title}" | ||
| 103 | elif title: | ||
| 104 | return title | ||
| 105 | except Exception as e: | ||
| 106 | logger.debug(f"Could not read metadata for {filename}: {str(e)}") | ||
| 107 | |||
| 108 | return filename | ||
| 109 | |||
| 110 | async def _search_autocomplete(self, ctx: discord.AutocompleteContext) -> List[OptionChoice]: | 77 | async def _search_autocomplete(self, ctx: discord.AutocompleteContext) -> List[OptionChoice]: |
| 111 | """Autocomplete handler for search queries.""" | 78 | """Autocomplete handler for search queries.""" |
| 112 | query = ctx.value.lower() | 79 | query = ctx.value.lower() |
| 113 | try: | 80 | try: |
| 114 | results = await search_utils.UniversalSearcher.search_local_autocomplete(query, max_results=25, filetype="Audio") | 81 | results = await search_utils.UniversalSearcher.search_copyparty_autocomplete(query, max_results=25) |
| 115 | choices = [] | 82 | choices = [] |
| 116 | self.path_mapping = {} # Reset the mapping for each new query | 83 | self.path_mapping = {} # Reset the mapping for each new query |
| 117 | 84 | ||
| 118 | for path in results: | 85 | for url in results: |
| 119 | filename = os.path.splitext(os.path.basename(path))[0] # Remove extension | 86 | filename = url.split('/')[-1] |
| 120 | display = self._get_display_name_from_metadata(path, filename) | 87 | filename = filename.split('?')[0] # Remove query params if any |
| 121 | 88 | filename = urllib.parse.unquote(filename) # Decode URL encoding | |
| 122 | # Clean up the display name (remove any extra whitespace or brackets) | 89 | display = os.path.splitext(filename)[0] # Remove extension |
| 90 | |||
| 123 | display = display.strip() | 91 | display = display.strip() |
| 124 | 92 | ||
| 125 | # Store the mapping (use the cleaned display name as the key) | 93 | self.path_mapping[display] = url |
| 126 | self.path_mapping[display] = path | 94 | |
| 127 | 95 | if len(display) > 90: | |
| 128 | # Truncate display name if too long | ||
| 129 | if len(display) > 90: # Leave room for potential numbering | ||
| 130 | display = display[:87] + '...' | 96 | display = display[:87] + '...' |
| 131 | 97 | ||
| 132 | choices.append(OptionChoice(display[:100], display[:100])) | 98 | choices.append(OptionChoice(display[:100], display[:100])) |
| 133 | 99 | ||
| 134 | # Limit to 25 choices (Discord's limit) | ||
| 135 | if len(choices) >= 25: | 100 | if len(choices) >= 25: |
| 136 | break | 101 | break |
| 137 | 102 | ||
| 138 | return choices | 103 | return choices |
| 139 | 104 | ||
| 140 | except Exception as e: | 105 | except Exception as e: |
| 141 | logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) | 106 | logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) |
| 142 | return [] | 107 | return [] |
| 143 | 108 | ||
| 144 | def _validate_play_inputs(self, file: str, stream: str, ctx: discord.ApplicationContext) -> bool: | 109 | async def _validate_play_inputs(self, file: str, stream: str, ctx: discord.ApplicationContext) -> bool: |
| 145 | """Validate input parameters for play command.""" | 110 | """Validate input parameters for play command.""" |
| 146 | if not file and not stream: | 111 | if not file and not stream: |
| 147 | ctx.respond("Musisz podać plik do odtworzenia lub adres streamu.", ephemeral=True) | 112 | await ctx.respond("Musisz podać plik do odtworzenia lub adres streamu.", ephemeral=True) |
| 148 | return False | 113 | return False |
| 149 | 114 | ||
| 150 | if file and stream: | 115 | if file and stream: |
| 151 | ctx.respond("Możesz podać tylko plik LUB stream, nie oba naraz.", ephemeral=True) | 116 | await ctx.respond("Możesz podać tylko plik LUB stream, nie oba naraz.", ephemeral=True) |
| 152 | return False | 117 | return False |
| 153 | 118 | ||
| 154 | return True | 119 | return True |
| @@ -157,38 +122,75 @@ class PlayCommands(commands.Cog): | |||
| 157 | """Handle internet stream playback.""" | 122 | """Handle internet stream playback.""" |
| 158 | await audio_playback.AudioPlaybackManager.play(ctx, stream) | 123 | await audio_playback.AudioPlaybackManager.play(ctx, stream) |
| 159 | 124 | ||
| 160 | async def _handle_compressed_file_play(self, ctx: discord.ApplicationContext, full_path: str) -> None: | 125 | async def _handle_compressed_file_play(self, ctx: discord.ApplicationContext, url: str) -> None: |
| 161 | """Handle compressed file playback with cleanup.""" | 126 | """Handle compressed file playback with cleanup.""" |
| 162 | compressed_path = await media_processing.AudioCompressor.compress(ctx, full_path) | 127 | is_url = url.startswith(('http://', 'https://')) |
| 163 | if not compressed_path: | 128 | |
| 164 | await ctx.respond("Nie udało się skompresować pliku.", ephemeral=True) | 129 | if is_url: |
| 165 | return | 130 | import aiohttp |
| 166 | 131 | local_path = None | |
| 167 | try: | 132 | try: |
| 168 | await audio_playback.AudioPlaybackManager.play(ctx, compressed_path, delete_after_play=True) | 133 | async with aiohttp.ClientSession() as session: |
| 169 | except Exception as e: | 134 | async with session.get(url) as response: |
| 170 | # Clean up the compressed file if play fails | 135 | if response.status != 200: |
| 171 | if os.path.exists(compressed_path): | 136 | await ctx.respond("Nie udało się pobrać pliku.", ephemeral=True) |
| 137 | return | ||
| 138 | |||
| 139 | filename = url.split('/')[-1].split('?')[0] | ||
| 140 | local_path = os.path.join(self.temp_dir, filename) | ||
| 141 | os.makedirs(self.temp_dir, exist_ok=True) | ||
| 142 | |||
| 143 | with open(local_path, 'wb') as f: | ||
| 144 | async for chunk in response.content.iter_chunked(8192): | ||
| 145 | f.write(chunk) | ||
| 146 | |||
| 147 | compressed_path = await media_processing.AudioCompressor.compress(ctx, local_path) | ||
| 148 | if not compressed_path: | ||
| 149 | await ctx.respond("Nie udało się skompresować pliku.", ephemeral=True) | ||
| 150 | return | ||
| 151 | |||
| 172 | try: | 152 | try: |
| 173 | os.remove(compressed_path) | 153 | await audio_playback.AudioPlaybackManager.play(ctx, compressed_path, delete_after_play=True) |
| 174 | logger.info(f"Cleaned up temporary file after play error: {compressed_path}") | 154 | except Exception as e: |
| 175 | except Exception as cleanup_error: | 155 | if os.path.exists(compressed_path): |
| 176 | logger.error(f"Error cleaning up file {compressed_path}: {str(cleanup_error)}") | 156 | try: |
| 177 | raise e | 157 | os.remove(compressed_path) |
| 158 | except Exception: | ||
| 159 | pass | ||
| 160 | raise e | ||
| 161 | finally: | ||
| 162 | if os.path.exists(local_path): | ||
| 163 | try: | ||
| 164 | os.remove(local_path) | ||
| 165 | except Exception: | ||
| 166 | pass | ||
| 167 | except Exception as e: | ||
| 168 | logger.error(f"Error downloading file from URL: {str(e)}", exc_info=True) | ||
| 169 | await ctx.respond(f"Błąd pobierania pliku: {str(e)}", ephemeral=True) | ||
| 170 | else: | ||
| 171 | compressed_path = await media_processing.AudioCompressor.compress(ctx, url) | ||
| 172 | if not compressed_path: | ||
| 173 | await ctx.respond("Nie udało się skompresować pliku.", ephemeral=True) | ||
| 174 | return | ||
| 175 | |||
| 176 | try: | ||
| 177 | await audio_playback.AudioPlaybackManager.play(ctx, compressed_path, delete_after_play=True) | ||
| 178 | except Exception as e: | ||
| 179 | if os.path.exists(compressed_path): | ||
| 180 | try: | ||
| 181 | os.remove(compressed_path) | ||
| 182 | except Exception: | ||
| 183 | pass | ||
| 184 | raise e | ||
| 178 | 185 | ||
| 179 | async def _handle_local_file_play(self, ctx: discord.ApplicationContext, file: str, compressed: bool) -> None: | 186 | async def _handle_local_file_play(self, ctx: discord.ApplicationContext, file: str, compressed: bool) -> None: |
| 180 | """Handle local file playback.""" | 187 | """Handle file playback from copyparty URL.""" |
| 181 | full_path = self.path_mapping.get(file, file) | 188 | url = self.path_mapping.get(file, file) |
| 182 | |||
| 183 | if not os.path.exists(full_path): | ||
| 184 | logger.warning(f"File not found: {full_path} (display name: {file})") | ||
| 185 | await ctx.respond("Wybrany plik nie istnieje.", ephemeral=True) | ||
| 186 | return | ||
| 187 | 189 | ||
| 188 | if compressed: | 190 | if compressed: |
| 189 | await self._handle_compressed_file_play(ctx, full_path) | 191 | await self._handle_compressed_file_play(ctx, url) |
| 190 | else: | 192 | else: |
| 191 | await audio_playback.AudioPlaybackManager.play(ctx, full_path) | 193 | await audio_playback.AudioPlaybackManager.play(ctx, url) |
| 192 | 194 | ||
| 193 | @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") | 195 | @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") |
| 194 | async def play( | 196 | async def play( |
