college-bot-v1/cogs/voice.py

414 lines
15 KiB
Python
Raw Normal View History

2023-05-02 19:38:39 +01:00
import functools
2023-05-02 18:08:09 +01:00
import io
2023-05-02 19:38:39 +01:00
import json
2023-05-01 19:40:11 +01:00
import shutil
import asyncio
2023-05-05 20:08:47 +01:00
import subprocess
2023-05-01 19:40:11 +01:00
import discord
2023-05-05 20:08:47 +01:00
import httpx
2023-05-01 19:40:11 +01:00
import yt_dlp
import tempfile
from pathlib import Path
from discord.ext import commands
2023-05-02 18:08:09 +01:00
class TransparentQueue(asyncio.Queue):
def __init__(self, maxsize: int = 0) -> None:
super().__init__(maxsize)
self._internal_queue = []
async def put(self, item):
await super().put(item)
self._internal_queue.append(item)
2023-05-02 19:38:39 +01:00
def task_done(self):
super().task_done()
2023-05-02 18:08:09 +01:00
self._internal_queue.pop(0)
2023-05-01 19:40:11 +01:00
class YTDLSource(discord.PCMVolumeTransformer):
def __init__(self, source: discord.AudioSource, *, data: dict, volume: float = 0.5):
super().__init__(source, volume)
self.data = data
self.title = data.get("title")
self.url = data.get("url")
2023-05-02 18:08:09 +01:00
@property
def duration(self):
return self.data.get("duration")
2023-05-01 19:40:11 +01:00
@classmethod
async def from_url(cls, ytdl: yt_dlp.YoutubeDL, url, *, loop=None, stream=False):
ffmpeg_options = {"options": "-vn -b:a 44.1k"}
loop = loop or asyncio.get_event_loop()
data = await loop.run_in_executor(
None, lambda: ytdl.extract_info(url, download=not stream)
)
if "entries" in data:
if not data["entries"]:
# Empty playlist
return None
# Takes the first item from a playlist
data = data["entries"][0]
filename = data["url"] if stream else ytdl.prepare_filename(data)
return cls(discord.FFmpegPCMAudio(filename, **ffmpeg_options), data=data)
class VoiceCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
2023-05-02 19:38:39 +01:00
self.cache = Path(tempfile.mkdtemp("jimmy-voice-cache-")).resolve()
self.cache.mkdir(exist_ok=True, parents=True)
2023-05-01 19:40:11 +01:00
self.ytdl_options = {
"format": "bestaudio/best",
"restrictfilenames": True,
"noplaylist": True,
"quiet": True,
"no_warnings": True,
"ignoreerrors": True,
"logtostderr": False,
"default_search": "auto",
2023-05-02 19:38:39 +01:00
"outtmpl": "%(title)s.%(ext)s".format(self.cache),
"keepvideo": False,
"cachedir": str(self.cache),
"paths": {
"home": str(self.cache),
"temp": str(self.cache),
}
2023-05-01 19:40:11 +01:00
}
self.yt_dl = yt_dlp.YoutubeDL(self.ytdl_options)
2023-05-02 19:38:39 +01:00
self.queue = TransparentQueue(100)
self._queue_task = self.bot.loop.create_task(self.queue_task())
self.song_done = asyncio.Event()
2023-05-02 18:08:09 +01:00
async def queue_task(self):
if not self.bot.is_ready():
await self.bot.wait_until_ready()
while True:
2023-05-02 19:38:39 +01:00
ctx, player, inserted_at = await self.queue.get()
if not ctx.guild.voice_client:
# no longer playing. clear queue
del self.queue
self.queue = TransparentQueue(100)
continue
2023-05-02 18:08:09 +01:00
ctx.guild.voice_client.play(player, after=self.after_player(ctx))
2023-05-02 19:38:39 +01:00
self.song_done.clear()
2023-05-02 18:08:09 +01:00
embed = discord.Embed(
2023-05-02 19:38:39 +01:00
description=f"Now playing: [{player.title}]({player.url}), as requested by {ctx.author.mention}, "
f"{discord.utils.format_dt(inserted_at, 'R')}.",
2023-05-02 18:08:09 +01:00
color=discord.Color.green(),
)
try:
2023-05-02 19:38:39 +01:00
await ctx.guild.voice_client.channel.send(
ctx.author.mention,
embed=embed,
allowed_mentions=discord.AllowedMentions(users=True),
2023-05-02 18:08:09 +01:00
)
except discord.HTTPException:
try:
2023-05-02 19:38:39 +01:00
await ctx.respond(embed=embed)
2023-05-02 18:08:09 +01:00
except discord.HTTPException:
pass
2023-05-02 19:38:39 +01:00
self.queue.task_done()
await self.song_done.wait()
2023-05-01 19:40:11 +01:00
def cog_unload(self):
2023-05-02 19:38:39 +01:00
for task in (self._queue_task,):
task.cancel()
2023-05-01 19:40:11 +01:00
shutil.rmtree(self.cache)
def after_player(self, ctx: discord.ApplicationContext):
def after(e):
2023-05-02 19:38:39 +01:00
self.song_done.set()
2023-05-01 19:40:11 +01:00
if e:
self.bot.loop.create_task(
ctx.respond(
f"An error occurred while playing the audio: {e}"
)
)
return after
async def unblock(self, func, *args, **kwargs):
2023-05-02 19:38:39 +01:00
call = functools.partial(func, *args, **kwargs)
return await self.bot.loop.run_in_executor(None, call)
2023-05-01 19:40:11 +01:00
@commands.slash_command(name="play")
2023-05-05 20:08:47 +01:00
async def stream(
self,
ctx: discord.ApplicationContext,
url: str,
volume: float = 100,
):
2023-05-01 19:40:11 +01:00
"""Streams a URL using yt-dl"""
if not ctx.user.voice:
await ctx.respond("You are not connected to a voice channel.")
return
player = await YTDLSource.from_url(self.yt_dl, url, loop=self.bot.loop, stream=True)
if not player:
await ctx.respond("Could not extract any audio from the given URL.")
2023-05-02 19:38:39 +01:00
player.volume = volume / 100
await self.queue.put((ctx, player, discord.utils.utcnow()))
2023-05-01 19:40:11 +01:00
embed = discord.Embed(
2023-05-02 19:38:39 +01:00
description=f"Added [{player.title}]({player.url}) to the queue.",
2023-05-01 19:40:11 +01:00
)
await ctx.respond(embed=embed)
2023-05-02 19:38:39 +01:00
@commands.command(hidden=True)
async def play(self, ctx: commands.Context, url: str, volume: float = 100):
"""Plays a song by downloading it first."""
if not ctx.author.voice:
await ctx.reply("You are not connected to a voice channel.")
return
player = await YTDLSource.from_url(self.yt_dl, url, loop=self.bot.loop)
if not player:
await ctx.reply("Could not extract any audio from the given URL.")
player.volume = volume / 100
await self.queue.put((ctx, player, discord.utils.utcnow()))
embed = discord.Embed(
description=f"Added [{player.title}]({player.url}) to the queue.",
)
await ctx.reply(embed=embed)
@commands.slash_command(name="queue")
async def view_queue(self, ctx: discord.ApplicationContext):
"""Views the current queue"""
if not ctx.guild.voice_client:
await ctx.respond("Not connected to a voice channel.")
return
if not self.queue._internal_queue and not ctx.guild.voice_client.is_playing():
await ctx.respond("The queue is empty.")
return
embed = discord.Embed(
title="Queue",
description="\n".join(
f"{i+1}. [{x[1].title}]({x[1].url})" for i, x in enumerate(self.queue._internal_queue)
),
)
if ctx.guild.voice_client.is_playing():
now_playing = ctx.guild.voice_client.source
embed.add_field(
name="Now Playing",
value=f"[{now_playing.title}]({now_playing.url})",
)
await ctx.respond(embed=embed)
2023-05-01 19:40:11 +01:00
@commands.slash_command(name="volume")
async def volume(self, ctx: discord.ApplicationContext, volume: float):
"""Changes the player's volume"""
if not 0 < volume < 101:
await ctx.respond("Volume must be between 1 and 100.")
return
ctx.guild.voice_client.source.volume = volume / 100
await ctx.respond(f"Changed volume to {volume}%")
@commands.slash_command(name="stop")
async def stop(self, ctx: discord.ApplicationContext):
"""Stops and disconnects the bot from voice"""
if not ctx.guild.voice_client:
await ctx.respond("Not connected to a voice channel.")
return
if ctx.voice_client:
if ctx.voice_client.is_playing():
ctx.voice_client.stop()
await ctx.voice_client.disconnect(force=True)
await ctx.respond("Disconnected from voice channel.")
else:
await ctx.respond("Not connected to a voice channel.")
2023-05-02 19:38:39 +01:00
@commands.slash_command(name="skip")
async def skip(self, ctx: discord.ApplicationContext):
"""Skips the current song"""
class VoteSkipDialog(discord.ui.View):
def __init__(self):
super().__init__()
self.voted = []
@discord.ui.button(label="Skip", style=discord.ButtonStyle.green)
async def _skip(self, button: discord.ui.Button, interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
humans = len(ctx.guild.voice_client.channel.members) - len(
[x for x in ctx.guild.voice_client.channel.members if x.bot]
)
target = humans / 2 if humans > 2 else humans
if interaction.user.id not in self.voted:
self.voted.append(interaction.user.id)
if humans <= 1 or target <= 1:
ctx.voice_client.stop()
self.stop()
self.disable_all_items()
return await interaction.edit_original_response(
view=self,
content="Skipped song."
)
if len(self.voted) >= target:
ctx.voice_client.stop()
self.stop()
self.disable_all_items()
return await interaction.edit_original_response(
view=self,
content="Skipped song."
)
else:
await ctx.respond(
f"Voted to skip. %d/%d" % (len(self.voted), target),
)
else:
await ctx.respond("You have already voted to skip.")
self.disable_all_items()
await interaction.edit_original_response(
view=self,
content="Vote skip (%d/%d)." % (len(self.voted), target)
)
if not ctx.guild.voice_client:
await ctx.respond("Not connected to a voice channel.")
return
if ctx.voice_client.is_playing():
_humans = len(ctx.guild.voice_client.channel.members) - len(
[x for x in ctx.guild.voice_client.channel.members if x.bot]
)
if _humans > 1:
_target = _humans / 2 if _humans > 2 else _humans
diag = VoteSkipDialog()
diag.voted.append(ctx.user.id)
await ctx.respond("Vote skip (1/%d)." % _target, view=VoteSkipDialog())
ctx.voice_client.stop()
self.song_done.set()
await ctx.respond("Skipped song.")
else:
await ctx.respond("Not playing any music.")
2023-05-02 18:08:09 +01:00
@commands.command(name="dump-metadata")
2023-05-02 19:38:39 +01:00
async def dump_metadata(self, ctx: commands.Context, url: str, traverse: str = None):
2023-05-02 18:08:09 +01:00
"""Dumps JSON YT-DLP metadata to a file"""
2023-05-02 19:38:39 +01:00
content = None
_params = self.yt_dl.params.copy()
_params.pop("noplaylist")
_params.pop("logtostderr")
_ytdl = yt_dlp.YoutubeDL(_params)
2023-05-02 18:08:09 +01:00
async with ctx.channel.typing():
file = io.StringIO()
2023-05-02 19:38:39 +01:00
data = await self.unblock(_ytdl.extract_info, url) or {}
data = await self.unblock(_ytdl.sanitize_info, data, remove_private_keys=True)
if traverse:
last_key = []
for key in traverse.split("."):
if key in data:
data = data[key]
last_key.append(key)
else:
content = "Key %r not found in metadata (got as far as %r)." % (key, '.'.join(last_key))
break
2023-05-02 18:08:09 +01:00
json.dump(data, file, indent=4)
file.seek(0)
2023-05-02 19:38:39 +01:00
# noinspection PyTypeChecker
return await ctx.reply(content, file=discord.File(file, filename="metadata.json"))
2023-05-02 18:08:09 +01:00
2023-05-02 19:38:39 +01:00
@volume.before_invoke
@play.before_invoke
@stop.before_invoke
@stream.before_invoke
async def before_invoke(self, ctx: discord.ApplicationContext | commands.Context):
if isinstance(ctx, discord.ApplicationContext):
await ctx.defer()
sender = ctx.respond
else:
sender = ctx.reply
2023-05-01 19:40:11 +01:00
if not self.cache.exists():
self.cache.mkdir()
if not ctx.guild.voice_client:
2023-05-02 19:38:39 +01:00
if ctx.author.voice:
await ctx.author.voice.channel.connect()
2023-05-01 19:40:11 +01:00
else:
2023-05-02 19:38:39 +01:00
await sender("You are not connected to a voice channel.")
2023-05-01 19:40:11 +01:00
raise commands.CommandError("User not connected to a voice channel.")
2023-05-05 20:08:47 +01:00
@commands.slash_command(name="boost-audio")
async def boost_audio(
self,
ctx: discord.ApplicationContext,
file: discord.Attachment,
level: discord.Option(
float,
"A level (in percentage) of volume (e.g. 150 = 150%)",
min_value=0.1,
max_value=999.99
)
):
"""Boosts an audio file's audio level."""
await ctx.defer()
if file.size >= (25 * 1024 * 1024):
return await ctx.respond("File is too large (25MB Max).")
with tempfile.TemporaryDirectory("jimmy-audio-boost-") as temp_dir_raw:
temp_dir = Path(temp_dir_raw).resolve()
_input = temp_dir / file.filename
output = _input.with_name(_input.name + "-processed" + '.'.join(_input.suffixes))
await file.save(_input)
proc: subprocess.CompletedProcess = await self.bot.loop.run_in_executor(
None,
functools.partial(
subprocess.run,
(
"ffmpeg",
"-hide_banner",
"-i",
str(_input),
"-b:a",
"44.1k",
"-af",
"volume=%d" % (level / 100),
str(output),
),
capture_output=True
)
)
if proc.returncode == 0:
if output.stat().st_size >= (25 * 1024 * 1024) + len(output.name):
return await ctx.respond("I'd love to serve you your boosted file, but its too large.")
return await ctx.respond(file=discord.File(output))
else:
data = {
"files": [
{
"content": proc.stderr.decode() or 'empty',
"filename": "stderr.txt"
},
{
"content": proc.stdout.decode() or 'empty',
"filename": "stdout.txt"
}
]
}
response = await httpx.AsyncClient().put("https://api.mystb.in/paste", json=data)
if response.status_code == 201:
data = response.json()
key = "https://mystb.in/" + data["id"]
else:
key = "https://www.youtube.com/watch?v=dgha9S39Y6M&status_code=%d" % response.status_code
await ctx.respond("Failed ([exit code %d](%s))" % (proc.returncode, key))
await ctx.edit(embed=None)
2023-05-01 19:40:11 +01:00
def setup(bot):
bot.add_cog(VoiceCog(bot))