From 669339f717b2871447d691f0f65cf7c79a7dcb6c Mon Sep 17 00:00:00 2001 From: Jared Kick Date: Thu, 24 Jul 2025 14:30:04 -0400 Subject: [PATCH] Made player controls based on Discord actions. --- __main__.py | 79 +++++ boywife_bot.py | 45 --- cogs/activities.py | 33 +- cogs/music_player.py | 769 +++++++++++++++++++++++++++++-------------- database.py | 189 ++++++----- 5 files changed, 735 insertions(+), 380 deletions(-) create mode 100755 __main__.py delete mode 100755 boywife_bot.py diff --git a/__main__.py b/__main__.py new file mode 100755 index 0000000..14ad53e --- /dev/null +++ b/__main__.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 + +""" +BoywifeBot - A Discord bot for the Gayming Group Discord server. + +This program provides a bot that plays music in a voice chat and fulfills other +commands in text channels. + +Author: Jared Kick +Version: 0.1.0 + +For detailed documentation, please refer to: + +Source Code: + https://github.com/jtkick/boywife-bot +""" + +PROJECT_VERSION = "0.1.0" + +# Standard imports +import logging +import os +import sys + +# Third-part imports +import discord +from discord.ext import commands +from dotenv import load_dotenv +from openai import OpenAI + +# Project imports +import database + +def main(): + # Create custom logging handler + console_handler = logging.StreamHandler(sys.stdout) + console_formatter = logging.Formatter( + "[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s") + console_handler.setFormatter(console_formatter) + + # Make sure all loggers use this handler + root_logger = logging.getLogger() + root_logger.setLevel(logging.INFO) + root_logger.addHandler(console_handler) + + # Get bot logger + logger = logging.getLogger("boywife-bot") + + # Load credentials + load_dotenv() + TOKEN = os.getenv('DISCORD_TOKEN') + + # Create custom bot with database connection + class BoywifeBot(commands.Bot): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.db = database.Database("boywife_bot.db") + self.ai = OpenAI() + client = BoywifeBot( + command_prefix = '!', + intents=discord.Intents.all(), + log_hander=False + ) + + # Load all bot cogs in directory + # You need to import os for this method + @client.event + async def on_ready(): + logger.info("%s is now running", client.user) + # Load cogs + for filename in os.listdir('./cogs'): + if filename.endswith('.py'): + await client.load_extension(f'cogs.{filename[:-3]}') + logger.info("Loaded %s cog", filename) + + client.run(TOKEN, log_handler=None) + +if __name__ == "__main__": + main() diff --git a/boywife_bot.py b/boywife_bot.py deleted file mode 100755 index eb49541..0000000 --- a/boywife_bot.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 - -import asyncio -import discord -from discord.ext import commands -from dotenv import load_dotenv -import logging -import os -import sys - -import database - -# Create custom logging handler -console_handler = logging.StreamHandler(sys.stdout) -console_formatter = logging.Formatter( - "[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s") -console_handler.setFormatter(console_formatter) - -# Make sure all loggers use this handler -root_logger = logging.getLogger() -root_logger.setLevel(logging.INFO) -root_logger.addHandler(console_handler) - -# Get bot logger -logger = logging.getLogger("boywife-bot") - -# Load credentials -load_dotenv() -TOKEN = os.getenv('DISCORD_TOKEN') - -# client = discord.Client() -client = commands.Bot( - command_prefix = '!', intents=discord.Intents.all(), log_hander=False) - -# You need to import os for this method -@client.event -async def on_ready(): - logger.info(f'{client.user} is now running') - # Load cogs - for filename in os.listdir('./cogs'): - if filename.endswith('.py'): - await client.load_extension(f'cogs.{filename[:-3]}') - logger.info(f'Loaded {filename} cog') - -client.run(TOKEN, log_handler=None) diff --git a/cogs/activities.py b/cogs/activities.py index 672d3bc..dd58bcf 100644 --- a/cogs/activities.py +++ b/cogs/activities.py @@ -7,17 +7,14 @@ import pathlib import sqlite3 import typing -import database - class Activities(commands.Cog): + """A cog to track and gather statistics on user activities.""" - """Related commands.""" __slots__ = ("nerd", "nerds", "fword", "fwords") def __init__(self, bot): self.bot = bot self.logger = logging.getLogger("activities") - self.db = database.Database("boywife_bot.db") async def __local_check(self, ctx): """A local check which applies to all commands in this cog.""" @@ -29,7 +26,8 @@ class Activities(commands.Cog): """A local error handler for all errors arising from commands in this cog.""" if isinstance(error, commands.NoPrivateMessage): try: - return await ctx.send('This command can not be used in Private Messages.') + return await ctx.send( + 'This command can not be used in private messages.') except discord.HTTPException: pass @@ -42,15 +40,30 @@ class Activities(commands.Cog): before: discord.Member, after: discord.Member): # Log the activity or status change - self.logger.info("Received activity change; logging") - self.db.insert_activity_change(before, after) + if after.activity: + self.logger.info( + f"User '{before.name}' changed activity to "\ + f"'{after.activity.name}'") + else: + self.logger.info( + f"User '{before.name}' changed status to '{after.status}'") + self.bot.db.insert_activity_change(before, after) @commands.command(name='nerd', aliases=['nerdscale'], description="Find how nerdy a user is.") async def nerd_(self, ctx, member: typing.Union[discord.Member, int, None]): - """Checks the play history of all users in the Discord server, and - based on how many hours they have in League of Legends compared to - other users, declare how nerdy they are.""" + """Clowns on users who play League of Legends. + + This command receives a user, computes the amount of time they have + spent playing League of Legends, and will make fun of them if they have + any time in it at all. It optionally takes no argument, and will find + the user in the guild with the most time in League of Legends and call + them out. + + Args: + member (discord.Member, int, None, optional): The member to check + for League stats. + """ # If member is not defined, find the user with the most time if not member: members = [member.id for member in ctx.guild.members] diff --git a/cogs/music_player.py b/cogs/music_player.py index c058594..90cd269 100644 --- a/cogs/music_player.py +++ b/cogs/music_player.py @@ -1,6 +1,9 @@ +import ast +import atexit import datetime import discord from discord.ext import commands +import enum import random import asyncio import itertools @@ -17,12 +20,10 @@ import yt_dlp from yt_dlp import YoutubeDL import logging -import database - logger = logging.getLogger("music_player") # Get API key for last.fm -LASTFM_API_KEY = os.getenv('LASTFM_API_KEY') +LASTFM_API_KEY = os.getenv("LASTFM_API_KEY") # TEMORARY LIST OF SONGS songs = [ @@ -84,37 +85,11 @@ songs = [ "MADELINE - INJI", "Baddy On The Floor - Jamix xx", "SWEET HONEY BUCKIIN' - Beyonce", - "Boots & Boys - Ke$ha" + "Boots & Boys - Ke$ha", ] # Suppress noise about console usage from errors -yt_dlp.utils.bug_reports_message = lambda: '' - -ytdlopts = { - 'format': 'bestaudio/best', - 'outtmpl': 'downloads/%(extractor)s-%(id)s-%(title)s.%(ext)s', - 'restrictfilenames': True, - 'noplaylist': True, - 'nocheckcertificate': True, - 'ignoreerrors': False, - 'logtostderr': False, - 'quiet': True, - 'no_warnings': True, - 'default_search': 'auto', - 'source_address': '0.0.0.0', # ipv6 addresses cause issues sometimes - 'retries': 5, - 'ignoreerrors': True, - # 'throttled_rate': '1M', - 'fragment_retries': 10 # Prevents seemingly random stream crashes -} - -ffmpegopts = { - 'before_options': '-nostdin', - 'options': '-vn' -} - -ytdl = YoutubeDL(ytdlopts) - +# yt_dlp.utils.bug_reports_message = lambda: "" class VoiceConnectionError(commands.CommandError): """Custom Exception class for connection errors.""" @@ -126,15 +101,33 @@ class InvalidVoiceChannel(VoiceConnectionError): class YTDLSource(discord.PCMVolumeTransformer): + _downloader = YoutubeDL({ + "format": "bestaudio[ext=opus]/bestaudio", # Use OPUS for FFmpeg + "outtmpl": "downloads/%(extractor)s-%(id)s-%(title)s.%(ext)s", + "restrictfilenames": True, + "noplaylist": True, + "nocheckcertificate": True, + "ignoreerrors": False, + "logtostderr": False, + "quiet": True, + "no_warnings": True, + "default_search": "auto", + "source_address": "0.0.0.0", # ipv6 addresses cause issues sometimes + "retries": 5, + "ignoreerrors": True, + 'throttled_rate': '1M', + "fragment_retries": 10, # Prevents seemingly random stream crashes + }) + def __init__(self, source, *, data, requester): super().__init__(source) self.requester = requester # YouTube Metadata - self.title = data.get('title') - self.web_url = data.get('webpage_url') - self.thumbnail_url = data.get('thumbnail') - self.duration = data.get('duration') + self.title = data.get("title") + self.web_url = data.get("webpage_url") + self.thumbnail_url = data.get("thumbnail") + self.duration = data.get("duration") # Song metadata self.search_term = "" @@ -151,14 +144,22 @@ class YTDLSource(discord.PCMVolumeTransformer): return self.__getattribute__(item) @classmethod - async def create_source(cls, ctx, search: str, *, loop, download=False, silent=False, artist='Unknown', song_title='Unknown'): - loop = loop or asyncio.get_event_loop() + async def create_source( + cls, + ctx, + search: str, + *, + download=False, + artist="", + song_title="", + ): + loop = ctx.bot.loop if ctx else asyncio.get_event_loop() # If we got a YouTube link, get the video title for the song search if validators.url(search): with YoutubeDL() as ydl: info = ydl.extract_info(search, download=False) - search_term = info.get('title', '') + search_term = info.get("title", "") else: search_term = search @@ -168,7 +169,6 @@ class YTDLSource(discord.PCMVolumeTransformer): f"track={search_term}&api_key={LASTFM_API_KEY}&format=json" response = requests.get(url) lastfm_data = response.json() - # Let's get the first result, if any if lastfm_data['results']['trackmatches']['track']: track = lastfm_data['results']['trackmatches']['track'][0] @@ -180,102 +180,290 @@ class YTDLSource(discord.PCMVolumeTransformer): search = f"{song_title} {artist} official audio" # Get YouTube video source - logger.info(f"Getting YouTube video: {search}") - to_run = partial(ytdl.extract_info, url=search, download=download) + logger.info(f"Getting YouTube video: {search_term}") + to_run = partial(cls._downloader.extract_info, url=search, download=download) data = await loop.run_in_executor(None, to_run) # There's an error with yt-dlp that throws a 403: Forbidden error, so # only proceed if it returns anything - if data and 'entries' in data: + if data and "entries" in data: # take first item from a playlist - data = data['entries'][0] + data = data["entries"][0] + # Get either source filename or URL, depending on if we're downloading if download: - source = ytdl.prepare_filename(data) + source = cls._downloader.prepare_filename(data) else: - return {'webpage_url': data['webpage_url'], 'requester': ctx.author, 'title': data['title']} + source = data["url"] + logger.info(f"Using source: {data["webpage_url"]}") - ffmpeg_source = cls(discord.FFmpegPCMAudio(source), data=data, requester=ctx.author) + ffmpeg_source = cls( + discord.FFmpegPCMAudio(source, before_options="-nostdin", options="-vn"), + data=data, + requester=ctx.author if ctx else None, + ) # TODO: ADD THESE TO THE CONSTRUCTOR ffmpeg_source.search_term = search_term + # ffmpeg_source.song_title = data["title"] ffmpeg_source.artist = artist ffmpeg_source.song_title = song_title ffmpeg_source.filename = source return ffmpeg_source - @classmethod - async def regather_stream(cls, data, *, loop): - """Used for preparing a stream, instead of downloading. - Since Youtube Streaming links expire.""" - loop = loop or asyncio.get_event_loop() - requester = data['requester'] - - to_run = partial(ytdl.extract_info, url=data['webpage_url'], download=False) - data = await loop.run_in_executor(None, to_run) - - return cls(discord.FFmpegPCMAudio(data['url']), data=data, requester=requester) - - class MusicPlayer: - """A class which is assigned to each guild using the bot for Music. - This class implements a queue and loop, which allows for different guilds to listen to different playlists - simultaneously. - When the bot disconnects from the Voice it's instance will be destroyed. + """ + A class used to play music in a voice channel. + + This class implements a queue and play loop that plays music in a single + guild. Since each player is assigned to a single voice channel, it allows + multiple guilds to use the bot simultaneously. + + Methods: + player_loop() -> None: + Provides the main loop that waits for requests and plays songs. + update_now_playing_message(repost[bool], emoji[str]) -> None: + Updates the channel message that states what song is currently + being played in the voice channel. """ - __slots__ = ('bot', '_guild', '_channel', '_cog', 'queue', 'next', 'current', 'np', 'volume', 'dj_mode') + __slots__ = ( + "bot", + "_guild", + "_channel", + "_cog", + "_np", + "_state", + "_queue", + "_next", + "_skipped", + "current", + "np", + "volume", + "dj_mode", + "_view", + ) # Each player is assiciated with a guild, so create a lock for when we do # volatile things in the server like delete previous messages - guild_lock_ = asyncio.Lock() + _guild_lock = asyncio.Lock() + + class State(enum.Enum): + IDLE=1 + PLAYING=2 + PAUSED=3 + + def __init__(self, ctx: discord.ext.commands.Context): + """ + Initializes the music player object associated with the given Discord + context. + + Args: + ctx (discord.ext.commands.Context): + The context within the player will connect to play music and + respond to requests. + """ + # Ensure proper cleanup + atexit.register(self.__del__) - def __init__(self, ctx): self.bot = ctx.bot self._guild = ctx.guild self._channel = ctx.channel self._cog = ctx.cog + self._np = None # 'Now Playing' message - self.queue = asyncio.Queue() - self.next = asyncio.Event() + self._state = self.State.IDLE - self.np = None # Now playing message - self.volume = .5 + self._queue = asyncio.Queue() + self._next = asyncio.Event() + self._skipped = False # Flag for skipping songs + + self.volume = 0.5 self.current = None self.dj_mode = False ctx.bot.loop.create_task(self.player_loop()) - async def player_loop(self): - """Our main player loop.""" + def __del__(self): + """ + Cleanup music player, which includes deleting messages like the + 'Now Playing' message. + """ + if self._np: + asyncio.run(self._np.delete()) + + async def _change_state(self, new_state: "MusicPlayer.State" = None): + """When state changes, update the Discord 'Now Playing' message.""" + if not self._channel: + return + + # 'None' state is used to refresh message without changing state + if new_state is not None: + self._state = new_state + + logger.info("Updating 'Now Playing' message") + await self.bot.wait_until_ready() + async with self._guild_lock: + # Create new 'Now Playing' message + if self._state is self.State.IDLE: + embed = discord.Embed( + title=f"◻️ Idle", color=discord.Color.light_gray() + ) + elif self._state is self.State.PLAYING: + embed = discord.Embed( + title=f"▶️ Now Playing", color=discord.Color.blue() + ) + elif self._state is self.State.PAUSED: + embed = discord.Embed( + title=f"⏸️ Paused", color=discord.Color.light_gray() + ) + else: + embed = discord.Embed( + title="UNKNOWN STATE", color=discord.Color.red() + ) + + # Get and add the thumbnail + if self._state in [self.State.PLAYING, self.State.PAUSED]: + embed.set_thumbnail(url=self.current.thumbnail_url) + embed.add_field( + name="", + value=( + f"[{self.current.song_title}]({self.current.web_url}) - " + f"{self.current.artist}" + ), + inline=False, + ) + + # Add all upcoming songs + # Possibly dangerous, but only obvious solution + queue = [s for s in self._queue._queue if s is not None] + if len(queue) > 0: + value_str = "" + for i, song in enumerate(queue): + value_str += ( + f"{i+1}. [{song.song_title}]({song.web_url}) -" + f" {song.artist}\n" + ) + embed.add_field(name="Queue", value=value_str, inline=False) + + # Build controls + controls = discord.ui.View(timeout=None) + # Construct 'back' button + prev_button = discord.ui.Button( + label="⏮️", + style=discord.ButtonStyle.secondary, + custom_id="prev" + ) + #prev_button.disabled = self._player.current + prev_button.disabled = True + #prev_button.callback = + controls.add_item(prev_button) + + # Construct 'play/pause' button + play_button = discord.ui.Button( + label="▶️" if self._state is self.State.PAUSED else "⏸️", + style=discord.ButtonStyle.secondary, + custom_id="playpause" + ) + play_button.disabled = self._state is self.State.IDLE + if self._state is self.State.PLAYING: + play_button.callback = self.pause + elif self._state is self.State.PAUSED: + play_button.callback = self.resume + controls.add_item(play_button) + + # Construct 'next' button + next_button = discord.ui.Button( + label="⏭️", + style=discord.ButtonStyle.secondary, + custom_id="next" + ) + next_button.disabled = self._state is self.State.IDLE + next_button.callback = self.next + controls.add_item(next_button) + + # If last post is the 'Now Playing' message, just update it + last_message = [m async for m in self._channel.history(limit=1)] + if last_message[0] and self._np and last_message[0].id == self._np.id: + await self._np.edit(embed=embed, view=controls) + else: + if self._np: + self._np = await self._np.delete() + self._np = await self._channel.send(embed=embed, view=controls) + + async def resume(self, interaction: discord.Interaction = None): + if interaction: await interaction.response.defer() + vc = self._guild.voice_client + if not vc or not vc.is_connected(): + return + if vc.is_paused(): + vc.resume() + await self._change_state(self.State.PLAYING) + + async def pause(self, interaction: discord.Interaction = None): + if interaction: await interaction.response.defer() + vc = self._guild.voice_client + if not vc or not vc.is_connected(): + return + if vc.is_playing(): + vc.pause() + await self._change_state(self.State.PAUSED) + + async def previous(self, interaction: discord.Interaction = None): + pass + + async def next(self, interaction: discord.Interaction = None): + if interaction: await interaction.response.defer() + vc = self._guild.voice_client + if not vc.is_playing() and not vc.is_paused(): + return + self._skipped = True # Notify loop that we skipped the song + vc.stop() + + async def queue(self, source: YTDLSource): + await self._queue.put(source) + await self._change_state(None) + + async def player_loop(self, interaction: discord.Interaction = None): + """ + The main loop that waits for song requests and plays music accordingly. + """ await self.bot.wait_until_ready() while not self.bot.is_closed(): - self.next.clear() + self._next.clear() + await self._change_state(self.State.IDLE) # Always get a song if there's one in the queue - if self.queue.qsize() > 0 or self.dj_mode is False: + if self._queue.qsize() > 0 or self.dj_mode is False: logger.info("Getting song from play queue") try: # Wait for the next song. If we timeout cancel the player # and disconnect... async with timeout(300): # 5 minutes... - source = await self.queue.get() + source = await self._queue.get() except asyncio.TimeoutError: - return self.destroy(self._guild) + return await self.destroy() # Otherwise we're in DJ mode and a user hasn't requested one, so # pick a song at random and create a source for it else: logger.info( - "Queue is empty and DJ mode is on. Picking song at random") - source = await YTDLSource.create_source( - None, random.choice(songs), - loop=self.bot.loop, - download=True + "Queue is empty and DJ mode is on. Picking song at random" ) + try: + source = await YTDLSource.create_source( + None, + random.choice(songs), + download=True, + ) + if not source: + raise RuntimeError("Could not get YouTube source.") + except Exception as e: + print(e) + await self._channel.send("Failed to get YouTube source.") # For the time being, we're going to use 'None' to signal to the - # player that is should go back around and check for a song again, + # player that it should go back around and check for a song again, # mainly because DJ mode was switched on and it should pick a song # at random this time if source is None: @@ -285,24 +473,52 @@ class MusicPlayer: # Source was probably a stream (not downloaded) # So we should regather to prevent stream expiration try: - source = await YTDLSource.regather_stream(source, loop=self.bot.loop) + source = await YTDLSource.regather_stream( + source, loop=self.bot.loop + ) except Exception as e: - await self._channel.send(f'There was an error processing your song.\n' - f'```css\n[{e}]\n```') + await self._channel.send( + "There was an error processing your" + f" song.\n```css\n[{e}]\n```" + ) continue source.volume = self.volume self.current = source logger.info(f"Playing '{source.song_title}' by '{source.artist}'") - self._guild.voice_client.play(source, after=lambda _: self.bot.loop.call_soon_threadsafe(self.next.set)) - - logger.info("Updating presense and 'now playing' message") - await self.bot.change_presence(activity=discord.Activity(type=discord.ActivityType.custom, name="custom", state=f"🎵 {source.song_title} by {source.artist}")) - await self.update_now_playing_message(repost=True) + row_id = self.bot.db.insert_song_play(self._channel.id, source) + def song_finished(error): + # Update database to reflect song finishing + if not error: + self.bot.db.update_song_play(row_id, not self._skipped) + self._skipped = False + logger.info(f"Song finiehd with error: {error}") + self.bot.loop.call_soon_threadsafe(self._next.set) + try: + self._guild.voice_client.play( + source, + after=song_finished + ) + logger.info("Updating presense and 'now playing' message") + await self.bot.change_presence( + activity=discord.Activity( + type=discord.ActivityType.custom, + name="custom", + state=f"🎵 {source.song_title} by {source.artist}", + ) + ) + except Exception as e: + # Post error message + embed = discord.Embed( + title=f"Error: {str(e)}", color=discord.Color.red() + ) + await self._channel.send(embed=embed) + raise e logger.info("Waiting for song to finish") - await self.next.wait() + await self._change_state(self.State.PLAYING) + await self._next.wait() if os.path.exists(source.filename): os.remove(source.filename) @@ -316,85 +532,25 @@ class MusicPlayer: # Update bot statuses to match no song playing await self.bot.change_presence(status=None) - await self.update_now_playing_message(repost=False) - async def update_now_playing_message(self, repost=False, emoji='▶️'): - await self.bot.wait_until_ready() - - # Create and post new 'Now Playing' message - embed = discord.Embed( - title=f"{emoji} Now Playing", color=discord.Color.green()) - - # Get and add the thumbnail - if self.current: - embed.set_thumbnail(url=self.current.thumbnail_url) - embed.add_field( - name="", - value=f"[{self.current.song_title}]({self.current.web_url}) - " - f"{self.current.artist}", - inline=False - ) - - # Add all upcoming songs - # Possibly dangerous, but only obvious solution - queue = [s for s in self.queue._queue if s is not None] - if self.queue.qsize() > 0: - value_str = "" - for i, song in enumerate(queue): - value_str += f"{i+1}. [{song.song_title}]({song.web_url}) - "\ - f"{song.artist}\n" - embed.add_field(name="Queue", value=value_str, inline=False) - - # There's a chance we'll delete a message another coroutine is using, so - # lock the messages - async with self. guild_lock_: - async for message in self._channel.history(limit=50): - if len(message.embeds) == 1 and message.embeds[0].title and "Now Playing" in message.embeds[0].title: - if repost: - await message.delete() - else: - await message.edit(embed=embed) - break - - if repost: - self.np = await self._channel.send(embed=embed) - - - def destroy(self, guild): + async def destroy(self): """Disconnect and cleanup the player.""" - return self.bot.loop.create_task(self._cog.cleanup(guild)) + if self._np: + self._np = await self._np.delete() + try: + return await self._cog.cleanup(self._guild) + except: + return None class Music(commands.Cog): """Music related commands.""" - __slots__ = ('bot', 'players') + __slots__ = ("bot", "players") def __init__(self, bot): self.bot = bot self.players = {} - self.last_tag_play_time = datetime.datetime.now() - - # Get a reference to the database - # TODO: MAKE THIS INJECTED - self.db = database.Database("boywife_bot.db") - - # def update_cache(): - # with yt_dlp.YoutubeDL({'quiet': True}) as ydl: - # self.boywife_tracks = ydl.extract_info('https://soundcloud.com/djboywife', download=False)['entries'] - - # with open('soundcloud-cache', 'w') as f: - # f.write(str(self.boywife_tracks)) - # # pickle.dump(self.boywife_tracks, f) - - # if os.path.exists('soundcloud-cache'): - # with open('soundcloud-cache', 'r') as f: - # exec(f'self.boywife_tracks = {f.read()}') - # # self.boywife_tracks = pickle.load(f) - # threading.Thread(target=update_cache).start() - # else: - # update_cache() - async def cleanup(self, guild): try: @@ -408,24 +564,38 @@ class Music(commands.Cog): pass async def __local_check(self, ctx): - """A local check which applies to all commands in this cog.""" + """ + A local check which applies to all commands in this cog and prevents + its use in private messages. + """ if not ctx.guild: raise commands.NoPrivateMessage return True async def __error(self, ctx, error): - """A local error handler for all errors arising from commands in this cog.""" + """ + A local error handler for all errors arising from commands in this cog. + """ if isinstance(error, commands.NoPrivateMessage): try: - return await ctx.send('This command can not be used in Private Messages.') + return await ctx.send( + "This command can not be used in Private Messages." + ) except discord.HTTPException: pass elif isinstance(error, InvalidVoiceChannel): - await ctx.send('Error connecting to Voice Channel. ' - 'Please make sure you are in a valid channel or provide me with one') + await ctx.send( + "Error connecting to Voice Channel. Please make sure you are" + " in a valid channel or provide me with one" + ) - print('Ignoring exception in command {}:'.format(ctx.command), file=sys.stderr) - traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr) + print( + "Ignoring exception in command {}:".format(ctx.command), + file=sys.stderr, + ) + traceback.print_exception( + type(error), error, error.__traceback__, file=sys.stderr + ) def get_player(self, ctx): """Retrieve the guild player, or generate one.""" @@ -437,8 +607,10 @@ class Music(commands.Cog): return player - @commands.command(name='join', aliases=['connect', 'j'], description="connects to voice") - async def connect_(self, ctx, *, channel: discord.VoiceChannel=None): + @commands.command( + name="join", aliases=["connect", "j"], description="connects to voice" + ) + async def connect_(self, ctx, *, channel: discord.VoiceChannel = None): """Connect to voice. Parameters ------------ @@ -451,9 +623,19 @@ class Music(commands.Cog): try: channel = ctx.author.voice.channel except AttributeError: - embed = discord.Embed(title="", description="No channel to join. Please call `,join` from a voice channel.", color=discord.Color.green()) + embed = discord.Embed( + title="", + description=( + "No channel to join. Please call `,join` from a voice" + " channel." + ), + color=discord.Color.green(), + ) await ctx.send(embed=embed) - raise InvalidVoiceChannel('No channel to join. Please either specify a valid channel or join one.') + raise InvalidVoiceChannel( + "No channel to join. Please either specify a valid channel" + " or join one." + ) vc = ctx.voice_client @@ -463,23 +645,31 @@ class Music(commands.Cog): try: await vc.move_to(channel) except asyncio.TimeoutError: - raise VoiceConnectionError(f'Moving to channel: <{channel}> timed out.') + raise VoiceConnectionError( + f"Moving to channel: <{channel}> timed out." + ) else: try: await channel.connect() except asyncio.TimeoutError: - raise VoiceConnectionError(f'Connecting to channel: <{channel}> timed out.') + raise VoiceConnectionError( + f"Connecting to channel: <{channel}> timed out." + ) # await ctx.message.add_reaction('👍') - @commands.command(name='play', aliases=['sing','p'], description="streams music") + @commands.command(name="play", aliases=["p", "queue", "q"]) async def play_(self, ctx, *, search: str = None): - """Request a song and add it to the queue. - This command attempts to join a valid voice channel if the bot is not already in one. - Uses YTDL to automatically search and retrieve a song. - Parameters - ------------ - search: str [Required] - The song to search and retrieve using YTDL. This could be a simple search, an ID or URL. + """Plays the given song in a voice channel. + + This method takes a string describing the song to play and plays it. In + the event that a song is already being played, the new one is added to + a queue of songs. + + Args: + search (str): The search term or URL used to find the song. + + Example: + !play Play That Funky Music by Wild Cherry """ # Ensure we're connected to the proper voice channel vc = ctx.voice_client @@ -490,34 +680,51 @@ class Music(commands.Cog): embed = discord.Embed( title=f"🔎 Searching for:", description=f"{search}", - color=discord.Color.green() + color=discord.Color.green(), ) message = await ctx.channel.send(embed=embed) # Create source try: - source = await YTDLSource.create_source(ctx, search, loop=self.bot.loop, download=True) - except: - pass + source = await YTDLSource.create_source( + ctx, search, download=True + ) + # Track song requests in database + self.bot.db.insert_song_request(message, source) + # Add song to the corresponding player object + player = self.get_player(ctx) + await player.queue(source) + # Update previous message to show found song and video + embed = discord.Embed( + title=f"Queued", + description=( + f"[{source.song_title}]({source.web_url}) -" + f" {source.artist}" + ), + color=discord.Color.green(), + ) + embed.set_thumbnail(url=source.thumbnail_url) + await message.edit(embed=embed) + except Exception as e: + # Gracefully tell user there was an issue + embed = discord.Embed( + title=f"ERROR", + description=f"{str(e)}", + color=discord.Color.red(), + ) + await message.edit(embed=embed) + raise e - # Update previous message to show found song and video - embed = discord.Embed( - title=f"Queued", - description=f"[{source.song_title}]({source.web_url}) - {source.artist}", - color=discord.Color.green() - ) - embed.set_thumbnail(url=source.thumbnail_url) - await message.edit(embed=embed) - - # Add song to the corresponding player object - player = self.get_player(ctx) - await player.queue.put(source) - await player.update_now_playing_message() - - @commands.command(name="djmode", aliases=['dj'], description="Turns DJ mode on or off.") + @commands.command( + name="djmode", aliases=["dj"], description="Turns DJ mode on or off." + ) async def djmode_(self, ctx, *, mode: str = "on"): """Turns DJ mode on or off. When on, the bot will play songs automatically.""" + # Ensure we're connected to the proper voice channel + vc = ctx.voice_client + if not vc: + await ctx.invoke(self.connect_) # Get desired mode mode = mode.lower().strip() if mode in ("true", "t", "yes", "y", "on"): @@ -526,22 +733,24 @@ class Music(commands.Cog): mode = False else: return - # Switch to desired mode player = self.get_player(ctx) player.dj_mode = mode - # Break player out of waiting on queue so it can pick a song at random if player.dj_mode: - await player.queue.put(None) + await player.queue(None) - @commands.command(name='pause', description="pauses music") + @commands.command(name="pause", description="pauses music") async def pause_(self, ctx): """Pause the currently playing song.""" vc = ctx.voice_client if not vc or not vc.is_playing(): - embed = discord.Embed(title="", description="I am currently not playing anything", color=discord.Color.green()) + embed = discord.Embed( + title="", + description="I am currently not playing anything", + color=discord.Color.green(), + ) return await ctx.send(embed=embed) elif vc.is_paused(): return @@ -550,15 +759,19 @@ class Music(commands.Cog): # Update the 'Now Playing' message to reflect its paused player = self.get_player(ctx) - await player.update_now_playing_message(emoji='⏸️') + await player.update_now_playing_message(emoji="⏸️") - @commands.command(name='resume', description="resumes music") + @commands.command(name="resume", description="resumes music") async def resume_(self, ctx): """Resume the currently paused song.""" vc = ctx.voice_client if not vc or not vc.is_connected(): - embed = discord.Embed(title="", description="I'm not connected to a voice channel", color=discord.Color.green()) + embed = discord.Embed( + title="", + description="I'm not connected to a voice channel", + color=discord.Color.green(), + ) return await ctx.send(embed=embed) elif not vc.is_paused(): return @@ -569,13 +782,17 @@ class Music(commands.Cog): player = self.get_player(ctx) await player.update_now_playing_message() - @commands.command(name='skip', description="skips to next song in queue") + @commands.command(name="skip", description="skips to next song in queue") async def skip_(self, ctx): """Skip the song.""" vc = ctx.voice_client if not vc or not vc.is_connected(): - embed = discord.Embed(title="", description="I'm not connected to a voice channel", color=discord.Color.green()) + embed = discord.Embed( + title="", + description="I'm not connected to a voice channel", + color=discord.Color.green(), + ) return await ctx.send(embed=embed) if vc.is_paused(): @@ -585,14 +802,22 @@ class Music(commands.Cog): vc.stop() - @commands.command(name='remove', aliases=['rm'], description="removes specified song from queue") - async def remove_(self, ctx, pos : int=None): + @commands.command( + name="remove", + aliases=["rm"], + description="removes specified song from queue", + ) + async def remove_(self, ctx, pos: int = None): """Removes specified song from queue""" vc = ctx.voice_client if not vc or not vc.is_connected(): - embed = discord.Embed(title="", description="I'm not connected to a voice channel", color=discord.Color.green()) + embed = discord.Embed( + title="", + description="I'm not connected to a voice channel", + color=discord.Color.green(), + ) return await ctx.send(embed=embed) player = self.get_player(ctx) @@ -600,48 +825,91 @@ class Music(commands.Cog): player.queue._queue.pop() else: try: - s = player.queue._queue[pos-1] - del player.queue._queue[pos-1] - embed = discord.Embed(title="", description=f"Removed [{s['title']}]({s['webpage_url']}) [{s['requester'].mention}]", color=discord.Color.green()) + s = player.queue._queue[pos - 1] + del player.queue._queue[pos - 1] + embed = discord.Embed( + title="", + description=( + f"Removed [{s['title']}]({s['webpage_url']})" + f" [{s['requester'].mention}]" + ), + color=discord.Color.green(), + ) await ctx.send(embed=embed) except: - embed = discord.Embed(title="", description=f'Could not find a track for "{pos}"', color=discord.Color.green()) + embed = discord.Embed( + title="", + description=f'Could not find a track for "{pos}"', + color=discord.Color.green(), + ) await ctx.send(embed=embed) - @commands.command(name='clear', aliases=['clr', 'cl', 'cr'], description="clears entire queue") + @commands.command( + name="clear", + aliases=["clr", "cl", "cr"], + description="clears entire queue", + ) async def clear_(self, ctx): - """Deletes entire queue of upcoming songs.""" - + """ + Deletes entire queue of upcoming songs. + + Args: + ctx (discord.ext.commands.Context): The Discord context associated + with the message. + """ vc = ctx.voice_client - if not vc or not vc.is_connected(): - embed = discord.Embed(title="", description="I'm not connected to a voice channel", color=discord.Color.green()) + embed = discord.Embed( + title="", + description="I am not currently connected to a voice channel.", + color=discord.Color.yellow(), + ) return await ctx.send(embed=embed) player = self.get_player(ctx) player.queue._queue.clear() - await ctx.send('**Cleared**') + await ctx.send("**Cleared**") - @commands.command(name='volume', aliases=['vol', 'v'], description="changes Kermit's volume") - async def change_volume(self, ctx, *, vol: float=None): - """Change the player volume. - Parameters - ------------ - volume: float or int [Required] - The volume to set the player to in percentage. This must be between 1 and 100. + @commands.command( + name="volume", + aliases=["vol", "v"], + description="Sets the bot's volume in the voice channel.", + ) + async def change_volume(self, ctx, *, vol: float = None): + """ + Change the player volume. + + Args: + ctx (discord.ext.commands.Context): The Discord context associated + with the message. + volume (float, int, required): + The volume to set the player to in percentage. This must be + between 1 and 100. """ vc = ctx.voice_client if not vc or not vc.is_connected(): - embed = discord.Embed(title="", description="I am not currently connected to voice", color=discord.Color.green()) + embed = discord.Embed( + title="", + description="I am not currently connected to a voice channel.", + color=discord.Color.yellow(), + ) return await ctx.send(embed=embed) if not vol: - embed = discord.Embed(title="", description=f"🔊 **{(vc.source.volume)*100}%**", color=discord.Color.green()) + embed = discord.Embed( + title="", + description=f"🔊 **{(vc.source.volume)*100}%**", + color=discord.Color.green(), + ) return await ctx.send(embed=embed) if not 0 < vol < 101: - embed = discord.Embed(title="", description="Please enter a value between 1 and 100", color=discord.Color.green()) + embed = discord.Embed( + title="", + description="Please enter a value between 1 and 100", + color=discord.Color.green(), + ) return await ctx.send(embed=embed) player = self.get_player(ctx) @@ -650,23 +918,40 @@ class Music(commands.Cog): vc.source.volume = vol / 100 player.volume = vol / 100 - embed = discord.Embed(title="", description=f'**`{ctx.author}`** set the volume to **{vol}%**', color=discord.Color.green()) + embed = discord.Embed( + title="", + description=f"**`{ctx.author}`** set the volume to **{vol}%**", + color=discord.Color.green(), + ) await ctx.send(embed=embed) - @commands.command(name='leave', aliases=["stop", "dc", "disconnect", "bye"], description="stops music and disconnects from voice") - async def leave_(self, ctx): - """Stop the currently playing song and destroy the player. - !Warning! - This will destroy the player assigned to your guild, also deleting any queued songs and settings. + @commands.command( + name="leave", + aliases=["stop", "dc", "disconnect", "bye"], + description="Stops music and disconnects from voice.", + ) + async def leave_(self, ctx: discord.ext.commands.Context): + """ + Stop the currently playing song and destroy the player. + + Args: + ctx (discord.ext.commands.Context): The Discord context associated + with the message. + + Notes: + This will destroy the player assigned to your guild, also deleting + any queued songs and settings. """ vc = ctx.voice_client - if not vc or not vc.is_connected(): - embed = discord.Embed(title="", description="I'm not connected to a voice channel", color=discord.Color.green()) + embed = discord.Embed( + title="", + description="I am not currently connected to a voice channel.", + color=discord.Color.yellow(), + ) return await ctx.send(embed=embed) - await ctx.message.add_reaction('👋') - + await ctx.message.add_reaction("👋") await self.cleanup(ctx.guild) diff --git a/database.py b/database.py index b96d348..8ec4f0b 100644 --- a/database.py +++ b/database.py @@ -3,82 +3,13 @@ import discord import sqlite3 import typing -from tqdm import tqdm - from cogs import music_player class Database: - def __init__(self, path: str = "boywife_bot.db"): + def __init__(self, path: str): self.path = path self._ensure_db() - # # TEMP: THIS IS FOR MIGRATING THE PREVIOUS DATABASE SCHEMA - # with sqlite3.connect("ingest.db") as conn: - # cursor = conn.cursor() - # cursor.execute(""" - # SELECT - # user_id, - # before_activity_type, - # before_activity_name, - # after_activity_type, - # after_activity_name, - # timestamp - # FROM - # user_activity - # """) - # activities = cursor.fetchall() - - # for activity in tqdm(activities): - - # # Convert activity type - # if activity[1] == "activity": - # new_before_type = "playing" - # elif activity[1] == "spotify": - # new_before_type = "listening" - # elif activity[1] == "game": - # new_before_type = "playing" - # else: - # new_before_type = None - # if activity[3] == "activity": - # new_after_type = "playing" - # elif activity[3] == "spotify": - # new_after_type = "listening" - # elif activity[3] == "game": - # new_after_type = "playing" - # else: - # new_after_type = None - - # new_before_name = None if activity[2] == "other" else activity[2] - # new_after_name = None if activity[4] == "other" else activity[4] - - # user_id = self._insert_user(activity[0]) - - # with sqlite3.connect(self.path) as conn: - # cursor = conn.cursor() - # cursor.execute(""" - # INSERT INTO activity_change ( - # user_id, - # before_activity_type, - # before_activity_name, - # before_activity_status, - # after_activity_type, - # after_activity_name, - # after_activity_status, - # timestamp - # ) VALUES ( - # ?, ?, ?, ?, ?, ?, ?, ? - # ) - # """, ( - # user_id, - # new_before_type, - # new_before_name, - # "unknown", - # new_after_type, - # new_after_name, - # "unknown", - # datetime.datetime.fromisoformat(activity[5]) - # )) - def _ensure_db(self): with sqlite3.connect(self.path) as conn: @@ -134,22 +65,80 @@ class Database: ) """) + + # # TEMP + # conn.execute(""" + # ALTER TABLE song_play ADD COLUMN finished BOOL; + # """) + + # Table for songs that actually get played conn.execute(""" CREATE TABLE IF NOT EXISTS song_play ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, + user_id INTEGER, channel_id INTEGER NOT NULL, search_term TEXT NOT NULL, song_title TEXT, song_artist TEXT, + finished BOOL DEFAULT 0, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ) """) + + # # ############ TEMP ############### + # conn.execute("""DROP TABLE IF EXISTS song_play_old;""") + + # conn.execute(""" + # ALTER TABLE + # song_play + # RENAME TO + # song_play_old; + # """) + + # conn.execute(""" + # CREATE TABLE song_play ( + # id INTEGER PRIMARY KEY, + # user_id INTEGER, + # channel_id INTEGER NOT NULL, + # search_term TEXT NOT NULL, + # song_title TEXT, + # song_artist TEXT, + # finished BOOLEAN DEFAULT 0, + # timestamp DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL + # ); + # """) + + # conn.execute(""" + # INSERT INTO song_play ( + # id, + # user_id, + # channel_id, + # search_term, + # song_title, + # song_artist, + # timestamp + # ) SELECT + # id, + # user_id, + # channel_id, + # search_term, + # song_title, + # song_artist, + # timestamp + # FROM song_play_old; + # """) + + # conn.execute(""" + # DROP TABLE song_play_old; + # """) + # # ################################## + conn.commit() def _insert_server(self, discord_id: int = None) -> int: - """Inserts Discord server ID into the 'server' table. + """ + Inserts Discord server ID into the 'server' table. This method takes an ID for a server used in Discord, and inserts it into the database. It ignores the case where the server ID is already @@ -187,7 +176,8 @@ class Database: return row_id def _insert_channel(self, discord_id: int = None) -> int: - """Inserts Discord channel ID into the 'channel' table. + """ + Inserts Discord channel ID into the 'channel' table. This method takes an ID for a channel used in Discord, and inserts it into the database. It ignores the case where the channel ID is already @@ -225,7 +215,8 @@ class Database: return row_id def _insert_user(self, discord_id: int = None) -> int: - """Inserts Discord user ID into the 'user' table. + """ + Inserts Discord user ID into the 'user' table. This method takes an ID for a user used in Discord, and inserts it into the database. It ignores the case where the user ID is already @@ -266,7 +257,8 @@ class Database: self, before: discord.Member, after: discord.Member): - """Inserts an activity change into the database. + """ + Inserts an activity change into the database. This method takes two discord.Memeber objects, and records the change in activity into the 'activity_change' table. @@ -326,7 +318,8 @@ class Database: self, message: discord.Message, source: music_player.YTDLSource): - """Inserts a song request into the database. + """ + Inserts a song request into the database. This method takes a message and its derived music source and inserts the relevant information into the 'song_request' table. @@ -359,7 +352,8 @@ class Database: self, channel_id: int, source: music_player.YTDLSource): - """Inserts a song play into the database. + """ + Inserts a song play into the database. This method takes a channel and the song being played and inserts the relevant information into the 'song_play' table. @@ -367,12 +361,16 @@ class Database: Args: channel (int): The Discord channel the song is being played in. source (music_player.YTDLSource): The audio source. + + Returns: + int: The row ID of the entered song. Used to update 'played' value. """ - user_id = self._insert_user(source.requester.id) - channel_id = self._insert_user(channel.id) + user_id = self._insert_user(source.requester.id) if source.requester else None + channel_id = self._insert_user(channel_id) # Insert the information with sqlite3.connect(self.path) as conn: - conn.execute(""" + cur = conn.cursor() + cur.execute(""" INSERT INTO song_play ( user_id, channel_id, @@ -389,13 +387,38 @@ class Database: source.song_title, source.artist )) + return cur.lastrowid + + def update_song_play(self, song_play_id: int, finished: bool): + """ + Updates a song_play entry on whether or not it was finished. + + When a song plays, we want to know if it was finished or not. This + implies that either a user didn't want to hear it anymore, or that the + bot chose the wrong song from the search term. + + Args: + song_play_id (int): The row ID within the database for the song + play. + finished (bool): Whether or not the song was completed. + """ + with sqlite3.connect(self.path) as conn: + conn.execute(""" + UPDATE + song_play + SET + finished = ? + WHERE + id = ? + """, (finished, song_play_id)) def get_activity_stats( self, member: typing.Union[discord.Member, int], start: datetime = datetime.now() - timedelta(days=30) - )-> dict[str, timedelta]: - """Gets stats on the activities of the given member. + ) -> dict[str, timedelta]: + """ + Gets stats on the activities of the given member. This method searches the database for activity changes by the given user and computes the amount of time spent in each activity. @@ -440,4 +463,4 @@ class Database: activity_stats[activity_name] = activity_time if None in activity_stats: del activity_stats[None] - return activity_stats + return activity_stats \ No newline at end of file