diff --git a/royalnet/serf/discord/__init__.py b/royalnet/serf/discord/__init__.py index 4ee723a8..38f94805 100644 --- a/royalnet/serf/discord/__init__.py +++ b/royalnet/serf/discord/__init__.py @@ -1,2 +1,9 @@ from .create_rich_embed import create_rich_embed -from .escape import escape \ No newline at end of file +from .escape import escape +from .discordserf import DiscordSerf + +__all__ = [ + "create_rich_embed", + "escape", + "DiscordSerf", +] diff --git a/royalnet/serf/discord/discord.py b/royalnet/serf/discord/discordserf.py similarity index 66% rename from royalnet/serf/discord/discord.py rename to royalnet/serf/discord/discordserf.py index 50501ad5..f40350db 100644 --- a/royalnet/serf/discord/discord.py +++ b/royalnet/serf/discord/discordserf.py @@ -1,6 +1,5 @@ import logging -import asyncio -from typing import Type, Optional, List, Callable, Union +from typing import Type, Optional, List, Union from royalnet.commands import Command, CommandInterface, CommandData, CommandArgs, CommandError, InvalidInputError, \ UnsupportedError from royalnet.utils import asyncify @@ -44,7 +43,13 @@ class DiscordSerf(Serf): network_config=network_config, 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 GenericInterface = super().interface_factory() @@ -55,7 +60,7 @@ class DiscordSerf(Serf): return DiscordInterface - def _data_factory(self) -> Type[CommandData]: + def data_factory(self) -> Type[CommandData]: # noinspection PyMethodParameters,PyAbstractClass class DiscordData(CommandData): def __init__(data, interface: CommandInterface, session, message: discord.Message): @@ -134,7 +139,7 @@ class DiscordSerf(Serf): if session is not None: 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`.""" # noinspection PyMethodParameters class DiscordClient(discord.Client): @@ -181,7 +186,7 @@ class DiscordSerf(Serf): return matching_channels 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 for voice_client in cli.voice_clients: if voice_client.guild == guild: @@ -190,80 +195,7 @@ class DiscordSerf(Serf): 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): - """Login to Discord, then run the bot.""" - if not self.initialized: - self._initialize() - log.debug("Getting Discord secret") token = self.get_secret("discord") - log.info(f"Logging in to Discord") await self.client.login(token) - log.info(f"Connecting to Discord") 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) diff --git a/royalnet/serf/discord/fileaudiosource.py b/royalnet/serf/discord/fileaudiosource.py deleted file mode 100644 index efb1ef0b..00000000 --- a/royalnet/serf/discord/fileaudiosource.py +++ /dev/null @@ -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 diff --git a/royalnet/serf/discord/playmodes.py b/royalnet/serf/discord/playmodes.py deleted file mode 100644 index 11bcc3fb..00000000 --- a/royalnet/serf/discord/playmodes.py +++ /dev/null @@ -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] diff --git a/royalnet/serf/discord/ytdldiscord.py b/royalnet/serf/discord/ytdldiscord.py deleted file mode 100644 index 76e8cc37..00000000 --- a/royalnet/serf/discord/ytdldiscord.py +++ /dev/null @@ -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