mirror of
https://github.com/RYGhub/royalnet.git
synced 2024-11-27 13:34:28 +00:00
complete discord
This commit is contained in:
parent
9176144392
commit
ef645ab143
5 changed files with 19 additions and 406 deletions
|
@ -1,2 +1,9 @@
|
||||||
from .create_rich_embed import create_rich_embed
|
from .create_rich_embed import create_rich_embed
|
||||||
from .escape import escape
|
from .escape import escape
|
||||||
|
from .discordserf import DiscordSerf
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"create_rich_embed",
|
||||||
|
"escape",
|
||||||
|
"DiscordSerf",
|
||||||
|
]
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
from typing import Type, Optional, List, Union
|
||||||
from typing import Type, Optional, List, Callable, Union
|
|
||||||
from royalnet.commands import Command, CommandInterface, CommandData, CommandArgs, CommandError, InvalidInputError, \
|
from royalnet.commands import Command, CommandInterface, CommandData, CommandArgs, CommandError, InvalidInputError, \
|
||||||
UnsupportedError
|
UnsupportedError
|
||||||
from royalnet.utils import asyncify
|
from royalnet.utils import asyncify
|
||||||
|
@ -44,7 +43,13 @@ class DiscordSerf(Serf):
|
||||||
network_config=network_config,
|
network_config=network_config,
|
||||||
secrets_name=secrets_name)
|
secrets_name=secrets_name)
|
||||||
|
|
||||||
def _interface_factory(self) -> Type[CommandInterface]:
|
self.Client = self.bot_factory()
|
||||||
|
"""The custom :class:`discord.Client` class that will be instantiated later."""
|
||||||
|
|
||||||
|
self.client = self.Client()
|
||||||
|
"""The custo :class:`discord.Client` instance."""
|
||||||
|
|
||||||
|
def interface_factory(self) -> Type[CommandInterface]:
|
||||||
# noinspection PyPep8Naming
|
# noinspection PyPep8Naming
|
||||||
GenericInterface = super().interface_factory()
|
GenericInterface = super().interface_factory()
|
||||||
|
|
||||||
|
@ -55,7 +60,7 @@ class DiscordSerf(Serf):
|
||||||
|
|
||||||
return DiscordInterface
|
return DiscordInterface
|
||||||
|
|
||||||
def _data_factory(self) -> Type[CommandData]:
|
def data_factory(self) -> Type[CommandData]:
|
||||||
# noinspection PyMethodParameters,PyAbstractClass
|
# noinspection PyMethodParameters,PyAbstractClass
|
||||||
class DiscordData(CommandData):
|
class DiscordData(CommandData):
|
||||||
def __init__(data, interface: CommandInterface, session, message: discord.Message):
|
def __init__(data, interface: CommandInterface, session, message: discord.Message):
|
||||||
|
@ -134,7 +139,7 @@ class DiscordSerf(Serf):
|
||||||
if session is not None:
|
if session is not None:
|
||||||
await asyncify(session.close)
|
await asyncify(session.close)
|
||||||
|
|
||||||
def _bot_factory(self) -> Type[discord.Client]:
|
def bot_factory(self) -> Type[discord.Client]:
|
||||||
"""Create a custom class inheriting from :py:class:`discord.Client`."""
|
"""Create a custom class inheriting from :py:class:`discord.Client`."""
|
||||||
# noinspection PyMethodParameters
|
# noinspection PyMethodParameters
|
||||||
class DiscordClient(discord.Client):
|
class DiscordClient(discord.Client):
|
||||||
|
@ -181,7 +186,7 @@ class DiscordSerf(Serf):
|
||||||
return matching_channels
|
return matching_channels
|
||||||
|
|
||||||
def find_voice_client(cli, guild: discord.Guild) -> Optional[discord.VoiceClient]:
|
def find_voice_client(cli, guild: discord.Guild) -> Optional[discord.VoiceClient]:
|
||||||
"""Find the :py:class:`discord.VoiceClient` belonging to a specific :py:class:`discord.Guild`."""
|
"""Find the :class:`discord.VoiceClient` belonging to a specific :py:class:`discord.Guild`."""
|
||||||
# TODO: the bug I was looking for might be here
|
# TODO: the bug I was looking for might be here
|
||||||
for voice_client in cli.voice_clients:
|
for voice_client in cli.voice_clients:
|
||||||
if voice_client.guild == guild:
|
if voice_client.guild == guild:
|
||||||
|
@ -190,80 +195,7 @@ class DiscordSerf(Serf):
|
||||||
|
|
||||||
return DiscordClient
|
return DiscordClient
|
||||||
|
|
||||||
# TODO: restart from here
|
|
||||||
|
|
||||||
def _init_client(self):
|
|
||||||
"""Create an instance of the DiscordClient class created in :py:func:`royalnet.bots.DiscordBot._bot_factory`."""
|
|
||||||
log.debug(f"Creating DiscordClient instance")
|
|
||||||
self._Client = self._bot_factory()
|
|
||||||
self.client = self._Client()
|
|
||||||
|
|
||||||
def _initialize(self):
|
|
||||||
super()._initialize()
|
|
||||||
self._init_client()
|
|
||||||
self._init_voice()
|
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
"""Login to Discord, then run the bot."""
|
|
||||||
if not self.initialized:
|
|
||||||
self._initialize()
|
|
||||||
log.debug("Getting Discord secret")
|
|
||||||
token = self.get_secret("discord")
|
token = self.get_secret("discord")
|
||||||
log.info(f"Logging in to Discord")
|
|
||||||
await self.client.login(token)
|
await self.client.login(token)
|
||||||
log.info(f"Connecting to Discord")
|
|
||||||
await self.client.connect()
|
await self.client.connect()
|
||||||
|
|
||||||
async def add_to_music_data(self, dfiles: typing.List[YtdlDiscord], guild: discord.Guild):
|
|
||||||
"""Add a list of :py:class:`royalnet.audio.YtdlDiscord` to the corresponding music_data object."""
|
|
||||||
guild_music_data = self.music_data[guild]
|
|
||||||
if guild_music_data is None:
|
|
||||||
raise CommandError(f"No music_data has been created for guild {guild}")
|
|
||||||
for dfile in dfiles:
|
|
||||||
log.debug(f"Adding {dfile} to music_data")
|
|
||||||
await asyncify(dfile.ready_up)
|
|
||||||
guild_music_data.playmode.add(dfile)
|
|
||||||
if guild_music_data.playmode.now_playing is None:
|
|
||||||
await self.advance_music_data(guild)
|
|
||||||
|
|
||||||
async def advance_music_data(self, guild: discord.Guild):
|
|
||||||
"""Try to play the next song, while it exists. Otherwise, just return."""
|
|
||||||
guild_music_data: MusicData = self.music_data[guild]
|
|
||||||
voice_client: discord.VoiceClient = guild_music_data.voice_client
|
|
||||||
next_source: discord.AudioSource = await guild_music_data.playmode.next()
|
|
||||||
await self.update_activity_with_source_title()
|
|
||||||
if next_source is None:
|
|
||||||
log.debug(f"Ending playback chain")
|
|
||||||
return
|
|
||||||
|
|
||||||
def advance(error=None):
|
|
||||||
if error:
|
|
||||||
voice_client.disconnect(force=True)
|
|
||||||
guild_music_data.voice_client = None
|
|
||||||
log.error(f"Error while advancing music_data: {error}")
|
|
||||||
return
|
|
||||||
self.loop.create_task(self.advance_music_data(guild))
|
|
||||||
|
|
||||||
log.debug(f"Starting playback of {next_source}")
|
|
||||||
voice_client.play(next_source, after=advance)
|
|
||||||
|
|
||||||
async def update_activity_with_source_title(self):
|
|
||||||
"""Change the bot's presence (using :py:func:`discord.Client.change_presence`) to match the current listening status.
|
|
||||||
|
|
||||||
If multiple guilds are using the bot, the bot will always have an empty presence."""
|
|
||||||
if len(self.music_data) != 1:
|
|
||||||
# Multiple guilds are using the bot, do not display anything
|
|
||||||
log.debug(f"Updating current Activity: setting to None, as multiple guilds are using the bot")
|
|
||||||
await self.client.change_presence(status=discord.Status.online)
|
|
||||||
return
|
|
||||||
play_mode: playmodes.PlayMode = self.music_data[list(self.music_data)[0]].playmode
|
|
||||||
now_playing = play_mode.now_playing
|
|
||||||
if now_playing is None:
|
|
||||||
# No songs are playing now
|
|
||||||
log.debug(f"Updating current Activity: setting to None, as nothing is currently being played")
|
|
||||||
await self.client.change_presence(status=discord.Status.online)
|
|
||||||
return
|
|
||||||
log.debug(f"Updating current Activity: listening to {now_playing.info.title}")
|
|
||||||
await self.client.change_presence(activity=discord.Activity(name=now_playing.info.title,
|
|
||||||
type=discord.ActivityType.listening),
|
|
||||||
status=discord.Status.online)
|
|
|
@ -1,41 +0,0 @@
|
||||||
import discord
|
|
||||||
|
|
||||||
|
|
||||||
class FileAudioSource(discord.AudioSource):
|
|
||||||
"""A :class:`discord.AudioSource` that uses a :class:`io.BufferedIOBase` as an input instead of memory.
|
|
||||||
|
|
||||||
The stream should be in the usual PCM encoding.
|
|
||||||
|
|
||||||
Warning:
|
|
||||||
This AudioSource will consume (and close) the passed stream."""
|
|
||||||
|
|
||||||
def __init__(self, file):
|
|
||||||
self.file = file
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
if self.file.seekable():
|
|
||||||
return f"<{self.__class__.__name__} @{self.file.tell()}>"
|
|
||||||
else:
|
|
||||||
return f"<{self.__class__.__name__}>"
|
|
||||||
|
|
||||||
def is_opus(self):
|
|
||||||
"""This audio file isn't Opus-encoded, but PCM-encoded.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``False``."""
|
|
||||||
return False
|
|
||||||
|
|
||||||
def read(self):
|
|
||||||
"""Reads 20ms worth of audio.
|
|
||||||
|
|
||||||
If the audio is complete, then returning an empty :py:class:`bytes`-like object to signal this is the way to do so."""
|
|
||||||
# If the stream is closed, it should stop playing immediatly
|
|
||||||
if self.file.closed:
|
|
||||||
return b""
|
|
||||||
data: bytes = self.file.read(discord.opus.Encoder.FRAME_SIZE)
|
|
||||||
# If there is no more data to be streamed
|
|
||||||
if len(data) != discord.opus.Encoder.FRAME_SIZE:
|
|
||||||
# Close the file
|
|
||||||
self.file.close()
|
|
||||||
return b""
|
|
||||||
return data
|
|
|
@ -1,213 +0,0 @@
|
||||||
from math import inf
|
|
||||||
from random import shuffle
|
|
||||||
from typing import Optional, List, AsyncGenerator, Union
|
|
||||||
from collections import namedtuple
|
|
||||||
from .ytdldiscord import YtdlDiscord
|
|
||||||
from .fileaudiosource import FileAudioSource
|
|
||||||
|
|
||||||
|
|
||||||
class PlayMode:
|
|
||||||
"""The base class for a PlayMode, such as :py:class:`royalnet.audio.Playlist`. Inherit from this class if you want to create a custom PlayMode."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Create a new PlayMode and initialize the generator inside."""
|
|
||||||
self.now_playing: Optional[YtdlDiscord] = None
|
|
||||||
self.generator: AsyncGenerator = self._generate_generator()
|
|
||||||
|
|
||||||
async def next(self) -> Optional[FileAudioSource]:
|
|
||||||
"""Get the next :py:class:`royalnet.audio.FileAudioSource` from the list and advance it.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The next :py:class:`royalnet.audio.FileAudioSource`."""
|
|
||||||
return await self.generator.__anext__()
|
|
||||||
|
|
||||||
def videos_left(self) -> Union[int, float]:
|
|
||||||
"""Return the number of videos left in the PlayMode.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Usually a :py:class:`int`, but may return also :py:obj:`math.inf` if the PlayMode is infinite."""
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
async def _generate_generator(self):
|
|
||||||
"""Factory function for an async generator that changes the ``now_playing`` property either to a :py:class:`royalnet.audio.FileAudioSource` or to ``None``, then yields the value it changed it to.
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
The :py:class:`royalnet.audio.FileAudioSource` to be played next."""
|
|
||||||
raise NotImplementedError()
|
|
||||||
# This is needed to make the coroutine an async generator
|
|
||||||
# noinspection PyUnreachableCode
|
|
||||||
yield NotImplemented
|
|
||||||
|
|
||||||
def add(self, item: YtdlDiscord) -> None:
|
|
||||||
"""Add a new :py:class:`royalnet.audio.YtdlDiscord` to the PlayMode.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
item: The item to add to the PlayMode."""
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def delete(self) -> None:
|
|
||||||
"""Delete all :py:class:`royalnet.audio.YtdlDiscord` contained inside this PlayMode."""
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def queue_preview(self) -> List[YtdlDiscord]:
|
|
||||||
"""Display all the videos in the PlayMode as a list, if possible.
|
|
||||||
|
|
||||||
To be used with ``queue`` packs, for example.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
NotImplementedError: If a preview can't be generated.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A list of videos contained in the queue."""
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
|
|
||||||
class Playlist(PlayMode):
|
|
||||||
"""A video list. :py:class:`royalnet.audio.YtdlDiscord` played are removed from the list."""
|
|
||||||
|
|
||||||
def __init__(self, starting_list: List[YtdlDiscord] = None):
|
|
||||||
"""Create a new Playlist.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
starting_list: A list of items with which the Playlist will be created."""
|
|
||||||
super().__init__()
|
|
||||||
if starting_list is None:
|
|
||||||
starting_list = []
|
|
||||||
self.list: List[YtdlDiscord] = starting_list
|
|
||||||
|
|
||||||
def videos_left(self) -> Union[int, float]:
|
|
||||||
return len(self.list)
|
|
||||||
|
|
||||||
async def _generate_generator(self):
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
next_video = self.list.pop(0)
|
|
||||||
except IndexError:
|
|
||||||
self.now_playing = None
|
|
||||||
yield None
|
|
||||||
else:
|
|
||||||
self.now_playing = next_video
|
|
||||||
yield next_video.spawn_audiosource()
|
|
||||||
if self.now_playing is not None:
|
|
||||||
self.now_playing.delete()
|
|
||||||
|
|
||||||
def add(self, item) -> None:
|
|
||||||
self.list.append(item)
|
|
||||||
|
|
||||||
def delete(self) -> None:
|
|
||||||
if self.now_playing is not None:
|
|
||||||
self.now_playing.delete()
|
|
||||||
while self.list:
|
|
||||||
self.list.pop(0).delete()
|
|
||||||
|
|
||||||
def queue_preview(self) -> List[YtdlDiscord]:
|
|
||||||
return self.list
|
|
||||||
|
|
||||||
|
|
||||||
class Pool(PlayMode):
|
|
||||||
"""A random pool. :py:class:`royalnet.audio.YtdlDiscord` are selected in random order and are not repeated until every song has been played at least once."""
|
|
||||||
|
|
||||||
def __init__(self, starting_pool: List[YtdlDiscord] = None):
|
|
||||||
"""Create a new Pool.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
starting_pool: A list of items the Pool will be created from."""
|
|
||||||
super().__init__()
|
|
||||||
if starting_pool is None:
|
|
||||||
starting_pool = []
|
|
||||||
self.pool: List[YtdlDiscord] = starting_pool
|
|
||||||
self._pool_copy: List[YtdlDiscord] = []
|
|
||||||
|
|
||||||
def videos_left(self) -> Union[int, float]:
|
|
||||||
return inf
|
|
||||||
|
|
||||||
async def _generate_generator(self):
|
|
||||||
while True:
|
|
||||||
if not self.pool:
|
|
||||||
self.now_playing = None
|
|
||||||
yield None
|
|
||||||
continue
|
|
||||||
self._pool_copy = self.pool.copy()
|
|
||||||
shuffle(self._pool_copy)
|
|
||||||
while self._pool_copy:
|
|
||||||
next_video = self._pool_copy.pop(0)
|
|
||||||
self.now_playing = next_video
|
|
||||||
yield next_video.spawn_audiosource()
|
|
||||||
|
|
||||||
def add(self, item) -> None:
|
|
||||||
self.pool.append(item)
|
|
||||||
self._pool_copy.append(item)
|
|
||||||
shuffle(self._pool_copy)
|
|
||||||
|
|
||||||
def delete(self) -> None:
|
|
||||||
for item in self.pool:
|
|
||||||
item.delete()
|
|
||||||
self.pool = None
|
|
||||||
self._pool_copy = None
|
|
||||||
|
|
||||||
def queue_preview(self) -> List[YtdlDiscord]:
|
|
||||||
preview_pool = self.pool.copy()
|
|
||||||
shuffle(preview_pool)
|
|
||||||
return preview_pool
|
|
||||||
|
|
||||||
|
|
||||||
class Layers(PlayMode):
|
|
||||||
"""A playmode for playing a single song with multiple layers."""
|
|
||||||
|
|
||||||
Layer = namedtuple("Layer", ["dfile", "source"])
|
|
||||||
|
|
||||||
def __init__(self, starting_layers: List[YtdlDiscord] = None):
|
|
||||||
super().__init__()
|
|
||||||
if starting_layers is None:
|
|
||||||
starting_layers = []
|
|
||||||
self.layers = []
|
|
||||||
for item in starting_layers:
|
|
||||||
self.add(item)
|
|
||||||
|
|
||||||
def videos_left(self) -> Union[int, float]:
|
|
||||||
return 1 if len(self.layers) > 0 else 0
|
|
||||||
|
|
||||||
async def _generate_generator(self):
|
|
||||||
current_layer = None
|
|
||||||
current_source = None
|
|
||||||
while True:
|
|
||||||
if len(self.layers) == 0:
|
|
||||||
yield None
|
|
||||||
continue
|
|
||||||
if self.now_playing is None:
|
|
||||||
self.now_playing = self.layers[0].dfile
|
|
||||||
current_source = self.layers[0].source
|
|
||||||
current_layer = 0
|
|
||||||
yield current_source
|
|
||||||
continue
|
|
||||||
if current_source.file.closed:
|
|
||||||
self.now_playing = None
|
|
||||||
self.layers = []
|
|
||||||
current_layer = None
|
|
||||||
current_source = None
|
|
||||||
yield None
|
|
||||||
continue
|
|
||||||
current_layer += 1
|
|
||||||
current_position = current_source.file.tell()
|
|
||||||
if current_layer >= len(self.layers):
|
|
||||||
self.now_playing = self.layers[0].dfile
|
|
||||||
current_source = self.layers[0].source
|
|
||||||
current_source.file.seek(current_position)
|
|
||||||
current_layer = 0
|
|
||||||
yield current_source
|
|
||||||
continue
|
|
||||||
self.now_playing = self.layers[current_layer].dfile
|
|
||||||
current_source = self.layers[current_layer].source
|
|
||||||
current_source.file.seek(current_position)
|
|
||||||
yield current_source
|
|
||||||
|
|
||||||
def add(self, item) -> None:
|
|
||||||
self.layers.append(self.Layer(dfile=item, source=item.spawn_audiosource()))
|
|
||||||
|
|
||||||
def delete(self) -> None:
|
|
||||||
for item in self.layers:
|
|
||||||
item.dfile.delete()
|
|
||||||
self.layers = None
|
|
||||||
|
|
||||||
def queue_preview(self) -> List[YtdlDiscord]:
|
|
||||||
return [layer.dfile for layer in self.layers]
|
|
|
@ -1,72 +0,0 @@
|
||||||
from typing import Optional, List
|
|
||||||
from re import sub
|
|
||||||
from ffmpeg import input
|
|
||||||
from os import path, remove
|
|
||||||
from royalnet.bard import YtdlInfo
|
|
||||||
from royalnet.bard import YtdlFile
|
|
||||||
from .fileaudiosource import FileAudioSource
|
|
||||||
|
|
||||||
|
|
||||||
class YtdlDiscord:
|
|
||||||
def __init__(self, ytdl_file: YtdlFile):
|
|
||||||
self.ytdl_file: YtdlFile = ytdl_file
|
|
||||||
self.pcm_filename: Optional[str] = None
|
|
||||||
self._fas_spawned: List[FileAudioSource] = []
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<{self.__class__.__name__} {self.info.title or 'Unknown Title'} ({'ready' if self.pcm_available() else 'not ready'}," \
|
|
||||||
f" {len(self._fas_spawned)} audiosources spawned)>"
|
|
||||||
|
|
||||||
def pcm_available(self):
|
|
||||||
return self.pcm_filename is not None and path.exists(self.pcm_filename)
|
|
||||||
|
|
||||||
def convert_to_pcm(self) -> None:
|
|
||||||
if not self.ytdl_file.is_downloaded():
|
|
||||||
raise FileNotFoundError("File hasn't been downloaded yet")
|
|
||||||
destination_filename = sub(r"\.[^.]+$", ".pcm", self.ytdl_file.filename)
|
|
||||||
(
|
|
||||||
input(self.ytdl_file.filename)
|
|
||||||
.output(destination_filename, format="s16le", ac=2, ar="48000")
|
|
||||||
.overwrite_output()
|
|
||||||
.run(quiet=not __debug__)
|
|
||||||
)
|
|
||||||
self.pcm_filename = destination_filename
|
|
||||||
|
|
||||||
def ready_up(self):
|
|
||||||
if not self.ytdl_file.has_info():
|
|
||||||
self.ytdl_file.retrieve_info()
|
|
||||||
if not self.ytdl_file.is_downloaded():
|
|
||||||
self.ytdl_file.download_file()
|
|
||||||
if not self.pcm_available():
|
|
||||||
self.convert_to_pcm()
|
|
||||||
|
|
||||||
def spawn_audiosource(self) -> FileAudioSource:
|
|
||||||
if not self.pcm_available():
|
|
||||||
raise FileNotFoundError("File hasn't been converted to PCM yet")
|
|
||||||
stream = open(self.pcm_filename, "rb")
|
|
||||||
source = FileAudioSource(stream)
|
|
||||||
# FIXME: it's a intentional memory leak
|
|
||||||
self._fas_spawned.append(source)
|
|
||||||
return source
|
|
||||||
|
|
||||||
def delete(self) -> None:
|
|
||||||
if self.pcm_available():
|
|
||||||
for source in self._fas_spawned:
|
|
||||||
if not source.file.closed:
|
|
||||||
source.file.close()
|
|
||||||
remove(self.pcm_filename)
|
|
||||||
self.pcm_filename = None
|
|
||||||
self.ytdl_file.delete()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create_from_url(cls, url, **ytdl_args) -> List["YtdlDiscord"]:
|
|
||||||
files = YtdlFile.download_from_url(url, **ytdl_args)
|
|
||||||
dfiles = []
|
|
||||||
for file in files:
|
|
||||||
dfile = YtdlDiscord(file)
|
|
||||||
dfiles.append(dfile)
|
|
||||||
return dfiles
|
|
||||||
|
|
||||||
@property
|
|
||||||
def info(self) -> Optional[YtdlInfo]:
|
|
||||||
return self.ytdl_file.info
|
|
Loading…
Reference in a new issue