diff --git a/royalnet/audio/playmodes.py b/royalnet/audio/playmodes.py index d9f4e904..6220b0c5 100644 --- a/royalnet/audio/playmodes.py +++ b/royalnet/audio/playmodes.py @@ -3,12 +3,19 @@ import random class PlayMode: + def __init__(self): + self.now_playing = None + self.generator = self._generator() + + async def next(self): + return await self.generator.__anext__() + def videos_left(self): raise NotImplementedError() - async def next(self): + async def _generator(self): """Get the next video from the list and advance it.""" - raise NotImplementedError() + yield NotImplemented def add(self, item): """Add a new video to the PlayMode.""" @@ -18,6 +25,7 @@ class PlayMode: class Playlist(PlayMode): """A video list. Videos played are removed from the list.""" def __init__(self, starting_list=None): + super().__init__() if starting_list is None: starting_list = [] self.list = starting_list @@ -25,14 +33,15 @@ class Playlist(PlayMode): def videos_left(self): return len(self.list) - async def next(self): + async def _generator(self): while True: try: next_video = self.list.pop(0) except IndexError: - yield None + self.now_playing = None else: - yield next_video + self.now_playing = next_video + yield self.now_playing def add(self, item): self.list.append(item) @@ -41,6 +50,7 @@ class Playlist(PlayMode): class Pool(PlayMode): """A video pool. Videos played are played back in random order, and they are kept in the pool.""" def __init__(self, starting_pool=None): + super().__init__() if starting_pool is None: starting_pool = [] self.pool = starting_pool @@ -49,15 +59,17 @@ class Pool(PlayMode): def videos_left(self): return math.inf - async def next(self): + async def _generator(self): while True: if self.pool: + self.now_playing = None yield None continue self._pool_copy = self.pool.copy() random.shuffle(self._pool_copy) while self._pool_copy: next_video = self._pool_copy.pop(0) + self.now_playing = next_video yield next_video def add(self, item): diff --git a/royalnet/audio/royalaudiofile.py b/royalnet/audio/royalaudiofile.py index 806f1ae4..3617f40d 100644 --- a/royalnet/audio/royalaudiofile.py +++ b/royalnet/audio/royalaudiofile.py @@ -19,10 +19,10 @@ class RoyalAudioFile(YtdlFile): def __init__(self, info: "YtdlInfo", **ytdl_args): # Overwrite the new ytdl_args self.ytdl_args = {**self.ytdl_args, **ytdl_args} - super().__init__(info, outtmpl="%(title)s-%(id)s.%(ext)s", **self.ytdl_args) + super().__init__(info, outtmpl="./opusfiles/%(title)s-%(id)s.%(ext)s", **self.ytdl_args) # Find the audio_filename with a regex (should be video.opus) - self.audio_filename = re.sub(rf"\.{self.info.ext}$", ".mp3", self.video_filename) - # Convert the video to mp3 + self.audio_filename = re.sub(rf"\.{self.info.ext}$", ".opus", self.video_filename) + # Convert the video to opus # Actually not needed, but we do this anyways for compression reasons converter = ffmpeg.input(self.video_filename) \ .output(self.audio_filename) diff --git a/royalnet/bots/discord.py b/royalnet/bots/discord.py index 50b41255..6f8a3d7f 100644 --- a/royalnet/bots/discord.py +++ b/royalnet/bots/discord.py @@ -6,9 +6,9 @@ import sys from ..commands import NullCommand from ..utils import asyncify, Call, Command from ..error import UnregisteredError, NoneFoundError, TooManyFoundError -from ..network import RoyalnetLink, Message, RequestSuccessful +from ..network import RoyalnetLink, Message, RequestSuccessful, RequestError from ..database import Alchemy, relationshiplinkchain -from ..audio import RoyalAudioFile +from ..audio import RoyalAudioFile, PlayMode, Playlist, Pool loop = asyncio.get_event_loop() log = _logging.getLogger(__name__) @@ -19,9 +19,9 @@ if not discord.opus.is_loaded(): class PlayMessage(Message): - def __init__(self, url: str, channel_identifier: typing.Optional[typing.Union[int, str]] = None): + def __init__(self, url: str, guild_identifier: typing.Optional[str] = None): self.url: str = url - self.channel_identifier: typing.Optional[typing.Union[int, str]] = channel_identifier + self.guild_identifier: typing.Optional[str] = guild_identifier class SummonMessage(Message): @@ -62,6 +62,8 @@ class DiscordBot: self.network: RoyalnetLink = RoyalnetLink(master_server_uri, master_server_secret, "discord", self.network_handler) loop.create_task(self.network.run()) + # Create the PlayModes dictionary + self.music_data: typing.Dict[discord.Guild, PlayMode] = {} # noinspection PyMethodParameters class DiscordCall(Call): @@ -86,6 +88,8 @@ class DiscordBot: async def net_request(call, message: Message, destination: str): response = await self.network.request(message, destination) + if isinstance(response, RequestError): + raise response.exc return response async def get_author(call, error_if_none=False): @@ -106,6 +110,7 @@ class DiscordBot: class DiscordClient(discord.Client): @staticmethod async def vc_connect_or_move(channel: discord.VoiceChannel): + # Connect to voice chat try: await channel.connect() except discord.errors.ClientException: @@ -116,6 +121,9 @@ class DiscordBot: if voice_client.guild != channel.guild: continue await voice_client.move_to(channel) + # Create a music_data entry, if it doesn't exist; default is a Playlist + if not self.music_data.get(channel.guild): + self.music_data[channel.guild] = Playlist() async def on_message(cli, message: discord.Message): text = message.content @@ -146,7 +154,7 @@ class DiscordBot: self.DiscordClient = DiscordClient self.bot = self.DiscordClient() - def find_guild(self, identifier: typing.Union[str, int]): + def find_guild(self, identifier: typing.Union[str, int]) -> discord.Guild: """Find the Guild with the specified identifier. Names are case-insensitive.""" if isinstance(identifier, str): all_guilds: typing.List[discord.Guild] = self.bot.guilds @@ -163,7 +171,9 @@ class DiscordBot: return self.bot.get_guild(identifier) raise TypeError("Invalid identifier type, should be str or int") - def find_channel(self, identifier: typing.Union[str, int], guild: typing.Optional[discord.Guild] = None): + def find_channel(self, + identifier: typing.Union[str, int], + guild: typing.Optional[discord.Guild] = None) -> discord.abc.GuildChannel: """Find the GuildChannel with the specified identifier. Names are case-insensitive.""" if isinstance(identifier, str): if guild is not None: @@ -193,6 +203,13 @@ class DiscordBot: return channel raise TypeError("Invalid identifier type, should be str or int") + def find_voice_client(self, guild: discord.Guild): + for voice_client in self.bot.voice_clients: + voice_client: discord.VoiceClient + if voice_client.guild == guild: + return voice_client + raise NoneFoundError("No voice clients found") + async def network_handler(self, message: Message) -> Message: """Handle a Royalnet request.""" if isinstance(message, SummonMessage): @@ -208,9 +225,39 @@ class DiscordBot: loop.create_task(self.bot.vc_connect_or_move(channel)) return RequestSuccessful() + async def add_to_music_data(self, url: str, guild: discord.Guild): + """Add a file to the corresponding music_data object.""" + files: typing.List[RoyalAudioFile] = await asyncify(RoyalAudioFile.create_from_url, url) + guild_music_data = self.music_data[guild] + for file in files: + guild_music_data.add(file) + if guild_music_data.now_playing is None: + await self.advance_music_data(guild) + + async def advance_music_data(self, guild: discord.Guild): + guild_music_data = self.music_data[guild] + next_file = await guild_music_data.next() + if next_file is None: + return + voice_client = self.find_voice_client(guild) + voice_client.play(next_file.as_audio_source(), after=lambda: loop.create_task(self.advance_music_data(guild))) + async def nh_play(self, message: PlayMessage): """Handle a play Royalnet request. That is, add audio to a PlayMode.""" - raise + # Find the matching guild + if message.guild_identifier: + guild = self.find_guild(message.guild_identifier) + else: + if len(self.music_data) != 1: + raise TooManyFoundError("Multiple guilds found") + guild = list(self.music_data)[0] + # Ensure the guild has a PlayMode before adding the file to it + if not self.music_data.get(guild): + # TODO: change Exception + raise Exception("No music_data for this guild") + # Start downloading + loop.create_task(self.add_to_music_data(message.url, guild)) + return RequestSuccessful() async def run(self): await self.bot.login(self.token) diff --git a/royalnet/bots/telegram.py b/royalnet/bots/telegram.py index cf45ab72..0ecc2ed9 100644 --- a/royalnet/bots/telegram.py +++ b/royalnet/bots/telegram.py @@ -5,8 +5,8 @@ import logging as _logging import sys from ..commands import NullCommand from ..utils import asyncify, Call, Command -from royalnet.error import UnregisteredError -from ..network import RoyalnetLink, Message +from ..error import UnregisteredError +from ..network import RoyalnetLink, Message, RequestError from ..database import Alchemy, relationshiplinkchain loop = asyncio.get_event_loop() @@ -71,6 +71,8 @@ class TelegramBot: async def net_request(call, message: Message, destination: str): response = await self.network.request(message, destination) + if isinstance(response, RequestError): + raise response.exc return response async def get_author(call, error_if_none=False): diff --git a/royalnet/commands/play.py b/royalnet/commands/play.py index 5910840b..02851695 100644 --- a/royalnet/commands/play.py +++ b/royalnet/commands/play.py @@ -7,11 +7,11 @@ from ..bots.discord import PlayMessage class PlayCommand(Command): command_name = "play" command_description = "Riproduce una canzone in chat vocale." - command_syntax = "(url)" + command_syntax = "[ [guild] ] (url)" @classmethod async def common(cls, call: Call): - url: str = call.args[0] - response: typing.Union[RequestSuccessful, RequestError] = await call.net_request(PlayMessage(url), "discord") + guild, url = call.args.match(r"(?:\[(.+)])?\s*(\S+)\s*") + response: typing.Union[RequestSuccessful, RequestError] = await call.net_request(PlayMessage(url, guild), "discord") response.raise_on_error() await call.reply(f"✅ Richiesta la riproduzione di [c]{url}[/c].") diff --git a/royalnet/commands/reminder.py b/royalnet/commands/reminder.py index cae785f8..bfc90adc 100644 --- a/royalnet/commands/reminder.py +++ b/royalnet/commands/reminder.py @@ -12,9 +12,7 @@ class ReminderCommand(Command): @classmethod async def common(cls, call: Call): - match = call.args.match(r"\[ *(.+?) *] *(.+?) *$") - date_str = match.group(1) - reminder_text = match.group(2) + date_str, reminder_text = call.args.match(r"\[ *(.+?) *] *(.+?) *$") date: typing.Optional[datetime.datetime] try: date = dateparser.parse(date_str) diff --git a/royalnet/utils/command.py b/royalnet/utils/command.py index cdb8cec3..399b8595 100644 --- a/royalnet/utils/command.py +++ b/royalnet/utils/command.py @@ -28,12 +28,12 @@ class CommandArgs(list): raise InvalidInputError("Not enough arguments") return " ".join(self) - def match(self, pattern: typing.Pattern) -> typing.Match: + def match(self, pattern: typing.Pattern) -> typing.Sequence[typing.AnyStr]: text = self.joined() match = re.match(pattern, text) if match is None: raise InvalidInputError("Pattern didn't match") - return match + return match.groups() def optional(self, index: int, default=None) -> typing.Optional: try: