diff --git a/.idea/modules.xml b/.idea/modules.xml
index 3b66fe55..502cd4e7 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -3,7 +3,6 @@
-
\ No newline at end of file
diff --git a/.idea/royalnet.iml b/.idea/royalnet.iml
index cb78e430..5ff8ce28 100644
--- a/.idea/royalnet.iml
+++ b/.idea/royalnet.iml
@@ -9,7 +9,6 @@
-
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 924c324e..bf9b0443 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -3,6 +3,5 @@
-
\ No newline at end of file
diff --git a/royalnet/backpack/commands/play.py b/royalnet/backpack/commands/play.py
index 08d04cf7..69e52c61 100644
--- a/royalnet/backpack/commands/play.py
+++ b/royalnet/backpack/commands/play.py
@@ -1,30 +1,26 @@
from royalnet.commands import *
+from typing import TYPE_CHECKING, Optional, List, Union
+import asyncio
try:
import discord
except ImportError:
discord = None
+if TYPE_CHECKING:
+ from royalnet.serf.discord import DiscordSerf
+
class PlayCommand(Command):
# TODO: possibly move this in another pack
name: str = "play"
- description = "Download a file located at an URL and play it on Discord."
+ description = ""
syntax = "[url]"
async def run(self, args: CommandArgs, data: CommandData) -> None:
- if self.interface.name != "discord":
- raise UnsupportedError()
- msg: "discord.Message" = data.message
- 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)
\ No newline at end of file
+ url = args.joined()
+ response: dict = await self.interface.call_herald_event("discord", "play", url=url)
+ await data.reply("blah")
diff --git a/royalnet/backpack/commands/summon.py b/royalnet/backpack/commands/summon.py
index 4803b4c6..bdfa780a 100644
--- a/royalnet/backpack/commands/summon.py
+++ b/royalnet/backpack/commands/summon.py
@@ -29,7 +29,7 @@ class SummonCommand(Command):
member = None
guild = None
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,
"guild_id": guild.id if guild is not None else None,
"user_id": member.id if member is not None else None,
diff --git a/royalnet/backpack/events/__init__.py b/royalnet/backpack/events/__init__.py
index 63d277e8..5771d686 100644
--- a/royalnet/backpack/events/__init__.py
+++ b/royalnet/backpack/events/__init__.py
@@ -5,7 +5,7 @@ from .play import PlayEvent
# Enter the commands of your Pack here!
available_events = [
SummonEvent,
- PlayEvent
+ PlayEvent,
]
# Don't change this, it should automatically generate __all__
diff --git a/royalnet/backpack/events/play.py b/royalnet/backpack/events/play.py
index 0a912ca4..64dcad76 100644
--- a/royalnet/backpack/events/play.py
+++ b/royalnet/backpack/events/play.py
@@ -1,12 +1,7 @@
from typing import Optional
from royalnet.commands import *
-from royalnet.serf.discord import DiscordSerf
-from royalnet.serf.discord.discordbard import YtdlDiscord, DiscordBard
-from royalnet.utils import asyncify
-import pickle
-import logging
-
-log = logging.getLogger(__name__)
+from royalnet.serf.discord import DiscordSerf, PlayableYTDQueue
+from royalnet.bard import YtdlDiscord
try:
import discord
@@ -17,53 +12,12 @@ except ImportError:
class PlayEvent(Event):
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):
- 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:
raise UnsupportedError("'discord' extra is not installed.")
- # Variables
- client = self.serf.client
- # Find the guild
- guild: Optional["discord.Guild"] = None
- 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
+ ytd = await YtdlDiscord.from_url(url)
+ self.serf.voice_players[0].playing.contents.append(ytd[0])
+ await self.serf.voice_players[0].start()
+ return {"ok": "ok"}
\ No newline at end of file
diff --git a/royalnet/backpack/events/summon.py b/royalnet/backpack/events/summon.py
index dd513b56..d6f314f9 100644
--- a/royalnet/backpack/events/summon.py
+++ b/royalnet/backpack/events/summon.py
@@ -1,6 +1,6 @@
from typing import Optional
from royalnet.commands import *
-from royalnet.serf.discord import DiscordSerf
+from royalnet.serf.discord import DiscordSerf, VoicePlayer, PlayableYTDQueue
try:
import discord
@@ -38,11 +38,13 @@ class SummonEvent(Event):
required_permissions=["connect", "speak"])
if channel is None:
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
- await self.serf.voice_connect(channel)
- # Find the created bard
- bard = self.serf.bards[channel.guild]
- bard_peek = await bard.peek()
+ await vp.connect(channel)
+ # Add the created VoicePlayer to the list
+ self.serf.voice_players.append(vp)
# Reply to the request
return {
"channel": {
@@ -51,9 +53,5 @@ class SummonEvent(Event):
"guild": {
"name": channel.guild.name,
},
- },
- "bard": {
- "type": bard.__class__.__qualname__,
- "peek": bard_peek,
}
}
diff --git a/royalnet/serf/discord/__init__.py b/royalnet/serf/discord/__init__.py
index 8e3a8c83..af5bb41e 100644
--- a/royalnet/serf/discord/__init__.py
+++ b/royalnet/serf/discord/__init__.py
@@ -4,10 +4,14 @@ It is pretty unstable, compared to the rest of the bot, but it *should* work."""
from .escape import escape
from .discordserf import DiscordSerf
-from . import discordbard
+from .playable import Playable
+from .playableytdqueue import PlayableYTDQueue
+from .voiceplayer import VoicePlayer
__all__ = [
"escape",
"DiscordSerf",
- "discordbard",
+ "Playable",
+ "PlayableYTDQueue",
+ "VoicePlayer",
]
diff --git a/royalnet/serf/discord/discordserf.py b/royalnet/serf/discord/discordserf.py
index bbcb1f74..902f6660 100644
--- a/royalnet/serf/discord/discordserf.py
+++ b/royalnet/serf/discord/discordserf.py
@@ -1,10 +1,12 @@
import asyncio
import logging
+import warnings
from typing import Type, Optional, List, Union, Dict
from royalnet.commands import *
from royalnet.utils import asyncify
from royalnet.serf import Serf
from .escape import escape
+from .voiceplayer import VoicePlayer
try:
@@ -52,6 +54,9 @@ class DiscordSerf(Serf):
self.client = self.Client()
"""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]:
# noinspection PyPep8Naming
GenericInterface = super().interface_factory()
@@ -178,6 +183,7 @@ class DiscordSerf(Serf):
Returns:
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:
accessible_to = []
if required_permissions is None:
@@ -225,23 +231,3 @@ class DiscordSerf(Serf):
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)
diff --git a/royalnet/serf/discord/errors.py b/royalnet/serf/discord/errors.py
index c25c2855..680e84e8 100644
--- a/royalnet/serf/discord/errors.py
+++ b/royalnet/serf/discord/errors.py
@@ -36,3 +36,7 @@ class PlayerNotConnectedError(VoicePlayerError):
"""The :class:`VoicePlayer` isn't connected to the Discord voice servers.
Use :meth:`VoicePlayer.connect` first!"""
+
+
+class PlayerAlreadyPlaying(VoicePlayerError):
+ """The :class:`VoicePlayer` is already playing audio and cannot start playing audio again."""
diff --git a/royalnet/serf/discord/playable.py b/royalnet/serf/discord/playable.py
index aeedf48a..e7f678a6 100644
--- a/royalnet/serf/discord/playable.py
+++ b/royalnet/serf/discord/playable.py
@@ -1,3 +1,4 @@
+import logging
from typing import Optional, AsyncGenerator, Tuple, Any, Dict
try:
import discord
@@ -5,11 +6,30 @@ except ImportError:
discord = None
+log = logging.getLogger(__name__)
+
+
class Playable:
"""An abstract class representing something that can be played back in a :class:`VoicePlayer`."""
def __init__(self):
- self.generator: \
- Optional[AsyncGenerator[Optional["discord.AudioSource"], Tuple[Tuple[Any, ...], Dict[str, Any]]]] = None
+ """Create a :class:`Playable`.
+
+ 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"]:
"""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
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) \
-> AsyncGenerator[Optional["discord.AudioSource"], Tuple[Tuple[Any, ...], Dict[str, Any]]]:
diff --git a/royalnet/serf/discord/playableytdqueue.py b/royalnet/serf/discord/playableytdqueue.py
index 1094244d..7d48c360 100644
--- a/royalnet/serf/discord/playableytdqueue.py
+++ b/royalnet/serf/discord/playableytdqueue.py
@@ -1,3 +1,4 @@
+import logging
from typing import Optional, List, AsyncGenerator, Tuple, Any, Dict
from royalnet.bard import YtdlDiscord
from .playable import Playable
@@ -7,6 +8,9 @@ except ImportError:
discord = None
+log = logging.getLogger(__name__)
+
+
class PlayableYTDQueue(Playable):
"""A queue of :class:`YtdlDiscord` to be played in sequence."""
def __init__(self, start_with: Optional[List[YtdlDiscord]] = None):
@@ -14,24 +18,28 @@ class PlayableYTDQueue(Playable):
self.contents: List[YtdlDiscord] = []
if start_with is not None:
self.contents = [*self.contents, *start_with]
+ log.debug(f"Created new PlayableYTDQueue containing: {self.contents}")
async def _generator(self) \
-> AsyncGenerator[Optional["discord.AudioSource"], Tuple[Tuple[Any, ...], Dict[str, Any]]]:
yield
while True:
+ log.debug(f"Dequeuing an item...")
try:
# Try to get the first YtdlDiscord of the queue
ytd: YtdlDiscord = self.contents.pop(0)
except IndexError:
# If there isn't anything, yield None
+ log.debug(f"Nothing to dequeue, yielding None.")
yield None
continue
- try:
- # Create a FileAudioSource from the YtdlDiscord
- # If the file hasn't been fetched / downloaded / converted yet, it will do so before yielding
- async with ytd.spawn_audiosource() as fas:
- # Yield the resulting AudioSource
- yield fas
- finally:
- # Delete the YtdlDiscord file
- await ytd.delete_asap()
+ log.debug(f"Yielding FileAudioSource from: {ytd}")
+ # Create a FileAudioSource from the YtdlDiscord
+ # If the file hasn't been fetched / downloaded / converted yet, it will do so before yielding
+ async with ytd.spawn_audiosource() as fas:
+ # Yield the resulting AudioSource
+ yield fas
+ # Delete the YtdlDiscord file
+ log.debug(f"Deleting: {ytd}")
+ await ytd.delete_asap()
+ log.debug(f"Deleted successfully!")
diff --git a/royalnet/serf/discord/voiceplayer.py b/royalnet/serf/discord/voiceplayer.py
index 5534122a..8d6d2466 100644
--- a/royalnet/serf/discord/voiceplayer.py
+++ b/royalnet/serf/discord/voiceplayer.py
@@ -1,4 +1,5 @@
import asyncio
+import logging
from typing import Optional
from .errors import *
from .playable import Playable
@@ -7,11 +8,17 @@ try:
except ImportError:
discord = None
+log = logging.getLogger(__name__)
+
class VoicePlayer:
- def __init__(self):
+ def __init__(self, *, loop: Optional[asyncio.AbstractEventLoop] = None):
self.voice_client: Optional["discord.VoiceClient"] = 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":
"""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():
raise PlayerAlreadyConnectedError()
+ log.debug(f"Connecting to: {channel}")
try:
self.voice_client = await channel.connect()
except asyncio.TimeoutError:
@@ -51,6 +59,7 @@ class VoicePlayer:
"""
if self.voice_client is None or not self.voice_client.is_connected():
raise PlayerNotConnectedError()
+ log.debug(f"Disconnecting...")
await self.voice_client.disconnect(force=True)
self.voice_client = None
@@ -58,15 +67,37 @@ class VoicePlayer:
"""Move the :class:`VoicePlayer` to a different channel.
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():
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)
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():
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())