Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement playlist support #49

Merged
merged 14 commits into from
Nov 25, 2021
99 changes: 80 additions & 19 deletions bot.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
"""
Serves a bot on Discord for playing music in voice chat, as well as some fun additions.
"""
# pylint: disable=import-error
# pylint: disable=missing-module-docstring
# pylint: disable=no-self-use
# pylint: disable=too-many-public-methods
# pylint: disable=too-many-locals
# pylint: disable=too-many-instance-attributes
# pylint: disable=too-many-arguments


import os
Expand All @@ -16,6 +22,7 @@
import jokeapi
import pafy
import youtubesearchpython
import pytube


class BotDispatcher(discord.Client):
Expand Down Expand Up @@ -43,7 +50,7 @@ async def on_message(self, message):

async def on_error(
self, event_name, *args, **kwargs
): # pylint: disable=arguments-differ,no-self-use
): # pylint: disable=arguments-differ
"""
Notify user of error and log it
"""
Expand Down Expand Up @@ -83,14 +90,13 @@ class MusicBot:
The main bot functionality
"""

# pylint: disable=no-self-use
# pylint: disable=too-many-instance-attributes
# pylint: disable=too-many-public-methods

COMMAND_PREFIX = "-"
REACTION_EMOJI = "👍"
DOCS_URL = "github.com/michael-je/the-lone-dancer"
DISCONNECT_TIMER_SECONDS = 600
TEXTWIDTH = 60
SYNTAX_LANGUAGE = "arm"
N_PLAYLIST_SHOW = 10

END_OF_QUEUE_MSG = ":sparkles: End of queue"

Expand All @@ -111,6 +117,7 @@ def __init__(self, guild, loop, dispatcher_user):
r"http[s]?://"
r"(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+"
)
self.playlist_regex = re.compile(r"\b(?:play)?list\b\=(\w+)")

# Boolean to control whether the after callback is called
self.after_callback_blocked = False
Expand Down Expand Up @@ -220,7 +227,7 @@ def __init__(self, guild, loop, dispatcher_user):
handler=self.joke,
)

def register_command( # pylint: disable=too-many-arguments
def register_command(
self,
command_name,
help_message: str,
Expand Down Expand Up @@ -260,10 +267,8 @@ def register_command( # pylint: disable=too-many-arguments
if guarded_by:

async def guarded_handler(*args):
logging.info("Aquiring lock")
async with guarded_by:
return await handler(*args)
logging.info("Releasing lock")

self.handlers[command_name] = guarded_handler
else:
Expand Down Expand Up @@ -341,7 +346,7 @@ def after_callback(self, _):
"""
if not self.after_callback_blocked:
self.loop.create_task(self.attempt_disconnect())
self.next_in_queue()
self.loop.create_task(self.next_in_queue())
else:
self.after_callback_blocked = False

Expand All @@ -358,7 +363,7 @@ def create_audio_source(self, audio_url):
"""Creates an audio sorce from an audio url"""
return discord.FFmpegPCMAudio(audio_url)

def next_in_queue(self):
async def next_in_queue(self):
"""
Switch to next song in queue
"""
Expand Down Expand Up @@ -386,10 +391,8 @@ def next_in_queue(self):
self.voice_client.play(audio_source, after=self.after_callback)
logging.info("Audio source started")

self.loop.create_task(
message.channel.send(
f":notes: Now Playing :notes:\n```\n{media.title}\n```"
)
await message.channel.send(
f":notes: Now Playing :notes:\n```\n{media.title}\n```"
)

async def create_or_get_voice_client(self, message):
Expand Down Expand Up @@ -453,6 +456,7 @@ async def attempt_disconnect(self):

if time.time() - self.last_played_time < self.DISCONNECT_TIMER_SECONDS:
return
logging.info("Disconnecting from voice chat due to inactivity")

self._stop()
await self.voice_client.disconnect()
Expand All @@ -470,6 +474,57 @@ async def notify_if_voice_client_is_missing(self, message):
return True
return False

async def playlist(self, message, command_content):
"""Play a playlist"""
logging.info("Fetching playlist for user %s", message.author)
playlist = pytube.Playlist(command_content)
await message.add_reaction(MusicBot.REACTION_EMOJI)
added = []
n_failed = 0
progress = 0
total = len(playlist)
status_fmt = "Fetching playlist... {}"
reply = await message.channel.send(status_fmt.format(""))
for video in playlist:
await reply.edit(content=status_fmt.format(f"{progress/total:.0%}"))
progress += 1
try:
media = pafy.new(video)
except KeyError as err:
logging.error(err)
n_failed += 1
continue
self.media_queue.put((media, message))
added.append(media)
if len(added) == 1 and not self.voice_client.is_playing():
await self.next_in_queue()
logging.info("%d items added to queue, %d failed", len(added), n_failed)

final_status = ""
final_status += f":clipboard: Added {len(added)} of "
final_status += f"{len(added)+n_failed} songs to queue :notes:\n"
final_status += f"```{self.SYNTAX_LANGUAGE}"
final_status += "\n"
for media in added[: self.N_PLAYLIST_SHOW]:
title = media.title
titlewidth = self.TEXTWIDTH - 10
if len(title) > titlewidth:
title = title[: titlewidth - 3] + "..."

duration_m = int(media.duration[:2]) * 60 + int(media.duration[3:5])
duration_s = int(media.duration[6:])
duration = f"({duration_m}:{duration_s:0>2})"
# Time: 5-6 char + () + buffer = 10
final_status += f"{title:<{titlewidth}}{duration:>10}"
final_status += "\n"
if len(added) >= self.N_PLAYLIST_SHOW:
final_status += "...\n"
final_status += "```"

logging.debug("final status message: \n%s", final_status)

await reply.edit(content=final_status)

async def play(self, message, command_content):
"""
Play URL or first search term from command_content in the author's voice channel
Expand All @@ -479,6 +534,7 @@ async def play(self, message, command_content):
return

if not command_content:
# No search term/url
if self.voice_client.is_paused():
await self.resume(message, command_content)
elif not self.voice_client.is_playing() and not self.media_queue.empty():
Expand All @@ -495,12 +551,18 @@ async def play(self, message, command_content):
)
return

if re.findall(self.playlist_regex, command_content):
morgaesis marked this conversation as resolved.
Show resolved Hide resolved
await self.playlist(message, command_content)
return

media = None
try:
if self.url_regex.match(command_content):
# url to video
logging.info("Fetching video metadata with pafy")
media = self.pafy_search(command_content)
else:
# search term
logging.info("Fetching search results with pafy")
search_result = self.youtube_search(command_content)
media = self.pafy_search(search_result["result"][0]["id"])
Expand All @@ -525,7 +587,7 @@ async def play(self, message, command_content):
)
else:
logging.info("Playing media")
self.next_in_queue()
await self.next_in_queue()

async def stop(self, message, _command_content):
"""
Expand Down Expand Up @@ -590,7 +652,7 @@ async def resume(self, message, _command_content):
self.voice_client.resume()
elif not self.voice_client.is_playing():
logging.info("Resuming for user %s (next_in_queue)", message.author)
self.next_in_queue()
await self.next_in_queue()
await message.add_reaction(MusicBot.REACTION_EMOJI)

async def skip(self, message, _command_content):
Expand All @@ -604,7 +666,7 @@ async def skip(self, message, _command_content):
await message.channel.send(MusicBot.END_OF_QUEUE_MSG)
self._stop()
else:
self.next_in_queue()
await self.next_in_queue()
await message.add_reaction(MusicBot.REACTION_EMOJI)

async def clear_queue(self, message, _command_content):
Expand Down Expand Up @@ -822,7 +884,6 @@ async def joke(self, message, command_content, joke_pause=3):
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)

# pylint: disable=invalid-name
token = os.getenv("DISCORD_TOKEN")
if token is None:
with open(".env", "r", encoding="utf-8") as env_file:
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pafy==0.5.5
pycparser==2.20
PyNaCl==1.4.0
python-dotenv==0.13.0
pytube==11.0.1
rfc3986==1.5.0
simplejson==3.17.0
six==1.16.0
Expand Down
5 changes: 4 additions & 1 deletion test_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ async def test_play_fails_when_user_not_in_voice_channel(self):
await self.music_bot_.handle_message(play_message)

play_message.channel.send.assert_awaited_with(
":studio_microphone: default_author, please join a voice channel to start the :robot:"
":studio_microphone: default_author, please join a voice channel to start "
"the :robot:"
)

async def test_play_connects_deafaned(self):
Expand Down Expand Up @@ -194,6 +195,8 @@ async def test_second_play_command_queues_media(self):

self.music_bot_.voice_client.finish_audio_source()

await asyncio.sleep(0.1)

play_message2.channel.send.assert_called_with(
":notes: Now Playing :notes:\n```\nsong2\n```"
)
Expand Down