diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a8ddb8e --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +.PHONY: help pull build clean +.DEFAULT_GOAL: build + +help: + @echo "make pull" + @echo " Start docker container with pull" + @echo "make build" + @echo " Start docker container rebuilding container" + +pull: + docker-compose up + +build: + docker-compose -f docker-compose_dev.yml up --build + +clean: + docker system prune -a diff --git a/PlexBot/__main__.py b/PlexBot/__main__.py index 8dfae5e..bd5dd58 100644 --- a/PlexBot/__main__.py +++ b/PlexBot/__main__.py @@ -29,6 +29,8 @@ plex_log.setLevel(config["plex"]["log_level"]) bot_log.setLevel(config["discord"]["log_level"]) bot = Bot(command_prefix=BOT_PREFIX) +# Remove help command, we have our own custom one. +bot.remove_command("help") bot.add_cog(General(bot)) bot.add_cog(Plex(bot, BASE_URL, PLEX_TOKEN, LIBRARY_NAME, BOT_PREFIX)) bot.run(TOKEN) diff --git a/PlexBot/bot.py b/PlexBot/bot.py index 4436f62..5e53759 100644 --- a/PlexBot/bot.py +++ b/PlexBot/bot.py @@ -9,14 +9,34 @@ from async_timeout import timeout from discord import FFmpegPCMAudio from discord.ext import commands from discord.ext.commands import command -from fuzzywuzzy import fuzz from plexapi.exceptions import Unauthorized from plexapi.server import PlexServer +from .exceptions import MediaNotFoundError +from .exceptions import VoiceChannelError + root_log = logging.getLogger() plex_log = logging.getLogger("Plex") bot_log = logging.getLogger("Bot") +help_text = """ +General: + kill [silent] - Halt the bot [silently]. + help - Print this help message. + cleanup - Delete old messages from the bot. + +Plex: + play - Play a song from the plex server. + album - Queue an entire album to play. + np - Print the current playing song. + stop - Halt playback and leave vc. + pause - Pause playback. + resume - Resume playback. + clear - Clear play queue. + +[] - Optional args. +""" + class General(commands.Cog): """General commands @@ -58,6 +78,22 @@ class General(commands.Cog): await self.bot.close() bot_log.info("Stopping upon the request of %s", ctx.author.mention) + @command(name="help") + async def help(self, ctx): + """Prints command help + + Args: + ctx: discord.ext.commands.Context message context from command + + Returns: + None + + Raise: + None + """ + + await ctx.send(f"```{help_text}```") + @command() async def cleanup(self, ctx, limit=250): """Delete old messages from bot @@ -89,11 +125,11 @@ class General(commands.Cog): class Plex(commands.Cog): - """Discord commands pertinent to interacting with Plex - - Contains user commands such as play, pause, resume, stop, etc. - Grabs, and parses all data from plex database. + """ + Discord commands pertinent to interacting with Plex + Contains user commands such as play, pause, resume, stop, etc. + Grabs, and parses all data from plex database. """ # pylint: disable=too-many-instance-attributes @@ -152,32 +188,40 @@ class Plex(commands.Cog): self.bot.loop.create_task(self._audio_player_task()) def _search_tracks(self, title: str): - """Search the Plex music db - - Uses a fuzzy search algorithm to find the closest matching song - title in the Plex music database. + """Search the Plex music db for track Args: title: str title of song to search for Returns: - plexapi.audio.Track pointing to closest matching title - None if song can't be found. + plexapi.audio.Track pointing to best matching title Raises: - None + MediaNotFoundError: Title of track can't be found in plex db """ - tracks = self.music.searchTracks() - score = [None, -1] - for i in tracks: - ratio = fuzz.ratio(title.lower(), i.title.lower()) - if ratio > score[1]: - score[0] = i - score[1] = ratio - elif ratio == score[1]: - score[0] = i + results = self.music.searchTracks(title=title, maxresults=1) + try: + return results[0] + except IndexError: + raise MediaNotFoundError("Track cannot be found") - return score[0] + def _search_albums(self, title: str): + """Search the Plex music db for album + + Args: + title: str title of album to search for + + Returns: + plexapi.audio.Album pointing to best matching title + + Raises: + MediaNotFoundError: Title of album can't be found in plex db + """ + results = self.music.searchAlbums(title=title, maxresults=1) + try: + return results[0] + except IndexError: + raise MediaNotFoundError("Album cannot be found") async def _play(self): """Heavy lifting of playing songs @@ -204,7 +248,7 @@ class Plex(commands.Cog): plex_log.debug("%s - URL: %s", self.current_track, track_url) - embed, img = self._build_embed(self.current_track) + embed, img = self._build_embed_track(self.current_track) self.np_message_id = await self.ctx.send(embed=embed, file=img) async def _audio_player_task(self): @@ -259,11 +303,11 @@ class Plex(commands.Cog): self.current_track = None self.bot.loop.call_soon_threadsafe(self.play_next_event.set) - def _build_embed(self, track, type_="play"): - """Creates a pretty embed card. + def _build_embed_track(self, track, type_="play"): + """Creates a pretty embed card for tracks Builds a helpful status embed with the following info: - Status, song title, album, artistm and album art. All + Status, song title, album, artist and album art. All pertitent information is grabbed dynamically from the Plex db. Args: @@ -271,7 +315,7 @@ class Plex(commands.Cog): type_: Type of card to make (play, queue). Returns: - embed: discord.embed fully constructed img payload. + embed: discord.embed fully constructed payload. thumb_art: io.BytesIO of album thumbnail img. Raises: @@ -302,13 +346,71 @@ class Plex(commands.Cog): # Point to file attached with ctx object. embed.set_thumbnail(url="attachment://image0.png") - bot_log.debug("Built embed for %s", track.title) + bot_log.debug("Built embed for track - %s", track.title) return embed, art_file + def _build_embed_album(self, album): + """Creates a pretty embed card for albums + + Builds a helpful status embed with the following info: + album, artist, and album art. All pertitent information + is grabbed dynamically from the Plex db. + + Args: + album: plexapi.audio.Album object of album + + Returns: + embed: discord.embed fully constructed payload. + thumb_art: io.BytesIO of album thumbnail img. + + Raises: + None + """ + # Grab the relevant thumbnail + img_stream = urlopen(album.thumbUrl) + img = io.BytesIO(img_stream.read()) + + # Attach to discord embed + art_file = discord.File(img, filename="image0.png") + title = "Added album to queue" + descrip = f"{album.title} - {album.artist().title}" + + embed = discord.Embed( + title=title, description=descrip, colour=discord.Color.red() + ) + embed.set_author(name="Plex") + embed.set_thumbnail(url="attachment://image0.png") + bot_log.debug("Built embed for album - %s", album.title) + + return embed, art_file + + async def _validate(self, ctx): + """Ensures user is in a vc + + Args: + ctx: discord.ext.commands.Context message context from command + + Returns: + None + + Raises: + VoiceChannelError: Author not in voice channel + """ + # Fail if user not in vc + if not ctx.author.voice: + await ctx.send("Join a voice channel first!") + bot_log.debug("Failed to play, requester not in voice channel") + raise VoiceChannelError + + # Connect to voice if not already + if not self.voice_channel: + self.voice_channel = await ctx.author.voice.channel.connect() + bot_log.debug("Connected to vc.") + @command() async def play(self, ctx, *args): - """User command to start playback + """User command to play song Searchs plex db and either, initiates playback, or adds to queue. Handles invalid usage from the user. @@ -325,41 +427,69 @@ class Plex(commands.Cog): """ # Save the context to use with async callbacks self.ctx = ctx - - if not args: - await ctx.send(f"Usage: {self.bot_prefix}play TITLE_OF_SONG") - bot_log.debug("Failed to play, invalid usage") - return - title = " ".join(args) - track = self._search_tracks(title) - # Fail if song title can't be found - if not track: + try: + track = self._search_tracks(title) + except MediaNotFoundError: await ctx.send(f"Can't find song: {title}") bot_log.debug("Failed to play, can't find song - %s", title) return - # Fail if user not in vc - if not ctx.author.voice: - await ctx.send("Join a voice channel first!") - bot_log.debug("Failed to play, requester not in voice channel") - return - - # Connect to voice if not already - if not self.voice_channel: - self.voice_channel = await ctx.author.voice.channel.connect() - bot_log.debug("Connected to vc.") + try: + await self._validate(ctx) + except VoiceChannelError: + pass # Specific add to queue message if self.voice_channel.is_playing(): bot_log.debug("Added to queue - %s", title) - embed, img = self._build_embed(track, type_="queue") + embed, img = self._build_embed_track(track, type_="queue") await ctx.send(embed=embed, file=img) # Add the song to the async queue await self.play_queue.put(track) + @command() + async def album(self, ctx, *args): + """User command to play song + + Searchs plex db and either, initiates playback, or + adds to queue. Handles invalid usage from the user. + + Args: + ctx: discord.ext.commands.Context message context from command + *args: Title of song to play + + Returns: + None + + Raises: + None + """ + # Save the context to use with async callbacks + self.ctx = ctx + title = " ".join(args) + + try: + album = self._search_albums(title) + except MediaNotFoundError: + await ctx.send(f"Can't find album: {title}") + bot_log.debug("Failed to queue album, can't find - %s", title) + return + + try: + await self._validate(ctx) + except VoiceChannelError: + pass + + bot_log.debug("Added to queue - %s", title) + embed, img = self._build_embed_album(album) + await ctx.send(embed=embed, file=img) + + for track in album.tracks(): + await self.play_queue.put(track) + @command() async def stop(self, ctx): """User command to stop playback @@ -461,7 +591,7 @@ class Plex(commands.Cog): None """ if self.current_track: - embed, img = self._build_embed(self.current_track) + embed, img = self._build_embed_track(self.current_track) bot_log.debug("Now playing") if self.np_message_id: await self.np_message_id.delete() diff --git a/PlexBot/exceptions.py b/PlexBot/exceptions.py new file mode 100644 index 0000000..df6c957 --- /dev/null +++ b/PlexBot/exceptions.py @@ -0,0 +1,10 @@ +class MediaNotFoundError(Exception): + """Raised when a PlexAPI media resource cannot be found.""" + + pass + + +class VoiceChannelError(Exception): + """Raised when user is not connected to a voice channel.""" + + pass diff --git a/README.md b/README.md index fba03ea..1e4e37e 100644 --- a/README.md +++ b/README.md @@ -114,19 +114,20 @@ docker-compose logs -f PlexBot ```text General: - kill - Stop the bot. -Plex: - np - View currently playing song. - pause - Pause currently playing song. - play - Play a song from the Plex library. - resume - Resume a paused song. - skip - Skip a song. - stop - Stop playing. -​No Category: - help Shows this message + kill [silent] - Halt the bot [silently]. + help - Print this help message. + cleanup - Delete old messages from the bot. -Type ?help command for more info on a command. -You can also type ?help category for more info on a category. +Plex: + play - Play a song from the plex server. + album - Queue an entire album to play. + np - Print the current playing song. + stop - Halt playback and leave vc. + pause - Pause playback. + resume - Resume playback. + clear - Clear play queue. + +[] - Optional args. ``` * * * diff --git a/docker-compose_dev.yml b/docker-compose_dev.yml new file mode 100644 index 0000000..50a4c7f --- /dev/null +++ b/docker-compose_dev.yml @@ -0,0 +1,13 @@ +version: "3" +services: + plex-bot: + container_name: "PlexBot" + build: . + environment: + - PUID=1000 + - PGID=1000 + - TZ=America/Denver + # Required dir for configuration files + volumes: + - "./config:/config:ro" + restart: "no"