Initial commit
This commit is contained in:
commit
0354f21ea8
12 changed files with 637 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
/venv/
|
||||||
|
/pyrightconfig.json
|
||||||
|
/.env
|
||||||
|
/.vimspector.json
|
||||||
|
*__pycache__*
|
||||||
|
/cache/
|
4
ari/__main__.py
Normal file
4
ari/__main__.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from .bot import bot
|
||||||
|
from .constants import BOT_TOKEN
|
||||||
|
|
||||||
|
bot.run(BOT_TOKEN, log_handler=None)
|
79
ari/bot.py
Normal file
79
ari/bot.py
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import logging
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
|
||||||
|
from .player import Player
|
||||||
|
|
||||||
|
from .constants import BAINS_POSIT_NOTE, MUSIC_CACHE, Emoji
|
||||||
|
from .messages import MessageHandler
|
||||||
|
from .cache import Cache
|
||||||
|
|
||||||
|
discord.utils.setup_logging()
|
||||||
|
logging.getLogger("ari").setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
intents = discord.Intents.default()
|
||||||
|
intents.message_content = True
|
||||||
|
|
||||||
|
|
||||||
|
class Bot(discord.Client):
|
||||||
|
def __init__(self, intents: discord.Intents) -> None:
|
||||||
|
super().__init__(intents=intents)
|
||||||
|
self.tree = app_commands.CommandTree(self)
|
||||||
|
self.message_handler = MessageHandler(self)
|
||||||
|
self.music_cache = Cache(self.http, MUSIC_CACHE, max_size=30000000)
|
||||||
|
self.players: Dict[int, Player] = {}
|
||||||
|
|
||||||
|
async def setup_hook(self) -> None:
|
||||||
|
return
|
||||||
|
await self.tree.sync()
|
||||||
|
|
||||||
|
async def on_message(self, message: discord.Message):
|
||||||
|
await self.message_handler.handle_message(message)
|
||||||
|
|
||||||
|
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
|
||||||
|
await self.message_handler.handle_reaction_add(payload)
|
||||||
|
|
||||||
|
|
||||||
|
bot = Bot(intents)
|
||||||
|
|
||||||
|
|
||||||
|
@bot.tree.command(name="skip", description="Skip specified number of songs")
|
||||||
|
@app_commands.describe(number="Number of songs to skip")
|
||||||
|
@app_commands.guild_only()
|
||||||
|
async def skip(interaction: discord.Interaction, number: int = 1):
|
||||||
|
assert interaction.guild is not None
|
||||||
|
if number < 1:
|
||||||
|
await interaction.response.send_message(
|
||||||
|
f"{Emoji.error} The number of songs to skip must be bigger or equal to 1",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
player = bot.players.get(interaction.guild.id)
|
||||||
|
if player is None or not player.is_running():
|
||||||
|
await interaction.response.send_message(
|
||||||
|
f"{Emoji.error} I am not currently playing anything", ephemeral=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
player.skip(number)
|
||||||
|
await interaction.response.send_message(
|
||||||
|
f"Skipping {number} song{'s' if number > 1 else ''}..."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bot.tree.command(name="stop", description="Stop playing and disconnect")
|
||||||
|
@app_commands.guild_only()
|
||||||
|
async def stop(interaction: discord.Interaction):
|
||||||
|
assert interaction.guild is not None
|
||||||
|
player = bot.players.get(interaction.guild.id)
|
||||||
|
if player is None or not player.is_running():
|
||||||
|
await interaction.response.send_message(
|
||||||
|
f"{Emoji.error} I am not currently playing anything", ephemeral=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
player.stop()
|
||||||
|
await interaction.response.send_message(f"Disconnecting...")
|
158
ari/cache.py
Normal file
158
ari/cache.py
Normal file
|
@ -0,0 +1,158 @@
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
from typing import Dict, Tuple, List
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import yt_dlp as youtube_dl
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CacheError(Exception):
|
||||||
|
"""Error while making sure a file exists"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def link_hash(link: str) -> str:
|
||||||
|
return hashlib.md5(link.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_info_yt(link):
|
||||||
|
with youtube_dl.YoutubeDL({"format": "bestaudio"}) as ydl:
|
||||||
|
return ydl.extract_info(link, download=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_size(link: str):
|
||||||
|
try:
|
||||||
|
if link.startswith("https://youtu.be/"):
|
||||||
|
return (await asyncio.to_thread(extract_info_yt, link)).get( # type: ignore
|
||||||
|
"filesize", -1
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.head(link, allow_redirects=True) as response:
|
||||||
|
return response.content_length or -1
|
||||||
|
except youtube_dl.DownloadError:
|
||||||
|
logger.info("failed to fetch video size")
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
class Cache:
|
||||||
|
def __init__(self, http, directory: str, max_size: int = 500 * 1000**2):
|
||||||
|
self._http = http
|
||||||
|
self._directory: str = directory
|
||||||
|
self._max_size: int = max_size
|
||||||
|
self._current_size: int = 0
|
||||||
|
self._links: Dict[str, Tuple[int, int, str]] = {}
|
||||||
|
self.locked_files: List[str] = []
|
||||||
|
self.cache_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
os.makedirs(self._directory, exist_ok=True)
|
||||||
|
|
||||||
|
async def _try_free_space(self, size: int) -> bool:
|
||||||
|
"""NEEDS TO BE GUARDED WITH CACHE LOCK"""
|
||||||
|
logger.debug(f"freeing {size} bytes of space")
|
||||||
|
if size > self._max_size:
|
||||||
|
return False
|
||||||
|
|
||||||
|
to_remove = []
|
||||||
|
successful = False
|
||||||
|
for link in sorted(self._links, key=lambda x: x[0]):
|
||||||
|
if self._links[link][2] not in self.locked_files:
|
||||||
|
os.remove(f"{self._directory}/{self._links[link][2]}")
|
||||||
|
self._current_size -= self._links[link][1]
|
||||||
|
to_remove.append(link)
|
||||||
|
if size + self._current_size <= self._max_size:
|
||||||
|
successful = True
|
||||||
|
break
|
||||||
|
if to_remove:
|
||||||
|
logger.info(f"removed {len(to_remove)} files")
|
||||||
|
for link in to_remove:
|
||||||
|
del self._links[link]
|
||||||
|
|
||||||
|
return successful
|
||||||
|
|
||||||
|
async def _download_youtube_file(self, link: str):
|
||||||
|
success = False
|
||||||
|
with youtube_dl.YoutubeDL(
|
||||||
|
{
|
||||||
|
"outtmpl": f"{self._directory}/{link_hash(link)}",
|
||||||
|
"format": "bestaudio",
|
||||||
|
"updatetime": False,
|
||||||
|
"ratelimit": 5000000,
|
||||||
|
}
|
||||||
|
) as ydl:
|
||||||
|
for _ in range(3):
|
||||||
|
ex = asyncio.to_thread(ydl.download, (link,))
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(ex, 10)
|
||||||
|
except (
|
||||||
|
asyncio.TimeoutError,
|
||||||
|
youtube_dl.utils.DownloadError,
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
success = True
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
# clean up potential leftovers from ytdl
|
||||||
|
if os.path.exists(f"{self._directory}/{link_hash(link)}.part"):
|
||||||
|
os.remove(f"{self._directory}/{link_hash(link)}.part")
|
||||||
|
if not success:
|
||||||
|
raise CacheError("Youtube download failed")
|
||||||
|
|
||||||
|
async def _download_file(self, link: str):
|
||||||
|
"""NEEDS TO BE GUARDED WITH CACHE LOCK"""
|
||||||
|
size = await get_size(link)
|
||||||
|
if size < 0:
|
||||||
|
raise CacheError("Music file size unknown")
|
||||||
|
|
||||||
|
logger.info(f"Downloading video of size {size / 1000:.2f}kb")
|
||||||
|
if (size < self._max_size - self._current_size) or (
|
||||||
|
await self._try_free_space(size)
|
||||||
|
):
|
||||||
|
logger.debug(f"size: {self._max_size - self._current_size}")
|
||||||
|
if link.startswith("https://youtu.be/"):
|
||||||
|
await self._download_youtube_file(link)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
data = await asyncio.wait_for(self._http.get_from_cdn(link), 10)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise CacheError("Discord download is taking too long")
|
||||||
|
with open(f"{self._directory}/{link_hash(link)}", "wb+") as f:
|
||||||
|
f.write(data)
|
||||||
|
self._current_size += size # reserve space
|
||||||
|
|
||||||
|
self._links[link] = (time.time_ns(), size, link_hash(link))
|
||||||
|
return True
|
||||||
|
|
||||||
|
raise CacheError("Music file size too large or unknown")
|
||||||
|
|
||||||
|
async def ensure_existence(self, link: str):
|
||||||
|
async with self.cache_lock:
|
||||||
|
fp = f"{self._directory}/{link_hash(link)}"
|
||||||
|
if self._links.get(link):
|
||||||
|
return CacheContextManager(self, fp)
|
||||||
|
else:
|
||||||
|
await self._download_file(link)
|
||||||
|
return CacheContextManager(self, fp)
|
||||||
|
|
||||||
|
|
||||||
|
class CacheContextManager:
|
||||||
|
def __init__(self, cache: Cache, file: str):
|
||||||
|
self._cache: Cache = cache
|
||||||
|
self._file: str = file
|
||||||
|
|
||||||
|
def __enter__(self) -> str:
|
||||||
|
self._cache.locked_files.append(self._file)
|
||||||
|
if not os.path.exists(self._file):
|
||||||
|
self._cache.locked_files.remove(self._file)
|
||||||
|
raise CacheError("cannot lock file; it no longer exists")
|
||||||
|
return self._file
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
self._cache.locked_files.remove(self._file)
|
17
ari/constants.py
Normal file
17
ari/constants.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import os
|
||||||
|
from typing import NamedTuple
|
||||||
|
import discord
|
||||||
|
|
||||||
|
BAINS_POSIT_NOTE = discord.Object(id=630144683359862814)
|
||||||
|
|
||||||
|
|
||||||
|
class Emoji(NamedTuple):
|
||||||
|
play = "<:play:1010298926550749247>"
|
||||||
|
skip_to = "<:push_to_front:1010299358174007396>"
|
||||||
|
download_error = "<:download_error:1010299866246807662>"
|
||||||
|
error = "<:error:755487487807324230>"
|
||||||
|
|
||||||
|
|
||||||
|
PRELOAD = bool(int(os.getenv("PRELOAD", "1")))
|
||||||
|
MUSIC_CACHE = os.getenv("MUSIC_CACHE", "cache")
|
||||||
|
BOT_TOKEN = os.getenv("BOT_TOKEN", "invalid")
|
167
ari/messages.py
Normal file
167
ari/messages.py
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING, Dict, Set
|
||||||
|
from bidict import bidict, MutableBidirectionalMapping
|
||||||
|
|
||||||
|
from .constants import Emoji
|
||||||
|
from .queue import Content, QueueItem
|
||||||
|
from .player import Player
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from typing import Dict, Set
|
||||||
|
from .bot import Bot
|
||||||
|
|
||||||
|
_youtube_regex = re.compile(
|
||||||
|
r"http(?:s?):\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-\_]*)(&(amp;)?[\w\?=]*)?"
|
||||||
|
)
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageHandler:
|
||||||
|
def __init__(self, client) -> None:
|
||||||
|
self.client: "Bot" = client
|
||||||
|
self.skippables: MutableBidirectionalMapping[int, QueueItem] = bidict()
|
||||||
|
self.requests = {
|
||||||
|
Emoji.play: self.handle_play_request,
|
||||||
|
Emoji.download_error: self.show_errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def handle_message(self, message: discord.Message) -> None:
|
||||||
|
if message.guild is None:
|
||||||
|
return
|
||||||
|
if _youtube_regex.search(message.content) is not None or any(
|
||||||
|
[
|
||||||
|
"audio" in a.content_type
|
||||||
|
for a in message.attachments
|
||||||
|
if a.content_type is not None
|
||||||
|
]
|
||||||
|
):
|
||||||
|
logger.debug("message %s has a youtuble link", message.id)
|
||||||
|
await message.add_reaction(Emoji.play)
|
||||||
|
|
||||||
|
async def handle_reaction_add(
|
||||||
|
self, payload: discord.RawReactionActionEvent
|
||||||
|
) -> None:
|
||||||
|
assert self.client.user is not None
|
||||||
|
if payload.guild_id is None:
|
||||||
|
return # the message must be in a guild
|
||||||
|
if payload.user_id == self.client.user.id:
|
||||||
|
return # ignore self
|
||||||
|
|
||||||
|
req = self.requests.get(str(payload.emoji))
|
||||||
|
if req is not None:
|
||||||
|
await req(payload)
|
||||||
|
|
||||||
|
async def get_or_fetch_message(
|
||||||
|
self, message_id: int, channel_id: int
|
||||||
|
) -> discord.Message:
|
||||||
|
message = next(
|
||||||
|
filter(lambda m: m.id == message_id, self.client.cached_messages),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if message is None:
|
||||||
|
logger.debug("message %s was not cached, fetching...", message_id)
|
||||||
|
channel = self.client.get_channel(
|
||||||
|
channel_id
|
||||||
|
) or await self.client.fetch_channel(channel_id)
|
||||||
|
assert isinstance(channel, discord.TextChannel)
|
||||||
|
message = await channel.fetch_message(message_id)
|
||||||
|
|
||||||
|
# manually add message to the client's message cache.
|
||||||
|
# this way the client can refresh the message when it is edited
|
||||||
|
cache = self.client._connection._messages
|
||||||
|
if cache is not None:
|
||||||
|
cache.append(message)
|
||||||
|
return message
|
||||||
|
|
||||||
|
async def handle_play_request(self, payload: discord.RawReactionActionEvent):
|
||||||
|
logger.info("play request on message %s", payload.message_id)
|
||||||
|
|
||||||
|
# check cache
|
||||||
|
message = await self.get_or_fetch_message(
|
||||||
|
payload.message_id, payload.channel_id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert message.guild is not None
|
||||||
|
user = message.guild.get_member(payload.user_id)
|
||||||
|
if user is None:
|
||||||
|
user = await message.guild.fetch_member(payload.user_id)
|
||||||
|
|
||||||
|
# get all videos and attachments from the message
|
||||||
|
playable = []
|
||||||
|
for attachment in message.attachments:
|
||||||
|
if attachment.content_type in ("audio/mpeg", "audio/ogg", "audio/wave"):
|
||||||
|
playable.append(attachment.url)
|
||||||
|
pos = 0
|
||||||
|
while pos < len(message.content):
|
||||||
|
match = _youtube_regex.search(message.content, pos)
|
||||||
|
if match is None:
|
||||||
|
break
|
||||||
|
pos = match.span()[1]
|
||||||
|
playable.append("https://youtu.be/" + match.groups()[0])
|
||||||
|
|
||||||
|
logger.debug("adding %s songs to queue", len(playable))
|
||||||
|
if not playable:
|
||||||
|
await user.send(
|
||||||
|
f"{Emoji.error} There are no playable videos/music in the message"
|
||||||
|
)
|
||||||
|
await message.clear_reaction(Emoji.play)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (
|
||||||
|
next(
|
||||||
|
filter(
|
||||||
|
lambda x: str(x.emoji) == Emoji.download_error, message.reactions
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
# clear any hanging errors on the message
|
||||||
|
# caused only when the message is played again before
|
||||||
|
# the reaction is automatically removed after a timeout
|
||||||
|
logger.debug("clearing download error reaction")
|
||||||
|
await message.clear_reaction(Emoji.download_error)
|
||||||
|
await message.remove_reaction(Emoji.play, discord.Object(id=payload.user_id))
|
||||||
|
|
||||||
|
# push video ids to the player queue
|
||||||
|
player = self.client.players.get(user.guild.id)
|
||||||
|
if player is None or not player.is_running():
|
||||||
|
player = await Player.create(self.client, user)
|
||||||
|
if player is None:
|
||||||
|
await user.send(
|
||||||
|
f"{Emoji.error} Failed to connect to voice. Are you in a voice channel?",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.client.players[user.guild.id] = player
|
||||||
|
for id in playable:
|
||||||
|
player.queue.push(Content(message, id))
|
||||||
|
|
||||||
|
if not player.is_running():
|
||||||
|
asyncio.create_task(player.run())
|
||||||
|
|
||||||
|
async def show_errors(self, payload: discord.RawReactionActionEvent):
|
||||||
|
assert payload.guild_id is not None
|
||||||
|
dm = await self.client.create_dm(discord.Object(id=payload.user_id))
|
||||||
|
player = self.client.players.get(payload.guild_id)
|
||||||
|
if player is None or not player.is_running():
|
||||||
|
# error emojis should be only visible when a player is running
|
||||||
|
# this was probably fired in the split second when the player was
|
||||||
|
# turning off
|
||||||
|
return
|
||||||
|
message = f"{Emoji.download_error} ***Sorry, I was not able to play the following songs:***\n```\n"
|
||||||
|
for error in player.errored_songs:
|
||||||
|
message += f" - {error}\n"
|
||||||
|
message += "```"
|
||||||
|
await dm.send(message)
|
||||||
|
message = await self.get_or_fetch_message(
|
||||||
|
payload.message_id, payload.channel_id
|
||||||
|
)
|
||||||
|
await message.remove_reaction(
|
||||||
|
Emoji.download_error, discord.Object(id=payload.user_id)
|
||||||
|
)
|
131
ari/player.py
Normal file
131
ari/player.py
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
from yt_dlp import os
|
||||||
|
from .cache import CacheError
|
||||||
|
from .constants import Emoji, PRELOAD
|
||||||
|
from .queue import Queue, QueueItem
|
||||||
|
import discord
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Set
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .bot import Bot
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Player:
|
||||||
|
def __init__(self, client: "Bot", voice_client: discord.VoiceClient) -> None:
|
||||||
|
self.queue = Queue()
|
||||||
|
self.voice = voice_client
|
||||||
|
self.client = client
|
||||||
|
self.errored: Set[discord.Message] = set()
|
||||||
|
self.errored_songs: Set[str] = set()
|
||||||
|
self._running = False
|
||||||
|
self._skip = 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def create(cls, client: "Bot", user: discord.Member):
|
||||||
|
if not user.voice or not user.voice.channel:
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.debug("creating player")
|
||||||
|
voice_client = user.guild.voice_client
|
||||||
|
assert voice_client is None or isinstance(voice_client, discord.VoiceClient)
|
||||||
|
if not voice_client:
|
||||||
|
voice_client = await user.voice.channel.connect() # type: ignore
|
||||||
|
else:
|
||||||
|
if not voice_client.is_connected():
|
||||||
|
try:
|
||||||
|
voice_client.stop()
|
||||||
|
logger.debug("reusing existing voice client")
|
||||||
|
await voice_client.connect(reconnect=True, timeout=10)
|
||||||
|
except asyncio.TimeoutError or discord.ConnectionClosed:
|
||||||
|
logger.warning(f"failed to connect")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return cls(client, voice_client)
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
logger.info(f"{hash(self)} running player")
|
||||||
|
self._running = True
|
||||||
|
while not self.queue.empty() and self.voice.is_connected() and self._running:
|
||||||
|
music = self.queue.pop()
|
||||||
|
if self._skip > 0:
|
||||||
|
logger.debug(f"skips remaining {self._skip-1}")
|
||||||
|
self._skip -= 1
|
||||||
|
continue
|
||||||
|
assert music is not None
|
||||||
|
logger.debug("playing %s", music)
|
||||||
|
try:
|
||||||
|
with await self.client.music_cache.ensure_existence(
|
||||||
|
music.content.video_id
|
||||||
|
) as file:
|
||||||
|
# ensuring existence can take a long time
|
||||||
|
if self.voice.is_connected():
|
||||||
|
self.voice.play(discord.FFmpegPCMAudio(file))
|
||||||
|
|
||||||
|
tried_preload = False
|
||||||
|
while (
|
||||||
|
self.voice.is_connected()
|
||||||
|
and self.voice.is_playing()
|
||||||
|
and self._running
|
||||||
|
):
|
||||||
|
if PRELOAD and not tried_preload:
|
||||||
|
tried_preload = await self.preload()
|
||||||
|
if self._skip > 0:
|
||||||
|
logger.debug("skipping currently playing")
|
||||||
|
self.voice.stop()
|
||||||
|
self._skip -= 1
|
||||||
|
break
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except CacheError:
|
||||||
|
await self.add_error(music)
|
||||||
|
self.errored.add(music.content.message)
|
||||||
|
self.errored_songs.add(music.content.video_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"{hash(self)}: exception while playing")
|
||||||
|
break
|
||||||
|
self._running = False
|
||||||
|
await self.voice.disconnect()
|
||||||
|
await self.cleanup_errors()
|
||||||
|
logger.info(f"{hash(self)} player shutdown")
|
||||||
|
|
||||||
|
async def cleanup_errors(self):
|
||||||
|
for message in self.errored:
|
||||||
|
try:
|
||||||
|
await message.clear_reaction(Emoji.download_error)
|
||||||
|
except discord.HTTPException:
|
||||||
|
# the message could already be gone
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def add_error(self, item: QueueItem):
|
||||||
|
try:
|
||||||
|
await item.content.message.add_reaction(Emoji.download_error)
|
||||||
|
except discord.HTTPException as e:
|
||||||
|
# the message could be already gone
|
||||||
|
logger.warning(
|
||||||
|
"could not add error to message %s: %s", item.content.message.id, e
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_running(self):
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
def skip(self, num: int):
|
||||||
|
self._skip += num
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
async def preload(self):
|
||||||
|
preload = self.queue.peek()
|
||||||
|
if preload is not None:
|
||||||
|
# we ignore the context manager, thus we're not actually
|
||||||
|
# locking the file, just preloading it
|
||||||
|
try:
|
||||||
|
await self.client.music_cache.ensure_existence(preload.content.video_id)
|
||||||
|
return True
|
||||||
|
except CacheError:
|
||||||
|
# error silently, maybe we can free up space by letting
|
||||||
|
# go of the currently playing song
|
||||||
|
pass
|
||||||
|
return False
|
71
ari/queue.py
Normal file
71
ari/queue.py
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Deque, NamedTuple, Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
|
from .constants import Emoji
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LimitReached(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Content(NamedTuple):
|
||||||
|
message: discord.Message
|
||||||
|
video_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(eq=False)
|
||||||
|
class QueueItem:
|
||||||
|
content: Content
|
||||||
|
next: Optional["QueueItem"]
|
||||||
|
queue: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
|
class Queue:
|
||||||
|
def __init__(self, max_length: int = 50) -> None:
|
||||||
|
self._lock = Lock()
|
||||||
|
self._front = None
|
||||||
|
self._back = None
|
||||||
|
self.length = 0
|
||||||
|
|
||||||
|
def push(self, item: Content) -> QueueItem:
|
||||||
|
with self._lock:
|
||||||
|
if self._front is None:
|
||||||
|
self._front = QueueItem(content=item, queue=id(self), next=None)
|
||||||
|
self._back = self._front
|
||||||
|
else:
|
||||||
|
assert self._back is not None
|
||||||
|
self._back.next = QueueItem(content=item, queue=id(self), next=None)
|
||||||
|
self._back = self._back.next
|
||||||
|
self.length += 1
|
||||||
|
return self._back
|
||||||
|
|
||||||
|
def pop(self) -> Optional[QueueItem]:
|
||||||
|
with self._lock:
|
||||||
|
item = self._front
|
||||||
|
if item is not None:
|
||||||
|
self._front = item.next
|
||||||
|
item.queue = None # item is no longer a part of the queue
|
||||||
|
self.length -= 1
|
||||||
|
return item
|
||||||
|
|
||||||
|
def skip_to(self, item: QueueItem):
|
||||||
|
with self._lock:
|
||||||
|
if item.queue != id(self):
|
||||||
|
raise ValueError("item must be from this queue")
|
||||||
|
self._front = item
|
||||||
|
|
||||||
|
def peek(self) -> Optional[QueueItem]:
|
||||||
|
return self._front
|
||||||
|
|
||||||
|
def __contains__(self, item: QueueItem) -> bool:
|
||||||
|
return item.queue == id(self)
|
||||||
|
|
||||||
|
def empty(self) -> bool:
|
||||||
|
return self.length == 0
|
BIN
images/emoji_play.png
Normal file
BIN
images/emoji_play.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 979 B |
BIN
images/emoji_pushtofront.png
Normal file
BIN
images/emoji_pushtofront.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
BIN
images/emoji_warning.png
Normal file
BIN
images/emoji_warning.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
4
requirements.txt
Normal file
4
requirements.txt
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
discord.py[voice]
|
||||||
|
bidict
|
||||||
|
yt_dlp
|
||||||
|
|
Loading…
Reference in a new issue