import discord from discord import ui # Added for views/buttons from discord.ext import commands, tasks import asyncio import yt_dlp as youtube_dl import logging from collections import deque import math # For pagination calculation # Configure logging logging.basicConfig(level=logging.INFO) log = logging.getLogger(__name__) # Suppress noisy yt-dlp logs unless debugging youtube_dl.utils.bug_reports_message = lambda: '' # --- yt-dlp Options --- YDL_OPTS_BASE = { 'format': 'bestaudio/best', 'outtmpl': '%(extractor)s-%(id)s-%(title)s.%(ext)s', 'restrictfilenames': True, 'noplaylist': False, # Allow playlists by default, override per call if needed 'nocheckcertificate': True, 'ignoreerrors': False, 'logtostderr': False, 'quiet': True, 'no_warnings': True, 'default_search': 'ytsearch', # Default to YouTube search 'source_address': '0.0.0.0', # Bind to all IPs for better connectivity 'cookiefile': 'cookies.txt' } FFMPEG_OPTIONS = { 'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5', 'options': '-vn' # No video } class Song: """Represents a song to be played.""" def __init__(self, source_url, title, webpage_url, duration, requested_by): self.source_url = source_url self.title = title self.webpage_url = webpage_url self.duration = duration self.requested_by = requested_by # User who requested the song def __str__(self): return f"**{self.title}** ({self.format_duration()})" def format_duration(self): """Formats duration in seconds to MM:SS or HH:MM:SS.""" if not self.duration: return "N/A" minutes, seconds = divmod(self.duration, 60) hours, minutes = divmod(minutes, 60) if hours > 0: return f"{hours:02d}:{minutes:02d}:{seconds:02d}" else: return f"{minutes:02d}:{seconds:02d}" class AudioCog(commands.Cog): def __init__(self, bot): self.bot = bot self.queues = {} # Dictionary to hold queues per guild {guild_id: deque()} self.current_song = {} # Dictionary for current song per guild {guild_id: Song} self.voice_clients = {} # Dictionary for voice clients per guild {guild_id: discord.VoiceClient} self.play_next_song.start() # Start the background task def get_queue(self, guild_id): """Gets the queue for a guild, creating it if it doesn't exist.""" return self.queues.setdefault(guild_id, deque()) def get_current_song(self, guild_id): """Gets the current song for a guild.""" return self.current_song.get(guild_id) def cleanup(self, guild_id): """Cleans up resources for a guild.""" if guild_id in self.queues: del self.queues[guild_id] if guild_id in self.current_song: del self.current_song[guild_id] if guild_id in self.voice_clients: vc = self.voice_clients.pop(guild_id) if vc and vc.is_connected(): # Use asyncio.create_task for fire-and-forget disconnect asyncio.create_task(vc.disconnect(force=True)) log.info(f"Cleaned up resources for guild {guild_id}") async def cog_unload(self): """Cog unload cleanup.""" self.play_next_song.cancel() for guild_id in list(self.voice_clients.keys()): # Iterate over keys copy self.cleanup(guild_id) @tasks.loop(seconds=1.0) async def play_next_song(self): """Background task to play the next song in the queue for each guild.""" for guild_id, vc in list(self.voice_clients.items()): # Iterate over copy if not vc or not vc.is_connected(): # If VC disconnected unexpectedly, clean up log.warning(f"VC for guild {guild_id} disconnected unexpectedly. Cleaning up.") self.cleanup(guild_id) continue queue = self.get_queue(guild_id) if not vc.is_playing() and not vc.is_paused() and queue: next_song = queue.popleft() self.current_song[guild_id] = next_song try: log.info(f"Playing next song in guild {guild_id}: {next_song.title}") source = discord.FFmpegPCMAudio(next_song.source_url, **FFMPEG_OPTIONS) vc.play(source, after=lambda e: self.handle_after_play(e, guild_id)) # Optionally send a "Now Playing" message to the channel # This requires storing the context or channel ID somewhere except Exception as e: log.error(f"Error playing song {next_song.title} in guild {guild_id}: {e}") self.current_song[guild_id] = None # Clear current song on error # Try to play the next one if available if queue: log.info(f"Trying next song in queue for guild {guild_id}") # Let the loop handle the next iteration naturally else: log.info(f"Queue empty for guild {guild_id} after error.") # Consider leaving VC after inactivity? elif not vc.is_playing() and not vc.is_paused() and not queue: # If nothing is playing and queue is empty, clear current song if self.current_song.get(guild_id): self.current_song[guild_id] = None log.info(f"Queue empty and playback finished for guild {guild_id}. Current song cleared.") # Add inactivity disconnect logic here if desired def handle_after_play(self, error, guild_id): """Callback function after a song finishes playing.""" if error: log.error(f'Player error in guild {guild_id}: {error}') else: log.info(f"Song finished playing in guild {guild_id}.") # The loop will handle playing the next song @play_next_song.before_loop async def before_play_next_song(self): await self.bot.wait_until_ready() log.info("AudioCog background task started.") async def _extract_info(self, query): """Extracts info using yt-dlp. Handles URLs and search queries.""" ydl_opts = YDL_OPTS_BASE.copy() is_search = not (query.startswith('http://') or query.startswith('https://')) if is_search: # For search, limit to 1 result and treat as single item ydl_opts['default_search'] = 'ytsearch1' ydl_opts['noplaylist'] = True # Explicitly search for single video log.info(f"Performing YouTube search for: {query}") else: # For URLs, let yt-dlp determine if it's a playlist or single video # Do not use extract_flat, get full info ydl_opts['noplaylist'] = False # Allow playlists log.info(f"Processing URL: {query}") try: # Extract full information with youtube_dl.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(query, download=False) # Determine if it's a playlist *after* extraction is_playlist = info.get('_type') == 'playlist' if is_search and 'entries' in info and info['entries']: # If search returned results, take the first one info = info['entries'][0] is_playlist = False # Search result is treated as single item elif is_search and ('entries' not in info or not info['entries']): # Handle case where search yields no results directly return None, False, True # Indicate no info found return info, is_playlist, is_search except youtube_dl.utils.DownloadError as e: # Handle specific errors if possible (e.g., video unavailable) error_msg = str(e) if 'video unavailable' in error_msg.lower(): raise commands.CommandError(f"The video '{query}' is unavailable.") elif 'playlist does not exist' in error_msg.lower(): raise commands.CommandError(f"The playlist '{query}' does not exist or is private.") log.error(f"yt-dlp download error for '{query}': {error_msg}") raise commands.CommandError(f"Could not process '{query}'. Is it a valid URL or search term?") except Exception as e: log.error(f"Unexpected yt-dlp error for '{query}': {e}") raise commands.CommandError("An unexpected error occurred while fetching video info.") async def _search_youtube(self, query: str, max_results: int = 15): # Increased max_results for pagination """Performs a YouTube search and returns multiple results.""" # Clamp max_results to avoid excessively long searches if abused max_results = min(max(1, max_results), 25) # Limit between 1 and 25 ydl_opts = YDL_OPTS_BASE.copy() # Use ytsearchN: query to get N results ydl_opts['default_search'] = f'ytsearch{max_results}' ydl_opts['noplaylist'] = True # Ensure only videos are searched log.info(f"Performing YouTube search for '{query}' (max {max_results} results)") try: with youtube_dl.YoutubeDL(ydl_opts) as ydl: # Extract info without downloading info = ydl.extract_info(query, download=False) # Check if 'entries' exist and contain results if 'entries' in info and info['entries']: return info['entries'] # Return the list of video dictionaries else: log.info(f"No search results found for '{query}'") return [] # Return empty list if no results except youtube_dl.utils.DownloadError as e: log.error(f"yt-dlp search download error for '{query}': {e}") # Don't raise here, let the command handle empty results return [] except Exception as e: log.error(f"Unexpected yt-dlp error during search for '{query}': {e}") # Don't raise here, let the command handle empty results return [] async def _ensure_voice_connection(self, ctx_or_interaction): """Ensures the bot is connected to the user's voice channel. Accepts Context or Interaction.""" is_interaction = isinstance(ctx_or_interaction, discord.Interaction) if is_interaction: guild = ctx_or_interaction.guild author = ctx_or_interaction.user if not guild: raise commands.CommandError("Interaction must be in a guild.") else: # Is Context guild = ctx_or_interaction.guild author = ctx_or_interaction.author if not guild: raise commands.CommandError("Command must be used in a guild.") if not isinstance(author, discord.Member) or not author.voice or not author.voice.channel: raise commands.CommandError("You are not connected to a voice channel.") vc = self.voice_clients.get(guild.id) target_channel = author.voice.channel if not vc or not vc.is_connected(): try: log.info(f"Connecting to voice channel {target_channel.name} in guild {guild.id}") vc = await target_channel.connect() self.voice_clients[guild.id] = vc except asyncio.TimeoutError: raise commands.CommandError(f"Connecting to {target_channel.name} timed out.") except discord.errors.ClientException as e: raise commands.CommandError(f"Already connected to a voice channel? Error: {e}") except Exception as e: log.error(f"Failed to connect to {target_channel.name}: {e}") raise commands.CommandError(f"Failed to connect to the voice channel: {e}") elif vc.channel != target_channel: try: log.info(f"Moving to voice channel {target_channel.name} in guild {guild.id}") await vc.move_to(target_channel) self.voice_clients[guild.id] = vc # Ensure the instance is updated if move_to returns a new one except Exception as e: log.error(f"Failed to move to {target_channel.name}: {e}") raise commands.CommandError(f"Failed to move to your voice channel: {e}") return vc # --- Commands --- @commands.command(name="join", aliases=['connect']) async def join(self, ctx: commands.Context): """Connects the bot to your current voice channel.""" try: await self._ensure_voice_connection(ctx) await ctx.reply(f"Connected to **{ctx.author.voice.channel.name}**.") except commands.CommandError as e: await ctx.reply(str(e)) except Exception as e: log.error(f"Error in join command: {e}") await ctx.reply("An unexpected error occurred while trying to join.") @commands.command(name="leave", aliases=['disconnect', 'dc']) async def leave(self, ctx: commands.Context): """Disconnects the bot from the voice channel.""" vc = self.voice_clients.get(ctx.guild.id) if not vc or not vc.is_connected(): await ctx.reply("I am not connected to a voice channel.") return log.info(f"Disconnecting from voice channel in guild {ctx.guild.id}") await ctx.reply(f"Disconnecting from **{vc.channel.name}**.") self.cleanup(ctx.guild.id) # This handles the disconnect and queue clearing @commands.command(name="play", aliases=['p']) async def play(self, ctx: commands.Context, *, query: str): """Plays a song or adds it/playlist to the queue. Accepts URL or search query.""" try: vc = await self._ensure_voice_connection(ctx) except commands.CommandError as e: await ctx.reply(str(e)) return except Exception as e: log.error(f"Error ensuring voice connection in play command: {e}") await ctx.reply("An unexpected error occurred before playing.") return queue = self.get_queue(ctx.guild.id) songs_added = 0 playlist_title = None song_to_announce = None # Store the single song if added async with ctx.typing(): # Indicate processing try: # info now contains full data for playlist or single video info, is_playlist, is_search = await self._extract_info(query) if not info: await ctx.reply("Could not find anything matching your query.") return if is_playlist: playlist_title = info.get('title', 'Unnamed Playlist') log.info(f"Adding playlist '{playlist_title}' to queue for guild {ctx.guild.id}") entries = info.get('entries', []) # Should contain full entry info now if not entries: await ctx.reply(f"Playlist '{playlist_title}' seems to be empty or could not be loaded.") return for entry in entries: if not entry: continue # Extract stream URL directly from the entry info stream_url = entry.get('url') # yt-dlp often provides the best stream URL here if not stream_url: # Fallback to formats if needed formats = entry.get('formats', []) for f in formats: # Prioritize opus or known good audio codecs if f.get('url') and f.get('acodec') != 'none' and (f.get('vcodec') == 'none' or f.get('acodec') == 'opus'): stream_url = f['url'] break # Last resort fallback if still no URL if not stream_url and formats: for f in formats: if f.get('url') and f.get('acodec') != 'none': stream_url = f['url'] break if not stream_url: log.warning(f"Could not find playable stream URL for playlist entry: {entry.get('title', entry.get('id'))}") await ctx.send(f"⚠️ Could not get audio for '{entry.get('title', 'an item')}' from playlist.", delete_after=15) continue try: song = Song( source_url=stream_url, title=entry.get('title', 'Unknown Title'), webpage_url=entry.get('webpage_url', entry.get('original_url')), # Use original_url as fallback duration=entry.get('duration'), requested_by=ctx.author ) queue.append(song) songs_added += 1 except Exception as song_e: log.error(f"Error creating Song object for entry {entry.get('title', entry.get('id'))}: {song_e}") await ctx.send(f"⚠️ Error processing metadata for '{entry.get('title', 'an item')}' from playlist.", delete_after=15) else: # Single video or search result # 'info' should be the dictionary for the single video here stream_url = info.get('url') if not stream_url: # Fallback if 'url' isn't top-level formats = info.get('formats', []) for f in formats: # Prioritize opus or known good audio codecs if f.get('url') and f.get('acodec') != 'none' and (f.get('vcodec') == 'none' or f.get('acodec') == 'opus'): stream_url = f['url'] break # Last resort fallback if still no URL if not stream_url and formats: for f in formats: if f.get('url') and f.get('acodec') != 'none': stream_url = f['url'] break if not stream_url: await ctx.reply("Could not extract a playable audio stream for the video.") return song = Song( source_url=stream_url, title=info.get('title', 'Unknown Title'), webpage_url=info.get('webpage_url'), duration=info.get('duration'), requested_by=ctx.author ) queue.append(song) songs_added = 1 song_to_announce = song # Store for announcement log.info(f"Added song '{song.title}' to queue for guild {ctx.guild.id}") except commands.CommandError as e: await ctx.reply(str(e)) return except Exception as e: log.exception(f"Error during song processing in play command: {e}") # Log full traceback await ctx.reply("An unexpected error occurred while processing your request.") return # --- Send confirmation message --- if songs_added > 0: if is_playlist: await ctx.reply(f"✅ Added **{songs_added}** songs from playlist **'{playlist_title}'** to the queue.") elif song_to_announce: # Check if a single song was added # For single adds, show position if queue was not empty before adding queue_pos = len(queue) # Position is the current length (after adding) if vc.is_playing() or vc.is_paused() or queue_pos > 1: # If something playing or queue had items before this add await ctx.reply(f"✅ Added **{song_to_announce.title}** to the queue (position #{queue_pos}).") else: # If nothing was playing and queue was empty, this song will play next # The loop will handle the "Now Playing" implicitly, so just confirm add await ctx.reply(f"✅ Added **{song_to_announce.title}** to the queue.") # No need to explicitly start playback here, the loop handles it. else: # This case might happen if playlist extraction failed for all entries or search failed if not is_playlist and is_search: # If it was a search and nothing was added, the earlier message handles it pass # Already sent "Could not find anything..." else: await ctx.reply("Could not add any songs from the provided source.") @commands.command(name="pause") async def pause(self, ctx: commands.Context): """Pauses the current playback.""" vc = self.voice_clients.get(ctx.guild.id) if not vc or not vc.is_playing(): await ctx.reply("I am not playing anything right now.") return if vc.is_paused(): await ctx.reply("Playback is already paused.") return vc.pause() await ctx.reply("⏸️ Playback paused.") log.info(f"Playback paused in guild {ctx.guild.id}") @commands.command(name="resume") async def resume(self, ctx: commands.Context): """Resumes paused playback.""" vc = self.voice_clients.get(ctx.guild.id) if not vc or not vc.is_connected(): await ctx.reply("I am not connected to a voice channel.") return if not vc.is_paused(): await ctx.reply("Playback is not paused.") return vc.resume() await ctx.reply("▶️ Playback resumed.") log.info(f"Playback resumed in guild {ctx.guild.id}") @commands.command(name="skip", aliases=['s']) async def skip(self, ctx: commands.Context): """Skips the current song.""" vc = self.voice_clients.get(ctx.guild.id) if not vc or not vc.is_playing(): await ctx.reply("I am not playing anything to skip.") return current = self.get_current_song(ctx.guild.id) await ctx.reply(f"⏭️ Skipping **{current.title if current else 'the current song'}**...") vc.stop() # Triggers the 'after' callback, which lets the loop play the next song log.info(f"Song skipped in guild {ctx.guild.id} by {ctx.author}") # The loop will handle playing the next song @commands.command(name="stop") async def stop(self, ctx: commands.Context): """Stops playback and clears the queue.""" vc = self.voice_clients.get(ctx.guild.id) if not vc or not vc.is_connected(): await ctx.reply("I am not connected to a voice channel.") return queue = self.get_queue(ctx.guild.id) queue.clear() self.current_song[ctx.guild.id] = None # Clear current song immediately if vc.is_playing() or vc.is_paused(): vc.stop() # Stop playback await ctx.reply("⏹️ Playback stopped and queue cleared.") log.info(f"Playback stopped and queue cleared in guild {ctx.guild.id} by {ctx.author}") else: await ctx.reply("⏹️ Queue cleared.") # If nothing was playing, just confirm queue clear log.info(f"Queue cleared in guild {ctx.guild.id} by {ctx.author} (nothing was playing).") @commands.command(name="queue", aliases=['q']) async def queue(self, ctx: commands.Context): """Displays the current song queue.""" queue = self.get_queue(ctx.guild.id) current = self.get_current_song(ctx.guild.id) if not queue and not current: await ctx.reply("The queue is empty and nothing is playing.") return embed = discord.Embed(title="Music Queue", color=discord.Color.blue()) if current: embed.add_field(name="Now Playing", value=f"[{current.title}]({current.webpage_url}) | `{current.format_duration()}` | Requested by {current.requested_by.mention}", inline=False) else: embed.add_field(name="Now Playing", value="Nothing currently playing.", inline=False) if queue: queue_list = [] max_display = 10 # Limit display to avoid huge embeds for i, song in enumerate(list(queue)[:max_display]): queue_list.append(f"`{i+1}.` [{song.title}]({song.webpage_url}) | `{song.format_duration()}` | Req by {song.requested_by.mention}") if queue_list: embed.add_field(name="Up Next", value="\n".join(queue_list), inline=False) if len(queue) > max_display: embed.set_footer(text=f"... and {len(queue) - max_display} more songs.") else: embed.add_field(name="Up Next", value="The queue is empty.", inline=False) await ctx.reply(embed=embed) @commands.command(name="nowplaying", aliases=['np', 'current']) async def nowplaying(self, ctx: commands.Context): """Shows the currently playing song.""" current = self.get_current_song(ctx.guild.id) vc = self.voice_clients.get(ctx.guild.id) if not vc or not vc.is_connected(): await ctx.reply("I'm not connected to a voice channel.") return if not current or not (vc.is_playing() or vc.is_paused()): await ctx.reply("Nothing is currently playing.") return embed = discord.Embed(title="Now Playing", description=f"[{current.title}]({current.webpage_url})", color=discord.Color.green()) embed.add_field(name="Duration", value=f"`{current.format_duration()}`", inline=True) embed.add_field(name="Requested by", value=current.requested_by.mention, inline=True) # Add progress bar if possible (requires tracking start time) # progress = ... # embed.add_field(name="Progress", value=progress, inline=False) if hasattr(current, 'thumbnail') and current.thumbnail: # Check if thumbnail exists embed.set_thumbnail(url=current.thumbnail) await ctx.reply(embed=embed) # --- Search Command and View --- @commands.command(name="search") async def search(self, ctx: commands.Context, *, query: str): """Searches YouTube and displays results with selection buttons.""" if not ctx.guild: await ctx.reply("This command can only be used in a server.") return # Store the initial message to edit later message = await ctx.reply(f"Searching for '{query}'...") async with ctx.typing(): # Keep typing indicator while searching try: # Fetch more results for pagination results = await self._search_youtube(query, max_results=15) except Exception as e: log.error(f"Error during YouTube search: {e}") await message.edit(content="An error occurred while searching.", view=None) return if not results: await message.edit(content=f"No results found for '{query}'.", view=None) return # Prepare data for the view search_results_data = [] for entry in results: # Store necessary info for adding to queue later search_results_data.append({ 'title': entry.get('title', 'Unknown Title'), 'webpage_url': entry.get('webpage_url'), 'duration': entry.get('duration'), 'id': entry.get('id'), # Need ID to re-fetch stream URL later 'uploader': entry.get('uploader', 'Unknown Uploader') }) # Create the view with pagination view = PaginatedSearchResultView(ctx.author, search_results_data, self, query) # Pass query for title view.interaction_message = message # Store message reference in the view # Initial update of the message with the first page await view.update_message(interaction=None) # Use interaction=None for initial send/edit @staticmethod def format_duration_static(duration): """Static version of format_duration for use outside Song objects.""" if not duration: return "N/A" minutes, seconds = divmod(duration, 60) hours, minutes = divmod(minutes, 60) if hours > 0: return f"{hours:02d}:{minutes:02d}:{seconds:02d}" else: return f"{minutes:02d}:{seconds:02d}" # Error Handling for Audio Cog specifically @commands.Cog.listener() async def on_command_error(self, ctx, error): # Handle errors specific to this cog, prevents double messages if global handler exists if isinstance(error, commands.CommandError) and ctx.cog == self: # Check if the error is specific to commands in this cog # Avoid handling errors already handled locally in commands if possible # This basic check just prevents duplicate generic messages log.warning(f"Command error in AudioCog: {error}") # await ctx.reply(f"An error occurred: {error}") # Optional: Send specific cog errors # Return True or pass to prevent global handler if needed # pass # Let global handler take care of it unless specific handling is needed async def setup(bot): await bot.add_cog(AudioCog(bot)) log.info("AudioCog loaded successfully.") # --- Paginated Search Result View --- class PaginatedSearchResultView(ui.View): RESULTS_PER_PAGE = 5 def __init__(self, author: discord.Member, results: list, cog: AudioCog, query: str, timeout=180.0): super().__init__(timeout=timeout) self.author = author self.results = results self.cog = cog self.query = query # Store original query for embed title self.current_page = 0 self.total_pages = math.ceil(len(self.results) / self.RESULTS_PER_PAGE) self.interaction_message: discord.Message = None # To disable view later self.update_buttons() # Initial button setup def update_buttons(self): """Clears and adds buttons based on the current page and total results.""" self.clear_items() start_index = self.current_page * self.RESULTS_PER_PAGE end_index = min(start_index + self.RESULTS_PER_PAGE, len(self.results)) # Add result selection buttons for the current page for i in range(start_index, end_index): result = self.results[i] button = ui.Button( label=f"{i+1}", # Overall result number style=discord.ButtonStyle.secondary, custom_id=f"search_select_{i}", row= (i - start_index) // 5 # Arrange buttons neatly if more than 5 per page (though we limit to 5) ) # Use lambda to capture the correct index 'i' button.callback = lambda interaction, index=i: self.select_button_callback(interaction, index) self.add_item(button) # Add navigation buttons (Previous/Next) - ensure they are on the last row nav_row = math.ceil(self.RESULTS_PER_PAGE / 5) # Calculate row for nav buttons if self.total_pages > 1: prev_button = ui.Button(label="◀ Previous", style=discord.ButtonStyle.primary, custom_id="search_prev", disabled=self.current_page == 0, row=nav_row) prev_button.callback = self.prev_button_callback self.add_item(prev_button) next_button = ui.Button(label="Next ▶", style=discord.ButtonStyle.primary, custom_id="search_next", disabled=self.current_page == self.total_pages - 1, row=nav_row) next_button.callback = self.next_button_callback self.add_item(next_button) def create_embed(self) -> discord.Embed: """Creates the embed for the current page.""" embed = discord.Embed( title=f"Search Results for '{self.query}' (Page {self.current_page + 1}/{self.total_pages})", description="Click a button below to add the song to the queue.", color=discord.Color.purple() ) start_index = self.current_page * self.RESULTS_PER_PAGE end_index = min(start_index + self.RESULTS_PER_PAGE, len(self.results)) if start_index >= len(self.results): # Should not happen with proper page clamping embed.description = "No results on this page." return embed for i in range(start_index, end_index): entry = self.results[i] title = entry.get('title', 'Unknown Title') url = entry.get('webpage_url') duration_sec = entry.get('duration') duration_fmt = self.cog.format_duration_static(duration_sec) if duration_sec else "N/A" # Use cog's static method uploader = entry.get('uploader', 'Unknown Uploader') embed.add_field( name=f"{i+1}. {title}", # Use overall index + 1 for label value=f"[{uploader}]({url}) | `{duration_fmt}`", inline=False ) embed.set_footer(text=f"Showing results {start_index + 1}-{end_index} of {len(self.results)}") return embed async def update_message(self, interaction: discord.Interaction = None): """Updates the message with the current page's embed and buttons.""" self.update_buttons() embed = self.create_embed() if interaction: await interaction.response.edit_message(embed=embed, view=self) elif self.interaction_message: # For initial send/edit await self.interaction_message.edit(content=None, embed=embed, view=self) # Remove "Searching..." text async def interaction_check(self, interaction: discord.Interaction) -> bool: # Only allow the original command author to interact if interaction.user != self.author: await interaction.response.send_message("Only the person who started the search can interact with this.", ephemeral=True) return False return True async def select_button_callback(self, interaction: discord.Interaction, index: int): """Callback when a result selection button is pressed.""" if not interaction.guild: return selected_result = self.results[index] log.info(f"Search result {index+1} ('{selected_result['title']}') selected by {interaction.user} in guild {interaction.guild.id}") # Defer the interaction await interaction.response.defer() # Disable all buttons in the view after selection for item in self.children: if isinstance(item, ui.Button): item.disabled = True # Update the original message to show disabled buttons and confirmation final_embed = self.create_embed() # Get current embed state final_embed.description = f"Selected: **{selected_result['title']}**. Adding to queue..." final_embed.color = discord.Color.green() await interaction.edit_original_response(embed=final_embed, view=self) self.stop() # Stop the view from listening further # --- Add the selected song to the queue --- try: # Ensure bot is connected to voice (use interaction here) vc = await self.cog._ensure_voice_connection(interaction) if not vc: log.error("Failed to ensure voice connection in search callback.") await interaction.followup.send("Could not connect to voice channel.", ephemeral=True) return queue = self.cog.get_queue(interaction.guild.id) # Re-fetch the stream URL try: query_for_stream = selected_result.get('webpage_url') or selected_result.get('id') if not query_for_stream: raise commands.CommandError("Missing video identifier for selected result.") info, _, _ = await self.cog._extract_info(query_for_stream) if not info: raise commands.CommandError("Could not retrieve details for the selected video.") stream_url = info.get('url') if not stream_url: formats = info.get('formats', []) for f in formats: if f.get('url') and f.get('acodec') != 'none' and (f.get('vcodec') == 'none' or f.get('acodec') == 'opus'): stream_url = f['url'] break if not stream_url and formats: for f in formats: if f.get('url') and f.get('acodec') != 'none': stream_url = f['url'] break if not stream_url: raise commands.CommandError("Could not extract a playable audio stream.") song = Song( source_url=stream_url, title=info.get('title', selected_result.get('title', 'Unknown Title')), webpage_url=info.get('webpage_url', selected_result.get('webpage_url')), duration=info.get('duration', selected_result.get('duration')), requested_by=interaction.user ) queue.append(song) log.info(f"Added search result '{song.title}' to queue for guild {interaction.guild.id}") # Send confirmation followup queue_pos = len(queue) if vc.is_playing() or vc.is_paused() or queue_pos > 1: await interaction.followup.send(f"✅ Added **{song.title}** to the queue (position #{queue_pos}).") else: await interaction.followup.send(f"✅ Added **{song.title}** to the queue.") except commands.CommandError as e: log.error(f"Error adding search result to queue: {e}") await interaction.followup.send(f"Error adding song: {e}", ephemeral=True) except Exception as e: log.exception(f"Unexpected error adding search result to queue: {e}") await interaction.followup.send("An unexpected error occurred while adding the song.", ephemeral=True) except commands.CommandError as e: await interaction.followup.send(str(e), ephemeral=True) except Exception as e: log.exception(f"Unexpected error in search select callback: {e}") await interaction.followup.send("An unexpected error occurred.", ephemeral=True) async def prev_button_callback(self, interaction: discord.Interaction): """Callback for the previous page button.""" if self.current_page > 0: self.current_page -= 1 await self.update_message(interaction) async def next_button_callback(self, interaction: discord.Interaction): """Callback for the next page button.""" if self.current_page < self.total_pages - 1: self.current_page += 1 await self.update_message(interaction) async def on_timeout(self): # Disable buttons on timeout log.info(f"Paginated search view timed out for user {self.author.id}") for item in self.children: if isinstance(item, ui.Button): item.disabled = True # Try to edit the original message if self.interaction_message: try: # Keep the last viewed embed but indicate timeout timeout_embed = self.create_embed() timeout_embed.description = "Search selection timed out." timeout_embed.color = discord.Color.default() # Reset color await self.interaction_message.edit(embed=timeout_embed, view=self) except discord.NotFound: log.warning("Original search message not found on timeout.") except discord.Forbidden: log.warning("Missing permissions to edit search message on timeout.") except Exception as e: log.error(f"Error editing search message on timeout: {e}") # Override on_error if specific error handling for the view is needed # async def on_error(self, interaction: discord.Interaction, error: Exception, item: ui.Item) -> None: # log.error(f"Error in PaginatedSearchResultView interaction: {error}") # await interaction.response.send_message("An error occurred with this interaction.", ephemeral=True)