62 Commits

Author SHA1 Message Date
360416bb02 Merge branch 'master' of github.com:jarulsamy/Plex-Bot 2022-08-03 16:45:12 -06:00
ccee843a36 added functionality to inspect current queue and skip multiple tracks at once 2022-08-03 22:40:54 +00:00
90c40fb0d6 rewrote looping 2022-04-15 16:11:44 +02:00
8b90fb8e34 added debug message 2022-04-15 16:06:22 +02:00
60ef54d8e4 python version screwup workaround 2022-04-15 15:53:30 +02:00
280cbaed92 added basic functionality for looping queues 2022-04-15 15:41:50 +02:00
2f17494ebd added documentation 2022-04-15 15:07:17 +02:00
ed9e677108 playlist shuffling implemented 2022-04-15 15:05:37 +02:00
aa51cc4301 bugfix for missing thumbnail 2022-04-15 14:27:45 +02:00
069d88fd1e implemented looping of tracks 2022-04-15 12:43:55 +02:00
15b7e0f50e catching a few exceptions and handling them accordingly 2022-04-15 12:42:49 +02:00
04da5dfd42 added functionality to inspect current queue and skip multiple tracks at once 2022-04-15 11:53:43 +02:00
19360e3101 Merge pull request #27 from cburgard/playlist-magic
Added command to show available playlists
2022-02-22 07:30:23 -07:00
52099fa794 added option to show available playlists 2022-02-13 13:12:35 +01:00
139ce07d24 fix bug related to empty playlists 2022-02-13 13:10:59 +01:00
5f9c83b75e added documentation for missing option 2022-02-13 13:03:07 +01:00
f8e5af87b6 allow executing outside of docker env by checking if user is root user for config path 2022-02-13 12:04:34 +01:00
a45ccb657e make dependency on lyricsgenius optional by moving import 2022-02-13 12:02:49 +01:00
bcbce98d91 allow lyrics section in yml file to be missing 2022-02-13 12:02:15 +01:00
6521e7e26c Merge pull request #25 from Raventhicc/patch-1
Added Lyrics in docker-compose.yml and slightly changed instructions
2021-11-28 14:54:32 -07:00
add1a1af0d Added Lyrics in docker-compose.yml and slightly changed instructions
This was just done to make it easier for people to understand since some people stumbled on this part.
2021-11-28 20:59:45 +05:30
8f05f5e27f Merge pull request #19 from profesaurus/feature_playlists
Feature playlists
2021-05-30 01:33:09 -06:00
eeb430c016 Fixed even more Codacy Static Code Analysis issues. 2021-05-27 15:39:50 -07:00
1ffa5a7229 Attempt to fix additional Codacy Static Code Analysis issues. 2021-05-27 15:35:34 -07:00
f4cd675502 Attempting to fix the Codacy Static Code Analysis issues. 2021-05-27 14:33:24 -07:00
921dfc02b8 Updated README.md to include the playlist command. Added the playlist command to bot.py comments. 2021-05-27 08:11:02 -07:00
9fb091e9e1 Added the ability to play a playlist with a new "playlist" command.
Ex. ?playlist MyPlaylistName
2021-05-26 09:28:57 -07:00
efdd604d65 Merge pull request #14 from jarulsamy/hotfix
Hotfix
2020-09-23 22:46:04 -06:00
7d6060ed15 🔖 Bump version 2020-09-23 22:40:20 -06:00
d6f5174d20 🐛 Bump discord version
Remedies a seemingly random socketio error:

```
    future: <Task finished coro=<VoiceClient._create_socket() done, defined at /usr/local/lib/python3.7/site-packages/discord/voice_client.py:172>
    exception=gaierror(-2, 'Name or service not known')>

    Traceback (most recent call last):
    File "/usr/local/lib/python3.7/site-packages/discord/voice_client.py", line 191, in _create_socket
    self.endpoint_ip = socket.gethostbyname(self.endpoint) socket.gaierror: [Errno -2] Name or service not known
```
2020-09-23 22:36:47 -06:00
151f650bb2 Merge pull request #13 from jarulsamy/dev
v1.0.2
2020-09-06 15:39:10 -06:00
5eceab7a22 📝 Add lyrics updates 2020-09-06 15:33:25 -06:00
545654f9a7 Add lyrics command
Optionally grab lyrics of a song using the
lyrics command. Required LyricsGenius API token.
2020-09-06 15:32:40 -06:00
57490cdf17 Merge pull request #12 from jarulsamy/dev
v1.0.1
2020-08-29 20:09:14 -06:00
a6be6eac37 Merge branch 'master' into dev 2020-08-29 20:08:38 -06:00
5ba7d751d0 👥 Add label of maintainer 2020-08-14 20:57:03 -06:00
d0b6c25359 Pinned dependencies 2020-08-13 03:21:00 -06:00
db188d968b 🎨 Static methods 2020-08-13 03:20:41 -06:00
b05dd0598b Merge pull request #11 from jarulsamy/dev
v1.0.1
2020-08-13 02:53:46 -06:00
fc4682c210 🔖 Bump version 2020-08-13 02:52:48 -06:00
228c2b480b Add custom help command 2020-08-13 02:52:22 -06:00
08a235d55e 📝 Add docstrings 2020-08-13 02:52:11 -06:00
880c4d50f1 📝 Update help 2020-08-13 02:51:34 -06:00
f12701f4c5 🐛 Fix auto restart on dev env 2020-08-13 02:47:54 -06:00
f95d5c1fd2 Add album playback features
Queue a whole album with one single command.
2020-08-13 02:27:46 -06:00
56fd4aa5ab Makefile for dev and prod envs 2020-08-13 01:58:31 -06:00
5f90b17b0e Switch to PlexAPI for search 2020-08-13 01:58:12 -06:00
cf2bc24f8a 🐛 Fix always skip push 2020-08-10 03:03:54 -06:00
1839cc5d03 Merge pull request #10 from codacy-badger/codacy-badge
Add a Codacy badge to README.md
2020-08-10 02:42:52 -06:00
e6c4b84538 Add Codacy badge 2020-08-10 08:42:13 +00:00
c1cba637b8 Merge pull request #9 from jarulsamy/dev
v1.0.0
2020-08-10 02:37:42 -06:00
7f49c4d958 🔖 Tag release 2020-08-10 02:33:15 -06:00
ed1a64cb52 🎨 Fix style of docstrings 2020-08-10 02:26:38 -06:00
7471da85f7 Seperate field for pushing package 2020-08-10 02:25:48 -06:00
4633247004 🐛 Cleaner builds, allow some compilation 2020-08-10 02:25:33 -06:00
98a36c6cbc 🐛 Update to support shell scripts for deploy 2020-08-10 02:18:06 -06:00
b7ab589f6e 🎨 Switch to shell scripts for deployment 2020-08-10 02:16:11 -06:00
4aadd886d5 📝 Format fixes 2020-08-09 20:12:31 -06:00
3e90c9e9ef Merge pull request #8 from jarulsamy/dev
Docs Overhaul
2020-08-09 15:26:54 -06:00
33bbf21bab Merge branch 'master' into dev 2020-08-09 15:26:01 -06:00
253f2a9a82 🚀 Use docker pull instead of building repo 2020-08-09 15:24:50 -06:00
af91883635 📝 Overhaul docs
Add better detail of bot creation

Add badges

Add sample docker-compose.yml
2020-08-09 15:24:15 -06:00
18 changed files with 886 additions and 176 deletions

View File

@ -1,8 +1,10 @@
FROM python:3.7-slim
LABEL maintainer="Joshua Arulsamy <joshua.gf.arul@gmail.com>"
# Install ffmpeg
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 clean && \
rm -rf /var/lib/apt/lists/*
@ -14,7 +16,7 @@ WORKDIR /src
COPY requirements.txt .
# 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/ PlexBot

16
Jenkinsfile vendored
View File

@ -25,6 +25,7 @@ pipeline {
sh ''' conda create --yes -n ${BUILD_TAG} python
source /var/lib/jenkins/miniconda3/etc/profile.d/conda.sh
conda activate ${BUILD_TAG}
pip install -r requirements.txt
pip install pylint
'''
}
@ -47,19 +48,12 @@ pipeline {
}
}
steps {
sh ''' source /var/lib/jenkins/miniconda3/etc/profile.d/conda.sh
conda activate ${BUILD_TAG}
python deploy/build.py
'''
sh './deploy/build.sh'
}
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
View 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

View File

@ -1,7 +1,5 @@
"""Plex music bot for discord.
Do not import this module, it is intended to be
used exclusively within a docker environment.
"""
Plex music bot for discord.
"""
import logging
@ -19,7 +17,7 @@ plex_log = logging.getLogger("Plex")
bot_log = logging.getLogger("Bot")
def load_config(filename: str) -> Dict[str, str]:
def load_config(basedir: str,filename: str) -> Dict[str, str]:
"""Loads config from yaml file
Grabs key/value config pairs from a file.
@ -35,12 +33,12 @@ def load_config(filename: str) -> Dict[str, str]:
"""
# All config files should be in /config
# for docker deployment.
filename = Path("/config", filename)
filename = Path(basedir, filename)
try:
with open(filename, "r") as config_file:
config = yaml.safe_load(config_file)
except FileNotFoundError:
root_log.fatal("Configuration file not found.")
root_log.fatal("Configuration file not found at '"+str(filename)+"'.")
sys.exit(-1)
# Convert str level type to logging constant
@ -56,4 +54,7 @@ def load_config(filename: str) -> Dict[str, str]:
config["plex"]["log_level"] = levels[config["plex"]["log_level"].upper()]
config["discord"]["log_level"] = levels[config["discord"]["log_level"].upper()]
if config["lyrics"] and config["lyrics"]["token"].lower() == "none":
config["lyrics"] = None
return config

View File

@ -1,7 +1,6 @@
"""Main entrypoint for bot
"""
Main entrypoint script.
Sets up loggers and initiates bot.
"""
import logging
@ -12,7 +11,11 @@ from .bot import General
from .bot import Plex
# Load config from file
config = load_config("config.yaml")
configdir = "config"
from os import geteuid
if geteuid() == 0:
configdir = "/config"
config = load_config(configdir,"config.yaml")
BOT_PREFIX = config["discord"]["prefix"]
TOKEN = config["discord"]["token"]
@ -21,6 +24,11 @@ BASE_URL = config["plex"]["base_url"]
PLEX_TOKEN = config["plex"]["token"]
LIBRARY_NAME = config["plex"]["library_name"]
if config["lyrics"]:
LYRICS_TOKEN = config["lyrics"]["token"]
else:
LYRICS_TOKEN = None
# Set appropiate log level
root_log = logging.getLogger()
plex_log = logging.getLogger("Plex")
@ -29,7 +37,16 @@ bot_log = logging.getLogger("Bot")
plex_log.setLevel(config["plex"]["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)
# 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.add_cog(Plex(bot, **plex_args))
bot.run(TOKEN)

View File

@ -1,2 +1,5 @@
"""Track version number of package"""
VERSION = "0.0.6"
"""Track version number of package."""
VERSION = "1.0.3"
if __name__ == "__main__":
print(VERSION)

View File

@ -1,31 +1,64 @@
""" All discord bot and Plex api interactions"""
"""All discord bot and Plex api interactions."""
import asyncio
import io
import logging
from urllib.request import urlopen
import requests
import discord
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.exceptions import NotFound
from plexapi.server import PlexServer
from .exceptions import MediaNotFoundError
from .exceptions import VoiceChannelError
root_log = logging.getLogger()
plex_log = logging.getLogger("Plex")
bot_log = logging.getLogger("Bot")
help_text = """
General:
kill [silent] - Halt the bot [silently].
help - Print this help message.
cleanup - Delete old messages from the bot.
Plex:
play <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.
show_playlists <ARG> <ARG> - Query for playlists with a name matching any of the arguments.
lyrics - Print the lyrics of the song (Requires Genius API)
np - Print the current playing song.
q - Print the current queue (This can take very long!)
stop - Halt playback and leave vc.
loop - Loop the current song.
loopq - Loop the current queue.
unloop - Disable looping the current track.
unloopq - Disable looping the current queue.
pause - Pause playback.
resume - Resume playback.
skip - Skip the current song. Give a number as argument to skip more than 1.
clear - Clear play queue.
[] - Optional args.
"""
class General(commands.Cog):
"""General commands
"""
General commands
Manage general bot behavior
"""
def __init__(self, bot):
"""Initialize commands
"""
Initialize commands
Args:
bot: discord.ext.command.Bot, bind for cogs
@ -40,7 +73,8 @@ class General(commands.Cog):
@command()
async def kill(self, ctx, *args):
"""Kill the bot
"""
Kill the bot
Args:
ctx: discord.ext.commands.Context message context from command
@ -58,9 +92,27 @@ 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
"""
Delete old messages from bot
Args:
ctx: discord.ext.commands.Context message context from command
@ -83,27 +135,35 @@ class General(commands.Cog):
except (discord.Forbidden, discord.NotFound, discord.HTTPException):
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:
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):
"""Discord commands pertinent to interacting with Plex
"""
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
def __init__(self, bot, **kwargs):
"""
Initializes Plex resources
Connects to Plex library and sets up
all asyncronous communications.
@ -113,7 +173,6 @@ class Plex(commands.Cog):
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
@ -123,10 +182,17 @@ class Plex(commands.Cog):
"""
self.bot = bot
self.base_url = base_url
self.plex_token = plex_token
self.library_name = lib_name
self.bot_prefix = bot_prefix
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"]:
import lyricsgenius
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:
@ -141,7 +207,10 @@ class Plex(commands.Cog):
# Initialize necessary vars
self.voice_channel = None
self.current_track = None
self.is_looping = False
self.loop_queue = None
self.np_message_id = None
self.show_queue_message_ids = []
self.ctx = None
# Initialize events
@ -152,35 +221,73 @@ class Plex(commands.Cog):
self.bot.loop.create_task(self._audio_player_task())
def _search_tracks(self, title: str):
"""Search the Plex music db
Uses a fuzzy search algorithm to find the closest matching song
title in the Plex music database.
"""
Search the Plex music db for track
Args:
title: str title of song to search for
Returns:
plexapi.audio.Track pointing to closest matching title
None if song can't be found.
plexapi.audio.Track pointing to best matching title
Raises:
None
MediaNotFoundError: Title of track can't be found in plex db
"""
tracks = self.music.searchTracks()
score = [None, -1]
for i in tracks:
ratio = fuzz.ratio(title.lower(), i.title.lower())
if ratio > score[1]:
score[0] = i
score[1] = ratio
elif ratio == score[1]:
score[0] = i
results = self.music.searchTracks(title=title, maxresults=1)
try:
return results[0]
except IndexError:
raise MediaNotFoundError("Track cannot be found")
return score[0]
def _search_albums(self, title: str):
"""
Search the Plex music db for album
Args:
title: str title of album to search for
Returns:
plexapi.audio.Album pointing to best matching title
Raises:
MediaNotFoundError: Title of album can't be found in plex db
"""
results = self.music.searchAlbums(title=title, maxresults=1)
try:
return results[0]
except IndexError:
raise MediaNotFoundError("Album cannot be found")
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")
def _get_playlists(self):
"""
Search the Plex music db for playlist
Returns:
List of plexapi.playlist
"""
return self.pms.playlists()
async def _play(self):
"""Heavy lifting of playing songs
"""
Heavy lifting of playing songs
Grabs the appropiate streaming URL, sends the `now playing`
message, and initiates playback in the vc.
@ -197,18 +304,45 @@ class Plex(commands.Cog):
track_url = self.current_track.getStreamURL()
audio_stream = FFmpegPCMAudio(track_url)
while self.voice_channel.is_playing():
asyncio.sleep(2)
while self.voice_channel and self.voice_channel.is_playing():
bot_log.debug("waiting for track to finish")
await asyncio.sleep(2)
bot_log.debug("track finished")
if self.voice_channel:
self.voice_channel.play(audio_stream, after=self._toggle_next)
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 _play_next(self):
try:
from asyncio.exceptions import CancelledError
except ImportError:
from concurrent.futures._base import CancelledError
if self.is_looping:
self.current_track = self.is_looping
else:
try:
self.current_track = await self.play_queue.get()
except CancelledError:
bot_log.debug("failed to pop queue")
if not self.current_track and self.loop_queue:
bot_log.debug("swapping loop queue and play queue")
for item in self.loop_queue:
await self.play_queue.put(item)
try:
self.current_track = await self.play_queue.get()
except CancelledError:
bot_log.debug("failed to pop queue")
async def _audio_player_task(self):
"""Coroutine to handle playback and queuing
"""
Coroutine to handle playback and queuing
Always-running function awaiting new songs to be added.
Auto disconnects from VC if idle for > 15 seconds.
@ -229,20 +363,23 @@ class Plex(commands.Cog):
try:
# Disconnect after 15 seconds idle
async with timeout(15):
self.current_track = await self.play_queue.get()
await self._play_next()
except asyncio.TimeoutError:
bot_log("timeout - disconnecting")
await self.voice_channel.disconnect()
self.voice_channel = None
if not self.current_track:
self.current_track = await self.play_queue.get()
await self._play_next()
await self._play()
await self.play_next_event.wait()
if self.np_message_id:
await self.np_message_id.delete()
def _toggle_next(self, error=None):
"""Callback for vc playback
"""
Callback for vc playback
Clears current track, then activates _audio_player_task
to play next in queue or disconnect.
@ -259,11 +396,13 @@ 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.
@staticmethod
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, artistm and album art. All
Status, song title, album, artist and album art. All
pertitent information is grabbed dynamically from the Plex db.
Args:
@ -271,23 +410,29 @@ 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:
ValueError: Unsupported type of embed {type_}
"""
# Grab the relevant thumbnail
img_stream = urlopen(track.thumbUrl)
if track.thumbUrl:
img_stream = requests.get(track.thumbUrl, stream=True).raw
img = io.BytesIO(img_stream.read())
# Attach to discord embed
art_file = discord.File(img, filename="image0.png")
else:
art_file = None
# Get appropiate status message
if type_ == "play":
title = f"Now Playing - {track.title}"
elif type_ == "queue":
title = f"Added to queue - {track.title}"
elif type_ == "queued":
title = f"Next in line - {track.title}"
else:
raise ValueError(f"Unsupported type of embed {type_}")
@ -302,13 +447,116 @@ 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
@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, title, descrip):
"""
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
try:
img_stream = requests.get(self.pms.url(playlist.composite, True), stream=True).raw
img = io.BytesIO(img_stream.read())
except:
raise MediaNotFoundError("no image available")
# Attach to discord embed
art_file = discord.File(img, filename="image0.png")
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:
try:
self.voice_channel = await ctx.author.voice.channel.connect()
bot_log.debug("Connected to vc.")
except asyncio.exceptions.TimeoutError:
bot_log.debug("Cannot connect to vc - timeout")
@command()
async def play(self, ctx, *args):
"""User command to start playback
"""
User command to play song
Searchs plex db and either, initiates playback, or
adds to queue. Handles invalid usage from the user.
@ -325,44 +573,188 @@ class Plex(commands.Cog):
"""
# Save the context to use with async callbacks
self.ctx = ctx
if not args:
await ctx.send(f"Usage: {self.bot_prefix}play TITLE_OF_SONG")
bot_log.debug("Failed to play, invalid usage")
return
title = " ".join(args)
track = self._search_tracks(title)
# Fail if song title can't be found
if not track:
try:
track = self._search_tracks(title)
except MediaNotFoundError:
await ctx.send(f"Can't find song: {title}")
bot_log.debug("Failed to play, can't find song - %s", title)
return
# Fail if user not in vc
if not ctx.author.voice:
await ctx.send("Join a voice channel first!")
bot_log.debug("Failed to play, requester not in voice channel")
return
# Connect to voice if not already
if not self.voice_channel:
self.voice_channel = await ctx.author.voice.channel.connect()
bot_log.debug("Connected to vc.")
try:
await self._validate(ctx)
except VoiceChannelError:
pass
# Specific add to queue message
if self.voice_channel.is_playing():
if self.voice_channel and 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)
async def play_playlist(self, title, shuffle=False):
try:
playlist = self._search_playlists(title)
except MediaNotFoundError:
await self.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(self.ctx)
except VoiceChannelError:
pass
try:
embed, img = self._build_embed_playlist(self, playlist, "Added playlist to queue", playlist.title)
await self.ctx.send(embed=embed, file=img)
items = [ item for item in playlist.items() if item.TYPE == "track" ]
if shuffle:
from random import shuffle
shuffle(items)
for item in items:
await self.play_queue.put(item)
bot_log.debug("Added to queue - %s", title)
except MediaNotFoundError:
await self.ctx.send(message="Playlist "+title+" seems to be empty!")
bot_log.debug("Playlist empty - %s", title)
@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)
await self.play_playlist(title)
@command()
async def playlist_shuffle(self, ctx, *args):
"""
User command to play playlist in shuffle mode
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)
await self.play_playlist(title,shuffle=True)
@command()
async def show_playlists(self, ctx, *args):
"""
User command to show playlists
Searchs plex db and shows playlists matching.
Args:
ctx: discord.ext.commands.Context message context from command
*args: String filter for playlist names
Returns:
None
Raises:
None
"""
# Save the context to use with async callbacks
self.ctx = ctx
playlists = self._get_playlists()
try:
await self._validate(ctx)
except VoiceChannelError:
pass
for playlist in playlists:
if args and not any(arg in playlist.title for arg in args):
continue
from datetime import timedelta
if playlist.duration:
seconds = playlist.duration / 1000
embed, img = self._build_embed_playlist(self, playlist, playlist.title, "{:0>8}".format(str(timedelta(seconds=seconds))))
await ctx.send(embed=embed, file=img)
@command()
async def stop(self, ctx):
"""User command to stop playback
"""
User command to stop playback
Stops playback and disconnects from vc.
@ -383,9 +775,84 @@ class Plex(commands.Cog):
bot_log.debug("Stopped")
await ctx.send(":stop_button: Stopped")
@command()
async def loop(self, ctx):
"""
User command to activate looping the current track
Args:
ctx: discord.ext.commands.Context message context from command
Returns:
None
Raises:
None
"""
bot_log.debug("Looping "+str(self.current_track))
self.is_looping = self.current_track
@command()
async def loopq(self, ctx):
"""
User command to activate looping the current queue
Args:
ctx: discord.ext.commands.Context message context from command
Returns:
None
Raises:
None
"""
bot_log.debug("Looping current queue")
if self.current_track:
self.loop_queue = [ self.current_track ]
else:
self.loop_queue = []
for item in self.play_queue._queue:
self.loop_queue.append(item)
@command()
async def unloop(self, ctx):
"""
User command to deactivate looping the current track
Args:
ctx: discord.ext.commands.Context message context from command
Returns:
None
Raises:
None
"""
bot_log.debug("Looping current queue")
self.is_looping = False
@command()
async def unloopq(self, ctx):
"""
User command to deactivate looping the current queue
Args:
ctx: discord.ext.commands.Context message context from command
Returns:
None
Raises:
None
"""
bot_log.debug("Unlooping")
self.loop_queue = None
@command()
async def pause(self, ctx):
"""User command to pause playback
"""
User command to pause playback
Pauses playback, but doesn't reset anything
to allow playback resuming.
@ -406,7 +873,8 @@ class Plex(commands.Cog):
@command()
async def resume(self, ctx):
"""User command to resume playback
"""
User command to resume playback
Args:
ctx: discord.ext.commands.Context message context from command
@ -423,8 +891,9 @@ class Plex(commands.Cog):
await ctx.send(":play_pause: Resumed")
@command()
async def skip(self, ctx):
"""User command to skip song in queue
async def skip(self, ctx, *args):
"""
User command to skip song in queue
Skips currently playing song. If no other songs in
queue, stops playback, otherwise moves to next song.
@ -438,15 +907,22 @@ class Plex(commands.Cog):
Raises:
None
"""
bot_log.debug("Skip")
n = 1
if args:
n = int(args[0])
bot_log.debug("Skipping "+str(n))
if self.voice_channel:
self.voice_channel.stop()
bot_log.debug("Skipped")
if n>1:
for i in range(n-1):
await self.play_queue.get()
self._toggle_next()
@command(name="np")
async def now_playing(self, ctx):
"""User command to get currently playing song.
"""
User command to get currently playing song.
Deletes old `now playing` status message,
Creates a new one with up to date information.
@ -461,18 +937,55 @@ 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,type_="play")
bot_log.debug("Now playing")
if self.np_message_id:
try:
await self.np_message_id.delete()
bot_log.debug("Deleted old np status")
except discord.errors.NotFound:
pass
bot_log.debug("Created np status")
self.np_message_id = await ctx.send(embed=embed, file=img)
@command(name="q")
async def show_queue(self, ctx):
"""
User command to print the current queue
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
"""
bot_log.debug("Deleted old queue messages")
for msg in self.show_queue_message_ids:
await msg.delete()
# need to do this in order to avoid errors when queue is modified during inspection
elems = []
for track in self.play_queue._queue:
elems.append(track)
for track in elems:
embed, img = self._build_embed_track(track,type_="queued")
bot_log.debug("Show queue")
bot_log.debug("Created queue message")
self.show_queue_message_ids.append(await ctx.send(embed=embed, file=img))
@command()
async def clear(self, ctx):
"""User command to clear play queue.
"""
User command to clear play queue.
Args:
ctx: discord.ext.commands.Context message context from command
@ -484,5 +997,58 @@ class Plex(commands.Cog):
None
"""
self.play_queue = asyncio.Queue()
self.loop_queue = None
bot_log.debug("Cleared queue")
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
View 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

143
README.md
View File

@ -1,28 +1,74 @@
# Plex-Bot
[![Codacy Badge](https://api.codacy.com/project/badge/Grade/c93b8ff976ce4205a95046487917476b)](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)
[![GPLv3 license](https://img.shields.io/badge/License-GPLv3-blue.svg)](http://perso.crans.org/besson/LICENSE.html)
![docker pulls](https://img.shields.io/docker/pulls/jarulsamy/plex-bot)
![docker img size](https://img.shields.io/docker/image-size/jarulsamy/plex-bot)
![black badge](https://img.shields.io/badge/code%20style-black-000000.svg)
A Python-based Plex music bot for discord.
![screenshot](assets/screenshot.png)
## 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/).
1. Clone the repository and `cd` into it:
1. Create a new folder and `cd` into it:
```
$ git clone https://github.com/jarulsamy/Plex-Bot
$ cd Plex-Bot
```bash
mkdir 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:
```
$ mkdir config
$ cp sample-config.yaml config/config.yaml
```yml
version: "3"
services:
plex-bot:
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"
lyrics:
token: "none" # Add your token here if you enable lyrics
```
4. Create a Discord bot application:
1. Go to the Discord developer portal, [here](https://discord.com/developers/applications).
@ -38,44 +84,69 @@ $ cp sample-config.yaml config/config.yaml
6. Click Create Bot User
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 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** and replace `None` with your token in `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
```
```text
General:
kill - Stop the bot.
kill [silent] - Halt the bot [silently].
help - Print this help message.
cleanup - Delete old messages from 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
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.
Type ?help command for more info on a command.
You can also type ?help category for more info on a category.
[] - Optional args.
```
## 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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
View 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

View File

@ -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
View 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

View File

@ -2,7 +2,7 @@ version: "3"
services:
plex-bot:
container_name: "PlexBot"
build: .
image: jarulsamy/plex-bot:latest
environment:
- PUID=1000
- PGID=1000
@ -10,4 +10,4 @@ services:
# Required dir for configuration files
volumes:
- "./config:/config:ro"
restart: "no"
restart: "unless-stopped"

13
docker-compose_dev.yml Normal file
View 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"

View File

@ -1,6 +1,7 @@
discord.py==1.3.4
discord.py==1.4.1
PlexAPI==4.0.0
fuzzywuzzy==0.18.0
pynacl==1.4.0
ffmpeg==1.4
PyYAML==5.3.1
lyricsgenius==2.0.0

View File

@ -11,3 +11,6 @@ plex:
token: "<PLEX_TOKEN>"
library_name: "<LIBRARY_NAME>"
log_level: "debug"
lyrics:
token: <CLIENT_ACCESS_TOKEN>