mirror of
https://github.com/RYGhub/royalnet.git
synced 2024-11-23 19:44:20 +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 .escape import escape
|
||||
from .discordserf import DiscordSerf
|
||||
|
||||
__all__ = [
|
||||
"create_rich_embed",
|
||||
"escape",
|
||||
"DiscordSerf",
|
||||
]
|
||||
|
|
|
@ -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)
|
|
@ -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