diff --git a/.env b/.env index 32a0035..c26d61e 100644 --- a/.env +++ b/.env @@ -1 +1,3 @@ -discord_token = "add_bot_token_here" \ No newline at end of file +DISCORD_TOKEN = "add_bot_token_here" +MONGO_URI = "mongodb://mongodb:27017" +BOT_OWNER_USER_NAME = "add bot owner user name here example: name#0001" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5391d87..8034918 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ __pycache__/ *.py[cod] *$py.class - +.idea/ # C extensions *.so diff --git a/Dizplayer.py b/Dizplayer.py index dc6f08f..a27bb3e 100644 --- a/Dizplayer.py +++ b/Dizplayer.py @@ -1,18 +1,19 @@ import os import discord -import nest_asyncio from discord.ext import commands from dotenv import load_dotenv - from Listener import ListenerCog -from Music import MusicPlayerCog +import pymongo +import datetime + +from Music import Music -nest_asyncio.apply() load_dotenv() # Get the API token from the .env file. -DISCORD_TOKEN = os.getenv("discord_token") -print(DISCORD_TOKEN) +BOT_OWNER_USER_NAME = os.getenv("BOT_OWNER_USER_NAME") +DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") +MONGO_URI = os.getenv("MONGO_URI") initial_extensions = ['cogs.listener', 'cogs.music'] @@ -20,7 +21,39 @@ intents = discord.Intents().all() bot = commands.Bot(command_prefix='!', intents=intents) +mongo_client = pymongo.MongoClient(MONGO_URI) +bot.mongo_db = mongo_client.youtube_streamer + + +@bot.event +async def on_guild_join(guild): + guilds = bot.mongo_db.guilds + this_guild = guilds.find_one({"guild_id": guild.id}) + if this_guild: + return + + inserted = guilds.insert_one({ + "guild_id": guild.id, + "name": guild.name, + "volume": 5, + "authorized": False, + "added_by": { + "name": str(guild.owner), + "id": guild.owner.id + }, + "created_at": datetime.datetime.utcnow() + }) + + guild_owner = await bot.fetch_user(guild.owner.id) + await guild_owner.send(f"You recently added me to {guild.name}. I cannot be used until authorized by whoever is running the bot. Please contact the bot admin to request authorization for your server.") + + owner_name = os.getenv("BOT_OWNER_USER_NAME").split('#')[0] + discriminator = os.getenv("BOT_OWNER_USER_NAME").split('#')[1] + bot_owner = discord.utils.get(bot.get_all_members(), name=owner_name, discriminator=discriminator) + await bot_owner.send(f"A new Guild is attempting to use the bot {guild.name} ({guild.id})\n" + f"Guild owner contact: {str(guild.owner)} ({guild.owner.id})") + if __name__ == "__main__": - bot.add_cog(MusicPlayerCog(bot)) + bot.add_cog(Music(bot)) bot.add_cog(ListenerCog(bot)) bot.run(DISCORD_TOKEN) diff --git a/Dockerfile b/Dockerfile index d363c65..d9b2f8b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,8 +4,7 @@ WORKDIR /app COPY ./requirements.txt /app ENV PYTHONUNBUFFERED 1 - +RUN apt-get -y update +RUN apt-get install -y ffmpeg RUN pip3 install --no-cache-dir -r requirements.txt -COPY . . - -CMD ["python", "Dizplayer.py"] \ No newline at end of file +COPY . . \ No newline at end of file diff --git a/Music.py b/Music.py index ba1a39b..c6689c5 100644 --- a/Music.py +++ b/Music.py @@ -1,215 +1,626 @@ -import asyncio -import collections - import discord +from discord import Option from discord.ext import commands +import random +import asyncio +import itertools +import sys +import traceback +from async_timeout import timeout +from functools import partial +import youtube_dl +from youtube_dl import YoutubeDL + +from decorators import copy_doc + + +# Suppress noise about console usage from errors +youtube_dl.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 +} + +ffmpegopts = { + 'before_options': '-nostdin', + 'options': '-vn' +} + +ytdl = YoutubeDL(ytdlopts) + + +class VoiceConnectionError(commands.CommandError): + """Custom Exception class for connection errors.""" + + +class InvalidVoiceChannel(VoiceConnectionError): + """Exception for cases of invalid Voice Channels.""" + + +class GuildNotAuthorized(VoiceConnectionError): + """This guild is not authorized to use the bot""" + + +class YTDLSource(discord.PCMVolumeTransformer): + + def __init__(self, source, *, data, requester): + super().__init__(source) + self.requester = requester + + self.title = data.get('title') + self.web_url = data.get('webpage_url') + self.duration = data.get('duration') + + # YTDL info dicts (data) have other useful information you might want + # https://github.com/rg3/youtube-dl/blob/master/README.md + + def __getitem__(self, item: str): + """Allows us to access attributes similar to a dict. + This is only useful when you are NOT downloading. + """ + return self.__getattribute__(item) + + @classmethod + async def create_source(cls, ctx, search: str, *, loop, download=False): + loop = loop or asyncio.get_event_loop() + + to_run = partial(ytdl.extract_info, url=search, download=download) + data = await loop.run_in_executor(None, to_run) + + if 'entries' in data: + # take first item from a playlist + data = data['entries'][0] + + embed = discord.Embed(title="", + description=f"Queued [{data['title']}]({data['webpage_url']}) [{ctx.author.mention}]", + color=discord.Color.green()) + await ctx.send(embed=embed) + + if download: + source = ytdl.prepare_filename(data) + else: + return {'webpage_url': data['webpage_url'], 'requester': ctx.author, 'title': data['title']} + + return cls(discord.FFmpegPCMAudio(source), data=data, requester=ctx.author) + + @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) -from Track import Track -from YTDL import YTDLSource -song_queue = collections.deque() -history = collections.deque() -currentVoiceClient = None -currentVoiceChannel = None -currentSongData = None -guildTextChannel = None -currentTrack = None -is_playing = False -runningTask = None +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. + """ + __slots__ = ('bot', '_guild', '_channel', '_cog', 'queue', 'next', 'current', 'np', 'volume', 'ctx', 'repeat', 'current_source') -def get_or_create_event_loop(): - try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - return loop + def __init__(self, ctx): + self.bot = ctx.bot + self._guild = ctx.guild + self._channel = ctx.channel + self._cog = ctx.cog + self.queue = asyncio.Queue() + self.next = asyncio.Event() -def add_songs_to_song_queue(data): - for song in data: - song_queue.appendleft(Track(song['artist'] + ' - ' + song['title'], song['webpage_url'])) + self.np = None # Now playing message + self.volume = .5 + self.current = None + self.ctx = ctx + self.repeat = False + self.current_source = None + ctx.bot.loop.create_task(self.player_loop()) -class MusicPlayerCog(commands.Cog): + async def player_loop(self): + """Our main player loop.""" + await self.bot.wait_until_ready() + + while not self.bot.is_closed(): + self.next.clear() + + 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() + except asyncio.TimeoutError: + return self.destroy(self._guild) + self.current_source = source + if not isinstance(source, YTDLSource): + # Source was probably a stream (not downloaded) + # So we should regather to prevent stream expiration + try: + gathered_source = await YTDLSource.regather_stream(source, loop=self.bot.loop) + except Exception as e: + await self.ctx.send(f'There was an error processing your song.\n' + f'```css\n[{e}]\n```') + continue + + gathered_source.volume = self.volume + self.current = gathered_source + if self.repeat: + # new_source = await YTDLSource.create_source(self.ctx, source['search'], loop=self.bot.loop, download=False) + # new_source.search = search + self.queue._queue.appendleft(source) + self._guild.voice_client.play(gathered_source, after=lambda _: self.bot.loop.call_soon_threadsafe(self.next.set)) + embed = discord.Embed(title="Now playing", + description=f"[{gathered_source.title}]({gathered_source.web_url}) [{gathered_source.requester.mention}]", + color=discord.Color.green()) + self.np = await self.ctx.send(embed=embed) + await self.next.wait() + + # Make sure the FFmpeg process is cleaned up. + gathered_source.cleanup() + self.current = None + + def destroy(self, guild): + """Disconnect and cleanup the player.""" + return self.bot.loop.create_task(self._cog.cleanup(guild)) + + +class Music(commands.Cog): + """Music related commands.""" + + __slots__ = ('bot', 'players') def __init__(self, bot): self.bot = bot + self.players = {} + self.bot.application_command(name="join", cls=discord.SlashCommand)(self.join_slash) + self.bot.application_command(name="play", cls=discord.SlashCommand)(self.play_slash) + self.bot.application_command(name="pause", cls=discord.SlashCommand)(self.pause_slash) + self.bot.application_command(name="resume", cls=discord.SlashCommand)(self.resume_slash) + self.bot.application_command(name="skip", cls=discord.SlashCommand)(self.skip_slash) + self.bot.application_command(name="volume", cls=discord.SlashCommand)(self.volume_slash) + self.bot.application_command(name="queue", cls=discord.SlashCommand)(self.queue_slash) + self.bot.application_command(name="now_playing", cls=discord.SlashCommand)(self.now_playing_slash) + self.bot.application_command(name="repeat", cls=discord.SlashCommand)(self.repeat_slash) + self.bot.application_command(name="remove", cls=discord.SlashCommand)(self.remove_slash) + self.bot.application_command(name="clear", cls=discord.SlashCommand)(self.clear_slash) + self.bot.application_command(name="leave", cls=discord.SlashCommand)(self.leave_slash) - # region bot.commands - - @commands.command(name='play', help='To play song') - async def play(self, ctx, url=None): - global history - global currentVoiceClient - global currentVoiceChannel - global guildTextChannel - global currentTrack - global is_playing - - if currentTrack: - history.append(currentTrack) - currentTrack = None - if url is None: - if song_queue: - track = song_queue.pop() - currentTrack = track - url = track.url - else: - await ctx.send('Nothing to play') - return + + + async def cleanup(self, guild): try: - await self.join(ctx) - if ctx is not None and ctx.message.guild.voice_client is not None and ctx.message.guild.voice_channels[ - 0] is not None: - currentVoiceClient = ctx.message.guild.voice_client - currentVoiceChannel = ctx.message.guild.voice_channels[0] - for text_channel in ctx.message.guild.text_channels: - if str(text_channel) == "bot-control": - guildTextChannel = text_channel - - await self.download_song_data(url) - - except RuntimeError as err: - print(f"Unexpected {err=}, {type(err)=}") - - @commands.command(name='add', help='Adds a track to the queue') - async def add_song(self, ctx, url): - global currentTrack + await guild.voice_client.disconnect() + except AttributeError: + pass + + try: + del self.players[guild.id] + except KeyError: + pass + + async def __local_check(self, ctx): + """A local check which applies to all commands in this cog.""" + 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.""" + if isinstance(error, commands.NoPrivateMessage): + try: + 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') + + print('Ignoring exception in command {}:'.format(ctx.command), file=sys.stderr) + traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr) + + async def get_player(self, ctx): + """Retrieve the guild player, or generate one.""" try: - async with ctx.typing(): - file_data = await YTDLSource.from_url(url, loops=self.bot.loop) - # If there is a playlist: - if type(file_data) is list: - add_songs_to_song_queue(file_data) - # If single track: - else: - song_queue.appendleft( - Track(file_data['artist'] + ' - ' + file_data['title'], file_data['webpage_url'])) - await self.print_queue(ctx) - except: - await ctx.send("Some error occurred while accessing ytdl") - - @commands.command(name='queue', help='Prints queue and previous songs') - async def print_queue(self, ctx): - s = "" - s += "-------------Previous-------------\n" - if len(history) > 0: - for track in history: - s += track.filename + "\n" - if currentTrack: - s += "--------------Playing-------------\n" - s += "**" + currentTrack.filename + "** \n" - s += "-------------In Queue-------------\n" - # Reversed for songs to upper in order: next song on toppy - for track in reversed(song_queue): - s += track.filename + "\n" - - await ctx.send(s) - - @commands.command(name='next', help='') - async def next_song(self, ctx): - await self.play(ctx) - - @commands.command(name='prev', help='') - async def prev(self, ctx): - global song_queue - track = history.pop() - song_queue.append(currentTrack) - await self.download_song_data(track.url) - - @commands.command(name='join', help='Tells the bot to join the voice channel') - async def join(self, ctx): - if ctx is None: + player = self.players[ctx.guild.id] + except KeyError: + guilds = self.bot.mongo_db.guilds + this_guild = guilds.find_one({"guild_id": ctx.guild.id}) + + if not this_guild or not this_guild['authorized']: + embed = discord.Embed(title="Error", + description=f'{ctx.guild.name} ({ctx.guild.id}) has not been authorized to use the streamer. Please request authorization.', color=discord.Color.green()) + await ctx.send(embed=embed) + raise GuildNotAuthorized( + f'{ctx.guild.name} ({ctx.guild.id}) has not been authorized to use the streamer. Please request authorization.') + + player = MusicPlayer(ctx) + self.players[ctx.guild.id] = player + default_volume_percentage = this_guild['volume'] + player.volume = default_volume_percentage / 100 + embed = discord.Embed(title="", description=f'**Player Spun Up** setting the default volume to **{default_volume_percentage}%**', + color=discord.Color.green()) + await ctx.send(embed=embed) + return player + + @commands.command(name='join', aliases=['connect', 'j'], description="connects to voice") + async def connect_(self, ctx, *, channel: discord.VoiceChannel = None): + """Connect to voice. + Parameters + ------------ + channel: discord.VoiceChannel [Optional] + The channel to connect to. If a channel is not specified, an attempt to join the voice channel you are in + will be made. + This command also handles moving the bot to different channels. + """ + if not channel: + 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()) + await ctx.send(embed=embed) + raise InvalidVoiceChannel('No channel to join. Please either specify a valid channel or join one.') + + vc = ctx.voice_client + + if vc: + if vc.channel.id == channel.id: + return + try: + await vc.move_to(channel) + except asyncio.TimeoutError: + 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.') + + await ctx.send(f'**Joined `{channel}`**') + + async def join_slash(self, + ctx: commands.Context, + channel: Option(discord.VoiceChannel, + description="The channel to connect to. By default, the bot will attempt to join your current voice channel.", + required=False, + default=None)): + """Connect the bot to a voice channel""" + await ctx.respond(f'Request to join {channel} received. Processing...', ephemeral=True) + await self.connect_(ctx, channel=channel) + + @commands.command(name='play', aliases=['sing', 'p'], description="streams music") + async def play_(self, ctx, *, search: str): + """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. + + Args: + search (str): The song to search and retrieve using YTDL. This could be a simple search, an ID or URL. + """ + await ctx.trigger_typing() + + vc = ctx.voice_client + + if not vc: + await ctx.invoke(self.connect_) + + player = await self.get_player(ctx) + + # If download is False, source will be a dict which will be used later to regather the stream. + # If download is True, source will be a discord.FFmpegPCMAudio with a VolumeTransformer. + source = await YTDLSource.create_source(ctx, search, loop=self.bot.loop, download=False) + + await player.queue.put(source) + + async def play_slash(self, + ctx: commands.Context, + *, + search: Option(str, + description="The song to search and retrieve. This could be a simple search, an ID or URL.", + required=True)): + """Request a song and add it to the queue.""" + await ctx.respond(f'Request to play {search} received. Processing...', ephemeral=True) + await self.play_(ctx, search=search) + + @commands.command(name='repeat', description="repeats song until called again") + async def repeat_(self, ctx): + """Repeat the currently paused song.""" + player = await self.get_player(ctx) + player.repeat = not player.repeat + if not player.repeat: + player.queue._queue.popleft() + if player.repeat: + player.queue._queue.appendleft(player.current_source) + + await ctx.send(f"Repeat 🔁️ toggled to {player.repeat}") + + async def repeat_slash(self, ctx): + """Repeat the current song until 'repeat' called again.""" + await ctx.respond(f'Toggling repeat...', ephemeral=True) + await self.repeat_(ctx) + + @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()) + return await ctx.send(embed=embed) + elif vc.is_paused(): return - if not ctx.message.author.voice: - await ctx.send("{} is not connected to a voice channel".format(ctx.message.author.name)) + + vc.pause() + await ctx.send("Paused ⏸️") + + async def pause_slash(self, ctx): + """Pause the currently playing song.""" + await ctx.respond(f'Pausing...', ephemeral=True) + await self.pause_(ctx) + + @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()) + return await ctx.send(embed=embed) + elif not vc.is_paused(): return - if ctx.message.guild.voice_client: + + vc.resume() + await ctx.send("Resuming ⏯️") + + async def resume_slash(self, ctx): + """Resume the currently paused song.""" + await ctx.respond(f'Attempting to resume...', ephemeral=True) + await self.resume_(ctx) + + @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()) + return await ctx.send(embed=embed) + + if vc.is_paused(): + pass + elif not vc.is_playing(): return - else: - channel = ctx.message.author.voice.channel - await channel.connect() - - @commands.command(name='pause', help='This command pauses the song') - async def pause(self, ctx): - voice_client = ctx.message.guild.voice_client - if voice_client.is_playing(): - voice_client.pause() - else: - await ctx.send("The bot is not playing anything at the moment.") - @commands.command(name='resume', help='Resumes the song') - async def resume(self, ctx): - voice_client = ctx.message.guild.voice_client - if voice_client.is_paused(): - voice_client.resume() - else: - await ctx.send("The bot was not playing anything before this. Use play_song command") - - @commands.command(name='leave', help='To make the bot leave the voice channel') - async def leave(self, ctx): - voice_client = ctx.message.guild.voice_client - if voice_client.is_connected(): - song_queue.clear() - history.clear() - await voice_client.disconnect() + vc.stop() + + async def skip_slash(self, ctx): + """Skip the song.""" + await ctx.respond(f'Attempting to skip...', ephemeral=True) + await self.skip_(ctx) + + @commands.command(name='remove', aliases=['rm', 'rem'], 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()) + return await ctx.send(embed=embed) + + player = await self.get_player(ctx) + if pos == None: + player.queue._queue.pop() else: - await ctx.send("The bot is not connected to a voice channel.") - - @commands.command(name='stop', help='Stops the song') - async def stop(self, ctx): - voice_client = ctx.message.guild.voice_client - global is_playing - global currentTrack - if voice_client.is_playing(): - voice_client.stop() - is_playing = False - history.append(currentTrack) - currentTrack = None - runningTask.cancel() + 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()) + await ctx.send(embed=embed) + except: + embed = discord.Embed(title="", description=f'Could not find a track for "{pos}"', + color=discord.Color.green()) + await ctx.send(embed=embed) + + async def remove_slash(self, ctx, pos: Option(int, + description="The position of the song in the queue to remove.", + required=True)): + """Removes specified song from queue""" + await ctx.respond(f'Attempting to remove song at position {pos}...', ephemeral=True) + await self.remove_(ctx, pos=pos) + + @commands.command(name='clear', aliases=['clr', 'cl', 'cr'], description="clears entire queue") + async def clear_(self, ctx): + """Deletes entire queue of upcoming songs.""" + + 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()) + return await ctx.send(embed=embed) + + player = await self.get_player(ctx) + player.queue._queue.clear() + await ctx.send('**Cleared**') + + async def clear_slash(self, ctx): + """Empties the queue.""" + await ctx.respond(f'Attempting to clear the queue...', ephemeral=True) + await self.clear_(ctx) + + @commands.command(name='queue', aliases=['q', 'playlist', 'que'], description="shows the queue") + async def queue_info(self, ctx): + """Retrieve a basic queue of upcoming songs.""" + 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()) + return await ctx.send(embed=embed) + + player = await self.get_player(ctx) + if player.queue.empty(): + embed = discord.Embed(title="", description="queue is empty", color=discord.Color.green()) + return await ctx.send(embed=embed) + + seconds = vc.source.duration % (24 * 3600) + hour = seconds // 3600 + seconds %= 3600 + minutes = seconds // 60 + seconds %= 60 + if hour > 0: + duration = "%dh %02dm %02ds" % (hour, minutes, seconds) else: - await ctx.send("The bot is not playing anything at the moment.") + duration = "%02dm %02ds" % (minutes, seconds) + + # Grabs the songs in the queue... + upcoming = list(itertools.islice(player.queue._queue, 0, int(len(player.queue._queue)))) + fmt = '\n'.join( + f"`{(upcoming.index(_)) + 1}.` [{_['title']}]({_['webpage_url']}) | ` {duration} Requested by: {_['requester']}`\n" + for _ in upcoming) + fmt = f"\n__Now Playing__:\n[{vc.source.title}]({vc.source.web_url}) | ` {duration} Requested by: {vc.source.requester}`\n\n__Up Next:__\n" + fmt + f"\n**{len(upcoming)} songs in queue**" + embed = discord.Embed(title=f'Queue for {ctx.guild.name}', description=fmt, color=discord.Color.green()) + embed.set_footer(text=f"{ctx.author.display_name}", icon_url=ctx.author.avatar.url) + + await ctx.send(embed=embed) + + async def queue_slash(self, ctx): + """Describes the queue of upcoming songs.""" + await ctx.respond(f'Attempting to describe the queue...', ephemeral=True) + await self.queue_info(ctx) - # endregion + @commands.command(name='np', aliases=['song', 'current', 'currentsong', 'playing'], + description="shows the current playing song") + async def now_playing_(self, ctx): + """Display information about the currently playing song.""" + vc = ctx.voice_client - async def download_song_data(self, url): - data = await YTDLSource.from_url(url, loops=self.bot.loop) - if type(data) is list: # If playlist - add_songs_to_song_queue(data[1:]) - await self.play_song(data[0]) + 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()) + return await ctx.send(embed=embed) + + player = await self.get_player(ctx) + if not player.current: + embed = discord.Embed(title="", description="I am currently not playing anything", + color=discord.Color.green()) + return await ctx.send(embed=embed) + + seconds = vc.source.duration % (24 * 3600) + hour = seconds // 3600 + seconds %= 3600 + minutes = seconds // 60 + seconds %= 60 + if hour > 0: + duration = "%dh %02dm %02ds" % (hour, minutes, seconds) else: - await self.play_song(data) - return data - - async def play_song(self, song): - global currentTrack, is_playing, currentVoiceClient, runningTask - file_name = song['artist'] + ' - ' + song['title'] - duration = song['duration'] - currentTrack = Track(file_name, song['webpage_url']) - audio_stream = discord.FFmpegPCMAudio(executable="ffmpeg.exe", source=song['url'], options='-vn', - before_options="-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5") - print('Song Duration: ', duration) - is_playing = True - await guildTextChannel.send('Playing **' + file_name + '**') - currentVoiceClient.stop() - currentVoiceClient.play(audio_stream, after=lambda e: print('Player error: %s' % e) if e else None) - if runningTask: - runningTask.cancel() - runningTask = None - runningTask = asyncio.create_task(self.play_next_on_end(duration)) - await runningTask - - async def play_next_on_end(self, duration): - try: - await asyncio.sleep(duration) - except asyncio.CancelledError: - print('cancel sleep') - return - print('End sleep, playing next') - if song_queue: - history.append(currentTrack) - track = song_queue.pop() - loop = get_or_create_event_loop() - loop.create_task(self.download_song_data(track.url)) + duration = "%02dm %02ds" % (minutes, seconds) + + embed = discord.Embed(title="", + description=f"[{vc.source.title}]({vc.source.web_url}) [{vc.source.requester.mention}] | `{duration}`", + color=discord.Color.green()) + embed.set_author(icon_url=self.bot.user.display_avatar.url, name=f"Now Playing 🎶") + await ctx.send(embed=embed) + + async def now_playing_slash(self, ctx): + """Display information about the currently playing song.""" + await ctx.respond(f'Attempting to report now playing...', ephemeral=True) + await self.now_playing_(ctx) + + @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. + """ + 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()) + return await ctx.send(embed=embed) + + if not vol: + 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()) + return await ctx.send(embed=embed) + + player = await self.get_player(ctx) + + if vc.source: + 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()) + + #store new volume on guild in mongo + query = {"guild_id": ctx.guild.id} + new_value = {"$set": {"volume": vol}} + guilds = self.bot.mongo_db.guilds + guilds.update_one(query, new_value) + + await ctx.send(embed=embed) + + async def volume_slash(self, ctx, *, vol: Option(float, + description="Volume level. Default is 5.", + required=True, + min_value=0, + max_value=100)): + """Change the player volume. A little goes a long way.""" + await ctx.respond(f'Attempting to change volume to {vol}...', ephemeral=True) + await self.change_volume(ctx, vol) + + @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. + """ + 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()) + return await ctx.send(embed=embed) + + await ctx.send('**Successfully disconnected**') + + await self.cleanup(ctx.guild) + + async def leave_slash(self, ctx): + """Stop the currently playing song and destroy the player.""" + await ctx.respond(f'Attempting to destroy the player...', ephemeral=True) + await self.leave_(ctx) + diff --git a/README.md b/README.md new file mode 100644 index 0000000..1980c41 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +#Run + +`DISCORD_TOKEN= BOT_OWNER_USER_NAME= docker-compose up` + +## All valid env vars +DISCORD_TOKEN +MONGO_URI +BOT_OWNER_USER_NAME + +#Authorize Guilds + +Whenever a new server tries to run the bot, it must be explicitly authorized. Connect to the mongo db instance using +mongo compass, retrieve the doc for the server in youtube_streamer.guilds, and set `authorized` to `True`. \ No newline at end of file diff --git a/decorators.py b/decorators.py new file mode 100644 index 0000000..829ce6f --- /dev/null +++ b/decorators.py @@ -0,0 +1,9 @@ +from typing import Callable + + +def copy_doc(copy_func: Callable) -> Callable: + """Use Example: copy_doc(self.copy_func)(self.func) or used as deco""" + def wrapper(func: Callable) -> Callable: + func.__doc__ = copy_func.__doc__ + return func + return wrapper \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..077daed --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: "3.9" +services: + bot: + build: . + environment: + DISCORD_TOKEN: ${DISCORD_TOKEN} + BOT_OWNER_USER_NAME: ${BOT_OWNER_USER_NAME} + PYTHONPATH: "." + command: python3 Dizplayer.py + mongodb: + image: mongo:6.0.3 + ports: + - "27017:27017" + volumes: + - mongo_data:/data/db +volumes: + mongo_data: \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ad195a7..ac7e8f3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -discord==1.0.1 -discord.py==1.6.0 +py-cord==2.3.2 python-dotenv==0.15.0 -youtube-dl==2021.2.10 -PyNaCl==1.4.0 -nest-asyncio==1.5.4 \ No newline at end of file +youtube-dl==2021.12.17 +PyNaCl==1.5.0 +nest-asyncio==1.5.6 +pymongo==4.3.2 \ No newline at end of file