18 Commits

Author SHA1 Message Date
cfd89ea6e8 Merge pull request #7 from jarulsamy/dev
v0.0.6
2020-08-09 14:48:58 -06:00
9cbd0be424 🔖 Bump version 2020-08-09 14:48:17 -06:00
1a8ec5f21f ♻️ Helpful documentation
Add docstrings and other useful comments.

Extended variable names to be more descriptive.
2020-08-09 14:47:00 -06:00
d0aacc3f0b Bumped version 2020-08-09 02:44:57 -06:00
9078ef616a Merge pull request #6 from jarulsamy/dev
v0.0.4
2020-08-09 01:32:45 -06:00
72935d8c7c 🚀 Stop deployment in favor of docker hub 2020-08-09 01:31:49 -06:00
76d2d97c62 🚀 Automatic deployment through jenkins 2020-08-09 00:58:50 -06:00
63fea747c6 🐛 Fix np status autoremoval 2020-08-09 00:35:27 -06:00
d48188870e 🔊 Major changes to logging systems
Discord bot and plex operations are now logged seperatly.
2020-08-09 00:28:14 -06:00
64a09def50 Remove unused dependency 2020-08-09 00:25:36 -06:00
786a7d3742 Massive docker image size reduction 2020-08-09 00:25:09 -06:00
5c45500450 Merge pull request #5 from jarulsamy/docker
Incremental Updates
2020-08-08 18:45:24 -06:00
2d633acc67 🐛 Reduce dependency load of ffmpeg 2020-08-08 18:44:44 -06:00
257ea91c4c 🐛 Add appropiate steps for docker build 2020-08-08 18:30:12 -06:00
21c6bcc746 Add auto message deletion
Now playing messages now auto delete on song completion.
2020-08-08 18:08:51 -06:00
7a8cd631a5 Merge pull request #4 from jarulsamy/docker
Async Improvements, v0.0.2
2020-08-08 17:21:23 -06:00
81e0b50620 🙈 Deployment script 2020-08-08 17:18:11 -06:00
9f9ba0f5e5 Add auto disconnect on idle
Bot now leaves a voice channel if nothing is playing for more than 15
seconds.
2020-08-08 17:07:21 -06:00
11 changed files with 425 additions and 95 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
# Project specific # Project specific
config.yaml config.yaml
config/ config/
deploy.sh
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/

View File

@ -1,11 +1,11 @@
# Python 3.7 FROM python:3.7-slim
FROM python:3.7
# Update system
RUN apt-get -y update
RUN apt-get -y upgrade
# Install ffmpeg # Install ffmpeg
RUN apt-get install -y ffmpeg RUN apt-get -y update && \
apt-get install -y --no-install-recommends ffmpeg && \
apt-get autoremove -y && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# All source code # All source code
WORKDIR /src WORKDIR /src
@ -14,11 +14,10 @@ WORKDIR /src
COPY requirements.txt . COPY requirements.txt .
# Install all dependencies. # Install all dependencies.
RUN pip install -r requirements.txt RUN pip install --only-binary all --no-cache-dir -r requirements.txt
# Copy PlexBot over to src. # Copy PlexBot over to src.
COPY PlexBot/ PlexBot COPY PlexBot/ PlexBot
# Run the bot # Run the bot
# CMD ["python", "-OO", "-m", "PlexBot"] CMD ["python", "-OO", "-m", "PlexBot"]
CMD ["python", "-m", "PlexBot"]

16
Jenkinsfile vendored
View File

@ -35,7 +35,7 @@ pipeline {
echo "Style check" echo "Style check"
sh ''' source /var/lib/jenkins/miniconda3/etc/profile.d/conda.sh sh ''' source /var/lib/jenkins/miniconda3/etc/profile.d/conda.sh
conda activate ${BUILD_TAG} conda activate ${BUILD_TAG}
pylint CHANGE_ME || true pylint PlexBot || true
''' '''
} }
} }
@ -49,9 +49,7 @@ pipeline {
steps { steps {
sh ''' source /var/lib/jenkins/miniconda3/etc/profile.d/conda.sh sh ''' source /var/lib/jenkins/miniconda3/etc/profile.d/conda.sh
conda activate ${BUILD_TAG} conda activate ${BUILD_TAG}
pwd python deploy/build.py
ls
python setup.py bdist_wheel
''' '''
} }
post { post {
@ -60,6 +58,7 @@ pipeline {
archiveArtifacts (allowEmptyArchive: true, archiveArtifacts (allowEmptyArchive: true,
artifacts: 'dist/*whl', artifacts: 'dist/*whl',
fingerprint: true) fingerprint: true)
sh 'python deploy/push.py'
} }
} }
} }
@ -68,14 +67,7 @@ pipeline {
post { post {
always { always {
sh 'conda remove --yes -n ${BUILD_TAG} --all' sh 'conda remove --yes -n ${BUILD_TAG} --all'
} sh 'docker system prune -a -f'
failure {
emailext (
subject: "FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'",
body: """<p>FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]':</p>
<p>Check console output at &QUOT;<a href='${env.BUILD_URL}'>${env.JOB_NAME} [${env.BUILD_NUMBER}]</a>&QUOT;</p>""",
recipientProviders: [[$class: 'DevelopersRecipientProvider']]
)
} }
} }
} }

View File

@ -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
@ -8,19 +14,33 @@ import yaml
FORMAT = "%(asctime)s %(levelname)s: [%(filename)s:%(lineno)s - %(funcName)20s() ] %(message)s" FORMAT = "%(asctime)s %(levelname)s: [%(filename)s:%(lineno)s - %(funcName)20s() ] %(message)s"
logging.basicConfig(format=FORMAT) logging.basicConfig(format=FORMAT)
logger = logging.getLogger("PlexBot") root_log = logging.getLogger()
plex_log = logging.getLogger("Plex")
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:
logging.fatal("Configuration file not found.") root_log.fatal("Configuration file not found.")
sys.exit(-1) sys.exit(-1)
# Convert str level type to logging constant # Convert str level type to logging constant
@ -31,7 +51,9 @@ def load_config(filename: str) -> Dict[str, str]:
"ERROR": logging.ERROR, "ERROR": logging.ERROR,
"CRITICAL": logging.CRITICAL, "CRITICAL": logging.CRITICAL,
} }
level = config["general"]["log_level"]
config["general"]["log_level"] = levels[level.upper()] config["root"]["log_level"] = levels[config["root"]["log_level"].upper()]
config["plex"]["log_level"] = levels[config["plex"]["log_level"].upper()]
config["discord"]["log_level"] = levels[config["discord"]["log_level"].upper()]
return config return config

View File

@ -1,11 +1,15 @@
"""Main entrypoint for bot
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")
@ -16,12 +20,14 @@ TOKEN = config["discord"]["token"]
BASE_URL = config["plex"]["base_url"] 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"]
LOG_LEVEL = config["general"]["log_level"]
# Set appropiate log level # Set appropiate log level
logger = logging.getLogger("PlexBot") root_log = logging.getLogger()
logging.basicConfig(format=FORMAT) plex_log = logging.getLogger("Plex")
logger.setLevel(LOG_LEVEL) bot_log = logging.getLogger("Bot")
plex_log.setLevel(config["plex"]["log_level"])
bot_log.setLevel(config["discord"]["log_level"])
bot = Bot(command_prefix=BOT_PREFIX) bot = Bot(command_prefix=BOT_PREFIX)
bot.add_cog(General(bot)) bot.add_cog(General(bot))

2
PlexBot/__version__.py Normal file
View File

@ -0,0 +1,2 @@
"""Track version number of package"""
VERSION = "0.0.6"

View File

@ -1,9 +1,11 @@
""" 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 discord import discord
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
@ -11,123 +13,322 @@ from fuzzywuzzy import fuzz
from plexapi.exceptions import Unauthorized from plexapi.exceptions import Unauthorized
from plexapi.server import PlexServer from plexapi.server import PlexServer
logger = logging.getLogger("PlexBot") root_log = logging.getLogger()
plex_log = logging.getLogger("Plex")
bot_log = logging.getLogger("Bot")
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): async def kill(self, ctx, *args):
await ctx.send(f"Stopping upon the request of {ctx.author.mention}") """Kill the bot
Args:
ctx: discord.ext.commands.Context message context from command
*args: optional flags
Returns:
None
Raises:
None
"""
if "silent" not in args:
await ctx.send(f"Stopping upon the request of {ctx.author.mention}")
await self.bot.close() await self.bot.close()
logger.info(f"Stopping upon the request of {ctx.author.mention}") bot_log.info("Stopping upon the request of %s", ctx.author.mention)
@command()
async def cleanup(self, ctx, limit=250):
"""Delete old messages from bot
Args:
ctx: discord.ext.commands.Context message context from command
limit: int number of messages to go back by to delete. Default 250
Raises:
None
Returns:
None
"""
channel = ctx.message.channel
try:
async for i in channel.history(limit=limit):
# Only delete messages sent by self
if i.author == self.bot.user:
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): class Plex(commands.Cog):
def __init__(self, bot, base_url, plex_token, lib_name, bot_prefix) -> None: """Discord commands pertinent to interacting with Plex
Contains user commands such as play, pause, resume, stop, etc.
Grabs, and parses all data from plex database.
"""
# pylint: disable=too-many-instance-attributes
# All are necessary to detect global interactions
# within the bot.
def __init__(
self, bot, base_url: str, plex_token: str, lib_name: str, bot_prefix: str
):
"""Initializes Plex resources
Connects to Plex library and sets up
all asyncronous communications.
Args:
bot: discord.ext.command.Bot, bind for cogs
base_url: str url to Plex server
plex_token: str X-Token of Plex server
lib_name: str name of Plex library to search through
bot_prefix: str prefix used to interact with bots
Raises:
plexapi.exceptions.Unauthorized: Invalid Plex token
Returns:
None
"""
self.bot = bot self.bot = bot
self.base_url = base_url self.base_url = base_url
self.plex_token = plex_token self.plex_token = plex_token
self.library_name = lib_name self.library_name = lib_name
self.bot_prefix = bot_prefix self.bot_prefix = bot_prefix
# 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:
logger.fatal("Invalid Plex token, stopping...") plex_log.fatal("Invalid Plex token, stopping...")
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("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.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())
logger.info("Started bot successfully") def _search_tracks(self, title: str):
"""Search the Plex music db
def _search_tracks(self, title): Uses a fuzzy search algorithm to find the closest matching song
title in the Plex music database.
Args:
title: str title of song to search for
Returns:
plexapi.audio.Track pointing to closest matching title
None if song can't be found.
Raises:
None
"""
tracks = self.music.searchTracks() tracks = self.music.searchTracks()
score = [None, -1] score = [None, -1]
for i in tracks: for i in tracks:
s = fuzz.ratio(title.lower(), i.title.lower()) ratio = fuzz.ratio(title.lower(), i.title.lower())
if s > score[1]: if ratio > score[1]:
score[0] = i score[0] = i
score[1] = s score[1] = ratio
elif s == score[1]: elif ratio == score[1]:
score[0] = i score[0] = i
return score[0] return score[0]
@command()
async def hello(self, ctx, *, member: discord.member = None):
member = member or ctx.author
await ctx.send(f"Hello {member}")
async def _play(self): async def _play(self):
track_url = self.current_track.getStreamURL() """Heavy lifting of playing songs
Grabs the appropiate streaming URL, sends the `now playing`
message, and initiates playback in the vc.
Args:
None
Returns:
None
Raises:
None
"""
track_url = self.current_track.getStreamURL()
audio_stream = FFmpegPCMAudio(track_url) audio_stream = FFmpegPCMAudio(track_url)
while self.vc.is_playing(): while self.voice_channel.is_playing():
asyncio.sleep(10) asyncio.sleep(2)
self.vc.play(audio_stream, after=self._toggle_next) self.voice_channel.play(audio_stream, after=self._toggle_next)
logger.debug(f"Playing {self.current_track.title}") plex_log.debug("%s - URL: %s", self.current_track, track_url)
logger.debug(f"URL: {track_url}")
embed, f = self._build_embed(self.current_track) embed, img = self._build_embed(self.current_track)
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()
self.current_track = await self.play_queue.get() if self.voice_channel:
try:
# Disconnect after 15 seconds idle
async with timeout(15):
self.current_track = await self.play_queue.get()
except asyncio.TimeoutError:
await self.voice_channel.disconnect()
self.voice_channel = None
if not self.current_track:
self.current_track = await self.play_queue.get()
await self._play() await self._play()
await self.play_next_event.wait() await self.play_next_event.wait()
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.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"): def _build_embed(self, track, type_="play"):
"""Creates a pretty embed card. """Creates a pretty embed card.
Builds a helpful status embed with the following info:
Status, song title, album, artistm and album art. All
pertitent information is grabbed dynamically from the Plex db.
Args:
track: plexapi.audio.Track object of song
type_: Type of card to make (play, queue).
Returns:
embed: discord.embed fully constructed img payload.
thumb_art: io.BytesIO of album thumbnail img.
Raises:
ValueError: Unsupported type of embed {type_}
""" """
# Grab the relevant thumbnail # Grab the relevant thumbnail
img_stream = urlopen(track.thumbUrl) img_stream = urlopen(track.thumbUrl)
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")
if t == "play": # Get appropiate status message
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
descrip = f"{track.album().title} - {track.artist().title}" descrip = f"{track.album().title} - {track.artist().title}"
# Build the actual embed # Build the actual embed
embed = discord.Embed( embed = discord.Embed(
title=title, description=descrip, colour=discord.Color.red() title=title, description=descrip, colour=discord.Color.red()
) )
embed.set_author(name="Plex") embed.set_author(name="Plex")
# 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")
return embed, f bot_log.debug("Built embed for %s", track.title)
return embed, art_file
@command() @command()
async def play(self, ctx, *args): async def play(self, ctx, *args):
"""User command to start playback
if not len(args): Searchs plex db and either, initiates playback, or
adds to queue. Handles invalid usage from the user.
Args:
ctx: discord.ext.commands.Context message context from command
*args: Title of song to play
Returns:
None
Raises:
None
"""
# Save the context to use with async callbacks
self.ctx = ctx
if not args:
await ctx.send(f"Usage: {self.bot_prefix}play TITLE_OF_SONG") await ctx.send(f"Usage: {self.bot_prefix}play TITLE_OF_SONG")
bot_log.debug("Failed to play, invalid usage")
return return
title = " ".join(args) title = " ".join(args)
@ -136,61 +337,152 @@ class Plex(commands.Cog):
# Fail if song title can't be found # Fail if song title can't be found
if not track: if not track:
await ctx.send(f"Can't find song: {title}") await ctx.send(f"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 # Fail if user not in vc
elif not ctx.author.voice: if not ctx.author.voice:
await ctx.send("Join a voice channel first!") await ctx.send("Join a voice channel first!")
bot_log.debug("Failed to play, requester not in voice channel")
return return
# Connect to voice if not already # Connect to voice if not already
if not self.vc: if not self.voice_channel:
self.vc = await ctx.author.voice.channel.connect() self.voice_channel = await ctx.author.voice.channel.connect()
logger.debug("Connected to vc.") 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():
embed, f = self._build_embed(track, t="queue") bot_log.debug("Added to queue - %s", title)
await ctx.send(embed=embed, file=f) embed, img = self._build_embed(track, type_="queue")
await ctx.send(embed=embed, file=img)
# Save the context to use with async callbacks
self.ctx = ctx
# 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() @command()
async def stop(self, ctx): async def stop(self, ctx):
if self.vc: """User command to stop playback
self.vc.stop()
await self.vc.disconnect() Stops playback and disconnects from vc.
self.vc = None
Args:
ctx: discord.ext.commands.Context message context from command
Returns:
None
Raises:
None
"""
if self.voice_channel:
self.voice_channel.stop()
await self.voice_channel.disconnect()
self.voice_channel = None
self.ctx = None
bot_log.debug("Stopped")
await ctx.send(":stop_button: Stopped") await ctx.send(":stop_button: Stopped")
@command() @command()
async def pause(self, ctx): async def pause(self, ctx):
if self.vc: """User command to pause playback
self.vc.pause()
Pauses playback, but doesn't reset anything
to allow playback resuming.
Args:
ctx: discord.ext.commands.Context message context from command
Returns:
None
Raises:
None
"""
if self.voice_channel:
self.voice_channel.pause()
bot_log.debug("Paused")
await ctx.send(":play_pause: Paused") await ctx.send(":play_pause: Paused")
@command() @command()
async def resume(self, ctx): async def resume(self, ctx):
if self.vc: """User command to resume playback
self.vc.resume()
Args:
ctx: discord.ext.commands.Context message context from command
Returns:
None
Raises:
None
"""
if self.voice_channel:
self.voice_channel.resume()
bot_log.debug("Resumed")
await ctx.send(":play_pause: Resumed") await ctx.send(":play_pause: Resumed")
@command() @command()
async def skip(self, ctx): async def skip(self, ctx):
logger.debug("Skip") """User command to skip song in queue
if self.vc:
self.vc.stop() Skips currently playing song. If no other songs in
queue, stops playback, otherwise moves to next song.
Args:
ctx: discord.ext.commands.Context message context from command
Returns:
None
Raises:
None
"""
bot_log.debug("Skip")
if self.voice_channel:
self.voice_channel.stop()
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(self.current_track)
await ctx.send(embed=embed, file=f) bot_log.debug("Now playing")
if self.np_message_id:
await self.np_message_id.delete()
bot_log.debug("Deleted old np status")
bot_log.debug("Created np status")
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")
await ctx.send(":boom: Queue cleared.") await ctx.send(":boom: Queue cleared.")

8
deploy/build.py Normal file
View File

@ -0,0 +1,8 @@
import os
import sys
sys.path.append("PlexBot")
from __version__ import VERSION
sys.exit(os.system(f"docker build -t jarulsamy/plex-bot:{VERSION} ."))

8
deploy/push.py Executable file
View File

@ -0,0 +1,8 @@
import os
import sys
sys.path.append("PlexBot")
from __version__ import VERSION
sys.exit(os.system(f"docker push jarulsamy/plex-bot:{VERSION}"))

View File

@ -1,7 +1,6 @@
discord.py==1.3.4 discord.py==1.3.4
PlexAPI==4.0.0 PlexAPI==4.0.0
fuzzywuzzy==0.18.0 fuzzywuzzy==0.18.0
python-Levenshtein==0.12.0
pynacl==1.4.0 pynacl==1.4.0
ffmpeg==1.4 ffmpeg==1.4
PyYAML==5.3.1 PyYAML==5.3.1

View File

@ -1,12 +1,13 @@
general: root:
# Options: debug, info, warning, error, critical
log_level: "info" log_level: "info"
discord: discord:
prefix: "?" prefix: "?"
token: "<BOT_TOKEN>" token: "<BOT_TOKEN>"
log_level: "debug"
plex: plex:
base_url: "<BASE_URL>" base_url: "<BASE_URL>"
token: "<PLEX_TOKEN>" token: "<PLEX_TOKEN>"
library_name: "<LIBRARY_NAME>" library_name: "<LIBRARY_NAME>"
log_level: "debug"