From b0dd7f2ca606d31121150f7a87c3c8aaf78cba4f Mon Sep 17 00:00:00 2001 From: CramMK Date: Fri, 24 Apr 2020 15:49:49 +0200 Subject: [PATCH] Improve Musicbot --- aquabot.py | 2 +- cogs/anime.py | 6 +- cogs/general.py | 5 +- cogs/meme.py | 20 +++ cogs/music.py | 411 ++++++++++++++++++++++++++++++++++++++++++++++ cogs/voice_new.py | 140 ---------------- cogs/welcome.py | 3 +- config/cogs.py | 4 +- config/media.py | 5 - config/memes.py | 5 + 10 files changed, 444 insertions(+), 157 deletions(-) create mode 100644 cogs/meme.py create mode 100644 cogs/music.py delete mode 100644 cogs/voice_new.py create mode 100644 config/memes.py diff --git a/aquabot.py b/aquabot.py index 2a5bfd1..29cdb0b 100644 --- a/aquabot.py +++ b/aquabot.py @@ -32,7 +32,7 @@ import loadconfig # INIT THE BOT bot = commands.Bot( command_prefix=loadconfig.__prefix__, - description="Holy Goddess Aqua! - for further help on a command, use the argument `list`") + description="Holy Goddess Aqua!") # LOAD COGS SPECIFIED IN 'config/cogs.py' for cog in loadconfig.__cogs__: diff --git a/cogs/anime.py b/cogs/anime.py index 6caf0ca..b4bd4ef 100644 --- a/cogs/anime.py +++ b/cogs/anime.py @@ -29,13 +29,13 @@ class Anime(commands.Cog): media = random.choice(media_type) await ctx.send(media) - @commands.command(name="animegirl") - async def girlmedia(self, ctx, query: str): + @commands.command(name="animegirl", aliases=["waifu"]) + async def girlmedia(self, ctx, name: str): """ Sends a random picture or gif of an Anime girl """ # config/media.py - girl = query.capitalize() + girl = name.capitalize() if girl == "List": girl_list = "" diff --git a/cogs/general.py b/cogs/general.py index 12df62d..13d8764 100644 --- a/cogs/general.py +++ b/cogs/general.py @@ -29,12 +29,9 @@ class General(commands.Cog): embed.add_field(name="Owner", value=self.bot.AppInfo.owner, inline=True) embed.add_field(name="Command Prefix", value=loadconfig.__prefix__, inline=True) - embed.add_field(name="Discord.py Version", value=discord.__version__, inline=True) - embed.add_field(name="Python Version", value=platform.python_version(), inline=True) - embed.add_field(name="OS", value=f"{platform.system()} {platform.release()} {platform.version()}", inline=True) footer_text = ( - "This Bot is an OpenSource project by Marc and can be found " + "This Bot is a project by MarcMK and can be found " "on github.com/CramMK/aquabot") embed.set_footer(text=footer_text, icon_url=loadconfig.__avatar__) diff --git a/cogs/meme.py b/cogs/meme.py new file mode 100644 index 0000000..8df5de1 --- /dev/null +++ b/cogs/meme.py @@ -0,0 +1,20 @@ +""" +Send spicy memes to chat + +https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html +""" + +# IMPORTS +import discord +from discord.ext import commands + +# COG INIT +class Meme(commands.Cog): + def __init__(self, bot): + self.bot = bot + +# COG BODY + +# COG ENDING +def setup(bot): + bot.add_cog(Meme(bot)) diff --git a/cogs/music.py b/cogs/music.py new file mode 100644 index 0000000..909cca1 --- /dev/null +++ b/cogs/music.py @@ -0,0 +1,411 @@ +""" +Rework the voice commands + +https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html +# https://gist.github.com/vbe0201/ade9b80f2d3b64643d854938d40a0a2d +""" + +# IMPORTS - external +import discord +from discord.ext import commands +import youtube_dl +import functools +import asyncio +import itertools +import math +import random +from async_timeout import timeout + +# COG BODY + +class VoiceError(Exception): + pass + +class YTDLError(Exception): + pass + +class YTDLSource(discord.PCMVolumeTransformer): + YTDL_OPTIONS = { + 'format': 'bestaudio/best', + 'extractaudio': True, + 'audioformat': 'mp3', + 'outtmpl': '%(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', + } + + FFMPEG_OPTIONS = { + 'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5', + 'options': '-vn', + } + + ytdl = youtube_dl.YoutubeDL(YTDL_OPTIONS) + + def __init__(self, ctx: commands.Context, source: discord.FFmpegPCMAudio, *, data: dict, volume: float = 0.5): + super().__init__(source, volume) + + self.requester = ctx.author + self.channel = ctx.channel + self.data = data + + self.uploader = data.get('uploader') + self.uploader_url = data.get('uploader_url') + date = data.get('upload_date') + self.upload_date = date[6:8] + '.' + date[4:6] + '.' + date[0:4] + self.title = data.get('title') + self.thumbnail = data.get('thumbnail') + self.description = data.get('description') + self.duration = self.parse_duration(int(data.get('duration'))) + self.tags = data.get('tags') + self.url = data.get('webpage_url') + self.views = data.get('view_count') + self.likes = data.get('like_count') + self.dislikes = data.get('dislike_count') + self.stream_url = data.get('url') + + def __str__(self): + return '**{0.title}** by **{0.uploader}**'.format(self) + + @classmethod + async def create_source(cls, ctx: commands.Context, search: str, *, loop: asyncio.BaseEventLoop = None): + loop = loop or asyncio.get_event_loop() + + partial = functools.partial(cls.ytdl.extract_info, search, download=False, process=False) + data = await loop.run_in_executor(None, partial) + + if data is None: + raise YTDLError('Couldn\'t find anything that matches `{}`'.format(search)) + + if 'entries' not in data: + process_info = data + else: + process_info = None + for entry in data['entries']: + if entry: + process_info = entry + break + + if process_info is None: + raise YTDLError('Couldn\'t find anything that matches `{}`'.format(search)) + + webpage_url = process_info['webpage_url'] + partial = functools.partial(cls.ytdl.extract_info, webpage_url, download=False) + processed_info = await loop.run_in_executor(None, partial) + + if processed_info is None: + raise YTDLError('Couldn\'t fetch `{}`'.format(webpage_url)) + + if 'entries' not in processed_info: + info = processed_info + else: + info = None + while info is None: + try: + info = processed_info['entries'].pop(0) + except IndexError: + raise YTDLError('Couldn\'t retrieve any matches for `{}`'.format(webpage_url)) + + return cls(ctx, discord.FFmpegPCMAudio(info['url'], **cls.FFMPEG_OPTIONS), data=info) + + @staticmethod + def parse_duration(duration: int): + minutes, seconds = divmod(duration, 60) + hours, minutes = divmod(minutes, 60) + days, hours = divmod(hours, 24) + + duration = [] + if days > 0: + duration.append('{} days'.format(days)) + if hours > 0: + duration.append('{} hours'.format(hours)) + if minutes > 0: + duration.append('{} minutes'.format(minutes)) + if seconds > 0: + duration.append('{} seconds'.format(seconds)) + + return ', '.join(duration) + +class Song: + def __init__(self, source: YTDLSource): + self.source = source + self.requester = source.requester + + def create_embed(self): + title = "Now playing" + description = f"{self.source.title}" + embed = (discord.Embed(title=title, + description=description, + colour=discord.Colour.blue() + )) + embed.add_field(name="Duration", value=self.source.duration) + embed.add_field(name="Requested by", value=self.requester.mention) + embed.set_thumbnail(url=self.source.thumbnail) + + return embed + +class SongQueue(asyncio.Queue): + def __getitem__(self, item): + if isinstance(item, slice): + return list(itertools.islice(self._queue, item.start, item.stop, item.step)) + else: + return self._queue[item] + + def __iter__(self): + return self._queue.__iter__() + + def __len__(self): + return self.qsize() + + def clear(self): + self._queue.clear() + + def shuffle(self): + random.shuffle(self._queue) + + def remove(self, index: int): + del self._queue[index] + +class VoiceState: + def __init__(self, bot: commands.Bot, ctx: commands.Context): + self.bot = bot + self._ctx = ctx + + self.current = None + self.voice = None + self.next = asyncio.Event() + self.songs = SongQueue() + + self._loop = False + self._volume = 0.5 + self.skip_votes = set() + + self.audio_player = bot.loop.create_task(self.audio_player_task()) + + def __del__(self): + self.audio_player.cancel() + + @property + def loop(self): + return self._loop + + @loop.setter + def loop(self, value: bool): + self._loop = value + + @property + def volume(self): + return self._volume + + @volume.setter + def volume(self, value: float): + self._volume = value + + @property + def is_playing(self): + return self.voice and self.current + + async def audio_player_task(self): + while True: + self.next.clear() + + if not self.loop: + # Try to get the next song within 3 minutes. + # If no song will be added to the queue in time, + # the player will disconnect due to performance + # reasons. + try: + async with timeout(180): # 3 minutes + self.current = await self.songs.get() + except asyncio.TimeoutError: + self.bot.loop.create_task(self.stop()) + return + + self.current.source.volume = self._volume + self.voice.play(self.current.source, after=self.play_next_song) + await self.current.source.channel.send(embed=self.current.create_embed()) + + await self.next.wait() + + def play_next_song(self, error=None): + if error: + raise VoiceError(str(error)) + + self.next.set() + + def skip(self): + self.skip_votes.clear() + + if self.is_playing: + self.voice.stop() + + async def stop(self): + self.songs.clear() + + if self.voice: + await self.voice.disconnect() + self.voice = None + +class Music(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.voice_states = {} + + def get_voice_state(self, ctx: commands.Context): + state = self.voice_states.get(ctx.guild.id) + if not state: + state = VoiceState(self.bot, ctx) + self.voice_states[ctx.guild.id] = state + + return state + + def cog_unload(self): + for state in self.voice_states.values(): + self.bot.loop.create_task(state.stop()) + + def cog_check(self, ctx: commands.Context): + if not ctx.guild: + raise commands.NoPrivateMessage('This command can\'t be used in DM channels.') + + return True + + async def cog_before_invoke(self, ctx: commands.Context): + ctx.voice_state = self.get_voice_state(ctx) + + async def cog_command_error(self, ctx: commands.Context, error: commands.CommandError): + await ctx.send('{}'.format(str(error))) + + ############ + # COMMANDS # + ############ + + @commands.command(name="join") + async def join(self, ctx: commands.Context, channel: discord.VoiceChannel = None): + """ + Joins your voice channel + """ + if not channel and not ctx.author.voice: + raise VoiceError("You're not connected to a voice channel!") + + destination = ctx.author.voice.channel + if ctx.voice_state.voice: + await ctx.voice_state.voice.move_to(destination) + return + + ctx.voice_state.voice = await destination.connect() + + @commands.command(name="leave", aliases=["dc"]) + async def leave(self, ctx: commands.Context): + """ + Leaves your voice channel + """ + if not ctx.voice_state.voice: + return await ctx.send("You're not connected to a voice channel!") + + await ctx.voice_state.stop() + del self.voice_states[ctx.guild.id] + + @commands.command(name="now", aliases=["np"]) + async def now(self, ctx: commands.Context): + """ + Shows the current playing song + """ + await ctx.send(embed=voice_state.current.create_embed()) + + @commands.command(name='pause') + async def pause(self, ctx: commands.Context): + """ + Pauses the currently playing song + """ + if not ctx.voice_state.is_playing and ctx.voice_state.voice.is_playing(): + ctx.voice_state.voice.pause() + await ctx.message.add_reaction('⏯') + + @commands.command(name='resume') + async def resume(self, ctx: commands.Context): + """ + Resumes a currently paused song. + """ + if not ctx.voice_state.is_playing and ctx.voice_state.voice.is_paused(): + ctx.voice_state.voice.resume() + await ctx.message.add_reaction('⏯') + + @commands.command(name="skip") + async def skip(self, ctx: commands.Context): + """ + Skips the currently playing song + """ + if not ctx.voice_state.is_playing: + return await ctx.send("Nothing is playing currently!") + + await ctx.message.add_reaction('⏭') + ctx.voice_state.skip() + + @commands.command(name='play') + async def play(self, ctx: commands.Context, *, search: str): + """ + Plays a song + """ + + if not ctx.voice_state.voice: + await ctx.invoke(self.join) + + async with ctx.typing(): + try: + source = await YTDLSource.create_source(ctx, search, loop=self.bot.loop) + except YTDLError as e: + await ctx.send('An error occurred while processing this request: {}'.format(str(e))) + else: + song = Song(source) + + await ctx.voice_state.songs.put(song) + await ctx.send('Enqueued {}'.format(str(source))) + + @join.before_invoke + @play.before_invoke + async def ensure_voice_state(self, ctx: commands.Context): + if not ctx.author.voice or not ctx.author.voice.channel: + raise commands.CommandError('You are not connected to any voice channel.') + + if ctx.voice_client: + if ctx.voice_client.channel != ctx.author.voice.channel: + raise commands.CommandError('Bot is already in a voice channel.') + +# @commands.command(name='loop') +# async def loop(self, ctx: commands.Context): +# """ +# Loops the currently playing song. +# """ +# if not ctx.voice_state.is_playing: +# return await ctx.send('Nothing being played at the moment.') +# +# # Swap states +# ctx.voice_state.loop = not ctx.voice_state.loop +# await ctx.message.add_reaction('✅') + + @commands.command(name="queue") + async def queue(self, ctx: commands.Context): + """ + Shows the queued songs + """ + if len(ctx.voice_state.songs) == 0: + return await ctx.send("The queue is empty.") + + queue = "" + for i, song in enumerate(ctx.voice_state.songs[start:end], start=start): + queue += '`{0}.` [**{1.source.title}**]({1.source.url})\n'.format(i + 1, song) + + embed = (discord.Embed(description='**{} tracks:**\n\n{}'.format(len(ctx.voice_state.songs), queue)) + .set_footer(text='Viewing page {}/{}'.format(page, pages))) + await ctx.send(embed=embed) + + +# COG ENDING +def setup(bot): + bot.add_cog(Music(bot)) diff --git a/cogs/voice_new.py b/cogs/voice_new.py deleted file mode 100644 index 1aba9db..0000000 --- a/cogs/voice_new.py +++ /dev/null @@ -1,140 +0,0 @@ -""" -Rework the voice commands - -https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html - -""" - -# IMPORTS - external -import discord -from discord.ext import commands - -# COG INIT -class voice(commands.Cog): - def __init__(self, bot): - self.bot = bot - -# COG BODY - -class VoiceError(Exception): - pass - -class YTDLError(Exception): - pass - - -# YTDLSource class from: -# https://gist.github.com/vbe0201/ade9b80f2d3b64643d854938d40a0a2d -class YTDLSource(discord.PCMVolumeTransformer): - YTDL_OPTIONS = { - 'format': 'bestaudio/best', - 'extractaudio': True, - 'audioformat': 'mp3', - 'outtmpl': '%(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', - } - - FFMPEG_OPTIONS = { - 'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5', - 'options': '-vn', - } - - ytdl = youtube_dl.YoutubeDL(YTDL_OPTIONS) - - def __init__(self, ctx: commands.Context, source: discord.FFmpegPCMAudio, *, data: dict, volume: float = 0.5): - super().__init__(source, volume) - - self.requester = ctx.author - self.channel = ctx.channel - self.data = data - - self.uploader = data.get('uploader') - self.uploader_url = data.get('uploader_url') - date = data.get('upload_date') - self.upload_date = date[6:8] + '.' + date[4:6] + '.' + date[0:4] - self.title = data.get('title') - self.thumbnail = data.get('thumbnail') - self.description = data.get('description') - self.duration = self.parse_duration(int(data.get('duration'))) - self.tags = data.get('tags') - self.url = data.get('webpage_url') - self.views = data.get('view_count') - self.likes = data.get('like_count') - self.dislikes = data.get('dislike_count') - self.stream_url = data.get('url') - - def __str__(self): - return '**{0.title}** by **{0.uploader}**'.format(self) - - @classmethod - async def create_source(cls, ctx: commands.Context, search: str, *, loop: asyncio.BaseEventLoop = None): - loop = loop or asyncio.get_event_loop() - - partial = functools.partial(cls.ytdl.extract_info, search, download=False, process=False) - data = await loop.run_in_executor(None, partial) - - if data is None: - raise YTDLError('Couldn\'t find anything that matches `{}`'.format(search)) - - if 'entries' not in data: - process_info = data - else: - process_info = None - for entry in data['entries']: - if entry: - process_info = entry - break - - if process_info is None: - raise YTDLError('Couldn\'t find anything that matches `{}`'.format(search)) - - webpage_url = process_info['webpage_url'] - partial = functools.partial(cls.ytdl.extract_info, webpage_url, download=False) - processed_info = await loop.run_in_executor(None, partial) - - if processed_info is None: - raise YTDLError('Couldn\'t fetch `{}`'.format(webpage_url)) - - if 'entries' not in processed_info: - info = processed_info - else: - info = None - while info is None: - try: - info = processed_info['entries'].pop(0) - except IndexError: - raise YTDLError('Couldn\'t retrieve any matches for `{}`'.format(webpage_url)) - - return cls(ctx, discord.FFmpegPCMAudio(info['url'], **cls.FFMPEG_OPTIONS), data=info) - - @staticmethod - def parse_duration(duration: int): - minutes, seconds = divmod(duration, 60) - hours, minutes = divmod(minutes, 60) - days, hours = divmod(hours, 24) - - duration = [] - if days > 0: - duration.append('{} days'.format(days)) - if hours > 0: - duration.append('{} hours'.format(hours)) - if minutes > 0: - duration.append('{} minutes'.format(minutes)) - if seconds > 0: - duration.append('{} seconds'.format(seconds)) - - return ', '.join(duration) - # End of copy pasta - - -# COG ENDING -def setup(bot): - bot.add_cog(voice(bot)) diff --git a/cogs/welcome.py b/cogs/welcome.py index 3f37194..6d260f1 100644 --- a/cogs/welcome.py +++ b/cogs/welcome.py @@ -23,10 +23,9 @@ class Welcome(commands.Cog): Greets new users joining your server """ channel = member.guild.system_channel - text = f"Welcome {member.mention} to our useless Discord!" + text = f"Welcome {member.mention} to our Discord!" if channel is not None: await channel.send(text) - await message.add_reaction("\N{THUMBS UP SIGN}") @commands.command(name="hello") async def hello(self, ctx): diff --git a/config/cogs.py b/config/cogs.py index fcf42e8..7a8b678 100644 --- a/config/cogs.py +++ b/config/cogs.py @@ -11,5 +11,5 @@ __cogs__ = [ "cogs.help", "cogs.utility", "cogs.anime", - "cogs.voice" - ] \ No newline at end of file + "cogs.music" + ] diff --git a/config/media.py b/config/media.py index 78f406b..f8e98ba 100644 --- a/config/media.py +++ b/config/media.py @@ -46,8 +46,3 @@ __media_girl__ = { "Akeno": girl_akeno, "Rem": girl_rem } - -__olli_memes__ = [ - "https://i.imgflip.com/3xpkiv.jpg", - "https://cdn.discordapp.com/attachments/541637988120133634/702992838702399518/grave.png", - ] diff --git a/config/memes.py b/config/memes.py new file mode 100644 index 0000000..c50c280 --- /dev/null +++ b/config/memes.py @@ -0,0 +1,5 @@ +# Meme list +__olli_memes__ = [ + "https://i.imgflip.com/3xpkiv.jpg", + "https://cdn.discordapp.com/attachments/541637988120133634/702992838702399518/grave.png", + ]