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

IT WOOOOOOOOOOOOOOORKS

This commit is contained in:
Steffo 2019-11-23 02:04:30 +01:00
parent 2b24d8cd09
commit 88a9ef35ca
14 changed files with 121 additions and 120 deletions

View file

@ -3,7 +3,6 @@
<component name="ProjectModuleManager"> <component name="ProjectModuleManager">
<modules> <modules>
<module fileurl="file://$PROJECT_DIR$/.idea/royalnet.iml" filepath="$PROJECT_DIR$/.idea/royalnet.iml" /> <module fileurl="file://$PROJECT_DIR$/.idea/royalnet.iml" filepath="$PROJECT_DIR$/.idea/royalnet.iml" />
<module fileurl="file://$PROJECT_DIR$/../royalpack/.idea/royalpack.iml" filepath="$PROJECT_DIR$/../royalpack/.idea/royalpack.iml" />
</modules> </modules>
</component> </component>
</project> </project>

View file

@ -9,7 +9,6 @@
</content> </content>
<orderEntry type="jdk" jdkName="Python 3.8 (royalnet-1MWM6-kd-py3.8)" jdkType="Python SDK" /> <orderEntry type="jdk" jdkName="Python 3.8 (royalnet-1MWM6-kd-py3.8)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="module" module-name="royalpack" />
</component> </component>
<component name="TestRunnerService"> <component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="Unittests" /> <option name="PROJECT_TEST_RUNNER" value="Unittests" />

View file

@ -3,6 +3,5 @@
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/docs_source/royalpack" vcs="Git" /> <mapping directory="$PROJECT_DIR$/docs_source/royalpack" vcs="Git" />
<mapping directory="$PROJECT_DIR$/../royalpack" vcs="Git" />
</component> </component>
</project> </project>

View file

@ -1,30 +1,26 @@
from royalnet.commands import * from royalnet.commands import *
from typing import TYPE_CHECKING, Optional, List, Union
import asyncio
try: try:
import discord import discord
except ImportError: except ImportError:
discord = None discord = None
if TYPE_CHECKING:
from royalnet.serf.discord import DiscordSerf
class PlayCommand(Command): class PlayCommand(Command):
# TODO: possibly move this in another pack # TODO: possibly move this in another pack
name: str = "play" name: str = "play"
description = "Download a file located at an URL and play it on Discord." description = ""
syntax = "[url]" syntax = "[url]"
async def run(self, args: CommandArgs, data: CommandData) -> None: async def run(self, args: CommandArgs, data: CommandData) -> None:
if self.interface.name != "discord": url = args.joined()
raise UnsupportedError() response: dict = await self.interface.call_herald_event("discord", "play", url=url)
msg: "discord.Message" = data.message await data.reply("blah")
guild: "discord.Guild" = msg.guild
url: str = args.joined()
response: dict = await self.interface.call_herald_event("discord", "play", {
"guild_id": guild.id,
"url": url,
})
message = f"▶️ Added to [c]{response['bard']['type']}[/c]:\n"
message += "\n".join([ytd['title'] for ytd in response['added']])
await data.reply(message)

View file

@ -29,7 +29,7 @@ class SummonCommand(Command):
member = None member = None
guild = None guild = None
name = args.joined() name = args.joined()
response: dict = await self.interface.call_herald_event("discord", "summon", { response: dict = await self.interface.call_herald_event("discord", "summon", **{
"channel_name": name, "channel_name": name,
"guild_id": guild.id if guild is not None else None, "guild_id": guild.id if guild is not None else None,
"user_id": member.id if member is not None else None, "user_id": member.id if member is not None else None,

View file

@ -5,7 +5,7 @@ from .play import PlayEvent
# Enter the commands of your Pack here! # Enter the commands of your Pack here!
available_events = [ available_events = [
SummonEvent, SummonEvent,
PlayEvent PlayEvent,
] ]
# Don't change this, it should automatically generate __all__ # Don't change this, it should automatically generate __all__

View file

@ -1,12 +1,7 @@
from typing import Optional from typing import Optional
from royalnet.commands import * from royalnet.commands import *
from royalnet.serf.discord import DiscordSerf from royalnet.serf.discord import DiscordSerf, PlayableYTDQueue
from royalnet.serf.discord.discordbard import YtdlDiscord, DiscordBard from royalnet.bard import YtdlDiscord
from royalnet.utils import asyncify
import pickle
import logging
log = logging.getLogger(__name__)
try: try:
import discord import discord
@ -17,53 +12,12 @@ except ImportError:
class PlayEvent(Event): class PlayEvent(Event):
name = "play" name = "play"
async def run(self, *, url: str, guild_id: Optional[int] = None, guild_name: Optional[str] = None, **kwargs): async def run(self, *, url: str):
if not isinstance(self.serf, DiscordSerf): if not isinstance(self.serf, DiscordSerf):
raise UnsupportedError("Play can't be called on interfaces other than Discord.") raise UnsupportedError("Summon can't be called on interfaces other than Discord.")
if discord is None: if discord is None:
raise UnsupportedError("'discord' extra is not installed.") raise UnsupportedError("'discord' extra is not installed.")
# Variables ytd = await YtdlDiscord.from_url(url)
client = self.serf.client self.serf.voice_players[0].playing.contents.append(ytd[0])
# Find the guild await self.serf.voice_players[0].start()
guild: Optional["discord.Guild"] = None return {"ok": "ok"}
if guild_id is not None:
guild = client.get_guild(guild_id)
elif guild_name is not None:
for g in client.guilds:
if g.name == guild_name:
guild = g
break
if guild is None:
raise InvalidInputError("No guild_id or guild_name specified.")
log.debug(f"Selected guild: {guild}")
# Find the bard
bard: Optional[DiscordBard] = self.serf.bards.get(guild)
if bard is None:
raise CommandError("Bot is not connected to voice chat.")
# Create the YtdlDiscords
log.debug(f"Downloading: {url}")
try:
ytdl = await YtdlDiscord.from_url(url)
except Exception as exc:
breakpoint()
return
# Add the YtdlDiscords to the queue
log.debug(f"Adding to bard: {ytdl}")
for ytd in ytdl:
await bard.add(ytd)
# Run the bard
log.debug(f"Running voice for: {guild}")
# FIXME: sure?
self.loop.create_task(self.serf.voice_run(guild))
# Return the results
log.debug(f"Sending results...")
results = {
"added": [{
"title": ytd.info.title,
"embed_pickle": pickle.dumps(ytd.embed())
} for ytd in ytdl],
"bard": {
"type": bard.__class__.__name__,
}
}
return results

View file

@ -1,6 +1,6 @@
from typing import Optional from typing import Optional
from royalnet.commands import * from royalnet.commands import *
from royalnet.serf.discord import DiscordSerf from royalnet.serf.discord import DiscordSerf, VoicePlayer, PlayableYTDQueue
try: try:
import discord import discord
@ -38,11 +38,13 @@ class SummonEvent(Event):
required_permissions=["connect", "speak"]) required_permissions=["connect", "speak"])
if channel is None: if channel is None:
raise InvalidInputError("No channels found with the specified name.") raise InvalidInputError("No channels found with the specified name.")
# Create a new VoicePlayer
vp = VoicePlayer(loop=self.loop)
vp.playing = await PlayableYTDQueue.create()
# Connect to the channel # Connect to the channel
await self.serf.voice_connect(channel) await vp.connect(channel)
# Find the created bard # Add the created VoicePlayer to the list
bard = self.serf.bards[channel.guild] self.serf.voice_players.append(vp)
bard_peek = await bard.peek()
# Reply to the request # Reply to the request
return { return {
"channel": { "channel": {
@ -51,9 +53,5 @@ class SummonEvent(Event):
"guild": { "guild": {
"name": channel.guild.name, "name": channel.guild.name,
}, },
},
"bard": {
"type": bard.__class__.__qualname__,
"peek": bard_peek,
} }
} }

View file

@ -4,10 +4,14 @@ It is pretty unstable, compared to the rest of the bot, but it *should* work."""
from .escape import escape from .escape import escape
from .discordserf import DiscordSerf from .discordserf import DiscordSerf
from . import discordbard from .playable import Playable
from .playableytdqueue import PlayableYTDQueue
from .voiceplayer import VoicePlayer
__all__ = [ __all__ = [
"escape", "escape",
"DiscordSerf", "DiscordSerf",
"discordbard", "Playable",
"PlayableYTDQueue",
"VoicePlayer",
] ]

View file

@ -1,10 +1,12 @@
import asyncio import asyncio
import logging import logging
import warnings
from typing import Type, Optional, List, Union, Dict from typing import Type, Optional, List, Union, Dict
from royalnet.commands import * from royalnet.commands import *
from royalnet.utils import asyncify from royalnet.utils import asyncify
from royalnet.serf import Serf from royalnet.serf import Serf
from .escape import escape from .escape import escape
from .voiceplayer import VoicePlayer
try: try:
@ -52,6 +54,9 @@ class DiscordSerf(Serf):
self.client = self.Client() self.client = self.Client()
"""The custom :class:`discord.Client` instance.""" """The custom :class:`discord.Client` instance."""
self.voice_players: List[VoicePlayer] = []
"""A :class:`list` of the :class:`VoicePlayer` in use by this :class:`DiscordSerf`."""
def interface_factory(self) -> Type[CommandInterface]: def interface_factory(self) -> Type[CommandInterface]:
# noinspection PyPep8Naming # noinspection PyPep8Naming
GenericInterface = super().interface_factory() GenericInterface = super().interface_factory()
@ -178,6 +183,7 @@ class DiscordSerf(Serf):
Returns: Returns:
Either a :class:`~discord.abc.GuildChannel`, or :const:`None` if no channels were found.""" Either a :class:`~discord.abc.GuildChannel`, or :const:`None` if no channels were found."""
warnings.warn("This function will be removed soon.", category=DeprecationWarning)
if accessible_to is None: if accessible_to is None:
accessible_to = [] accessible_to = []
if required_permissions is None: if required_permissions is None:
@ -225,23 +231,3 @@ class DiscordSerf(Serf):
return channels[0] return channels[0]
async def voice_run(self, guild: "discord.Guild"):
"""Send the data from the bard to the voice websocket for a specific client."""
bard: Optional[DiscordBard] = self.bards.get(guild)
if bard is None:
return
def finished_playing(error=None):
if error:
log.error(f"Finished playing with error: {error}")
return
self.loop.create_task(self.voice_run(guild))
if bard.now_playing is None:
fas = await bard.next()
if fas is None:
return
# FIXME: possible race condition here, pls check
bard = self.bards.get(guild)
if bard.voice_client is not None and bard.voice_client.is_connected():
bard.voice_client.play(fas, after=finished_playing)

View file

@ -36,3 +36,7 @@ class PlayerNotConnectedError(VoicePlayerError):
"""The :class:`VoicePlayer` isn't connected to the Discord voice servers. """The :class:`VoicePlayer` isn't connected to the Discord voice servers.
Use :meth:`VoicePlayer.connect` first!""" Use :meth:`VoicePlayer.connect` first!"""
class PlayerAlreadyPlaying(VoicePlayerError):
"""The :class:`VoicePlayer` is already playing audio and cannot start playing audio again."""

View file

@ -1,3 +1,4 @@
import logging
from typing import Optional, AsyncGenerator, Tuple, Any, Dict from typing import Optional, AsyncGenerator, Tuple, Any, Dict
try: try:
import discord import discord
@ -5,11 +6,30 @@ except ImportError:
discord = None discord = None
log = logging.getLogger(__name__)
class Playable: class Playable:
"""An abstract class representing something that can be played back in a :class:`VoicePlayer`.""" """An abstract class representing something that can be played back in a :class:`VoicePlayer`."""
def __init__(self): def __init__(self):
self.generator: \ """Create a :class:`Playable`.
Optional[AsyncGenerator[Optional["discord.AudioSource"], Tuple[Tuple[Any, ...], Dict[str, Any]]]] = None
Warning:
Avoid using this method, as it does not initialize the generator! Use :meth:`.create` instead."""
log.debug("Creating a Playable...")
self.generator: Optional[AsyncGenerator[Optional["discord.AudioSource"],
Tuple[Tuple[Any, ...], Dict[str, Any]]]] = self._generator()
# PyCharm doesn't like what I'm doing here.
# noinspection PyTypeChecker
@classmethod
async def create(cls, *args, **kwargs):
"""Create a :class:`Playable` and initialize its generator."""
playable = cls(*args, **kwargs)
log.debug("Sending None to the generator...")
await playable.generator.asend(None)
log.debug("Playable ready!")
return playable
async def next(self, *args, **kwargs) -> Optional["discord.AudioSource"]: async def next(self, *args, **kwargs) -> Optional["discord.AudioSource"]:
"""Get the next :class:`discord.AudioSource` that should be played. """Get the next :class:`discord.AudioSource` that should be played.
@ -23,7 +43,10 @@ class Playable:
:const:`None` if there is nothing available to play, otherwise the :class:`discord.AudioSource` that should :const:`None` if there is nothing available to play, otherwise the :class:`discord.AudioSource` that should
be played. be played.
""" """
return await self.generator.asend((args, kwargs,)) log.debug("Getting next AudioSource...")
audio_source: Optional["discord.AudioSource"] = await self.generator.asend((args, kwargs,))
log.debug(f"Next: {audio_source}")
return audio_source
async def _generator(self) \ async def _generator(self) \
-> AsyncGenerator[Optional["discord.AudioSource"], Tuple[Tuple[Any, ...], Dict[str, Any]]]: -> AsyncGenerator[Optional["discord.AudioSource"], Tuple[Tuple[Any, ...], Dict[str, Any]]]:

View file

@ -1,3 +1,4 @@
import logging
from typing import Optional, List, AsyncGenerator, Tuple, Any, Dict from typing import Optional, List, AsyncGenerator, Tuple, Any, Dict
from royalnet.bard import YtdlDiscord from royalnet.bard import YtdlDiscord
from .playable import Playable from .playable import Playable
@ -7,6 +8,9 @@ except ImportError:
discord = None discord = None
log = logging.getLogger(__name__)
class PlayableYTDQueue(Playable): class PlayableYTDQueue(Playable):
"""A queue of :class:`YtdlDiscord` to be played in sequence.""" """A queue of :class:`YtdlDiscord` to be played in sequence."""
def __init__(self, start_with: Optional[List[YtdlDiscord]] = None): def __init__(self, start_with: Optional[List[YtdlDiscord]] = None):
@ -14,24 +18,28 @@ class PlayableYTDQueue(Playable):
self.contents: List[YtdlDiscord] = [] self.contents: List[YtdlDiscord] = []
if start_with is not None: if start_with is not None:
self.contents = [*self.contents, *start_with] self.contents = [*self.contents, *start_with]
log.debug(f"Created new PlayableYTDQueue containing: {self.contents}")
async def _generator(self) \ async def _generator(self) \
-> AsyncGenerator[Optional["discord.AudioSource"], Tuple[Tuple[Any, ...], Dict[str, Any]]]: -> AsyncGenerator[Optional["discord.AudioSource"], Tuple[Tuple[Any, ...], Dict[str, Any]]]:
yield yield
while True: while True:
log.debug(f"Dequeuing an item...")
try: try:
# Try to get the first YtdlDiscord of the queue # Try to get the first YtdlDiscord of the queue
ytd: YtdlDiscord = self.contents.pop(0) ytd: YtdlDiscord = self.contents.pop(0)
except IndexError: except IndexError:
# If there isn't anything, yield None # If there isn't anything, yield None
log.debug(f"Nothing to dequeue, yielding None.")
yield None yield None
continue continue
try: log.debug(f"Yielding FileAudioSource from: {ytd}")
# Create a FileAudioSource from the YtdlDiscord # Create a FileAudioSource from the YtdlDiscord
# If the file hasn't been fetched / downloaded / converted yet, it will do so before yielding # If the file hasn't been fetched / downloaded / converted yet, it will do so before yielding
async with ytd.spawn_audiosource() as fas: async with ytd.spawn_audiosource() as fas:
# Yield the resulting AudioSource # Yield the resulting AudioSource
yield fas yield fas
finally:
# Delete the YtdlDiscord file # Delete the YtdlDiscord file
log.debug(f"Deleting: {ytd}")
await ytd.delete_asap() await ytd.delete_asap()
log.debug(f"Deleted successfully!")

View file

@ -1,4 +1,5 @@
import asyncio import asyncio
import logging
from typing import Optional from typing import Optional
from .errors import * from .errors import *
from .playable import Playable from .playable import Playable
@ -7,11 +8,17 @@ try:
except ImportError: except ImportError:
discord = None discord = None
log = logging.getLogger(__name__)
class VoicePlayer: class VoicePlayer:
def __init__(self): def __init__(self, *, loop: Optional[asyncio.AbstractEventLoop] = None):
self.voice_client: Optional["discord.VoiceClient"] = None self.voice_client: Optional["discord.VoiceClient"] = None
self.playing: Optional[Playable] = None self.playing: Optional[Playable] = None
if loop is None:
self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
else:
self.loop = loop
async def connect(self, channel: "discord.VoiceChannel") -> "discord.VoiceClient": async def connect(self, channel: "discord.VoiceChannel") -> "discord.VoiceClient":
"""Connect the :class:`VoicePlayer` to a :class:`discord.VoiceChannel`, creating a :class:`discord.VoiceClient` """Connect the :class:`VoicePlayer` to a :class:`discord.VoiceChannel`, creating a :class:`discord.VoiceClient`
@ -32,6 +39,7 @@ class VoicePlayer:
""" """
if self.voice_client is not None and self.voice_client.is_connected(): if self.voice_client is not None and self.voice_client.is_connected():
raise PlayerAlreadyConnectedError() raise PlayerAlreadyConnectedError()
log.debug(f"Connecting to: {channel}")
try: try:
self.voice_client = await channel.connect() self.voice_client = await channel.connect()
except asyncio.TimeoutError: except asyncio.TimeoutError:
@ -51,6 +59,7 @@ class VoicePlayer:
""" """
if self.voice_client is None or not self.voice_client.is_connected(): if self.voice_client is None or not self.voice_client.is_connected():
raise PlayerNotConnectedError() raise PlayerNotConnectedError()
log.debug(f"Disconnecting...")
await self.voice_client.disconnect(force=True) await self.voice_client.disconnect(force=True)
self.voice_client = None self.voice_client = None
@ -58,15 +67,37 @@ class VoicePlayer:
"""Move the :class:`VoicePlayer` to a different channel. """Move the :class:`VoicePlayer` to a different channel.
This requires the :class:`VoicePlayer` to already be connected, and for the passed :class:`discord.VoiceChannel` This requires the :class:`VoicePlayer` to already be connected, and for the passed :class:`discord.VoiceChannel`
to be in the same :class:`discord.Guild` as """ to be in the same :class:`discord.Guild` of the :class:`VoicePlayer`."""
if self.voice_client is None or not self.voice_client.is_connected(): if self.voice_client is None or not self.voice_client.is_connected():
raise PlayerNotConnectedError() raise PlayerNotConnectedError()
if self.voice_client.guild != channel.guild:
raise ValueError("Can't move between two guilds.")
log.debug(f"Moving to: {channel}")
await self.voice_client.move_to(channel) await self.voice_client.move_to(channel)
async def start(self): async def start(self):
"""Start playing music on the :class:`discord.VoiceClient`.""" """Start playing music on the :class:`discord.VoiceClient`.
Info:
Doesn't pass any ``*args`` or ``**kwargs`` to the :class:`Playable`.
"""
if self.voice_client is None or not self.voice_client.is_connected(): if self.voice_client is None or not self.voice_client.is_connected():
raise PlayerNotConnectedError() raise PlayerNotConnectedError()
if self.voice_client.is_playing():
raise PlayerAlreadyPlaying()
log.debug("Getting next AudioSource...")
next_source: Optional["discord.AudioSource"] = await self.playing.next()
if next_source is None:
log.debug(f"Next source would be None, stopping here...")
return
log.debug(f"Next: {next_source}")
self.voice_client.play(next_source, after=self._playback_ended)
def _playback_ended(self, error=None): def _playback_ended(self, error: Exception = None):
... """An helper method that is called when the :attr:`.voice_client._player` has finished playing."""
if error is not None:
# TODO: capture exception with Sentry
log.error(f"Error during playback: {error}")
return
# Create a new task to create
self.loop.create_task(self.start())