diff --git a/Pipfile b/Pipfile index d1124a4..1ef211a 100644 --- a/Pipfile +++ b/Pipfile @@ -11,7 +11,13 @@ python_version = "3.9" [packages] requests = "*" selenium = "*" -py-cord = {git = "https://github.com/Pycord-Development/pycord.git", editable = true, ref = "master"} pillow = "*" asyncpraw = "*" markovify = "*" +py-cord = {version = "==2.0.0b7", extras = ["voice"]} +pynacl = "*" +youtube-dl = "*" +youtube-search-python = "*" + +[scripts] +bot = "python3 bot.py" diff --git a/Pipfile.lock b/Pipfile.lock index bc0db5d..b3225f3 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "18b9b0fb1f7bbe783986528bcb59c901c992f0458d4c79e39bb999406bee468e" + "sha256": "1df6a057b021dcac89b1117bd7f487f7c25b911bcac87e882caaf2e158ab5b6f" }, "pipfile-spec": 6, "requires": { @@ -117,6 +117,14 @@ "markers": "python_version >= '3.6'", "version": "==0.17.0" }, + "anyio": { + "hashes": [ + "sha256:a0aeffe2fb1fdf374a8e4b471444f0f3ac4fb9f5a5b542b48824475e0042a5a6", + "sha256:b5fa16c5ff93fa1046f2eeb5bbff2dad4d3514d6cda61d02816dba34fa8c3c2e" + ], + "markers": "python_full_version >= '3.6.2'", + "version": "==3.5.0" + }, "async-generator": { "hashes": [ "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b", @@ -321,16 +329,32 @@ "sha256:f96293d6f982c58ebebb428c50163d010c2f05de0cde99fd681bfdc18d4b2dc2", "sha256:ff9310f05b9d9c5c4dd472983dc956901ee6cb2c3ec1ab116ecdde25f3ce4951" ], - "markers": "python_full_version >= '3.7.0'", + "markers": "python_version >= '3.7'", "version": "==1.3.0" }, "h11": { "hashes": [ - "sha256:70813c1135087a248a4d38cc0e1a0181ffab2188141a93eaf567940c3957ff06", - "sha256:8ddd78563b633ca55346c8cd41ec0af27d3c79931828beffb46ce70a379e7442" + "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", + "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" ], "markers": "python_version >= '3.6'", - "version": "==0.13.0" + "version": "==0.12.0" + }, + "httpcore": { + "hashes": [ + "sha256:47d772f754359e56dd9d892d9593b6f9870a37aeb8ba51e9a88b09b3d68cfade", + "sha256:7503ec1c0f559066e7e39bc4003fd2ce023d01cf51793e3c173b864eb456ead1" + ], + "markers": "python_version >= '3.6'", + "version": "==0.14.7" + }, + "httpx": { + "hashes": [ + "sha256:d8e778f76d9bbd46af49e7f062467e3157a5a3d2ae4876a4bbfd8a51ed9c9cb4", + "sha256:e35e83d1d2b9b2a609ef367cc4c1e66fd80b750348b20cc9e19d1952fc2ca3f6" + ], + "markers": "python_version >= '3.6'", + "version": "==0.22.0" }, "idna": { "hashes": [ @@ -409,7 +433,7 @@ "sha256:feba80698173761cddd814fa22e88b0661e98cb810f9f986c54aa34d281e4937", "sha256:feea820722e69451743a3d56ad74948b68bf456984d63c1a92e8347b7b88452d" ], - "markers": "python_full_version >= '3.7.0'", + "markers": "python_version >= '3.7'", "version": "==6.0.2" }, "outcome": { @@ -465,9 +489,15 @@ "version": "==9.1.0" }, "py-cord": { - "editable": true, - "git": "https://github.com/Pycord-Development/pycord.git", - "ref": "9fdfd56cb7f73daf98b5013a9fd231665746dd0b" + "extras": [ + "voice" + ], + "hashes": [ + "sha256:4a38ea3a535ab301e53edb7ac8244a793ab174577b5360fbddf9670c9a5f620a", + "sha256:681cc1589eaa90f39a812e8dd94115eff305103ce974f4ca4a2062cea027b47f" + ], + "index": "pypi", + "version": "==2.0.0b7" }, "pycparser": { "hashes": [ @@ -476,6 +506,22 @@ ], "version": "==2.21" }, + "pynacl": { + "hashes": [ + "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", + "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", + "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", + "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", + "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", + "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", + "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", + "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", + "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", + "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543" + ], + "index": "pypi", + "version": "==1.5.0" + }, "pyopenssl": { "hashes": [ "sha256:660b1b1425aac4a1bea1d94168a85d99f0b3144c869dd4390d27629d0087f1bf", @@ -499,6 +545,16 @@ "index": "pypi", "version": "==2.27.1" }, + "rfc3986": { + "extras": [ + "idna2008" + ], + "hashes": [ + "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", + "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97" + ], + "version": "==1.5.0" + }, "selenium": { "hashes": [ "sha256:14d28a628c831c105d38305c881c9c7847199bfd728ec84240c5e86fa1c9bd5a" @@ -526,7 +582,7 @@ "sha256:670a52d3115d0e879e1ac838a4eb999af32f858163e3a704fe4839de2a676070", "sha256:fb2d48e4eab0dfb786a472cd514aaadc71e3445b203bc300bad93daa75d77c1a" ], - "markers": "python_full_version >= '3.7.0'", + "markers": "python_version >= '3.7'", "version": "==0.20.0" }, "trio-websocket": { @@ -539,11 +595,11 @@ }, "typing-extensions": { "hashes": [ - "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42", - "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2" + "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708", + "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376" ], - "markers": "python_version >= '3.6'", - "version": "==4.1.1" + "markers": "python_version >= '3.7'", + "version": "==4.2.0" }, "unidecode": { "hashes": [ @@ -577,7 +633,7 @@ "sha256:2218cb57952d90b9fca325c0dcfb08c3bda93e8fd8070b0a17f048e2e47a521b", "sha256:a2e56bfd5c7cd83c1369d83b5feccd6d37798b74872866e62616e0ecf111bda8" ], - "markers": "python_full_version >= '3.7.0'", + "markers": "python_version >= '3.7'", "version": "==1.1.0" }, "yarl": { @@ -657,6 +713,22 @@ ], "markers": "python_version >= '3.6'", "version": "==1.7.2" + }, + "youtube-dl": { + "hashes": [ + "sha256:bc59e86c5d15d887ac590454511f08ce2c47698d5a82c27bfe27b5d814bbaed2", + "sha256:f1336d5de68647e0364a47b3c0712578e59ec76f02048ff5c50ef1c69d79cd55" + ], + "index": "pypi", + "version": "==2021.12.17" + }, + "youtube-search-python": { + "hashes": [ + "sha256:75b6ce5d14a15fdd82c44f7461ad678c7c10ade76a43a3f2ab154177fb87b5fd", + "sha256:f5901968b9096f6984834aecb41c4ebecf7f2f8c2d6cbb824dd1433aec4ca8dd" + ], + "index": "pypi", + "version": "==1.6.4" } }, "develop": {} diff --git a/extension/audio.py b/extension/audio.py new file mode 100644 index 0000000..0e0ca33 --- /dev/null +++ b/extension/audio.py @@ -0,0 +1,163 @@ +import config +import discord +from discord.ext import commands, pages, tasks +import youtube_dl +from youtubesearchpython import VideosSearch +import asyncio + +ytdl_format_options = { + "format": "bestaudio/best", + "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", # Bind to ipv4 since ipv6 addresses cause issues at certain times +} + +ytdl = youtube_dl.YoutubeDL(ytdl_format_options) + +ffmpeg_options = {"options": "-vn"} + +class YTDLSource(discord.PCMVolumeTransformer): + def __init__(self, source, *, youtube_url, data, volume=0.5): + super().__init__(source, volume) + + self.data = data + + self.title = data.get("title") + self.url = data.get("url") + self.youtube_url = youtube_url + self.requester = "Nobody" + + @classmethod + async def from_url(cls, url, *, loop=None, stream=True): + loop = loop or asyncio.get_event_loop() + data = await loop.run_in_executor(None, lambda: ytdl.extract_info(url, download=not stream)) + + if "entries" in data: + # Takes the first item from a playlist + data = data["entries"][0] + + filename = data["url"] if stream else ytdl.prepare_filename(data) + return cls(discord.FFmpegPCMAudio(filename, **ffmpeg_options), youtube_url=url, data=data) + +class Audio(commands.Cog): + def __init__(self, bot: discord.Bot): + self.bot = bot + self.song_queue: list[YTDLSource] = list() + self.current_song: YTDLSource = None + + audio_group = discord.SlashCommandGroup('radio', 'Audio-related commands', [config.GUILD_ID]) + + @tasks.loop(minutes=5, count=None) + async def refresh_ytdl_task(self): + print('refreshing ytdls') + """ + Refreshes the urls for songs in queue because they get invalidated after a certain amount of time. + """ + new_queue: list[YTDLSource] = list() + for song in self.song_queue: + new_song = await YTDLSource.from_url(song.youtube_url, loop=self.bot.loop) + new_song.requester = song.requester + new_queue.append(new_song) + await asyncio.sleep(1) + self.song_queue = new_queue + + @audio_group.command(description='Play audio from the given YouTube URL or search query.') + async def play(self, ctx: discord.ApplicationContext, video: discord.Option(str, "YouTube URL or search query"), channel: discord.Option(discord.VoiceChannel, "The channel to connect to", required=False)): + """ + Adds music to queue. + Starts playing music if it isn't playing already. + """ + # if no channel specified, assume it's the one that the user is connected to + if channel is None: + if ctx.author.voice: + channel = ctx.author.voice.channel + else: + return await ctx.respond('Please specify a channel to connect to or be connected to a channel already.', ephemeral=True) + await ctx.defer() + + client = ctx.voice_client or await channel.connect() + + if video.startswith('http'): + url = video + else: + url = VideosSearch(video, limit=1).result()['result'][0]['link'] + print("url:", url) + + song = await YTDLSource.from_url(url, loop=self.bot.loop) + song.requester = ctx.author.display_name + song.youtube_url = url + self.song_queue.append(song) + self.play_music(client) + + if not self.refresh_ytdl_task.is_running(): + self.refresh_ytdl_task.start() + + await ctx.respond(f'Added to queue: **{song.title}**') + + @audio_group.command(description="Vote to skip the current song.") + async def skip(self, ctx: discord.ApplicationContext): + if not ctx.author.voice or ctx.author.voice.channel != ctx.voice_client.channel: + return await ctx.respond("You're not in the correct voice channel!", ephemeral=True) + await ctx.defer() + await ctx.respond("skipped lol") + ctx.voice_client.pause() + + # manually refresh just in case + await self.refresh_ytdl_task() + self.play_music(ctx.voice_client) + + def play_music(self, client: discord.VoiceClient): + """ + Helper function for `play` and `skip`. + Starts playing songs from queue. + """ + if len(self.song_queue) == 0 and not client.is_playing(): + return self.refresh_ytdl_task.stop() + elif client.is_playing(): + return + self.current_song = self.song_queue.pop(0) + client.play(self.current_song, after=lambda e: self.play_music(client) if not e else print(f'Player error: {e}')) + + @audio_group.command(description='Stop playing music.') + async def stop(self, ctx: discord.ApplicationContext): + if ctx.voice_client: + self.refresh_ytdl_task.stop() + await ctx.voice_client.stop() + await ctx.voice_client.disconnect() + await ctx.respond('Disconnected from voice.') + else: + await ctx.respond('Not connected to a voice channel!', ephemeral=True) + + @audio_group.command(description='View the current song queue.') + async def queue(self, ctx: discord.ApplicationContext): + # not playing music and no songs in queue + if not ctx.voice_client or (ctx.voice_client and not ctx.voice_client.is_playing()): + return await ctx.respond("Nothing is playing.", ephemeral=True) + # no songs in queue, but playing music + elif len(self.song_queue) == 0: + embed = discord.Embed( + title="**There are no songs in queue**" + ) + embed.add_field(name=f"**Currently playing:** {self.current_song.title}", value= f"[Added by {self.current_song.requester}]({self.current_song.youtube_url})", inline=False) + paginator = pages.Paginator(pages=[embed]) + return await paginator.respond(ctx.interaction) + + paginator_pages = list() + for i in range(0, len(self.song_queue), 5): + embed = discord.Embed(title='**Song queue**') + embed.add_field(name=f"**Currently playing:** {self.current_song.title}", value= f"[Added by {self.current_song.requester}]({self.current_song.youtube_url})", inline=False) + for j, song in enumerate(self.song_queue[i:i+5]): + embed.add_field(name=f"{j+i+1}. **{song.title}**", value=f"[Added by {song.requester}]({song.youtube_url})", inline=False) + paginator_pages.append(embed) + paginator = pages.Paginator(pages=paginator_pages) + await paginator.respond(ctx.interaction) + +def setup(bot): + bot.add_cog(Audio(bot))