diff --git a/royalnet/backpack/commands/__init__.py b/royalnet/backpack/commands/__init__.py index 661ed137..bc02fc58 100644 --- a/royalnet/backpack/commands/__init__.py +++ b/royalnet/backpack/commands/__init__.py @@ -2,12 +2,14 @@ from .ping import PingCommand from .version import VersionCommand from .summon import SummonCommand +from .play import PlayCommand # Enter the commands of your Pack here! available_commands = [ PingCommand, VersionCommand, SummonCommand, + PlayCommand, ] # Don't change this, it should automatically generate __all__ diff --git a/royalnet/backpack/commands/play.py b/royalnet/backpack/commands/play.py new file mode 100644 index 00000000..ed4866a0 --- /dev/null +++ b/royalnet/backpack/commands/play.py @@ -0,0 +1,28 @@ +from royalnet.commands import * + +try: + import discord +except ImportError: + discord = None + + +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." + + 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, + }) + await data.reply(f"✅ !") diff --git a/royalnet/backpack/events/__init__.py b/royalnet/backpack/events/__init__.py index dde03ebb..63d277e8 100644 --- a/royalnet/backpack/events/__init__.py +++ b/royalnet/backpack/events/__init__.py @@ -1,9 +1,11 @@ # Imports go here! from .summon import SummonEvent +from .play import PlayEvent # Enter the commands of your Pack here! available_events = [ SummonEvent, + PlayEvent ] # Don't change this, it should automatically generate __all__ diff --git a/royalnet/backpack/events/play.py b/royalnet/backpack/events/play.py new file mode 100644 index 00000000..6d3eddb4 --- /dev/null +++ b/royalnet/backpack/events/play.py @@ -0,0 +1,54 @@ +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 logging + +log = logging.getLogger(__name__) + +try: + import discord +except ImportError: + discord = None + + +class PlayEvent(Event): + name = "play" + + async def run(self, *, url: str, guild_id: Optional[int] = None, guild_name: Optional[str] = None, **kwargs): + if not isinstance(self.serf, DiscordSerf): + raise UnsupportedError("Play 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.") + # 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}") + await self.serf.voice_run(guild) diff --git a/royalnet/backpack/events/summon.py b/royalnet/backpack/events/summon.py index 6b313968..dd513b56 100644 --- a/royalnet/backpack/events/summon.py +++ b/royalnet/backpack/events/summon.py @@ -11,7 +11,7 @@ except ImportError: class SummonEvent(Event): name = "summon" - async def run(self, *, channel_name: str, guild_id: Optional[int], user_id: Optional[int], **kwargs): + async def run(self, *, channel_name: str, guild_id: Optional[int] = None, user_id: Optional[int] = None, **kwargs): if not isinstance(self.serf, DiscordSerf): raise UnsupportedError("Summon can't be called on interfaces other than Discord.") if discord is None: diff --git a/royalnet/bard/__init__.py b/royalnet/bard/__init__.py index f934d0e8..7e1e3f56 100644 --- a/royalnet/bard/__init__.py +++ b/royalnet/bard/__init__.py @@ -1,7 +1,6 @@ from .ytdlinfo import YtdlInfo from .ytdlfile import YtdlFile from .ytdlmp3 import YtdlMp3 -from .ytdldiscord import YtdlDiscord from .errors import * try: @@ -19,4 +18,5 @@ __all__ = [ "NotFoundError", "MultipleFilesError", "FileAudioSource", + "UnsupportedError", ] diff --git a/royalnet/serf/discord/__init__.py b/royalnet/serf/discord/__init__.py index 05842174..76963ee3 100644 --- a/royalnet/serf/discord/__init__.py +++ b/royalnet/serf/discord/__init__.py @@ -1,7 +1,10 @@ +"""A :class:`Serf` implementation for Discord. + +It is pretty unstable, compared to the rest of the bot, but it *should* work.""" + from .createrichembed import create_rich_embed from .escape import escape from .discordserf import DiscordSerf -from .fileaudiosource import FileAudioSource from . import discordbard __all__ = [ @@ -9,5 +12,4 @@ __all__ = [ "escape", "DiscordSerf", "discordbard", - "FileAudioSource", ] diff --git a/royalnet/serf/discord/discordbard/__init__.py b/royalnet/serf/discord/discordbard/__init__.py index 77f03ff6..f8ae05d0 100644 --- a/royalnet/serf/discord/discordbard/__init__.py +++ b/royalnet/serf/discord/discordbard/__init__.py @@ -1,9 +1,11 @@ from .discordbard import DiscordBard -from .dbstack import DBStack from .dbqueue import DBQueue +from .fileaudiosource import FileAudioSource +from .ytdldiscord import YtdlDiscord __all__ = [ - "DBStack", "DBQueue", "DiscordBard", + "FileAudioSource", + "YtdlDiscord", ] diff --git a/royalnet/serf/discord/discordbard/dbqueue.py b/royalnet/serf/discord/discordbard/dbqueue.py index ef322ff2..46cdf8cb 100644 --- a/royalnet/serf/discord/discordbard/dbqueue.py +++ b/royalnet/serf/discord/discordbard/dbqueue.py @@ -1,6 +1,7 @@ -from royalnet.bard import FileAudioSource, YtdlDiscord +from royalnet.bard import FileAudioSource from typing import List, AsyncGenerator, Tuple, Any, Dict, Optional from .discordbard import DiscordBard +from .ytdldiscord import YtdlDiscord try: import discord diff --git a/royalnet/serf/discord/discordbard/dbstack.py b/royalnet/serf/discord/discordbard/dbstack.py deleted file mode 100644 index f7355b50..00000000 --- a/royalnet/serf/discord/discordbard/dbstack.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import List, AsyncGenerator, Tuple, Any, Dict, Optional -from royalnet.bard import FileAudioSource, YtdlDiscord -from .discordbard import DiscordBard - -try: - import discord -except ImportError: - discord = None - - -class DBStack(DiscordBard): - """A First-In-Last-Out music queue. - - Not really sure if it is going to be useful...""" - def __init__(self, voice_client: "discord.VoiceClient"): - super().__init__(voice_client) - self.list: List[YtdlDiscord] = [] - - async def _generator(self) -> AsyncGenerator[Optional[FileAudioSource], Tuple[Tuple[Any, ...], Dict[str, Any]]]: - yield - while True: - try: - ytd = self.list.pop() - except IndexError: - yield None - else: - async with ytd.spawn_audiosource() as fas: - yield fas - - async def add(self, ytd: YtdlDiscord): - self.list.append(ytd) - - async def peek(self) -> List[YtdlDiscord]: - return self.list - - async def remove(self, ytd: YtdlDiscord): - self.list.remove(ytd) - - async def cleanup(self) -> None: - for ytd in self.list: - await ytd.delete_asap() diff --git a/royalnet/serf/discord/discordbard/discordbard.py b/royalnet/serf/discord/discordbard/discordbard.py index 530211cd..f9621e64 100644 --- a/royalnet/serf/discord/discordbard/discordbard.py +++ b/royalnet/serf/discord/discordbard/discordbard.py @@ -1,5 +1,7 @@ from typing import Optional, AsyncGenerator, List, Tuple, Any, Dict -from royalnet.bard import YtdlDiscord, FileAudioSource, UnsupportedError +from royalnet.bard import UnsupportedError +from .fileaudiosource import FileAudioSource +from .ytdldiscord import YtdlDiscord try: import discord @@ -20,7 +22,7 @@ class DiscordBard: self.voice_client: "discord.VoiceClient" = voice_client """The voice client that this :class:`DiscordBard` refers to.""" - self.now_playing: Optional[YtdlDiscord] = None + self.now_playing: Optional[FileAudioSource] = None """The :class:`YtdlDiscord` that's currently being played.""" self.generator: \ @@ -40,7 +42,7 @@ class DiscordBard: """Create an instance of the :class:`DiscordBard`, and initialize its async generator.""" bard = cls(voice_client=voice_client) # noinspection PyTypeChecker - none = bard.generator.asend(None) + none = await bard.generator.asend(None) assert none is None return bard @@ -90,3 +92,8 @@ class DiscordBard: Raises: UnsupportedError: If :meth:`.peek` is unsupported.""" return len(await self.peek()) + + async def stop(self): + """Stop the playback of the current song.""" + if self.now_playing is not None: + self.now_playing.stop() diff --git a/royalnet/serf/discord/fileaudiosource.py b/royalnet/serf/discord/discordbard/fileaudiosource.py similarity index 100% rename from royalnet/serf/discord/fileaudiosource.py rename to royalnet/serf/discord/discordbard/fileaudiosource.py diff --git a/royalnet/serf/discord/ytdldiscord.py b/royalnet/serf/discord/discordbard/ytdldiscord.py similarity index 91% rename from royalnet/serf/discord/ytdldiscord.py rename to royalnet/serf/discord/discordbard/ytdldiscord.py index 51eb86d8..5e9ac5b1 100644 --- a/royalnet/serf/discord/ytdldiscord.py +++ b/royalnet/serf/discord/discordbard/ytdldiscord.py @@ -6,7 +6,7 @@ from royalnet.utils import asyncify, MultiLock from royalnet.bard import YtdlInfo, YtdlFile try: - from royalnet.serf.discord.fileaudiosource import FileAudioSource + from .fileaudiosource import FileAudioSource except ImportError: FileAudioSource = None @@ -72,5 +72,7 @@ class YtdlDiscord: if FileAudioSource is None: raise ImportError("'discord' extra is not installed") await self.convert_to_pcm() - with open(self.pcm_filename, "rb") as stream: - yield FileAudioSource(stream) + async with self.lock.normal(): + with open(self.pcm_filename, "rb") as stream: + fas = FileAudioSource(stream) + yield fas diff --git a/royalnet/serf/discord/discordserf.py b/royalnet/serf/discord/discordserf.py index 1d163a27..eaeb1186 100644 --- a/royalnet/serf/discord/discordserf.py +++ b/royalnet/serf/discord/discordserf.py @@ -247,7 +247,23 @@ class DiscordSerf(Serf): # TODO: safely move the bot somewhere else raise CommandError("The bot is already connected in another channel.\n" " Please disconnect it before resummoning!") - self.bards[channel.guild] = DBQueue(voice_client=voice_client) + self.bards[channel.guild] = await DBQueue.create(voice_client=voice_client) - async def voice_change(self, guild: "discord.Guild", bard: Type[DiscordBard]): - """Safely change the :class:`DiscordBard` for a guild.""" + 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() + # 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)