mirror of
				https://github.com/jarulsamy/Plex-Bot.git
				synced 2024-08-19 15:01:55 +02:00 
			
		
		
		
	Compare commits
	
		
			45 Commits
		
	
	
		
			v0.0.4
			...
			8f05f5e27f
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 8f05f5e27f | ||
|  | eeb430c016 | ||
|  | 1ffa5a7229 | ||
|  | f4cd675502 | ||
|  | 921dfc02b8 | ||
|  | 9fb091e9e1 | ||
|  | efdd604d65 | ||
|  | 7d6060ed15 | ||
|  | d6f5174d20 | ||
|  | 151f650bb2 | ||
|  | 5eceab7a22 | ||
|  | 545654f9a7 | ||
|  | 57490cdf17 | ||
|  | a6be6eac37 | ||
|  | 5ba7d751d0 | ||
|  | d0b6c25359 | ||
|  | db188d968b | ||
|  | b05dd0598b | ||
|  | fc4682c210 | ||
|  | 228c2b480b | ||
|  | 08a235d55e | ||
|  | 880c4d50f1 | ||
|  | f12701f4c5 | ||
|  | f95d5c1fd2 | ||
|  | 56fd4aa5ab | ||
|  | 5f90b17b0e | ||
|  | cf2bc24f8a | ||
|  | 1839cc5d03 | ||
|  | e6c4b84538 | ||
|  | c1cba637b8 | ||
|  | 7f49c4d958 | ||
|  | ed1a64cb52 | ||
|  | 7471da85f7 | ||
|  | 4633247004 | ||
|  | 98a36c6cbc | ||
|  | b7ab589f6e | ||
|  | 4aadd886d5 | ||
|  | 3e90c9e9ef | ||
|  | 33bbf21bab | ||
|  | 253f2a9a82 | ||
|  | af91883635 | ||
|  | cfd89ea6e8 | ||
|  | 9cbd0be424 | ||
|  | 1a8ec5f21f | ||
|  | d0aacc3f0b | 
| @@ -1,8 +1,10 @@ | |||||||
| FROM python:3.7-slim | FROM python:3.7-slim | ||||||
|  |  | ||||||
|  | LABEL maintainer="Joshua Arulsamy <joshua.gf.arul@gmail.com>" | ||||||
|  |  | ||||||
| # Install ffmpeg | # Install ffmpeg | ||||||
| RUN apt-get -y update && \ | RUN apt-get -y update && \ | ||||||
|     apt-get install -y --no-install-recommends ffmpeg && \ |     apt-get install -y --no-install-recommends ffmpeg=7:4.1.6-1~deb10u1 && \ | ||||||
|     apt-get autoremove -y && \ |     apt-get autoremove -y && \ | ||||||
|     apt-get clean && \ |     apt-get clean && \ | ||||||
|     rm -rf /var/lib/apt/lists/* |     rm -rf /var/lib/apt/lists/* | ||||||
| @@ -14,7 +16,7 @@ WORKDIR /src | |||||||
| COPY requirements.txt . | COPY requirements.txt . | ||||||
|  |  | ||||||
| # Install all dependencies. | # Install all dependencies. | ||||||
| RUN pip install --only-binary all --no-cache-dir -r requirements.txt | RUN pip install --no-cache-dir -r requirements.txt | ||||||
|  |  | ||||||
| # Copy PlexBot over to src. | # Copy PlexBot over to src. | ||||||
| COPY PlexBot/ PlexBot | COPY PlexBot/ PlexBot | ||||||
|   | |||||||
							
								
								
									
										16
									
								
								Jenkinsfile
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										16
									
								
								Jenkinsfile
									
									
									
									
										vendored
									
									
								
							| @@ -25,6 +25,7 @@ pipeline { | |||||||
|                 sh  ''' conda create --yes -n ${BUILD_TAG} python |                 sh  ''' conda create --yes -n ${BUILD_TAG} python | ||||||
|                         source /var/lib/jenkins/miniconda3/etc/profile.d/conda.sh |                         source /var/lib/jenkins/miniconda3/etc/profile.d/conda.sh | ||||||
|                         conda activate ${BUILD_TAG} |                         conda activate ${BUILD_TAG} | ||||||
|  |                         pip install -r requirements.txt | ||||||
|                         pip install pylint |                         pip install pylint | ||||||
|                     ''' |                     ''' | ||||||
|             } |             } | ||||||
| @@ -47,19 +48,12 @@ pipeline { | |||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|             steps { |             steps { | ||||||
|                 sh  ''' source /var/lib/jenkins/miniconda3/etc/profile.d/conda.sh |                 sh  './deploy/build.sh' | ||||||
|                         conda activate ${BUILD_TAG} |  | ||||||
|                         python deploy/build.py |  | ||||||
|                     ''' |  | ||||||
|             } |             } | ||||||
|             post { |  | ||||||
|                 always { |  | ||||||
|                     // Archive unit tests for the future |  | ||||||
|                     archiveArtifacts (allowEmptyArchive: true, |  | ||||||
|                                      artifacts: 'dist/*whl', |  | ||||||
|                                      fingerprint: true) |  | ||||||
|                     // sh 'python deploy/push.py' |  | ||||||
|         } |         } | ||||||
|  |         stage('Push Image') { | ||||||
|  |             steps { | ||||||
|  |                 sh './deploy/push.sh' | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										17
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
| @@ -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 logging | ||||||
| import sys | import sys | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| @@ -14,13 +20,25 @@ bot_log = logging.getLogger("Bot") | |||||||
|  |  | ||||||
|  |  | ||||||
| def load_config(filename: str) -> Dict[str, str]: | 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 |     # All config files should be in /config | ||||||
|     # for docker deployment. |     # for docker deployment. | ||||||
|     filename = Path("/config", filename) |     filename = Path("/config", filename) | ||||||
|     try: |     try: | ||||||
|         with open(filename, "r") as f: |         with open(filename, "r") as config_file: | ||||||
|             config = yaml.safe_load(f) |             config = yaml.safe_load(config_file) | ||||||
|     except FileNotFoundError: |     except FileNotFoundError: | ||||||
|         root_log.fatal("Configuration file not found.") |         root_log.fatal("Configuration file not found.") | ||||||
|         sys.exit(-1) |         sys.exit(-1) | ||||||
| @@ -38,4 +56,7 @@ def load_config(filename: str) -> Dict[str, str]: | |||||||
|     config["plex"]["log_level"] = levels[config["plex"]["log_level"].upper()] |     config["plex"]["log_level"] = levels[config["plex"]["log_level"].upper()] | ||||||
|     config["discord"]["log_level"] = levels[config["discord"]["log_level"].upper()] |     config["discord"]["log_level"] = levels[config["discord"]["log_level"].upper()] | ||||||
|  |  | ||||||
|  |     if config["lyrics"]["token"].lower() == "none": | ||||||
|  |         config["lyrics"]["token"] = None | ||||||
|  |  | ||||||
|     return config |     return config | ||||||
|   | |||||||
| @@ -1,11 +1,14 @@ | |||||||
|  | """ | ||||||
|  | Main entrypoint script. | ||||||
|  | Sets up loggers and initiates bot. | ||||||
|  | """ | ||||||
| import logging | import logging | ||||||
|  |  | ||||||
| from discord.ext.commands import Bot | from discord.ext.commands import Bot | ||||||
|  |  | ||||||
| from . import FORMAT | from . import load_config | ||||||
| from .bot import General | from .bot import General | ||||||
| from .bot import Plex | from .bot import Plex | ||||||
| from PlexBot import load_config |  | ||||||
|  |  | ||||||
| # Load config from file | # Load config from file | ||||||
| config = load_config("config.yaml") | config = load_config("config.yaml") | ||||||
| @@ -17,6 +20,8 @@ BASE_URL = config["plex"]["base_url"] | |||||||
| PLEX_TOKEN = config["plex"]["token"] | PLEX_TOKEN = config["plex"]["token"] | ||||||
| LIBRARY_NAME = config["plex"]["library_name"] | LIBRARY_NAME = config["plex"]["library_name"] | ||||||
|  |  | ||||||
|  | LYRICS_TOKEN = config["lyrics"]["token"] | ||||||
|  |  | ||||||
| # Set appropiate log level | # Set appropiate log level | ||||||
| root_log = logging.getLogger() | root_log = logging.getLogger() | ||||||
| plex_log = logging.getLogger("Plex") | plex_log = logging.getLogger("Plex") | ||||||
| @@ -25,7 +30,16 @@ bot_log = logging.getLogger("Bot") | |||||||
| plex_log.setLevel(config["plex"]["log_level"]) | plex_log.setLevel(config["plex"]["log_level"]) | ||||||
| bot_log.setLevel(config["discord"]["log_level"]) | bot_log.setLevel(config["discord"]["log_level"]) | ||||||
|  |  | ||||||
|  | plex_args = { | ||||||
|  |     "base_url": BASE_URL, | ||||||
|  |     "plex_token": PLEX_TOKEN, | ||||||
|  |     "lib_name": LIBRARY_NAME, | ||||||
|  |     "lyrics_token": LYRICS_TOKEN, | ||||||
|  | } | ||||||
|  |  | ||||||
| bot = Bot(command_prefix=BOT_PREFIX) | 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(General(bot)) | ||||||
| bot.add_cog(Plex(bot, BASE_URL, PLEX_TOKEN, LIBRARY_NAME, BOT_PREFIX)) | bot.add_cog(Plex(bot, **plex_args)) | ||||||
| bot.run(TOKEN) | bot.run(TOKEN) | ||||||
|   | |||||||
| @@ -1 +1,5 @@ | |||||||
| VERSION = "0.0.4" | """Track version number of package.""" | ||||||
|  | VERSION = "1.0.3" | ||||||
|  |  | ||||||
|  | if __name__ == "__main__": | ||||||
|  |     print(VERSION) | ||||||
|   | |||||||
							
								
								
									
										720
									
								
								PlexBot/bot.py
									
									
									
									
									
								
							
							
						
						
									
										720
									
								
								PlexBot/bot.py
									
									
									
									
									
								
							| @@ -1,36 +1,123 @@ | |||||||
|  | """All discord bot and Plex api interactions.""" | ||||||
| import asyncio | import asyncio | ||||||
| import io | import io | ||||||
| import logging | import logging | ||||||
| from urllib.request import urlopen | from urllib.request import urlopen | ||||||
|  | import requests | ||||||
|  |  | ||||||
| import discord | import discord | ||||||
|  | import lyricsgenius | ||||||
| from async_timeout import timeout | from async_timeout import timeout | ||||||
| from discord import FFmpegPCMAudio | from discord import FFmpegPCMAudio | ||||||
| from discord.ext import commands | from discord.ext import commands | ||||||
| from discord.ext.commands import command | from discord.ext.commands import command | ||||||
| from fuzzywuzzy import fuzz |  | ||||||
| from plexapi.exceptions import Unauthorized | from plexapi.exceptions import Unauthorized | ||||||
|  | from plexapi.exceptions import NotFound | ||||||
| from plexapi.server import PlexServer | from plexapi.server import PlexServer | ||||||
|  |  | ||||||
|  | from .exceptions import MediaNotFoundError | ||||||
|  | from .exceptions import VoiceChannelError | ||||||
|  |  | ||||||
| root_log = logging.getLogger() | root_log = logging.getLogger() | ||||||
| plex_log = logging.getLogger("Plex") | plex_log = logging.getLogger("Plex") | ||||||
| bot_log = logging.getLogger("Bot") | 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 <SONG_NAME> - Play a song from the plex server. | ||||||
|  |     album <ALBUM_NAME> - Queue an entire album to play. | ||||||
|  |     playlist <PLAYLIST_NAME> - Queue an entire playlist to play. | ||||||
|  |     lyrics - Print the lyrics of the song (Requires Genius API) | ||||||
|  |     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): | class General(commands.Cog): | ||||||
|  |     """ | ||||||
|  |     General commands | ||||||
|  |  | ||||||
|  |     Manage general bot behavior | ||||||
|  |     """ | ||||||
|  |  | ||||||
|     def __init__(self, bot): |     def __init__(self, bot): | ||||||
|  |         """ | ||||||
|  |         Initialize commands | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             bot: discord.ext.command.Bot, bind for cogs | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             None | ||||||
|  |  | ||||||
|  |         Raises: | ||||||
|  |             None | ||||||
|  |         """ | ||||||
|         self.bot = bot |         self.bot = bot | ||||||
|  |  | ||||||
|     @command() |     @command() | ||||||
|     async def kill(self, ctx, *args): |     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: |         if "silent" not in args: | ||||||
|             await ctx.send(f"Stopping upon the request of {ctx.author.mention}") |             await ctx.send(f"Stopping upon the request of {ctx.author.mention}") | ||||||
|  |  | ||||||
|         await self.bot.close() |         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(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() |     @command() | ||||||
|     async def cleanup(self, ctx, limit=250): |     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 |         channel = ctx.message.channel | ||||||
|  |  | ||||||
|         try: |         try: | ||||||
| @@ -42,18 +129,65 @@ class General(commands.Cog): | |||||||
|                     except (discord.Forbidden, discord.NotFound, discord.HTTPException): |                     except (discord.Forbidden, discord.NotFound, discord.HTTPException): | ||||||
|                         pass |                         pass | ||||||
|  |  | ||||||
|  |             async for i in channel.history(limit=limit): | ||||||
|  |                 if i.author == ctx.message.author and i.content.startswith( | ||||||
|  |                     self.bot.command_prefix | ||||||
|  |                 ): | ||||||
|  |                     try: | ||||||
|  |                         await i.delete() | ||||||
|  |                     except (discord.Forbidden, discord.NotFound, discord.HTTPException): | ||||||
|  |                         pass | ||||||
|  |  | ||||||
|         except discord.Forbidden: |         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.") |             await ctx.send("I don't have the necessary permissions to delete messages.") | ||||||
|  |  | ||||||
|  |  | ||||||
| class Plex(commands.Cog): | class Plex(commands.Cog): | ||||||
|     def __init__(self, bot, base_url, plex_token, lib_name, bot_prefix) -> None: |     """ | ||||||
|         self.bot = bot |     Discord commands pertinent to interacting with Plex | ||||||
|         self.base_url = base_url |  | ||||||
|         self.plex_token = plex_token |  | ||||||
|         self.library_name = lib_name |  | ||||||
|         self.bot_prefix = bot_prefix |  | ||||||
|  |  | ||||||
|  |     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, **kwargs): | ||||||
|  |         """ | ||||||
|  |         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 | ||||||
|  |  | ||||||
|  |         Raises: | ||||||
|  |             plexapi.exceptions.Unauthorized: Invalid Plex token | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             None | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         self.bot = bot | ||||||
|  |         self.base_url = kwargs["base_url"] | ||||||
|  |         self.plex_token = kwargs["plex_token"] | ||||||
|  |         self.library_name = kwargs["lib_name"] | ||||||
|  |         self.bot_prefix = bot.command_prefix | ||||||
|  |  | ||||||
|  |         if kwargs["lyrics_token"]: | ||||||
|  |             self.genius = lyricsgenius.Genius(kwargs["lyrics_token"]) | ||||||
|  |         else: | ||||||
|  |             plex_log.warning("No lyrics token specified, lyrics disabled") | ||||||
|  |             self.genius = None | ||||||
|  |  | ||||||
|  |         # Log fatal invalid plex token | ||||||
|         try: |         try: | ||||||
|             self.pms = PlexServer(self.base_url, self.plex_token) |             self.pms = PlexServer(self.base_url, self.plex_token) | ||||||
|         except Unauthorized: |         except Unauthorized: | ||||||
| @@ -61,57 +195,133 @@ class Plex(commands.Cog): | |||||||
|             raise Unauthorized("Invalid Plex token") |             raise Unauthorized("Invalid Plex token") | ||||||
|  |  | ||||||
|         self.music = self.pms.library.section(self.library_name) |         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.current_track = None | ||||||
|         self.np_message_id = None |         self.np_message_id = None | ||||||
|  |         self.ctx = None | ||||||
|  |  | ||||||
|  |         # Initialize events | ||||||
|         self.play_queue = asyncio.Queue() |         self.play_queue = asyncio.Queue() | ||||||
|         self.play_next_event = asyncio.Event() |         self.play_next_event = asyncio.Event() | ||||||
|  |  | ||||||
|  |         bot_log.info("Started bot successfully") | ||||||
|         self.bot.loop.create_task(self._audio_player_task()) |         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 for track | ||||||
|  |  | ||||||
|     def _search_tracks(self, title): |         Args: | ||||||
|         tracks = self.music.searchTracks() |             title: str title of song to search for | ||||||
|         score = [None, -1] |  | ||||||
|         for i in tracks: |  | ||||||
|             s = fuzz.ratio(title.lower(), i.title.lower()) |  | ||||||
|             if s > score[1]: |  | ||||||
|                 score[0] = i |  | ||||||
|                 score[1] = s |  | ||||||
|             elif s == score[1]: |  | ||||||
|                 score[0] = i |  | ||||||
|  |  | ||||||
|         return score[0] |         Returns: | ||||||
|  |             plexapi.audio.Track pointing to best matching title | ||||||
|  |  | ||||||
|  |         Raises: | ||||||
|  |             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 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") | ||||||
|  |  | ||||||
|  |     def _search_playlists(self, title: str): | ||||||
|  |         """ | ||||||
|  |         Search the Plex music db for playlist | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             title: str title of playlist to search for | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             plexapi.playlist pointing to best matching title | ||||||
|  |  | ||||||
|  |         Raises: | ||||||
|  |             MediaNotFoundError: Title of playlist can't be found in plex db | ||||||
|  |         """ | ||||||
|  |         try: | ||||||
|  |             return self.pms.playlist(title) | ||||||
|  |         except NotFound: | ||||||
|  |             raise MediaNotFoundError("Playlist cannot be found") | ||||||
|  |  | ||||||
|     async def _play(self): |     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() |         track_url = self.current_track.getStreamURL() | ||||||
|         audio_stream = FFmpegPCMAudio(track_url) |         audio_stream = FFmpegPCMAudio(track_url) | ||||||
|  |  | ||||||
|         while self.vc.is_playing(): |         while self.voice_channel.is_playing(): | ||||||
|             asyncio.sleep(2) |             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) |         embed, img = self._build_embed_track(self.current_track) | ||||||
|         self.np_message_id = await self.ctx.send(embed=embed, file=f) |         self.np_message_id = await self.ctx.send(embed=embed, file=img) | ||||||
|  |  | ||||||
|     async def _audio_player_task(self): |     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: |         while True: | ||||||
|             self.play_next_event.clear() |             self.play_next_event.clear() | ||||||
|             if self.vc: |             if self.voice_channel: | ||||||
|                 try: |                 try: | ||||||
|                     # Disconnect after 15 seconds idle |                     # Disconnect after 15 seconds idle | ||||||
|                     async with timeout(15): |                     async with timeout(15): | ||||||
|                         self.current_track = await self.play_queue.get() |                         self.current_track = await self.play_queue.get() | ||||||
|                 except asyncio.TimeoutError: |                 except asyncio.TimeoutError: | ||||||
|                     await self.vc.disconnect() |                     await self.voice_channel.disconnect() | ||||||
|                     self.vc = None |                     self.voice_channel = None | ||||||
|  |  | ||||||
|             if not self.current_track: |             if not self.current_track: | ||||||
|                 self.current_track = await self.play_queue.get() |                 self.current_track = await self.play_queue.get() | ||||||
| @@ -121,25 +331,57 @@ class Plex(commands.Cog): | |||||||
|             await self.np_message_id.delete() |             await self.np_message_id.delete() | ||||||
|  |  | ||||||
|     def _toggle_next(self, error=None): |     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.current_track = None | ||||||
|         self.bot.loop.call_soon_threadsafe(self.play_next_event.set) |         self.bot.loop.call_soon_threadsafe(self.play_next_event.set) | ||||||
|  |  | ||||||
|     def _build_embed(self, track, t="play"): |     @staticmethod | ||||||
|         """Creates a pretty embed card. |     def _build_embed_track(track, type_="play"): | ||||||
|  |         """ | ||||||
|  |         Creates a pretty embed card for tracks | ||||||
|  |  | ||||||
|  |         Builds a helpful status embed with the following info: | ||||||
|  |         Status, song title, album, artist 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 payload. | ||||||
|  |             thumb_art: io.BytesIO of album thumbnail img. | ||||||
|  |  | ||||||
|  |         Raises: | ||||||
|  |             ValueError: Unsupported type of embed {type_} | ||||||
|         """ |         """ | ||||||
|         # Grab the relevant thumbnail |         # Grab the relevant thumbnail | ||||||
|         img_stream = urlopen(track.thumbUrl) |         img_stream = requests.get(track.thumbUrl, stream=True).raw | ||||||
|         img = io.BytesIO(img_stream.read()) |         img = io.BytesIO(img_stream.read()) | ||||||
|  |  | ||||||
|         # Attach to discord embed |         # Attach to discord embed | ||||||
|         f = discord.File(img, filename="image0.png") |         art_file = discord.File(img, filename="image0.png") | ||||||
|         # Get appropiate status message |         # Get appropiate status message | ||||||
|         if t == "play": |         if type_ == "play": | ||||||
|             title = f"Now Playing - {track.title}" |             title = f"Now Playing - {track.title}" | ||||||
|         elif t == "queue": |         elif type_ == "queue": | ||||||
|             title = f"Added to queue - {track.title}" |             title = f"Added to queue - {track.title}" | ||||||
|         else: |         else: | ||||||
|             raise ValueError(f"Unsupported type of embed {t}") |             raise ValueError(f"Unsupported type of embed {type_}") | ||||||
|  |  | ||||||
|         # Include song details |         # Include song details | ||||||
|         descrip = f"{track.album().title} - {track.artist().title}" |         descrip = f"{track.album().title} - {track.artist().title}" | ||||||
| @@ -152,95 +394,415 @@ class Plex(commands.Cog): | |||||||
|         # Point to file attached with ctx object. |         # Point to file attached with ctx object. | ||||||
|         embed.set_thumbnail(url="attachment://image0.png") |         embed.set_thumbnail(url="attachment://image0.png") | ||||||
|  |  | ||||||
|         bot_log.debug(f"Built embed for {track.title}") |         bot_log.debug("Built embed for track - %s", track.title) | ||||||
|  |  | ||||||
|         return embed, f |         return embed, art_file | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def _build_embed_album(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 = requests.get(album.thumbUrl, stream=True).raw | ||||||
|  |         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 | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def _build_embed_playlist(self, playlist): | ||||||
|  |         """ | ||||||
|  |         Creates a pretty embed card for playlists | ||||||
|  |  | ||||||
|  |         Builds a helpful status embed with the following info: | ||||||
|  |         playlist art. All pertitent information | ||||||
|  |         is grabbed dynamically from the Plex db. | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             playlist: plexapi.playlist object of playlist | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             embed: discord.embed fully constructed payload. | ||||||
|  |             thumb_art: io.BytesIO of playlist thumbnail img. | ||||||
|  |  | ||||||
|  |         Raises: | ||||||
|  |             None | ||||||
|  |         """ | ||||||
|  |         # Grab the relevant thumbnail | ||||||
|  |         img_stream = requests.get(self.pms.url(playlist.composite, True), stream=True).raw | ||||||
|  |         img = io.BytesIO(img_stream.read()) | ||||||
|  |  | ||||||
|  |         # Attach to discord embed | ||||||
|  |         art_file = discord.File(img, filename="image0.png") | ||||||
|  |         title = "Added playlist to queue" | ||||||
|  |         descrip = f"{playlist.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 playlist - %s", playlist.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() |     @command() | ||||||
|     async def play(self, ctx, *args): |     async def play(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 |         # Save the context to use with async callbacks | ||||||
|         self.ctx = ctx |         self.ctx = ctx | ||||||
|  |  | ||||||
|         if not len(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) |         title = " ".join(args) | ||||||
|  |  | ||||||
|  |         try: | ||||||
|             track = self._search_tracks(title) |             track = self._search_tracks(title) | ||||||
|  |         except MediaNotFoundError: | ||||||
|         # Fail if song title can't be found |  | ||||||
|         if not track: |  | ||||||
|             await ctx.send(f"Can't find song: {title}") |             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 |             return | ||||||
|  |  | ||||||
|         # Fail if user not in vc |         try: | ||||||
|         elif not ctx.author.voice: |             await self._validate(ctx) | ||||||
|             await ctx.send("Join a voice channel first!") |         except VoiceChannelError: | ||||||
|             bot_log.debug("Failed to play, requester not in voice channel") |             pass | ||||||
|             return |  | ||||||
|  |  | ||||||
|         # Connect to voice if not already |  | ||||||
|         if not self.vc: |  | ||||||
|             self.vc = await ctx.author.voice.channel.connect() |  | ||||||
|             bot_log.debug("Connected to vc.") |  | ||||||
|  |  | ||||||
|         # Specific add to queue message |         # Specific add to queue message | ||||||
|         if self.vc.is_playing(): |         if self.voice_channel.is_playing(): | ||||||
|             bot_log.debug(f"Added to queue - {title}") |             bot_log.debug("Added to queue - %s", title) | ||||||
|             embed, f = self._build_embed(track, t="queue") |             embed, img = self._build_embed_track(track, type_="queue") | ||||||
|             await ctx.send(embed=embed, file=f) |             await ctx.send(embed=embed, file=img) | ||||||
|  |  | ||||||
|         # Add the song to the async queue |         # Add the song to the async queue | ||||||
|         await self.play_queue.put(track) |         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 playlist(self, ctx, *args): | ||||||
|  |         """ | ||||||
|  |         User command to play playlist | ||||||
|  |  | ||||||
|  |         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 playlist to play | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             None | ||||||
|  |  | ||||||
|  |         Raises: | ||||||
|  |             None | ||||||
|  |         """ | ||||||
|  |         # Save the context to use with async callbacks | ||||||
|  |         self.ctx = ctx | ||||||
|  |         title = " ".join(args) | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             playlist = self._search_playlists(title) | ||||||
|  |         except MediaNotFoundError: | ||||||
|  |             await ctx.send(f"Can't find playlist: {title}") | ||||||
|  |             bot_log.debug("Failed to queue playlist, 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_playlist(self, playlist) | ||||||
|  |         await ctx.send(embed=embed, file=img) | ||||||
|  |  | ||||||
|  |         for item in playlist.items(): | ||||||
|  |             if (item.TYPE == "track"): | ||||||
|  |                 await self.play_queue.put(item) | ||||||
|  |  | ||||||
|     @command() |     @command() | ||||||
|     async def stop(self, ctx): |     async def stop(self, ctx): | ||||||
|         if self.vc: |         """ | ||||||
|             self.vc.stop() |         User command to stop playback | ||||||
|             await self.vc.disconnect() |  | ||||||
|             self.vc = None |         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 |             self.ctx = None | ||||||
|             bot_log.debug("Stopped") |             bot_log.debug("Stopped") | ||||||
|             await ctx.send(":stop_button: Stopped") |             await ctx.send(":stop_button: Stopped") | ||||||
|  |  | ||||||
|     @command() |     @command() | ||||||
|     async def pause(self, ctx): |     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") |             bot_log.debug("Paused") | ||||||
|             await ctx.send(":play_pause: Paused") |             await ctx.send(":play_pause: Paused") | ||||||
|  |  | ||||||
|     @command() |     @command() | ||||||
|     async def resume(self, ctx): |     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") |             bot_log.debug("Resumed") | ||||||
|             await ctx.send(":play_pause: Resumed") |             await ctx.send(":play_pause: Resumed") | ||||||
|  |  | ||||||
|     @command() |     @command() | ||||||
|     async def skip(self, ctx): |     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") |         bot_log.debug("Skip") | ||||||
|         if self.vc: |         if self.voice_channel: | ||||||
|             self.vc.stop() |             self.voice_channel.stop() | ||||||
|             bot_log.debug("Skipped") |             bot_log.debug("Skipped") | ||||||
|             self._toggle_next() |             self._toggle_next() | ||||||
|  |  | ||||||
|     @command() |     @command(name="np") | ||||||
|     async def np(self, ctx): |     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: |         if self.current_track: | ||||||
|             embed, f = self._build_embed(self.current_track) |             embed, img = self._build_embed_track(self.current_track) | ||||||
|             bot_log.debug("Now playing") |             bot_log.debug("Now playing") | ||||||
|             if self.np_message_id: |             if self.np_message_id: | ||||||
|                 await self.np_message_id.delete() |                 await self.np_message_id.delete() | ||||||
|                 bot_log("Deleted old np status") |                 bot_log.debug("Deleted old np status") | ||||||
|  |  | ||||||
|             bot_log("Created np status") |             bot_log.debug("Created np status") | ||||||
|             self.np_message_id = await ctx.send(embed=embed, file=f) |             self.np_message_id = await ctx.send(embed=embed, file=img) | ||||||
|  |  | ||||||
|     @command() |     @command() | ||||||
|     async def clear(self, ctx): |     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() |         self.play_queue = asyncio.Queue() | ||||||
|         bot_log.debug("Cleared queue") |         bot_log.debug("Cleared queue") | ||||||
|         await ctx.send(":boom: Queue cleared.") |         await ctx.send(":boom: Queue cleared.") | ||||||
|  |  | ||||||
|  |     @command() | ||||||
|  |     async def lyrics(self, ctx): | ||||||
|  |         """ | ||||||
|  |         User command to get lyrics of a song. | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             ctx: discord.ext.commands.Context message context from command | ||||||
|  |  | ||||||
|  |             Returns: | ||||||
|  |                 None | ||||||
|  |  | ||||||
|  |             Raises: | ||||||
|  |                 None | ||||||
|  |         """ | ||||||
|  |         if not self.current_track: | ||||||
|  |             plex_log.info("No song currently playing") | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         if self.genius: | ||||||
|  |             plex_log.info( | ||||||
|  |                 "Searching for %s, %s", | ||||||
|  |                 self.current_track.title, | ||||||
|  |                 self.current_track.artist().title, | ||||||
|  |             ) | ||||||
|  |             try: | ||||||
|  |                 song = self.genius.search_song( | ||||||
|  |                     self.current_track.title, self.current_track.artist().title | ||||||
|  |                 ) | ||||||
|  |             except TypeError: | ||||||
|  |                 self.genius = None | ||||||
|  |                 plex_log.error("Invalid genius token, disabling lyrics") | ||||||
|  |                 return | ||||||
|  |  | ||||||
|  |             try: | ||||||
|  |                 lyrics = song.lyrics | ||||||
|  |                 # Split into 1950 char chunks | ||||||
|  |                 # Discord max message length is 2000 | ||||||
|  |                 lines = [(lyrics[i : i + 1950]) for i in range(0, len(lyrics), 1950)] | ||||||
|  |  | ||||||
|  |                 for i in lines: | ||||||
|  |                     if i == "": | ||||||
|  |                         continue | ||||||
|  |                     # Apply code block format | ||||||
|  |                     i = f"```{i}```" | ||||||
|  |                     await ctx.send(i) | ||||||
|  |  | ||||||
|  |             except (IndexError, TypeError): | ||||||
|  |                 plex_log.info("Could not find lyrics") | ||||||
|  |                 await ctx.send("Can't find lyrics for this song.") | ||||||
|  |         else: | ||||||
|  |             plex_log.warning("Attempted lyrics without valid token") | ||||||
|   | |||||||
							
								
								
									
										10
									
								
								PlexBot/exceptions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								PlexBot/exceptions.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
							
								
								
									
										142
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										142
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,28 +1,71 @@ | |||||||
| # Plex-Bot | # Plex-Bot | ||||||
|  |  | ||||||
|  | [](https://app.codacy.com/manual/jarulsamy/Plex-Bot?utm_source=github.com&utm_medium=referral&utm_content=jarulsamy/Plex-Bot&utm_campaign=Badge_Grade_Dashboard) | ||||||
|  | [](http://perso.crans.org/besson/LICENSE.html) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| A Python-based Plex music bot for discord. | A Python-based Plex music bot for discord. | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| ## Setup | ## Setup | ||||||
|  |  | ||||||
| Plex-Bot runs entirely in a Docker container. Ensure you have Docker and docker-compose installed according to the official Docker [documentation](https://docs.docker.com/get-docker/). | Plex-Bot runs entirely in a Docker container. Ensure you have Docker and docker-compose installed according to the official Docker [documentation](https://docs.docker.com/get-docker/). | ||||||
|  |  | ||||||
| 1.  Clone the repository and `cd` into it: | 1. Create a new folder and `cd` into it: | ||||||
|  |  | ||||||
| ``` |    ```bash | ||||||
| $ git clone https://github.com/jarulsamy/Plex-Bot |    mkdir Plex-Bot | ||||||
| $ cd Plex-Bot |    cd Plex-Bot | ||||||
|    ``` |    ``` | ||||||
|  |  | ||||||
| 2. Create a configuration folder: | 2. Make a `docker-compose.yml` file or use this sample: | ||||||
|  |  | ||||||
| Create a new `config` folder and copy the sample config file into it: |    ```yml | ||||||
|  |    version: "3" | ||||||
| ``` |    services: | ||||||
| $ mkdir config |      plex-bot: | ||||||
| $ cp sample-config.yaml config/config.yaml |        container_name: "PlexBot" | ||||||
|  |        image: jarulsamy/plex-bot:latest | ||||||
|  |        environment: | ||||||
|  |          - PUID=1000 | ||||||
|  |          - PGID=1000 | ||||||
|  |          - TZ=America/Denver | ||||||
|  |        # Required dir for configuration files | ||||||
|  |        volumes: | ||||||
|  |          - "./config:/config:ro" | ||||||
|  |        restart: "unless-stopped" | ||||||
|    ``` |    ``` | ||||||
|  |  | ||||||
| 3.  Create a Discord bot application: | 3. Create a new `config` folder and create a config file like this:: | ||||||
|  |  | ||||||
|  |     ```bash | ||||||
|  |     mkdir config | ||||||
|  |     cd config | ||||||
|  |     touch config.yaml | ||||||
|  |     ``` | ||||||
|  |  | ||||||
|  |    ```yml | ||||||
|  |    # Create a file called config.yaml with the following contents | ||||||
|  |  | ||||||
|  |    root: | ||||||
|  |      log_level: "info" | ||||||
|  |  | ||||||
|  |    discord: | ||||||
|  |      prefix: "?" | ||||||
|  |      token: "<BOT_TOKEN>" | ||||||
|  |      log_level: "debug" | ||||||
|  |  | ||||||
|  |    plex: | ||||||
|  |      base_url: "<BASE_URL>" | ||||||
|  |      token: "<PLEX_TOKEN>" | ||||||
|  |      library_name: "<LIBRARY_NAME>" | ||||||
|  |      log_level: "debug" | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 4. Create a Discord bot application: | ||||||
|  |  | ||||||
|     1. Go to the Discord developer portal, [here](https://discord.com/developers/applications). |     1. Go to the Discord developer portal, [here](https://discord.com/developers/applications). | ||||||
|  |  | ||||||
| @@ -38,44 +81,71 @@ $ cp sample-config.yaml config/config.yaml | |||||||
|     6. Click Create Bot User |     6. Click Create Bot User | ||||||
|         This will provide you with your bot Username and Token |         This will provide you with your bot Username and Token | ||||||
|  |  | ||||||
|     7. Fill in all the necessary numbers in `config/config.yaml` |     7. Fill in the bot token in `config/config.yaml` | ||||||
|  |  | ||||||
| 4. Get your plex token: | 5. Get your plex token: | ||||||
|  |  | ||||||
|    Refer to the official [plex documentation](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/). |      * Refer to the official [plex documentation](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/). | ||||||
|  |  | ||||||
|    Add it to `config/config.yaml` in the appropiate spot. |      * Add it to `config/config.yaml` in the appropiate spot. | ||||||
|  |  | ||||||
| 5. Start the service: | 6. Get your Lyrics Genius token (Optional): | ||||||
|  |  | ||||||
|  |    If you wanty to disable this feature, set token to `None` in `config/config.yaml` | ||||||
|  |  | ||||||
|  |    If you would like to enable the lyrics feature of the bot, you need to signup for a free GeniusLyrics account, [here](https://genius.com/api-clients). | ||||||
|  |  | ||||||
|  |    After you make an account: | ||||||
|  |  | ||||||
|  |    1. Click New API Client | ||||||
|  |  | ||||||
|  |    2. Set the app website url to: `https://github.com/jarulsamy/Plex-Bot` | ||||||
|  |  | ||||||
|  |    3. Set the redirect url to: `http://localhost` | ||||||
|  |  | ||||||
|  |    4. Copy the **Client Access Token** to `config/config.yaml` | ||||||
|  |  | ||||||
|  | 7. Customize remaining settings | ||||||
|  |  | ||||||
|  |     Set any remaining settings in the config file that you would like. Such as music library, and base url of the Plex server. | ||||||
|  |  | ||||||
|  | 8. Start the service: | ||||||
|  |  | ||||||
|  |    ```bash | ||||||
|  |    docker-compose up -d | ||||||
|    ``` |    ``` | ||||||
| $ docker-compose up --build |  | ||||||
|  | ## Logs | ||||||
|  |  | ||||||
|  | You can view the logs with the following command | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | docker-compose logs -f CONTAINER_NAME_OR_ID | ||||||
|  |  | ||||||
|  | # For example | ||||||
|  | docker-compose logs -f PlexBot | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| ## Usage | ## Usage | ||||||
|  |  | ||||||
| ``` | ```text | ||||||
| General: | General: | ||||||
|   kill - Stop the bot. |     kill [silent] - Halt the bot [silently]. | ||||||
|  |     help - Print this help message. | ||||||
|  |     cleanup - Delete old messages from the bot. | ||||||
|  |  | ||||||
| Plex: | Plex: | ||||||
|   np - View currently playing song. |     play <SONG_NAME> - Play a song from the plex server. | ||||||
|   pause - Pause currently playing song. |     album <ALBUM_NAME> - Queue an entire album to play. | ||||||
|   play - Play a song from the Plex library. |     playlist <PLAYLIST_NAME> - Queue an entire playlist to play. | ||||||
|   resume - Resume a paused song. |     lyrics - Print the lyrics of the song (Requires Genius API) | ||||||
|   skip - Skip a song. |     np - Print the current playing song. | ||||||
|   stop - Stop playing. |     stop - Halt playback and leave vc. | ||||||
| No Category: |     pause - Pause playback. | ||||||
|   help   Shows this message |     resume - Resume playback. | ||||||
|  |     clear - Clear play queue. | ||||||
|  |  | ||||||
| Type ?help command for more info on a command. | [] - Optional args. | ||||||
| You can also type ?help category for more info on a category. |  | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| ## Support |  | ||||||
|  |  | ||||||
| Reach out to me at one of the following places! |  | ||||||
|  |  | ||||||
| -   Email (Best) at joshua.gf.arul@gmail.com |  | ||||||
| -   Twitter at <a href="http://twitter.com/jarulsamy_" target="_blank">`@jarulsamy_`</a> |  | ||||||
|  |  | ||||||
| * * * | * * * | ||||||
|   | |||||||
							
								
								
									
										
											BIN
										
									
								
								assets/screenshot.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								assets/screenshot.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 17 KiB | 
| @@ -1,8 +0,0 @@ | |||||||
| import os |  | ||||||
| import sys |  | ||||||
|  |  | ||||||
| sys.path.append("PlexBot") |  | ||||||
|  |  | ||||||
| from __version__ import VERSION |  | ||||||
|  |  | ||||||
| sys.exit(os.system(f"docker build -t jarulsamy/plex-bot:{VERSION} .")) |  | ||||||
							
								
								
									
										14
									
								
								deploy/build.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										14
									
								
								deploy/build.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,14 @@ | |||||||
|  | #!/usr/bin/env bash | ||||||
|  |  | ||||||
|  | VERSION=$(python PlexBot/__version__.py) | ||||||
|  |  | ||||||
|  | docker build -t "jarulsamy/plex-bot:$VERSION" . | ||||||
|  |  | ||||||
|  | if [ $? -eq 0 ] | ||||||
|  | then | ||||||
|  |   echo "Successfully build docker image." | ||||||
|  |   exit 0 | ||||||
|  | else | ||||||
|  |   echo "Failed to build docker image." >&2 | ||||||
|  |   exit 1 | ||||||
|  | fi | ||||||
| @@ -1,8 +0,0 @@ | |||||||
| import os |  | ||||||
| import sys |  | ||||||
|  |  | ||||||
| sys.path.append("PlexBot") |  | ||||||
|  |  | ||||||
| from __version__ import VERSION |  | ||||||
|  |  | ||||||
| sys.exit(os.system(f"docker push jarulsamy/plex-bot:{VERSION}")) |  | ||||||
							
								
								
									
										14
									
								
								deploy/push.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										14
									
								
								deploy/push.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,14 @@ | |||||||
|  | #!/usr/bin/env bash | ||||||
|  |  | ||||||
|  | VERSION=$(python PlexBot/__version__.py) | ||||||
|  |  | ||||||
|  | docker push "jarulsamy/plex-bot:$VERSION" | ||||||
|  |  | ||||||
|  | if [ $? -eq 0 ] | ||||||
|  | then | ||||||
|  |   echo "Successfully pushed docker image." | ||||||
|  |   exit 0 | ||||||
|  | else | ||||||
|  |   echo "Failed to push docker image." >&2 | ||||||
|  |   exit 1 | ||||||
|  | fi | ||||||
| @@ -2,7 +2,7 @@ version: "3" | |||||||
| services: | services: | ||||||
|   plex-bot: |   plex-bot: | ||||||
|     container_name: "PlexBot" |     container_name: "PlexBot" | ||||||
|     build: . |     image: jarulsamy/plex-bot:latest | ||||||
|     environment: |     environment: | ||||||
|       - PUID=1000 |       - PUID=1000 | ||||||
|       - PGID=1000 |       - PGID=1000 | ||||||
| @@ -10,4 +10,4 @@ services: | |||||||
|     # Required dir for configuration files |     # Required dir for configuration files | ||||||
|     volumes: |     volumes: | ||||||
|       - "./config:/config:ro" |       - "./config:/config:ro" | ||||||
|     restart: "no" |     restart: "unless-stopped" | ||||||
|   | |||||||
							
								
								
									
										13
									
								
								docker-compose_dev.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								docker-compose_dev.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -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" | ||||||
| @@ -1,6 +1,7 @@ | |||||||
| discord.py==1.3.4 | discord.py==1.4.1 | ||||||
| PlexAPI==4.0.0 | PlexAPI==4.0.0 | ||||||
| fuzzywuzzy==0.18.0 | fuzzywuzzy==0.18.0 | ||||||
| pynacl==1.4.0 | pynacl==1.4.0 | ||||||
| ffmpeg==1.4 | ffmpeg==1.4 | ||||||
| PyYAML==5.3.1 | PyYAML==5.3.1 | ||||||
|  | lyricsgenius==2.0.0 | ||||||
|   | |||||||
| @@ -11,3 +11,6 @@ plex: | |||||||
|   token: "<PLEX_TOKEN>" |   token: "<PLEX_TOKEN>" | ||||||
|   library_name: "<LIBRARY_NAME>" |   library_name: "<LIBRARY_NAME>" | ||||||
|   log_level: "debug" |   log_level: "debug" | ||||||
|  |  | ||||||
|  | lyrics: | ||||||
|  |   token: <CLIENT_ACCESS_TOKEN> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user