From c634305604d0c993343a34262a3f3811d4c22e2c Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 4 Jan 2026 13:31:49 +0100 Subject: live search with good algorithm --- cogs/music/play.py | 272 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 172 insertions(+), 100 deletions(-) (limited to 'cogs/music/play.py') diff --git a/cogs/music/play.py b/cogs/music/play.py index 8390ff5..c9abe4b 100644 --- a/cogs/music/play.py +++ b/cogs/music/play.py @@ -1,10 +1,11 @@ import os - import discord import mutagen +from typing import List, Tuple, Optional, Callable, Awaitable, Union from . import play_utils from discord.ext import commands +from discord.commands import Option, OptionChoice import logging logger = logging.getLogger(__name__) @@ -13,113 +14,184 @@ class Play(commands.Cog): def __init__(self, bot): self.bot = bot self.temp_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "temp") + self.path_mapping = {} # Maps display names to full paths - @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") - async def play( - self, - ctx: discord.ApplicationContext, - query: str = discord.Option(str, "co zagrać??", required=True), - source: str = discord.Option(str, "co zagrać??", required=True, choices=["local", "soundcloud", "youtube"]) - - ): - await ctx.defer(ephemeral=True) - if source == "local": - candidates = await play_utils.PlayUtils(self.bot).search_local(query) - if candidates: - embed = discord.Embed(title="Wyniki wyszukiwania", description="Wybierz plik do odtworzenia:", color=discord.Color.blue()) - view = discord.ui.View(disable_on_timeout=True) - for i, candidate in enumerate(candidates, start=1): - filename = os.path.basename(candidate) - metadata = None - if os.path.splitext(filename)[1].lower() in ['.mp3', '.wav', '.opus']: - metadata = mutagen.File(candidate) + async def _create_search_embed(self, candidates: List[str]) -> Tuple[discord.Embed, discord.ui.View]: + """Create an embed and view for search results.""" + embed = discord.Embed( + title="Wyniki wyszukiwania", + description="Wybierz plik do odtworzenia:", + color=discord.Color.blue() + ) + + view = discord.ui.View(disable_on_timeout=True) + + for i, candidate in enumerate(candidates, start=1): + filename = os.path.basename(candidate) + title = None + + # Try to get metadata if it's an audio file + if os.path.splitext(filename)[1].lower() in ['.mp3', '.wav', '.opus']: + try: + metadata = mutagen.File(candidate) + if metadata: title = metadata.get('title', None) - if metadata and title: - embed.add_field(name=f"{i}. {title}", value="\u200b", inline=False) - else: - embed.add_field(name=f"{i}. {filename}", value="\u200b", inline=False) - button = discord.ui.Button(label=f"Play {i}") - async def button_callback(interaction: discord.Interaction, candidate=candidate): - await ctx.interaction.delete_original_response() - await play_utils.PlayUtils(self.bot).play(ctx, candidate) - button.callback = button_callback - view.add_item(button) - await ctx.respond(embed=embed, view=view, ephemeral=True) - else: - await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True) + except Exception as e: + logger.debug(f"Could not read metadata for {filename}: {str(e)}") + + # Add field with title if available, otherwise use filename + display_name = title if title else filename + embed.add_field(name=f"{i}. {display_name}", value="\u200b", inline=False) + + return embed, view + + async def _handle_search_results( + self, + ctx: discord.ApplicationContext, + candidates: List[str], + play_callback: Callable[[discord.Interaction, str], Awaitable[None]] + ) -> None: + """Handle the display of search results and button interactions.""" + if not candidates: + await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True) + return + + embed, view = await self._create_search_embed(candidates) + + # Add buttons for each candidate + for i, candidate in enumerate(candidates, start=1): + button = discord.ui.Button(label=f"Play {i}") + + async def button_callback(interaction: discord.Interaction, candidate_path=candidate): + await interaction.response.defer(ephemeral=True) + await ctx.interaction.delete_original_response() + try: + await play_callback(interaction, candidate_path) + except Exception as e: + logger.error(f"Error in play callback: {str(e)}", exc_info=True) + await interaction.followup.send(f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True) + + button.callback = button_callback + view.add_item(button) + + await ctx.respond(embed=embed, view=view, ephemeral=True) - @discord.slash_command(name="grajcuj_zkompresowane", description="grajcujmy wszyscy w wysokiej jakosci!") - async def play_compressed( + async def _search_autocomplete(self, ctx: discord.AutocompleteContext) -> List[OptionChoice]: + """Autocomplete handler for search queries.""" + query = ctx.value.lower() + try: + results = await play_utils.PlayUtils.search_local_autocomplete(query, max_results=25) + choices = [] + self.path_mapping = {} # Reset the mapping for each new query + + for path in results: + filename = os.path.splitext(os.path.basename(path))[0] # Remove extension + display = filename # Default display name + + # Try to get metadata for display + try: + if os.path.splitext(path)[1].lower() in ['.mp3', '.wav', '.opus', '.m4a']: + metadata = mutagen.File(path, easy=True) + + # Get metadata fields, falling back to filename if not available + title = metadata.get('title', [filename])[0] + artist = metadata.get('artist', [''])[0] + album_artist = metadata.get('albumartist', [artist])[0] + + # Build display name based on available metadata + if album_artist and title: + display = f"{album_artist} - {title}" + elif artist and title: + display = f"{artist} - {title}" + elif title: + display = title + + except Exception as e: + logger.debug(f"Could not read metadata for {filename}: {str(e)}") + + # Clean up the display name (remove any extra whitespace or brackets) + display = display.strip() + + # Store the mapping (use the cleaned display name as the key) + self.path_mapping[display] = path + + # Truncate display name if too long + if len(display) > 90: # Leave room for potential numbering + display = display[:87] + '...' + + choices.append(OptionChoice(display[:100], display[:100])) + + # Limit to 25 choices (Discord's limit) + if len(choices) >= 25: + break + + return choices + + except Exception as e: + logger.error(f"Error in autocomplete: {str(e)}", exc_info=True) + return [] + + @discord.slash_command(name="grajcuj", description="grajcujmy wszyscy!") + async def play( self, ctx: discord.ApplicationContext, - query: str = discord.Option(str, "co zagrać??", required=True), - source: str = discord.Option(str, "skąd zagrać??", required=True, - choices=["local", "soundcloud", "youtube"]) + file: str = discord.Option( + str, + "Wybierz plik do odtworzenia", + autocomplete=_search_autocomplete, + required=True + ), + source: str = discord.Option( + str, + "Źródło dźwięku", + choices=["local", "soundcloud", "youtube"], + default="local" + ), + compressed: bool = discord.Option( + bool, + "Czy skompresować dźwięk?", + default=False + ) ): - await ctx.defer(ephemeral=True) - if source == "local": - candidates = await play_utils.PlayUtils(self.bot).search_local(query) - if candidates: - embed = discord.Embed(title="Wyniki wyszukiwania", description="Wybierz plik do odtworzenia:", - color=discord.Color.blue()) - view = discord.ui.View(disable_on_timeout=True) - for i, candidate in enumerate(candidates, start=1): - filename = os.path.basename(candidate) - metadata = None - if os.path.splitext(filename)[1].lower() in ['.mp3', '.wav', '.opus']: - metadata = mutagen.File(candidate) - title = metadata.get('title', None) - if metadata and title: - embed.add_field(name=f"{i}. {title}", value="\u200b", inline=False) - else: - embed.add_field(name=f"{i}. {filename}", value="\u200b", inline=False) - button = discord.ui.Button(label=f"Play {i}") - - async def button_callback(interaction: discord.Interaction, candidate=candidate): - await interaction.response.defer(ephemeral=True) + """Odtwarza wybrany plik dźwiękowy z opcjonalną kompresją.""" + await ctx.defer() + + if source != "local": + await ctx.respond("Tylko źródło 'local' jest obecnie obsługiwane w tej komendzie.", ephemeral=True) + return + + # Look up the full path from the display name + full_path = self.path_mapping.get(file, file) + + if not os.path.exists(full_path): + logger.warning(f"File not found: {full_path} (display name: {file})") + await ctx.respond("Wybrany plik nie istnieje.", ephemeral=True) + return + + try: + if compressed: + # Compress the file before playing + compressed_path = await play_utils.PlayUtils(self.bot).compress(ctx, full_path) + if not compressed_path: + await ctx.respond("Nie udało się skompresować pliku.", ephemeral=True) + return + + try: + await play_utils.PlayUtils(self.bot).play(ctx, compressed_path, delete_after_play=True) + except Exception as e: + # Clean up the compressed file if play fails + if os.path.exists(compressed_path): try: - await ctx.interaction.delete_original_response() - # Compress the file before playing - compressed_path = await play_utils.PlayUtils(self.bot).compress(ctx, candidate) - if compressed_path: - try: - await play_utils.PlayUtils(self.bot).play(ctx, compressed_path, delete_after_play=True) - except Exception as e: - # Clean up the compressed file if play fails - if os.path.exists(compressed_path): - try: - os.remove(compressed_path) - logger.info(f"Cleaned up temporary file after play error: {compressed_path}") - except Exception as cleanup_error: - logger.error(f"Error cleaning up file {compressed_path}: {str(cleanup_error)}") - raise e - except Exception as e: - logger.error(f"Error in play_compressed: {str(e)}", exc_info=True) - await ctx.respond(f"Error playing compressed file: {str(e)}", ephemeral=True) - - button.callback = button_callback - view.add_item(button) - await ctx.respond(embed=embed, view=view, ephemeral=True) + os.remove(compressed_path) + logger.info(f"Cleaned up temporary file after play error: {compressed_path}") + except Exception as cleanup_error: + logger.error(f"Error cleaning up file {compressed_path}: {str(cleanup_error)}") + raise e else: - await ctx.respond("Nie znaleziono pliku do odtworzenia", ephemeral=True) - else: - # For YouTube/SoundCloud - try: - url, title = await play_utils.PlayUtils(self.bot).get_url(ctx, query) - if not url: - return - - filename = await play_utils.PlayUtils(self.bot).download(ctx, url) - if not filename: - return - - # Compress the downloaded file - compressed_filename = await play_utils.PlayUtils(self.bot).compress(ctx, filename) - if compressed_filename: - await play_utils.PlayUtils(self.bot).play(ctx, compressed_filename, title) - except Exception as e: - logger.error(f"Error in play_compressed: {str(e)}") - await ctx.respond(f"Error: {str(e)}", ephemeral=True) + await play_utils.PlayUtils(self.bot).play(ctx, full_path) + except Exception as e: + logger.error(f"Error playing file: {str(e)}", exc_info=True) + await ctx.respond(f"Wystąpił błąd podczas odtwarzania: {str(e)}", ephemeral=True) @discord.slash_command(name="grajcuj_plik", description="grajcuj plik") async def play_file(self, ctx: discord.ApplicationContext, attachment: discord.Attachment = discord.Option(discord.Attachment, "co zagrać??", required=True)): -- cgit v1.2.3