From 5f90b17b0e954ef3fd6a507e947016b9d21e0b91 Mon Sep 17 00:00:00 2001 From: Joshua Arulsamy Date: Thu, 13 Aug 2020 01:58:12 -0600 Subject: [PATCH 1/7] :zap: Switch to PlexAPI for search --- PlexBot/bot.py | 34 ++++++++++++---------------------- PlexBot/exceptions.py | 2 ++ 2 files changed, 14 insertions(+), 22 deletions(-) create mode 100644 PlexBot/exceptions.py diff --git a/PlexBot/bot.py b/PlexBot/bot.py index 4436f62..19b29b5 100644 --- a/PlexBot/bot.py +++ b/PlexBot/bot.py @@ -9,10 +9,11 @@ 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 TrackNotFoundError + root_log = logging.getLogger() plex_log = logging.getLogger("Plex") bot_log = logging.getLogger("Bot") @@ -152,32 +153,22 @@ 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. Raises: - None + TrackNotFoundError: 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 - - return score[0] + results = self.music.searchTracks(title=title, maxresults=1) + try: + return results[0] + except IndexError: + raise TrackNotFoundError async def _play(self): """Heavy lifting of playing songs @@ -332,10 +323,9 @@ class Plex(commands.Cog): 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 TrackNotFoundError: await ctx.send(f"Can't find song: {title}") bot_log.debug("Failed to play, can't find song - %s", title) return diff --git a/PlexBot/exceptions.py b/PlexBot/exceptions.py new file mode 100644 index 0000000..782496a --- /dev/null +++ b/PlexBot/exceptions.py @@ -0,0 +1,2 @@ +class TrackNotFoundError(Exception): + pass From 56fd4aa5ab6e4fa3c8624cb547bc4984853714a8 Mon Sep 17 00:00:00 2001 From: Joshua Arulsamy Date: Thu, 13 Aug 2020 01:58:31 -0600 Subject: [PATCH 2/7] :sparkles: Makefile for dev and prod envs --- Makefile | 17 +++++++++++++++++ docker-compose_dev.yml | 13 +++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 Makefile create mode 100644 docker-compose_dev.yml 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/docker-compose_dev.yml b/docker-compose_dev.yml new file mode 100644 index 0000000..9607413 --- /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: "unless-stopped" From f95d5c1fd23e9545418d90f53249e798df94ec21 Mon Sep 17 00:00:00 2001 From: Joshua Arulsamy Date: Thu, 13 Aug 2020 02:25:15 -0600 Subject: [PATCH 3/7] :sparkles: Add album playback features Queue a whole album with one single command. --- PlexBot/bot.py | 166 ++++++++++++++++++++++++++++++++++-------- PlexBot/exceptions.py | 6 +- 2 files changed, 141 insertions(+), 31 deletions(-) diff --git a/PlexBot/bot.py b/PlexBot/bot.py index 19b29b5..2a50782 100644 --- a/PlexBot/bot.py +++ b/PlexBot/bot.py @@ -12,7 +12,8 @@ from discord.ext.commands import command from plexapi.exceptions import Unauthorized from plexapi.server import PlexServer -from .exceptions import TrackNotFoundError +from .exceptions import MediaNotFoundError +from .exceptions import VoiceChannelError root_log = logging.getLogger() plex_log = logging.getLogger("Plex") @@ -159,16 +160,34 @@ class Plex(commands.Cog): title: str title of song to search for Returns: - plexapi.audio.Track pointing to closest matching title + plexapi.audio.Track pointing to best matching title Raises: - TrackNotFoundError: Title of track can't be found in plex db. + MediaNotFoundError: Title of track can't be found in plex db """ results = self.music.searchTracks(title=title, maxresults=1) try: return results[0] except IndexError: - raise TrackNotFoundError + raise MediaNotFoundError("Track cannot be found") + + 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 @@ -195,7 +214,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): @@ -250,11 +269,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: @@ -262,7 +281,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: @@ -293,13 +312,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. @@ -316,40 +393,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) + try: track = self._search_tracks(title) - except TrackNotFoundError: + 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 @@ -451,7 +557,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 index 782496a..193aac7 100644 --- a/PlexBot/exceptions.py +++ b/PlexBot/exceptions.py @@ -1,2 +1,6 @@ -class TrackNotFoundError(Exception): +class MediaNotFoundError(Exception): + pass + + +class VoiceChannelError(Exception): pass From f12701f4c56415ceb196f80d9a0d073d984ff67e Mon Sep 17 00:00:00 2001 From: Joshua Arulsamy Date: Thu, 13 Aug 2020 02:47:54 -0600 Subject: [PATCH 4/7] :bug: Fix auto restart on dev env --- docker-compose_dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose_dev.yml b/docker-compose_dev.yml index 9607413..50a4c7f 100644 --- a/docker-compose_dev.yml +++ b/docker-compose_dev.yml @@ -10,4 +10,4 @@ services: # Required dir for configuration files volumes: - "./config:/config:ro" - restart: "unless-stopped" + restart: "no" From 880c4d50f1bbbce1b59a271b7eb6e4f695a79497 Mon Sep 17 00:00:00 2001 From: Joshua Arulsamy Date: Thu, 13 Aug 2020 02:51:34 -0600 Subject: [PATCH 5/7] :pencil: Update help --- README.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8580acf..ec0e1d9 100644 --- a/README.md +++ b/README.md @@ -113,19 +113,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. ``` * * * From 08a235d55eea7270bf35784081a5ab06b800902d Mon Sep 17 00:00:00 2001 From: Joshua Arulsamy Date: Thu, 13 Aug 2020 02:52:11 -0600 Subject: [PATCH 6/7] :pencil: Add docstrings --- PlexBot/exceptions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/PlexBot/exceptions.py b/PlexBot/exceptions.py index 193aac7..df6c957 100644 --- a/PlexBot/exceptions.py +++ b/PlexBot/exceptions.py @@ -1,6 +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 From 228c2b480bbd7009aad3bc15946c8767fa34f9e1 Mon Sep 17 00:00:00 2001 From: Joshua Arulsamy Date: Thu, 13 Aug 2020 02:52:22 -0600 Subject: [PATCH 7/7] :sparkles: Add custom help command --- PlexBot/__main__.py | 2 ++ PlexBot/bot.py | 42 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 40 insertions(+), 4 deletions(-) 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 2a50782..5e53759 100644 --- a/PlexBot/bot.py +++ b/PlexBot/bot.py @@ -19,6 +19,24 @@ 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 @@ -60,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 @@ -91,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