mirror of
https://github.com/jarulsamy/Plex-Bot.git
synced 2024-08-19 15:01:55 +02:00
1a8ec5f21f
Add docstrings and other useful comments. Extended variable names to be more descriptive.
489 lines
14 KiB
Python
489 lines
14 KiB
Python
""" All discord bot and Plex api interactions"""
|
|
import asyncio
|
|
import io
|
|
import logging
|
|
from urllib.request import urlopen
|
|
|
|
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.server import PlexServer
|
|
|
|
root_log = logging.getLogger()
|
|
plex_log = logging.getLogger("Plex")
|
|
bot_log = logging.getLogger("Bot")
|
|
|
|
|
|
class General(commands.Cog):
|
|
"""General commands
|
|
|
|
Manage general bot behavior
|
|
"""
|
|
|
|
def __init__(self, bot):
|
|
"""Initialize commands
|
|
|
|
Args:
|
|
bot: discord.ext.command.Bot, bind for cogs
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
None
|
|
"""
|
|
self.bot = bot
|
|
|
|
@command()
|
|
async def kill(self, ctx, *args):
|
|
"""Kill the bot
|
|
|
|
Args:
|
|
ctx: discord.ext.commands.Context message context from command
|
|
*args: optional flags
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
None
|
|
"""
|
|
if "silent" not in args:
|
|
await ctx.send(f"Stopping upon the request of {ctx.author.mention}")
|
|
|
|
await self.bot.close()
|
|
bot_log.info("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):
|
|
"""Discord commands pertinent to interacting with Plex
|
|
|
|
Contains user commands such as play, pause, resume, stop, etc.
|
|
Grabs, and parses all data from plex database.
|
|
|
|
"""
|
|
|
|
# pylint: disable=too-many-instance-attributes
|
|
# All are necessary to detect global interactions
|
|
# within the bot.
|
|
|
|
def __init__(
|
|
self, bot, base_url: str, plex_token: str, lib_name: str, bot_prefix: str
|
|
):
|
|
"""Initializes Plex resources
|
|
|
|
Connects to Plex library and sets up
|
|
all asyncronous communications.
|
|
|
|
Args:
|
|
bot: discord.ext.command.Bot, bind for cogs
|
|
base_url: str url to Plex server
|
|
plex_token: str X-Token of Plex server
|
|
lib_name: str name of Plex library to search through
|
|
bot_prefix: str prefix used to interact with bots
|
|
|
|
Raises:
|
|
plexapi.exceptions.Unauthorized: Invalid Plex token
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
|
|
self.bot = bot
|
|
self.base_url = base_url
|
|
self.plex_token = plex_token
|
|
self.library_name = lib_name
|
|
self.bot_prefix = bot_prefix
|
|
|
|
# Log fatal invalid plex token
|
|
try:
|
|
self.pms = PlexServer(self.base_url, self.plex_token)
|
|
except Unauthorized:
|
|
plex_log.fatal("Invalid Plex token, stopping...")
|
|
raise Unauthorized("Invalid Plex token")
|
|
|
|
self.music = self.pms.library.section(self.library_name)
|
|
plex_log.debug("Connected to plex library: %s", self.library_name)
|
|
|
|
# Initialize necessary vars
|
|
self.voice_channel = None
|
|
self.current_track = None
|
|
self.np_message_id = None
|
|
self.ctx = None
|
|
|
|
# Initialize events
|
|
self.play_queue = asyncio.Queue()
|
|
self.play_next_event = asyncio.Event()
|
|
|
|
bot_log.info("Started bot successfully")
|
|
self.bot.loop.create_task(self._audio_player_task())
|
|
|
|
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.
|
|
|
|
Args:
|
|
title: str title of song to search for
|
|
|
|
Returns:
|
|
plexapi.audio.Track pointing to closest matching title
|
|
None if song can't be found.
|
|
|
|
Raises:
|
|
None
|
|
"""
|
|
tracks = self.music.searchTracks()
|
|
score = [None, -1]
|
|
for i in tracks:
|
|
ratio = fuzz.ratio(title.lower(), i.title.lower())
|
|
if ratio > score[1]:
|
|
score[0] = i
|
|
score[1] = ratio
|
|
elif ratio == score[1]:
|
|
score[0] = i
|
|
|
|
return score[0]
|
|
|
|
async def _play(self):
|
|
"""Heavy lifting of playing songs
|
|
|
|
Grabs the appropiate streaming URL, sends the `now playing`
|
|
message, and initiates playback in the vc.
|
|
|
|
Args:
|
|
None
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
None
|
|
"""
|
|
track_url = self.current_track.getStreamURL()
|
|
audio_stream = FFmpegPCMAudio(track_url)
|
|
|
|
while self.voice_channel.is_playing():
|
|
asyncio.sleep(2)
|
|
|
|
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)
|
|
self.np_message_id = await self.ctx.send(embed=embed, file=img)
|
|
|
|
async def _audio_player_task(self):
|
|
"""Coroutine to handle playback and queuing
|
|
|
|
Always-running function awaiting new songs to be added.
|
|
Auto disconnects from VC if idle for > 15 seconds.
|
|
Handles auto deletion of now playing song notifications.
|
|
|
|
Args:
|
|
None
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
None
|
|
"""
|
|
while True:
|
|
self.play_next_event.clear()
|
|
if self.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_next_event.wait()
|
|
await self.np_message_id.delete()
|
|
|
|
def _toggle_next(self, error=None):
|
|
"""Callback for vc playback
|
|
|
|
Clears current track, then activates _audio_player_task
|
|
to play next in queue or disconnect.
|
|
|
|
Args:
|
|
error: Optional parameter required for discord.py callback
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
None
|
|
"""
|
|
self.current_track = None
|
|
self.bot.loop.call_soon_threadsafe(self.play_next_event.set)
|
|
|
|
def _build_embed(self, track, type_="play"):
|
|
"""Creates a pretty embed card.
|
|
|
|
Builds a helpful status embed with the following info:
|
|
Status, song title, album, artistm and album art. All
|
|
pertitent information is grabbed dynamically from the Plex db.
|
|
|
|
Args:
|
|
track: plexapi.audio.Track object of song
|
|
type_: Type of card to make (play, queue).
|
|
|
|
Returns:
|
|
embed: discord.embed fully constructed img payload.
|
|
thumb_art: io.BytesIO of album thumbnail img.
|
|
|
|
Raises:
|
|
ValueError: Unsupported type of embed {type_}
|
|
"""
|
|
# Grab the relevant thumbnail
|
|
img_stream = urlopen(track.thumbUrl)
|
|
img = io.BytesIO(img_stream.read())
|
|
|
|
# Attach to discord embed
|
|
art_file = discord.File(img, filename="image0.png")
|
|
# Get appropiate status message
|
|
if type_ == "play":
|
|
title = f"Now Playing - {track.title}"
|
|
elif type_ == "queue":
|
|
title = f"Added to queue - {track.title}"
|
|
else:
|
|
raise ValueError(f"Unsupported type of embed {type_}")
|
|
|
|
# Include song details
|
|
descrip = f"{track.album().title} - {track.artist().title}"
|
|
|
|
# Build the actual embed
|
|
embed = discord.Embed(
|
|
title=title, description=descrip, colour=discord.Color.red()
|
|
)
|
|
embed.set_author(name="Plex")
|
|
# Point to file attached with ctx object.
|
|
embed.set_thumbnail(url="attachment://image0.png")
|
|
|
|
bot_log.debug("Built embed for %s", track.title)
|
|
|
|
return embed, art_file
|
|
|
|
@command()
|
|
async def play(self, ctx, *args):
|
|
"""User command to start playback
|
|
|
|
Searchs plex db and either, initiates playback, or
|
|
adds to queue. Handles invalid usage from the user.
|
|
|
|
Args:
|
|
ctx: discord.ext.commands.Context message context from command
|
|
*args: Title of song to play
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
None
|
|
"""
|
|
# Save the context to use with async callbacks
|
|
self.ctx = ctx
|
|
|
|
if not 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:
|
|
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.")
|
|
|
|
# Specific add to queue message
|
|
if self.voice_channel.is_playing():
|
|
bot_log.debug("Added to queue - %s", title)
|
|
embed, img = self._build_embed(track, type_="queue")
|
|
await ctx.send(embed=embed, file=img)
|
|
|
|
# Add the song to the async queue
|
|
await self.play_queue.put(track)
|
|
|
|
@command()
|
|
async def stop(self, ctx):
|
|
"""User command to stop playback
|
|
|
|
Stops playback and disconnects from vc.
|
|
|
|
Args:
|
|
ctx: discord.ext.commands.Context message context from command
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
None
|
|
"""
|
|
if self.voice_channel:
|
|
self.voice_channel.stop()
|
|
await self.voice_channel.disconnect()
|
|
self.voice_channel = None
|
|
self.ctx = None
|
|
bot_log.debug("Stopped")
|
|
await ctx.send(":stop_button: Stopped")
|
|
|
|
@command()
|
|
async def pause(self, ctx):
|
|
"""User command to pause playback
|
|
|
|
Pauses playback, but doesn't reset anything
|
|
to allow playback resuming.
|
|
|
|
Args:
|
|
ctx: discord.ext.commands.Context message context from command
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
None
|
|
"""
|
|
if self.voice_channel:
|
|
self.voice_channel.pause()
|
|
bot_log.debug("Paused")
|
|
await ctx.send(":play_pause: Paused")
|
|
|
|
@command()
|
|
async def resume(self, ctx):
|
|
"""User command to resume playback
|
|
|
|
Args:
|
|
ctx: discord.ext.commands.Context message context from command
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
None
|
|
"""
|
|
if self.voice_channel:
|
|
self.voice_channel.resume()
|
|
bot_log.debug("Resumed")
|
|
await ctx.send(":play_pause: Resumed")
|
|
|
|
@command()
|
|
async def skip(self, ctx):
|
|
"""User command to skip song in queue
|
|
|
|
Skips currently playing song. If no other songs in
|
|
queue, stops playback, otherwise moves to next song.
|
|
|
|
Args:
|
|
ctx: discord.ext.commands.Context message context from command
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
None
|
|
"""
|
|
bot_log.debug("Skip")
|
|
if self.voice_channel:
|
|
self.voice_channel.stop()
|
|
bot_log.debug("Skipped")
|
|
self._toggle_next()
|
|
|
|
@command(name="np")
|
|
async def now_playing(self, ctx):
|
|
"""User command to get currently playing song.
|
|
|
|
Deletes old `now playing` status message,
|
|
Creates a new one with up to date information.
|
|
|
|
Args:
|
|
ctx: discord.ext.commands.Context message context from command
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
None
|
|
"""
|
|
if self.current_track:
|
|
embed, img = self._build_embed(self.current_track)
|
|
bot_log.debug("Now playing")
|
|
if self.np_message_id:
|
|
await self.np_message_id.delete()
|
|
bot_log.debug("Deleted old np status")
|
|
|
|
bot_log.debug("Created np status")
|
|
self.np_message_id = await ctx.send(embed=embed, file=img)
|
|
|
|
@command()
|
|
async def clear(self, ctx):
|
|
"""User command to clear play queue.
|
|
|
|
Args:
|
|
ctx: discord.ext.commands.Context message context from command
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
None
|
|
"""
|
|
self.play_queue = asyncio.Queue()
|
|
bot_log.debug("Cleared queue")
|
|
await ctx.send(":boom: Queue cleared.")
|