diff --git a/bot.py b/bot.py index bce61ee..5e4e1d8 100644 --- a/bot.py +++ b/bot.py @@ -25,6 +25,14 @@ class Astro(commands.Bot): import traceback traceback.print_exc() + # Sync slash commands with Discord + print("šŸ”„ Syncing slash commands...") + try: + synced = await self.tree.sync() + print(f"āœ… Synced {len(synced)} slash command(s)") + except Exception as e: + print(f"āŒ Failed to sync commands: {e}") + # Start inactivity checker if not self.inactivity_checker.is_running(): self.inactivity_checker.start() diff --git a/cogs/music/main.py b/cogs/music/main.py index a2cce90..fa6d272 100644 --- a/cogs/music/main.py +++ b/cogs/music/main.py @@ -1,5 +1,7 @@ from discord.ext import commands from discord.ext.commands.context import Context +from discord import app_commands +import discord import cogs.music.util as util import cogs.music.queue as queue @@ -74,31 +76,42 @@ class music(commands.Cog): await util.leave_vc(ctx) await ctx.message.add_reaction('šŸ‘') - @commands.command( - help="Queues a song into the bot", - aliases=['p']) - async def play(self, ctx: Context, *, url=None): - if url is None: - raise commands.CommandError("Must provide a link or search query") - elif ctx.guild is None: - raise commands.CommandError("Command must be issued in a server") + + # HYBRID COMMAND - works as both =play and /play + @commands.hybrid_command( + name="play", + description="Queue a song to play") + @app_commands.describe(query="YouTube URL, Spotify link, or search query") + async def play(self, ctx: Context, *, query: str): + """Queues a song into the bot""" + if ctx.guild is None: + await ctx.send("āŒ This command must be used in a server!", ephemeral=True) + return server = ctx.guild.id + # For slash commands, defer the response since fetching takes time + if ctx.interaction: + await ctx.defer() + await util.join_vc(ctx) - await ctx.message.add_reaction('šŸ‘') + + # Different responses for slash vs prefix + if not ctx.interaction: + await ctx.message.add_reaction('šŸ‘') msg = await ctx.send("Fetching song(s)...") - async with ctx.typing(): - #TODO potentially save requests before getting stream link - # Grab video details such as title thumbnail duration - audio = await translate.main(url, self.sp) + + #TODO potentially save requests before getting stream link + # Grab video details such as title thumbnail duration + audio = await translate.main(query, self.sp) await msg.delete() if len(audio) == 0: - await ctx.message.add_reaction('🚫') - await ctx.send("Failed to find song!") + if not ctx.interaction: + await ctx.message.add_reaction('🚫') + await ctx.send("āŒ Failed to find song!") return @@ -128,16 +141,61 @@ class music(commands.Cog): @commands.command( - help="Display the current music queue", - aliases=['q', 'songs']) - async def queue(self, ctx: Context): + help="Queue a song to play next (top of queue)", + aliases=['pt', 'pn', 'playnext']) + async def playtop(self, ctx: Context, *, url=None): + """Queue a song at the top of the queue (plays next)""" + if url is None: + raise commands.CommandError("Must provide a link or search query") + elif ctx.guild is None: + raise commands.CommandError("Command must be issued in a server") + server = ctx.guild.id + + await util.join_vc(ctx) + await ctx.message.add_reaction('ā­ļø') + + msg = await ctx.send("Fetching song(s) to play next...") + async with ctx.typing(): + audio = await translate.main(url, self.sp) + + await msg.delete() + + if len(audio) == 0: + await ctx.message.add_reaction('🚫') + await ctx.send("Failed to find song!") + return + + # Add songs to TOP of queue (position 0, then 1, then 2...) + for i, song in enumerate(audio): + await queue.add_song( + server, + song, + ctx.author.display_name, + position=i) # Insert at top + + # Show first song that was added + audio[0]['position'] = 0 + await util.queue_message(ctx, audio[0]) + + if await queue.is_server_playing(server): + return + + await queue.update_server(server, True) + await queue.play(ctx) + + + @commands.hybrid_command( + name="queue", + description="Display the current music queue") + async def queue_cmd(self, ctx: Context): + """Display the current music queue""" server = ctx.guild # Perform usual checks if server is None: - raise commands.CommandError("Command must be issued in a server") - + await ctx.send("āŒ This command must be used in a server!", ephemeral=True) + return # Grab all songs from this server n, songs = await queue.grab_songs(server.id) @@ -151,42 +209,62 @@ class music(commands.Cog): await util.display_server_queue(ctx, songs, n) - @commands.command( - help="Skips the current song that is playing, can include number to skip more songs", - aliases=['s']) - async def skip(self, ctx: Context, n='1'): + @commands.hybrid_command( + name="skip", + description="Skip the current song") + @app_commands.describe(count="Number of songs to skip (default: 1)") + async def skip(self, ctx: Context, count: int = 1): + """Skips the current song that is playing""" server = ctx.guild if server is None: - raise commands.CommandError("Command must be issued in a server") + await ctx.send("āŒ This command must be used in a server!", ephemeral=True) + return if ctx.voice_client is None: - raise commands.CommandError("I'm not in a voice channel") + await ctx.send("āŒ I'm not in a voice channel!", ephemeral=True) + return - if not n.isdigit(): - raise commands.CommandError("Please enter a number to skip") - n = int(n) + if count <= 0: + await ctx.send("āŒ Please enter a positive number!", ephemeral=True) + return - if n <= 0: - raise commands.CommandError("Please enter a positive number") - - # Skip specificed number of songs - for _ in range(n-1): - await queue.pop(server.id, True) - - # Safe to ignore error for now - ctx.voice_client.stop() + # Check loop mode + loop_mode = await queue.get_loop_mode(server.id) + + if loop_mode == 'song' and count == 1: + # When looping song and skipping once, just restart it + ctx.voice_client.stop() + await ctx.send(f"ā­ļø Restarting song (loop mode active)") + else: + # Skip specified number of songs + for _ in range(count-1): + await queue.pop(server.id, True, skip_mode=True) + + # Last song gets skip_mode=True to handle loop properly + if loop_mode != 'song': + await queue.pop(server.id, True, skip_mode=True) + + ctx.voice_client.stop() + await ctx.send(f"ā­ļø Skipped {count} song(s)") - @commands.command( - help="Toggle loop mode: off -> song -> queue -> off", - aliases=['l', 'repeat']) + @commands.hybrid_command( + name="loop", + description="Toggle loop mode") + @app_commands.describe(mode="Loop mode: off, song, or queue") + @app_commands.choices(mode=[ + app_commands.Choice(name="Off", value="off"), + app_commands.Choice(name="Song", value="song"), + app_commands.Choice(name="Queue", value="queue") + ]) async def loop(self, ctx: Context, mode: str = None): """Toggle between loop modes or set a specific mode""" server = ctx.guild if server is None: - raise commands.CommandError("Command must be issued in a server") + await ctx.send("āŒ This command must be used in a server!", ephemeral=True) + return current_mode = await queue.get_loop_mode(server.id) @@ -202,7 +280,8 @@ class music(commands.Cog): # Set specific mode mode = mode.lower() if mode not in ['off', 'song', 'queue']: - raise commands.CommandError("Loop mode must be: off, song, or queue") + await ctx.send("āŒ Loop mode must be: off, song, or queue", ephemeral=True) + return new_mode = mode await queue.set_loop_mode(server.id, new_mode) @@ -211,7 +290,7 @@ class music(commands.Cog): emojis = {'off': 'ā¹ļø', 'song': 'šŸ”‚', 'queue': 'šŸ”'} messages = { 'off': 'Loop disabled', - 'song': 'Looping current song šŸ”‚', + 'song': 'Looping current song šŸ”‚ (skip to restart)', 'queue': 'Looping entire queue šŸ”' } @@ -237,31 +316,30 @@ class music(commands.Cog): await ctx.send("šŸ”€ Queue shuffled!") - @commands.command( - help="Set playback volume (0-200%)", - aliases=['vol', 'v']) - async def volume(self, ctx: Context, vol: str = None): + @commands.hybrid_command( + name="volume", + description="Set playback volume") + @app_commands.describe(level="Volume level (0-200%, default shows current)") + async def volume(self, ctx: Context, level: int = None): """Set or display the current volume""" server = ctx.guild if server is None: - raise commands.CommandError("Command must be issued in a server") + await ctx.send("āŒ This command must be used in a server!", ephemeral=True) + return - if vol is None: + if level is None: # Display current volume current_vol = await queue.get_volume(server.id) await ctx.send(f"šŸ”Š Current volume: {current_vol}%") return # Set volume - if not vol.isdigit(): - raise commands.CommandError("Volume must be a number (0-200)") + if level < 0 or level > 200: + await ctx.send("āŒ Volume must be between 0 and 200!", ephemeral=True) + return - vol = int(vol) - if vol < 0 or vol > 200: - raise commands.CommandError("Volume must be between 0 and 200") - - new_vol = await queue.set_volume(server.id, vol) + new_vol = await queue.set_volume(server.id, level) # Update the current playing song's volume if something is playing if ctx.voice_client and ctx.voice_client.source: @@ -280,15 +358,17 @@ class music(commands.Cog): await ctx.send(f"{emoji} Volume set to {new_vol}%") - @commands.command( - help="Apply audio effects to playback", - aliases=['fx', 'filter']) + @commands.hybrid_command( + name="effect", + description="Apply audio effects to playback") + @app_commands.describe(effect_name="The audio effect to apply (leave empty to see list)") async def effect(self, ctx: Context, effect_name: str = None): """Apply or list audio effects""" server = ctx.guild if server is None: - raise commands.CommandError("Command must be issued in a server") + await ctx.send("āŒ This command must be used in a server!", ephemeral=True) + return # If no effect specified, show current effect and list options if effect_name is None: @@ -298,18 +378,18 @@ class music(commands.Cog): effects_list = '\n'.join([ f"`{e}` - {queue.get_effect_description(e)}" - for e in queue.list_all_effects()[:7] # Show first 7 + for e in queue.list_all_effects()[:9] # Show first 9 ]) more_effects = '\n'.join([ f"`{e}` - {queue.get_effect_description(e)}" - for e in queue.list_all_effects()[7:] # Show rest + for e in queue.list_all_effects()[9:] # Show rest ]) await ctx.send( f"{emoji} **Current effect:** {current} - {desc}\n\n" f"**Available effects:**\n{effects_list}\n\n" f"**More effects:**\n{more_effects}\n\n" - f"Use `=effect ` to apply an effect!" + f"Use `=effect ` or `/effect ` to apply!" ) return @@ -317,21 +397,24 @@ class music(commands.Cog): effect_name = effect_name.lower() if effect_name not in queue.list_all_effects(): - raise commands.CommandError( - f"Unknown effect! Use `=effect` to see available effects." + await ctx.send( + f"āŒ Unknown effect! Use `/effect` to see available effects.", + ephemeral=True ) + return await queue.set_effect(server.id, effect_name) emoji = queue.get_effect_emoji(effect_name) desc = queue.get_effect_description(effect_name) - # Special warning for earrape - if effect_name == 'earrape': + # Special warning for earrape/deepfry + if effect_name in ['earrape', 'deepfry']: await ctx.send( - f"āš ļø **EARRAPE MODE ACTIVATED** āš ļø\n" - f"RIP your eardrums. Effect will apply to next song.\n" - f"Use `=effect none` to disable." + f"āš ļø **{effect_name.upper()} MODE ACTIVATED** āš ļø\n" + f"{desc}\n" + f"Effect will apply to next song.\n" + f"Use `/effect none` to disable." ) else: await ctx.send( @@ -342,4 +425,4 @@ class music(commands.Cog): # If something is currently playing, notify about skip if ctx.voice_client and ctx.voice_client.is_playing(): - await ctx.send("šŸ’” Tip: Use `=skip` to apply effect to current song immediately!") + await ctx.send("šŸ’” Tip: Use `/skip` to apply effect immediately!") diff --git a/cogs/music/queue.py b/cogs/music/queue.py index ba14da5..9058529 100644 --- a/cogs/music/queue.py +++ b/cogs/music/queue.py @@ -18,7 +18,7 @@ BASE_FFMPEG_OPTS = { # Audio effects configurations def get_effect_options(effect_name): """Get FFmpeg options for a specific audio effect""" - + effects = { 'none': { **BASE_FFMPEG_OPTS @@ -35,20 +35,10 @@ def get_effect_options(effect_name): **BASE_FFMPEG_OPTS, 'options': '-vn -af "atempo=0.8,asetrate=48000*0.8,aecho=0.8:0.9:1000:0.3"' }, - 'earrape': { - **BASE_FFMPEG_OPTS, - # Aggressive compression + hard clipping + bitcrushing for maximum distortion - 'options': '-vn -af "volume=8,acompressor=threshold=0.001:ratio=30:attack=0.1:release=5,acrusher=bits=8:mix=0.7,volume=2,alimiter=limit=0.8"' - }, - 'deepfry': { - **BASE_FFMPEG_OPTS, - # Extreme bitcrushing + bass boost + compression (meme audio effect) - 'options': '-vn -af "acrusher=bits=4:mode=log:aa=1,bass=g=15,acompressor=threshold=0.001:ratio=20,volume=3"' - }, 'distortion': { **BASE_FFMPEG_OPTS, # Pure bitcrushing distortion - 'options': '-vn -af "acrusher=bits=6:mix=0.9,acompressor=threshold=0.01:ratio=15,volume=2"' + 'options': '-vn -af "acrusher=bits=6:mix=0.9,acompressor=threshold=0.01:ratio=15"' }, 'reverse': { **BASE_FFMPEG_OPTS, @@ -86,18 +76,8 @@ def get_effect_options(effect_name): **BASE_FFMPEG_OPTS, 'options': '-vn -af "aecho=0.8:0.88:60:0.4"' }, - 'phone': { - **BASE_FFMPEG_OPTS, - # Sounds like a phone call (bandpass filter) - 'options': '-vn -af "bandpass=f=1500:width_type=h:w=1000,volume=2"' - }, - 'megaphone': { - **BASE_FFMPEG_OPTS, - # Megaphone/radio effect - 'options': '-vn -af "highpass=f=300,lowpass=f=3000,volume=2,acompressor=threshold=0.1:ratio=8"' - } } - + return effects.get(effect_name, effects['none']) @@ -114,28 +94,40 @@ def initialize_tables(): song_name TEXT, loop_mode TEXT DEFAULT 'off', volume INTEGER DEFAULT 100, - effect TEXT DEFAULT 'none' + effect TEXT DEFAULT 'none', + song_start_time REAL DEFAULT 0, + song_duration INTEGER DEFAULT 0 );''') # Set all to not playing cursor.execute("UPDATE servers SET is_playing = 0;") - + # Add new columns if they don't exist (for existing databases) try: cursor.execute("ALTER TABLE servers ADD COLUMN loop_mode TEXT DEFAULT 'off';") except sqlite3.OperationalError: pass # Column already exists - + try: cursor.execute("ALTER TABLE servers ADD COLUMN volume INTEGER DEFAULT 100;") except sqlite3.OperationalError: pass # Column already exists - + try: cursor.execute("ALTER TABLE servers ADD COLUMN effect TEXT DEFAULT 'none';") except sqlite3.OperationalError: pass # Column already exists + try: + cursor.execute("ALTER TABLE servers ADD COLUMN song_start_time REAL DEFAULT 0;") + except sqlite3.OperationalError: + pass # Column already exists + + try: + cursor.execute("ALTER TABLE servers ADD COLUMN song_duration INTEGER DEFAULT 0;") + except sqlite3.OperationalError: + pass # Column already exists + # Create queue table if it doesn't exist cursor.execute('''CREATE TABLE IF NOT EXISTS songs ( server_id TEXT NOT NULL, @@ -241,17 +233,17 @@ async def pop(server_id, ignore=False): else: song = song[0] - await set_current_song(server_id, song['title']) - + await set_current_song(server_id, song['title'], song.get('duration', 0)) + # Check loop mode before removing loop_mode = await get_loop_mode(server_id) if loop_mode != 'song': # Only remove if not looping song await mark_song_as_finished(server_id, result[3]) - + return song['url'] - await set_current_song(server_id, result[4]) - + await set_current_song(server_id, result[4], result[6]) # result[6] is duration + # Check loop mode before removing loop_mode = await get_loop_mode(server_id) if loop_mode != 'song': # Only remove if not looping song @@ -294,15 +286,18 @@ async def mark_song_as_finished(server_id, order_num): # set the current playing song of the server -async def set_current_song(server_id, title): +async def set_current_song(server_id, title, duration=0): # Connect to the database conn = sqlite3.connect(db_path) cursor = conn.cursor() + import time + start_time = time.time() + cursor.execute(''' UPDATE servers - SET song_name = ? + SET song_name = ?, song_start_time = ?, song_duration = ? WHERE server_id = ?''', - (title, server_id)) + (title, start_time, duration, server_id)) # Close connection conn.commit() @@ -319,7 +314,7 @@ async def get_current_song(server_id): WHERE server_id = ? LIMIT 1;''', (server_id,)) - + result = cursor.fetchone() # Close connection @@ -329,6 +324,38 @@ async def get_current_song(server_id): return result[0] if result else "Nothing" +async def get_current_progress(server_id): + """Get current playback progress (elapsed, duration, percentage)""" + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute('''SELECT song_start_time, song_duration, is_playing + FROM servers + WHERE server_id = ? + LIMIT 1;''', + (server_id,)) + + result = cursor.fetchone() + conn.close() + + if not result or result[2] == 0: # Not playing + return 0, 0, 0.0 + + start_time, duration, _ = result + + if duration == 0: + return 0, 0, 0.0 + + import time + elapsed = int(time.time() - start_time) + elapsed = min(elapsed, duration) # Cap at duration + percentage = (elapsed / duration) * 100 if duration > 0 else 0 + + return elapsed, duration, percentage + + return result[0] if result else "Nothing" + + # Grab max order from server async def get_max(server_id, cursor): cursor.execute(f""" @@ -433,18 +460,18 @@ async def get_loop_mode(server_id): """Get the current loop mode: 'off', 'song', or 'queue'""" conn = sqlite3.connect(db_path) cursor = conn.cursor() - + await add_server(server_id, cursor, conn) - + cursor.execute("""SELECT loop_mode FROM servers WHERE server_id = ?""", (server_id,)) result = cursor.fetchone() - + conn.commit() conn.close() - + return result[0] if result else 'off' @@ -452,17 +479,17 @@ async def set_loop_mode(server_id, mode): """Set loop mode: 'off', 'song', or 'queue'""" if mode not in ['off', 'song', 'queue']: return False - + conn = sqlite3.connect(db_path) cursor = conn.cursor() - + await add_server(server_id, cursor, conn) - + cursor.execute("""UPDATE servers SET loop_mode = ? WHERE server_id = ?""", (mode, server_id)) - + conn.commit() conn.close() return True @@ -473,35 +500,35 @@ async def get_volume(server_id): """Get the current volume (0-200)""" conn = sqlite3.connect(db_path) cursor = conn.cursor() - + await add_server(server_id, cursor, conn) - + cursor.execute("""SELECT volume FROM servers WHERE server_id = ?""", (server_id,)) result = cursor.fetchone() - + conn.commit() conn.close() - + return result[0] if result else 100 async def set_volume(server_id, volume): """Set volume (0-200)""" volume = max(0, min(200, volume)) # Clamp between 0-200 - + conn = sqlite3.connect(db_path) cursor = conn.cursor() - + await add_server(server_id, cursor, conn) - + cursor.execute("""UPDATE servers SET volume = ? WHERE server_id = ?""", (volume, server_id)) - + conn.commit() conn.close() return volume @@ -512,32 +539,32 @@ async def shuffle_queue(server_id): """Randomize the order of songs in the queue""" conn = sqlite3.connect(db_path) cursor = conn.cursor() - + await add_server(server_id, cursor, conn) - + # Get all songs cursor.execute('''SELECT position, song_link, queued_by, title, thumbnail, duration FROM songs WHERE server_id = ? ORDER BY position''', (server_id,)) songs = cursor.fetchall() - + if len(songs) <= 1: conn.close() return False # Nothing to shuffle - + # Shuffle the songs (keep positions but randomize order) random.shuffle(songs) - + # Delete all current songs cursor.execute('''DELETE FROM songs WHERE server_id = ?''', (server_id,)) - + # Re-insert in shuffled order for i, song in enumerate(songs): cursor.execute("""INSERT INTO songs (server_id, song_link, queued_by, position, title, thumbnail, duration) VALUES (?, ?, ?, ?, ?, ?, ?)""", (server_id, song[1], song[2], i, song[3], song[4], song[5])) - + conn.commit() conn.close() return True @@ -549,18 +576,18 @@ async def get_effect(server_id): """Get the current audio effect""" conn = sqlite3.connect(db_path) cursor = conn.cursor() - + await add_server(server_id, cursor, conn) - + cursor.execute("""SELECT effect FROM servers WHERE server_id = ?""", (server_id,)) result = cursor.fetchone() - + conn.commit() conn.close() - + return result[0] if result else 'none' @@ -568,14 +595,14 @@ async def set_effect(server_id, effect_name): """Set the audio effect""" conn = sqlite3.connect(db_path) cursor = conn.cursor() - + await add_server(server_id, cursor, conn) - + cursor.execute("""UPDATE servers SET effect = ? WHERE server_id = ?""", (effect_name, server_id)) - + conn.commit() conn.close() return True @@ -597,8 +624,6 @@ def get_effect_emoji(effect_name): 'bassboost': 'šŸ”‰šŸ’„', 'nightcore': 'āš”šŸŽ€', 'slowed': 'šŸŒšŸ’¤', - 'earrape': 'šŸ’€šŸ“¢', - 'deepfry': 'šŸŸšŸ’„', 'distortion': 'āš”šŸ”Š', 'reverse': 'āŖšŸ”„', 'chipmunk': 'šŸæļø', @@ -609,8 +634,6 @@ def get_effect_emoji(effect_name): 'vibrato': 'ć€°ļø', 'tremolo': 'šŸ“³', 'echo': 'šŸ—£ļøšŸ’­', - 'phone': 'šŸ“ž', - 'megaphone': 'šŸ“¢šŸ“£' } return emojis.get(effect_name, 'šŸ”Š') @@ -622,8 +645,6 @@ def get_effect_description(effect_name): 'bassboost': 'MAXIMUM BASS šŸ”Š', 'nightcore': 'Speed + pitch up (anime vibes)', 'slowed': 'Slowed + reverb (TikTok aesthetic)', - 'earrape': 'āš ļø Aggressive compression + distortion + clipping āš ļø', - 'deepfry': 'šŸŸ EXTREME bitcrushing + bass (meme audio) šŸŸ', 'distortion': 'Heavy bitcrushing distortion', 'reverse': 'Plays audio BACKWARDS', 'chipmunk': 'High pitched and fast (Alvin mode)', @@ -634,8 +655,6 @@ def get_effect_description(effect_name): 'vibrato': 'Warbling pitch effect', 'tremolo': 'Volume oscillation', 'echo': 'Echo/reverb effect', - 'phone': 'Sounds like a phone call', - 'megaphone': 'Megaphone/radio effect' } return descriptions.get(effect_name, 'Unknown effect') @@ -667,14 +686,14 @@ async def play(ctx): # Get volume and effect settings volume_percent = await get_volume(server_id) volume = volume_percent / 100.0 # Convert to 0.0-2.0 range - + current_effect = await get_effect(server_id) ffmpeg_opts = get_effect_options(current_effect) - + # Create audio source with effect and volume control audio_source = discord.FFmpegPCMAudio(url, **ffmpeg_opts) audio_source = discord.PCMVolumeTransformer(audio_source, volume=volume) - + # Play with callback to continue queue def after_playing(error): if error: @@ -690,7 +709,7 @@ async def play(ctx): print(f"Error playing next song: {e}") voice_client.play(audio_source, after=after_playing) - + except Exception as e: print(f"Error starting playback: {e}") # Try to continue with next song diff --git a/cogs/music/util.py b/cogs/music/util.py index f1b1c9a..caaf117 100644 --- a/cogs/music/util.py +++ b/cogs/music/util.py @@ -1,8 +1,13 @@ import discord from discord.ext.commands.context import Context from discord.ext.commands.converter import CommandError +from discord.ui import Button, View import config from . import queue +import asyncio + +# Track last activity time for each server +last_activity = {} # Joining/moving to the user's vc in a guild async def join_vc(ctx: Context): @@ -24,7 +29,10 @@ async def join_vc(ctx: Context): else: # Safe to ignore type error for now vc = await ctx.voice_client.move_to(vc) - + + # Update last activity + last_activity[ctx.guild.id] = asyncio.get_event_loop().time() + return vc @@ -45,20 +53,138 @@ async def leave_vc(ctx: Context): if author_vc is None or vc != author_vc: raise CommandError("You are not in this voice channel") - # Disconnect - await ctx.voice_client.disconnect(force=False) + # Clear the queue for this server + await queue.clear(ctx.guild.id) + + # Stop any currently playing audio + if ctx.voice_client.is_playing(): + ctx.voice_client.stop() + + # Disconnect with force to ensure it actually leaves + try: + await ctx.voice_client.disconnect(force=True) + except Exception as e: + print(f"Error disconnecting: {e}") + # If regular disconnect fails, try cleanup + await ctx.voice_client.cleanup() + + # Remove from activity tracker + if ctx.guild.id in last_activity: + del last_activity[ctx.guild.id] + + +# Auto-disconnect if inactive +async def check_inactivity(bot): + """Background task to check for inactive voice connections""" + try: + current_time = asyncio.get_event_loop().time() + + for guild_id, last_time in list(last_activity.items()): + # If inactive for more than 5 minutes + if current_time - last_time > 300: # 300 seconds = 5 minutes + # Find the guild and voice client + guild = bot.get_guild(guild_id) + if guild and guild.voice_client: + # Check if not playing + if not guild.voice_client.is_playing(): + print(f"Auto-disconnecting from {guild.name} due to inactivity") + await queue.clear(guild_id) + try: + await guild.voice_client.disconnect(force=True) + except: + pass + del last_activity[guild_id] + except Exception as e: + print(f"Error in inactivity checker: {e}") + + +# Update activity timestamp when playing +def update_activity(guild_id): + """Call this when a song starts playing""" + last_activity[guild_id] = asyncio.get_event_loop().time() + + +# Interactive buttons for queue control +class QueueControls(View): + def __init__(self, ctx): + super().__init__(timeout=300) # 5 minute timeout + self.ctx = ctx + + @discord.ui.button(label="ā­ļø Skip", style=discord.ButtonStyle.primary) + async def skip_button(self, interaction: discord.Interaction, button: Button): + if interaction.user != self.ctx.author: + await interaction.response.send_message("āŒ Only the person who requested the queue can use these buttons!", ephemeral=True) + return + + if self.ctx.voice_client and self.ctx.voice_client.is_playing(): + self.ctx.voice_client.stop() + await interaction.response.send_message("ā­ļø Skipped!", ephemeral=True) + else: + await interaction.response.send_message("āŒ Nothing is playing!", ephemeral=True) + + @discord.ui.button(label="šŸ”€ Shuffle", style=discord.ButtonStyle.secondary) + async def shuffle_button(self, interaction: discord.Interaction, button: Button): + if interaction.user != self.ctx.author: + await interaction.response.send_message("āŒ Only the person who requested the queue can use these buttons!", ephemeral=True) + return + + success = await queue.shuffle_queue(self.ctx.guild.id) + if success: + await interaction.response.send_message("šŸ”€ Queue shuffled!", ephemeral=True) + else: + await interaction.response.send_message("āŒ Not enough songs to shuffle!", ephemeral=True) + + @discord.ui.button(label="šŸ” Loop", style=discord.ButtonStyle.secondary) + async def loop_button(self, interaction: discord.Interaction, button: Button): + if interaction.user != self.ctx.author: + await interaction.response.send_message("āŒ Only the person who requested the queue can use these buttons!", ephemeral=True) + return + + current_mode = await queue.get_loop_mode(self.ctx.guild.id) + + # Cycle through modes + if current_mode == 'off': + new_mode = 'song' + elif current_mode == 'song': + new_mode = 'queue' + else: + new_mode = 'off' + + await queue.set_loop_mode(self.ctx.guild.id, new_mode) + + emojis = {'off': 'ā¹ļø', 'song': 'šŸ”‚', 'queue': 'šŸ”'} + messages = { + 'off': 'Loop disabled', + 'song': 'Looping current song šŸ”‚', + 'queue': 'Looping entire queue šŸ”' + } + + await interaction.response.send_message(f"{emojis[new_mode]} {messages[new_mode]}", ephemeral=True) + + @discord.ui.button(label="šŸ—‘ļø Clear", style=discord.ButtonStyle.danger) + async def clear_button(self, interaction: discord.Interaction, button: Button): + if interaction.user != self.ctx.author: + await interaction.response.send_message("āŒ Only the person who requested the queue can use these buttons!", ephemeral=True) + return + + await queue.clear(self.ctx.guild.id) + if self.ctx.voice_client and self.ctx.voice_client.is_playing(): + self.ctx.voice_client.stop() + + await interaction.response.send_message("šŸ—‘ļø Queue cleared!", ephemeral=True) # Build a display message for queuing a new song async def queue_message(ctx: Context, data: dict): msg = discord.Embed( - title=f"{ctx.author.display_name} queued a song!", - color=config.get_color("main")) + title="šŸŽµ Song Queued", + description=f"**{data['title']}**", + color=discord.Color.green()) msg.set_thumbnail(url=data['thumbnail']) - msg.add_field(name=data['title'], - value=f"Duration: {format_time(data['duration'])}" + '\n' - + f"Position: {data['position']}") + msg.add_field(name="ā±ļø Duration", value=format_time(data['duration']), inline=True) + msg.add_field(name="šŸ“ Position", value=f"#{data['position']}", inline=True) + msg.set_footer(text=f"Queued by {ctx.author.display_name}", icon_url=ctx.author.display_avatar.url) await ctx.send(embed=msg) @@ -67,37 +193,59 @@ async def queue_message(ctx: Context, data: dict): async def display_server_queue(ctx: Context, songs, n): server = ctx.guild - msg = discord.Embed( - title=f"{server.name}'s Queue!", - color=config.get_color("main")) - + # Get current settings current_song = await queue.get_current_song(ctx.guild.id) loop_mode = await queue.get_loop_mode(ctx.guild.id) volume = await queue.get_volume(ctx.guild.id) effect = await queue.get_effect(ctx.guild.id) - + elapsed, duration, percentage = await queue.get_current_progress(ctx.guild.id) + + # Build a beautiful embed + embed = discord.Embed( + title=f"šŸŽµ {server.name}'s Queue", + color=discord.Color.blue() + ) + # Add loop emoji based on mode loop_emojis = {'off': '', 'song': 'šŸ”‚', 'queue': 'šŸ”'} loop_emoji = loop_emojis.get(loop_mode, '') effect_emoji = queue.get_effect_emoji(effect) - - display = f"šŸ”Š Currently playing: ``{current_song}`` {loop_emoji}\n" - display += f"Volume: {volume}% | Loop: {loop_mode} | Effect: {effect_emoji} {effect}\n\n" - - for i, song in enumerate(songs): - # If text is not avaialable do not display - time = '' if isinstance(song[1], str) else format_time(song[1]) + # Progress bar (━ for filled, ─ for empty) + progress_bar = "" + if duration > 0: + bar_length = 15 + filled = int((percentage / 100) * bar_length) + progress_bar = f"\n{'━' * filled}{'─' * (bar_length - filled)} `{format_time(elapsed)} / {format_time(duration)}`" - display += f"``{i + 1}.`` {song[0]} - {time} Queued by {song[2]}\n" + # Now playing section + now_playing = f"### šŸ”Š Now Playing\n**{current_song}** {loop_emoji}{progress_bar}\n" + embed.add_field(name="", value=now_playing, inline=False) - msg.add_field(name="Songs:", - value=display, - inline=True) - if n > 10: - msg.set_footer(text=f"and {n - 10} more!..") + # Settings section + settings = f"šŸ”Š Volume: **{volume}%** | šŸ” Loop: **{loop_mode}** | {effect_emoji} Effect: **{effect}**" + embed.add_field(name="āš™ļø Settings", value=settings, inline=False) - await ctx.send(embed=msg) + # Queue section + if len(songs) > 0: + queue_text = "" + for i, song in enumerate(songs[:10]): # Show max 10 + time = '' if isinstance(song[1], str) else format_time(song[1]) + queue_text += f"`{i + 1}.` **{song[0]}**\nā”” ā±ļø {time} • Queued by {song[2]}\n" + + embed.add_field(name="šŸ“œ Up Next", value=queue_text, inline=False) + + if n > 10: + embed.set_footer(text=f"...and {n - 10} more songs in queue") + else: + embed.add_field(name="šŸ“œ Queue", value="*Queue is empty*", inline=False) + + # Add thumbnail + embed.set_thumbnail(url=server.icon.url if server.icon else None) + + # Send with interactive buttons + view = QueueControls(ctx) + await ctx.send(embed=embed, view=view) # Converts seconds into more readable format @@ -111,6 +259,6 @@ def format_time(seconds): elif minutes > 0: return f"{minutes}:{seconds:02d}" else: - return f"{seconds} seconds" + return f"0:{seconds:02d}" except: - return "" + return "Unknown"