diff --git a/PlexBot/__init__.py b/PlexBot/__init__.py index c2ffd18..9faccea 100644 --- a/PlexBot/__init__.py +++ b/PlexBot/__init__.py @@ -1,3 +1,9 @@ +"""Plex music bot for discord. + + Do not import this module, it is intended to be + used exclusively within a docker environment. + +""" import logging import sys from pathlib import Path @@ -14,13 +20,25 @@ bot_log = logging.getLogger("Bot") def load_config(filename: str) -> Dict[str, str]: + """Loads config from yaml file + Grabs key/value config pairs from a file. + + Args: + filename: str path to yaml file. + + Returns: + Dict[str, str] Values from config file. + + Raises: + FileNotFound Configuration file not found. + """ # All config files should be in /config # for docker deployment. filename = Path("/config", filename) try: - with open(filename, "r") as f: - config = yaml.safe_load(f) + with open(filename, "r") as config_file: + config = yaml.safe_load(config_file) except FileNotFoundError: root_log.fatal("Configuration file not found.") sys.exit(-1) diff --git a/PlexBot/__main__.py b/PlexBot/__main__.py index e72e7b1..7079a38 100644 --- a/PlexBot/__main__.py +++ b/PlexBot/__main__.py @@ -1,11 +1,15 @@ +"""Main entrypoint for bot + + Sets up loggers and initiates bot. + +""" import logging from discord.ext.commands import Bot -from . import FORMAT +from . import load_config from .bot import General from .bot import Plex -from PlexBot import load_config # Load config from file config = load_config("config.yaml") diff --git a/PlexBot/__version__.py b/PlexBot/__version__.py index 0862e1e..3ae0e0f 100644 --- a/PlexBot/__version__.py +++ b/PlexBot/__version__.py @@ -1 +1,2 @@ +"""Track version number of package""" VERSION = "0.0.5" diff --git a/PlexBot/bot.py b/PlexBot/bot.py index c71e64a..616daa8 100644 --- a/PlexBot/bot.py +++ b/PlexBot/bot.py @@ -1,3 +1,4 @@ +""" All discord bot and Plex api interactions""" import asyncio import io import logging @@ -18,19 +19,59 @@ bot_log = logging.getLogger("Bot") class General(commands.Cog): + """General commands + + Manage general bot behavior + """ + def __init__(self, bot): + """Initialize commands + + Args: + bot: discord.ext.command.Bot, bind for cogs + + Returns: + None + + Raises: + None + """ self.bot = bot @command() async def kill(self, ctx, *args): + """Kill the bot + + Args: + ctx: discord.ext.commands.Context message context from command + *args: optional flags + + Returns: + None + + Raises: + None + """ if "silent" not in args: await ctx.send(f"Stopping upon the request of {ctx.author.mention}") await self.bot.close() - bot_log.info(f"Stopping upon the request of {ctx.author.mention}") + bot_log.info("Stopping upon the request of %s", ctx.author.mention) @command() async def cleanup(self, ctx, limit=250): + """Delete old messages from bot + + Args: + ctx: discord.ext.commands.Context message context from command + limit: int number of messages to go back by to delete. Default 250 + + Raises: + None + + Returns: + None + """ channel = ctx.message.channel try: @@ -43,17 +84,51 @@ class General(commands.Cog): pass except discord.Forbidden: + bot_log.info("Unable to delete messages, insufficient permissions.") await ctx.send("I don't have the necessary permissions to delete messages.") class Plex(commands.Cog): - def __init__(self, bot, base_url, plex_token, lib_name, bot_prefix) -> None: + """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 + # All are necessary to detect global interactions + # within the bot. + + def __init__( + self, bot, base_url: str, plex_token: str, lib_name: str, bot_prefix: str + ): + """Initializes Plex resources + + Connects to Plex library and sets up + all asyncronous communications. + + Args: + bot: discord.ext.command.Bot, bind for cogs + base_url: str url to Plex server + plex_token: str X-Token of Plex server + lib_name: str name of Plex library to search through + bot_prefix: str prefix used to interact with bots + + Raises: + plexapi.exceptions.Unauthorized: Invalid Plex token + + Returns: + None + """ + self.bot = bot self.base_url = base_url self.plex_token = plex_token self.library_name = lib_name self.bot_prefix = bot_prefix + # Log fatal invalid plex token try: self.pms = PlexServer(self.base_url, self.plex_token) except Unauthorized: @@ -61,57 +136,103 @@ class Plex(commands.Cog): raise Unauthorized("Invalid Plex token") self.music = self.pms.library.section(self.library_name) - plex_log.debug(f"Connected to plex library: {self.library_name}") + plex_log.debug("Connected to plex library: %s", self.library_name) - self.vc = None + # Initialize necessary vars + self.voice_channel = None self.current_track = None self.np_message_id = None + self.ctx = None + # Initialize events self.play_queue = asyncio.Queue() self.play_next_event = asyncio.Event() + bot_log.info("Started bot successfully") self.bot.loop.create_task(self._audio_player_task()) - bot_log.info("Started bot successfully") + def _search_tracks(self, title: str): + """Search the Plex music db - def _search_tracks(self, title): + Uses a fuzzy search algorithm to find the closest matching song + title in the Plex music database. + + 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 + """ tracks = self.music.searchTracks() score = [None, -1] for i in tracks: - s = fuzz.ratio(title.lower(), i.title.lower()) - if s > score[1]: + ratio = fuzz.ratio(title.lower(), i.title.lower()) + if ratio > score[1]: score[0] = i - score[1] = s - elif s == score[1]: + score[1] = ratio + elif ratio == score[1]: score[0] = i return score[0] async def _play(self): + """Heavy lifting of playing songs + + Grabs the appropiate streaming URL, sends the `now playing` + message, and initiates playback in the vc. + + Args: + None + + Returns: + None + + Raises: + None + """ track_url = self.current_track.getStreamURL() audio_stream = FFmpegPCMAudio(track_url) - while self.vc.is_playing(): + while self.voice_channel.is_playing(): asyncio.sleep(2) - self.vc.play(audio_stream, after=self._toggle_next) + self.voice_channel.play(audio_stream, after=self._toggle_next) - plex_log.debug(f"{self.current_track.title} - URL: {track_url}") + plex_log.debug("%s - URL: %s", self.current_track, track_url) - embed, f = self._build_embed(self.current_track) - self.np_message_id = await self.ctx.send(embed=embed, file=f) + embed, img = self._build_embed(self.current_track) + self.np_message_id = await self.ctx.send(embed=embed, file=img) async def _audio_player_task(self): + """Coroutine to handle playback and queuing + + Always-running function awaiting new songs to be added. + Auto disconnects from VC if idle for > 15 seconds. + Handles auto deletion of now playing song notifications. + + Args: + None + + Returns: + None + + Raises: + None + """ while True: self.play_next_event.clear() - if self.vc: + if self.voice_channel: try: # Disconnect after 15 seconds idle async with timeout(15): self.current_track = await self.play_queue.get() except asyncio.TimeoutError: - await self.vc.disconnect() - self.vc = None + await self.voice_channel.disconnect() + self.voice_channel = None if not self.current_track: self.current_track = await self.play_queue.get() @@ -121,25 +242,54 @@ class Plex(commands.Cog): await self.np_message_id.delete() def _toggle_next(self, error=None): + """Callback for vc playback + + Clears current track, then activates _audio_player_task + to play next in queue or disconnect. + + Args: + error: Optional parameter required for discord.py callback + + Returns: + None + + Raises: + None + """ self.current_track = None self.bot.loop.call_soon_threadsafe(self.play_next_event.set) - def _build_embed(self, track, t="play"): + def _build_embed(self, track, type_="play"): """Creates a pretty embed card. + + Builds a helpful status embed with the following info: + Status, song title, album, artistm and album art. All + pertitent information is grabbed dynamically from the Plex db. + + Args: + track: plexapi.audio.Track object of song + type_: Type of card to make (play, queue). + + Returns: + embed: discord.embed fully constructed img payload. + thumb_art: io.BytesIO of album thumbnail img. + + Raises: + ValueError: Unsupported type of embed {type_} """ # Grab the relevant thumbnail img_stream = urlopen(track.thumbUrl) img = io.BytesIO(img_stream.read()) # Attach to discord embed - f = discord.File(img, filename="image0.png") + art_file = discord.File(img, filename="image0.png") # Get appropiate status message - if t == "play": + if type_ == "play": title = f"Now Playing - {track.title}" - elif t == "queue": + elif type_ == "queue": title = f"Added to queue - {track.title}" else: - raise ValueError(f"Unsupported type of embed {t}") + raise ValueError(f"Unsupported type of embed {type_}") # Include song details descrip = f"{track.album().title} - {track.artist().title}" @@ -152,16 +302,31 @@ class Plex(commands.Cog): # Point to file attached with ctx object. embed.set_thumbnail(url="attachment://image0.png") - bot_log.debug(f"Built embed for {track.title}") + bot_log.debug("Built embed for %s", track.title) - return embed, f + return embed, art_file @command() async def play(self, ctx, *args): + """User command to start playback + + 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 - if not len(args): + if not args: await ctx.send(f"Usage: {self.bot_prefix}play TITLE_OF_SONG") bot_log.debug("Failed to play, invalid usage") return @@ -172,75 +337,152 @@ class Plex(commands.Cog): # Fail if song title can't be found if not track: await ctx.send(f"Can't find song: {title}") - bot_log.debug(f"Failed to play, can't find song - {title}") + bot_log.debug("Failed to play, can't find song - %s", title) return # Fail if user not in vc - elif not ctx.author.voice: + 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.vc: - self.vc = await ctx.author.voice.channel.connect() + if not self.voice_channel: + self.voice_channel = await ctx.author.voice.channel.connect() bot_log.debug("Connected to vc.") # Specific add to queue message - if self.vc.is_playing(): - bot_log.debug(f"Added to queue - {title}") - embed, f = self._build_embed(track, t="queue") - await ctx.send(embed=embed, file=f) + if self.voice_channel.is_playing(): + bot_log.debug("Added to queue - %s", title) + embed, img = self._build_embed(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 stop(self, ctx): - if self.vc: - self.vc.stop() - await self.vc.disconnect() - self.vc = None + """User command to stop playback + + Stops playback and disconnects from vc. + + Args: + ctx: discord.ext.commands.Context message context from command + + Returns: + None + + Raises: + None + """ + if self.voice_channel: + self.voice_channel.stop() + await self.voice_channel.disconnect() + self.voice_channel = None self.ctx = None bot_log.debug("Stopped") await ctx.send(":stop_button: Stopped") @command() async def pause(self, ctx): - if self.vc: - self.vc.pause() + """User command to pause playback + + Pauses playback, but doesn't reset anything + to allow playback resuming. + + Args: + ctx: discord.ext.commands.Context message context from command + + Returns: + None + + Raises: + None + """ + if self.voice_channel: + self.voice_channel.pause() bot_log.debug("Paused") await ctx.send(":play_pause: Paused") @command() async def resume(self, ctx): - if self.vc: - self.vc.resume() + """User command to resume playback + + Args: + ctx: discord.ext.commands.Context message context from command + + Returns: + None + + Raises: + None + """ + if self.voice_channel: + self.voice_channel.resume() bot_log.debug("Resumed") await ctx.send(":play_pause: Resumed") @command() async def skip(self, ctx): + """User command to skip song in queue + + Skips currently playing song. If no other songs in + queue, stops playback, otherwise moves to next song. + + Args: + ctx: discord.ext.commands.Context message context from command + + Returns: + None + + Raises: + None + """ bot_log.debug("Skip") - if self.vc: - self.vc.stop() + if self.voice_channel: + self.voice_channel.stop() bot_log.debug("Skipped") self._toggle_next() - @command() - async def np(self, ctx): + @command(name="np") + async def now_playing(self, ctx): + """User command to get currently playing song. + + Deletes old `now playing` status message, + Creates a new one with up to date information. + + Args: + ctx: discord.ext.commands.Context message context from command + + Returns: + None + + Raises: + None + """ if self.current_track: - embed, f = self._build_embed(self.current_track) + embed, img = self._build_embed(self.current_track) bot_log.debug("Now playing") if self.np_message_id: await self.np_message_id.delete() - bot_log("Deleted old np status") + bot_log.debug("Deleted old np status") - bot_log("Created np status") - self.np_message_id = await ctx.send(embed=embed, file=f) + bot_log.debug("Created np status") + self.np_message_id = await ctx.send(embed=embed, file=img) @command() async def clear(self, ctx): + """User command to clear play queue. + + Args: + ctx: discord.ext.commands.Context message context from command + + Returns: + None + + Raises: + None + """ self.play_queue = asyncio.Queue() bot_log.debug("Cleared queue") await ctx.send(":boom: Queue cleared.")