1
Fork 0
mirror of https://github.com/RYGhub/royalnet.git synced 2024-11-27 13:34:28 +00:00

complete discord

This commit is contained in:
Steffo 2019-11-14 18:56:59 +01:00
parent 9176144392
commit ef645ab143
5 changed files with 19 additions and 406 deletions

View file

@ -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",
]

View file

@ -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)

View file

@ -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

View file

@ -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]

View file

@ -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