From 4ef4bdf309f53cbd0da0acf8628f2cf828bea777 Mon Sep 17 00:00:00 2001 From: Top1055 <123alexfeetham@gmail.com> Date: Sat, 29 Nov 2025 18:56:44 +0000 Subject: [PATCH] added type hints all over the project, updated mypy to ignore missing types from libraries --- bot.py | 25 ++++-- cogs/music/main.py | 6 +- cogs/music/translate.py | 44 +++++----- cogs/music/util.py | 190 ++++++++++++++++++++++++++++------------ mypy.ini | 31 +++++++ 5 files changed, 211 insertions(+), 85 deletions(-) create mode 100644 mypy.ini diff --git a/bot.py b/bot.py index 4456810..de62481 100644 --- a/bot.py +++ b/bot.py @@ -1,20 +1,30 @@ +""" +Groovy-Zilean Bot Class +Main bot implementation with cog loading and background tasks +""" + +from typing import Any from discord.ext import commands from discord.ext import tasks from cogs.music.main import music -from help import GroovyHelp # Import the new Help Cog +from help import GroovyHelp -cogs = [ +# List of cogs to load on startup +cogs: list[type[commands.Cog]] = [ music, GroovyHelp ] + class Groovy(commands.Bot): - def __init__(self, *args, **kwargs): + """Custom bot class with automatic cog loading and inactivity checking""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: # We force help_command to None because we are using a custom Cog for it # But we pass all other args (like command_prefix) to the parent super().__init__(*args, help_command=None, **kwargs) - async def on_ready(self): + async def on_ready(self) -> None: import config # Imported here to avoid circular dependencies if any # Set status @@ -47,11 +57,12 @@ class Groovy(commands.Bot): print(f"✅ {self.user} is ready and online!") @tasks.loop(seconds=30) - async def inactivity_checker(self): - """Check for inactive voice connections""" + async def inactivity_checker(self) -> None: + """Check for inactive voice connections every 30 seconds""" from cogs.music import util await util.check_inactivity(self) @inactivity_checker.before_loop - async def before_inactivity_checker(self): + async def before_inactivity_checker(self) -> None: + """Wait for bot to be ready before starting inactivity checker""" await self.wait_until_ready() diff --git a/cogs/music/main.py b/cogs/music/main.py index 6398e3a..7a2fded 100644 --- a/cogs/music/main.py +++ b/cogs/music/main.py @@ -328,7 +328,7 @@ class music(commands.Cog): app_commands.Choice(name="Song", value="song"), app_commands.Choice(name="Queue", value="queue") ]) - async def loop(self, ctx: Context, mode: str = None): + async def loop(self, ctx: Context, mode: str | None = None): """Toggle between loop modes or set a specific mode""" server = ctx.guild @@ -391,7 +391,7 @@ class music(commands.Cog): description="Set playback volume", aliases=['vol', 'v']) @app_commands.describe(level="Volume level (0-200%, default shows current)") - async def volume(self, ctx: Context, level: int = None): + async def volume(self, ctx: Context, level: int | None = None): """Set or display the current volume""" server = ctx.guild @@ -435,7 +435,7 @@ class music(commands.Cog): description="Apply audio effects to playback", aliases=['fx', 'filter']) @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): + async def effect(self, ctx: Context, effect_name: str | None = None): """Apply or list audio effects""" server = ctx.guild diff --git a/cogs/music/translate.py b/cogs/music/translate.py index 7da3503..cea0e4f 100644 --- a/cogs/music/translate.py +++ b/cogs/music/translate.py @@ -1,5 +1,9 @@ -# Handles translating urls and search terms +""" +URL and search query handling for Groovy-Zilean +Translates YouTube, Spotify, SoundCloud URLs and search queries into playable audio +""" +from typing import Any import yt_dlp as ytdlp import spotipy @@ -22,7 +26,7 @@ ydl_opts = { }, } -async def main(url, sp): +async def main(url: str, sp: spotipy.Spotify) -> list[dict[str, Any] | str]: #url = url.lower() @@ -60,7 +64,7 @@ async def main(url, sp): return [] -async def search_song(search): +async def search_song(search: str) -> list[dict[str, Any]]: with ytdlp.YoutubeDL(ydl_opts) as ydl: try: info = ydl.extract_info(f"ytsearch1:{search}", download=False) @@ -89,7 +93,7 @@ async def search_song(search): return [data] -async def spotify_song(url, sp): +async def spotify_song(url: str, sp: spotipy.Spotify) -> list[dict[str, Any]]: track = sp.track(url.split("/")[-1].split("?")[0]) search = "" @@ -106,7 +110,11 @@ async def spotify_song(url, sp): return await search_song(query) -async def spotify_playlist(url, sp): +async def spotify_playlist(url: str, sp: spotipy.Spotify) -> list[str | dict[str, Any]]: + """ + Get songs from a Spotify playlist + Returns a mixed list where first item is dict, rest are search strings + """ # Get the playlist uri code code = url.split("/")[-1].split("?")[0] @@ -115,42 +123,36 @@ async def spotify_playlist(url, sp): results = sp.playlist_tracks(code)['items'] except spotipy.exceptions.SpotifyException: return [] - - # Go through the tracks - songs = [] + + # Go through the tracks and build search queries + songs: list[str | dict[str, Any]] = [] # Explicit type for mypy for track in results: search = "" # Fetch all artists for artist in track['track']['artists']: - # Add all artists to search search += f"{artist['name']}, " - # Remove last column + # Remove last comma search = search[:-2] search += f" - {track['track']['name']}" songs.append(search) - #searched_result = search_song(search) - #if searched_result == []: - #continue - - #songs.append(searched_result[0]) - + # Fetch first song's full data while True: - search_result = await search_song(songs[0]) + search_result = await search_song(songs[0]) # type: ignore if search_result == []: songs.pop(0) continue else: - songs[0] = search_result[0] + songs[0] = search_result[0] # Replace string with dict break - + return songs -async def song_download(url): +async def song_download(url: str) -> list[dict[str, Any]]: with ytdlp.YoutubeDL(ydl_opts) as ydl: try: info = ydl.extract_info(url, download=False) @@ -180,7 +182,7 @@ async def song_download(url): return [data] -async def playlist_download(url): +async def playlist_download(url: str) -> list[dict[str, Any]]: with ytdlp.YoutubeDL(ydl_opts) as ydl: try: info = ydl.extract_info(url, download=False) diff --git a/cogs/music/util.py b/cogs/music/util.py index 846064c..e049bea 100644 --- a/cogs/music/util.py +++ b/cogs/music/util.py @@ -1,3 +1,9 @@ +""" +Utility functions for Groovy-Zilean music bot +Handles voice channel operations, queue display, and inactivity tracking +""" + +from typing import Any import discord from discord.ext.commands.context import Context from discord.ext.commands.converter import CommandError @@ -7,15 +13,29 @@ from . import queue import asyncio # Track last activity time for each server -last_activity = {} +last_activity: dict[int, float] = {} -# Joining/moving to the user's vc in a guild -async def join_vc(ctx: Context): +# =================================== +# Voice Channel Management +# =================================== + +async def join_vc(ctx: Context) -> discord.VoiceClient: + """ + Join or move to the user's voice channel + + Args: + ctx: Command context + + Returns: + The voice client connection + + Raises: + CommandError: If user is not in a voice channel + """ # Get the user's vc author_voice = getattr(ctx.author, "voice") if author_voice is None: - # Raise exception if user is not in vc raise CommandError("User is not in voice channel") # Get user's vc @@ -25,19 +45,26 @@ async def join_vc(ctx: Context): # Join or move to the user's vc if ctx.voice_client is None: - vc = await vc.connect() + vc_client = await vc.connect() else: - # Safe to ignore type error for now - vc = await ctx.voice_client.move_to(vc) + vc_client = await ctx.voice_client.move_to(vc) # Update last activity last_activity[ctx.guild.id] = asyncio.get_event_loop().time() - return vc + return vc_client -# Leaving the voice channel of a user -async def leave_vc(ctx: Context): +async def leave_vc(ctx: Context) -> None: + """ + Leave the voice channel and clean up + + Args: + ctx: Command context + + Raises: + CommandError: If bot is not in VC or user is not in same VC + """ # If the bot is not in a vc of this server if ctx.voice_client is None: raise CommandError("I am not in a voice channel") @@ -73,9 +100,18 @@ async def leave_vc(ctx: Context): del last_activity[ctx.guild.id] -# Auto-disconnect if inactive -async def check_inactivity(bot): - """Background task to check for inactive voice connections""" +# =================================== +# Inactivity Management +# =================================== + +async def check_inactivity(bot: discord.Client) -> None: + """ + Background task to check for inactive voice connections + Auto-disconnects after 5 minutes of inactivity + + Args: + bot: The Discord bot instance + """ try: current_time = asyncio.get_event_loop().time() @@ -98,20 +134,34 @@ async def check_inactivity(bot): print(f"Error in inactivity checker: {e}") -# Update activity timestamp when playing -def update_activity(guild_id): - """Call this when a song starts playing""" +def update_activity(guild_id: int) -> None: + """ + Update activity timestamp when a song starts playing + + Args: + guild_id: Discord guild/server ID + """ last_activity[guild_id] = asyncio.get_event_loop().time() -# Interactive buttons for queue control +# =================================== +# Queue Display & Controls +# =================================== + class QueueControls(View): - def __init__(self, ctx): - super().__init__(timeout=None) # No timeout allows buttons to stay active longer + """Interactive buttons for queue control""" + + def __init__(self, ctx: Context) -> None: + super().__init__(timeout=None) # No timeout allows buttons to stay active longer self.ctx = ctx - async def refresh_message(self, interaction: discord.Interaction): - """Helper to regenerate the embed and edit the message""" + async def refresh_message(self, interaction: discord.Interaction) -> None: + """ + Helper to regenerate the embed and edit the message + + Args: + interaction: Discord interaction from button press + """ try: # Generate new embed embed, view = await generate_queue_ui(self.ctx) @@ -119,10 +169,13 @@ class QueueControls(View): except Exception as e: # Fallback if edit fails if not interaction.response.is_done(): - await interaction.response.send_message("Refreshed, but something went wrong updating the display.", ephemeral=True) + await interaction.response.send_message( + "Refreshed, but something went wrong updating the display.", + ephemeral=True + ) @discord.ui.button(label="â­ī¸ Skip", style=discord.ButtonStyle.primary) - async def skip_button(self, interaction: discord.Interaction, button: Button): + async def skip_button(self, interaction: discord.Interaction, button: Button) -> None: if interaction.user not in self.ctx.voice_client.channel.members: await interaction.response.send_message("❌ You must be in the voice channel!", ephemeral=True) return @@ -130,11 +183,6 @@ class QueueControls(View): # Loop logic check loop_mode = await queue.get_loop_mode(self.ctx.guild.id) - # Logic mimics the command - if loop_mode == 'song': - # Just restart current song effectively but here we assume standard skip behavior for button - pass - # Perform the skip await queue.pop(self.ctx.guild.id, True, skip_mode=True) if self.ctx.voice_client: @@ -144,35 +192,45 @@ class QueueControls(View): await self.refresh_message(interaction) @discord.ui.button(label="🔀 Shuffle", style=discord.ButtonStyle.secondary) - async def shuffle_button(self, interaction: discord.Interaction, button: Button): + async def shuffle_button(self, interaction: discord.Interaction, button: Button) -> None: await queue.shuffle_queue(self.ctx.guild.id) await self.refresh_message(interaction) @discord.ui.button(label="🔁 Loop", style=discord.ButtonStyle.secondary) - async def loop_button(self, interaction: discord.Interaction, button: Button): + async def loop_button(self, interaction: discord.Interaction, button: Button) -> None: current_mode = await queue.get_loop_mode(self.ctx.guild.id) new_mode = 'song' if current_mode == 'off' else ('queue' if current_mode == 'song' else 'off') await queue.set_loop_mode(self.ctx.guild.id, new_mode) await self.refresh_message(interaction) @discord.ui.button(label="đŸ—‘ī¸ Clear", style=discord.ButtonStyle.danger) - async def clear_button(self, interaction: discord.Interaction, button: Button): + async def clear_button(self, interaction: discord.Interaction, button: Button) -> None: 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 self.refresh_message(interaction) @discord.ui.button(label="🔄 Refresh", style=discord.ButtonStyle.gray) - async def refresh_button(self, interaction: discord.Interaction, button: Button): + async def refresh_button(self, interaction: discord.Interaction, button: Button) -> None: await self.refresh_message(interaction) -async def generate_queue_ui(ctx: Context): + +async def generate_queue_ui(ctx: Context) -> tuple[discord.Embed, QueueControls]: + """ + Generate the queue embed and controls + + Args: + ctx: Command context + + Returns: + Tuple of (embed, view) for displaying queue + """ guild_id = ctx.guild.id server = ctx.guild # Fetch all data n, songs = await queue.grab_songs(guild_id) - current = await queue.get_current_song(guild_id) # Returns title, thumbnail, url + current = await queue.get_current_song(guild_id) loop_mode = await queue.get_loop_mode(guild_id) volume = await queue.get_volume(guild_id) effect = await queue.get_effect(guild_id) @@ -183,10 +241,10 @@ async def generate_queue_ui(ctx: Context): # Map loop mode to nicer text loop_map = { - 'off': {'emoji': 'âšī¸', 'text': 'Off'}, - 'song': {'emoji': '🔂', 'text': 'Song'}, - 'queue': {'emoji': '🔁', 'text': 'Queue'} - } + 'off': {'emoji': 'âšī¸', 'text': 'Off'}, + 'song': {'emoji': '🔂', 'text': 'Song'}, + 'queue': {'emoji': '🔁', 'text': 'Queue'} + } loop_info = loop_map.get(loop_mode, loop_map['off']) loop_emoji = loop_info['emoji'] loop_text = loop_info['text'] @@ -197,11 +255,9 @@ async def generate_queue_ui(ctx: Context): # Progress Bar Logic progress_bar = "" - # Only show bar if duration > 0 (prevents weird 00:00 bars) if duration > 0: bar_length = 16 filled = int((percentage / 100) * bar_length) - # Ensure filled isn't bigger than length filled = min(filled, bar_length) bar_str = 'â–Ŧ' * filled + '🔘' + 'â–Ŧ' * (bar_length - filled) progress_bar = f"\n`{format_time(elapsed)}` {bar_str} `{format_time(duration)}`" @@ -215,14 +271,11 @@ async def generate_queue_ui(ctx: Context): description = "## 💤 Nothing is playing\nUse `/play` to start the party!" else: # Create Hyperlink [Title](URL) - # If no URL exists, link to Discord homepage as fallback or just bold if url and url.startswith("http"): song_link = f"[{title}]({url})" else: song_link = f"**{title}**" - # CLEARER STATUS LINE: - # Loop: Mode | Effect: Name | Vol: % description = ( f"## đŸ’ŋ Now Playing\n" f"### {song_link}\n" @@ -241,7 +294,7 @@ async def generate_queue_ui(ctx: Context): embed.add_field(name="âŗ Up Next", value=queue_text, inline=False) - remaining = (n) - 9 # Approx calculation based on your grabbing logic + remaining = n - 9 if remaining > 0: embed.set_footer(text=f"Waitlist: {remaining} more songs...") else: @@ -251,23 +304,38 @@ async def generate_queue_ui(ctx: Context): if thumb and isinstance(thumb, str) and thumb.startswith("http"): embed.set_thumbnail(url=thumb) elif server.icon: - # Fallback to server icon embed.set_thumbnail(url=server.icon.url) view = QueueControls(ctx) return embed, view -# The command entry point calls this -async def display_server_queue(ctx: Context, songs, n): + +async def display_server_queue(ctx: Context, songs: list, n: int) -> None: + """ + Display the server's queue with interactive controls + + Args: + ctx: Command context + songs: List of songs in queue + n: Total number of songs + """ embed, view = await generate_queue_ui(ctx) await ctx.send(embed=embed, view=view) -# Build a display message for queuing a new song -async def queue_message(ctx: Context, data: dict): + +async def queue_message(ctx: Context, data: dict[str, Any]) -> None: + """ + Display a message when a song is queued + + Args: + ctx: Command context + data: Song data dictionary + """ msg = discord.Embed( - title="đŸŽĩ Song Queued", - description=f"**{data['title']}**", - color=discord.Color.green()) + title="đŸŽĩ Song Queued", + description=f"**{data['title']}**", + color=discord.Color.green() + ) msg.set_thumbnail(url=data['thumbnail']) msg.add_field(name="âąī¸ Duration", value=format_time(data['duration']), inline=True) @@ -276,9 +344,23 @@ async def queue_message(ctx: Context, data: dict): await ctx.send(embed=msg) -# Converts seconds into more readable format -def format_time(seconds): + +# =================================== +# Utility Functions +# =================================== + +def format_time(seconds: int | float) -> str: + """ + Convert seconds into readable time format (MM:SS or HH:MM:SS) + + Args: + seconds: Time in seconds + + Returns: + Formatted time string + """ try: + seconds = int(seconds) minutes, seconds = divmod(seconds, 60) hours, minutes = divmod(minutes, 60) diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..15da7dd --- /dev/null +++ b/mypy.ini @@ -0,0 +1,31 @@ +# mypy configuration for groovy-zilean +# Type checking configuration that's practical for a Discord bot + +[mypy] +# Python version +python_version = 3.13 + +# Ignore missing imports for libraries without type stubs +# Discord.py, spotipy, yt-dlp don't have complete type stubs +ignore_missing_imports = True + +# Be strict about our own code +# Start lenient, can tighten later +disallow_untyped_defs = False +check_untyped_defs = True +# Too noisy with discord.py +warn_return_any = False +warn_unused_configs = True + +# Exclude patterns +exclude = venv/ + +# Per-module overrides +[mypy-discord.*] +ignore_missing_imports = True + +[mypy-spotipy.*] +ignore_missing_imports = True + +[mypy-yt_dlp.*] +ignore_missing_imports = True