diff --git a/royalgames.py b/royalgames.py index 70f7cebe..4385b901 100644 --- a/royalgames.py +++ b/royalgames.py @@ -24,7 +24,7 @@ logging.getLogger("royalnet.bots.telegram").setLevel(logging.DEBUG) commands = [PingCommand, ShipCommand, SmecdsCommand, ColorCommand, CiaoruoziCommand, DebugCreateCommand, SyncCommand, AuthorCommand, DiarioCommand, RageCommand, DateparserCommand, ReminderCommand, KvactiveCommand, KvCommand, - KvrollCommand, VideoinfoCommand, SummonCommand, PlayCommand, SkipCommand] + KvrollCommand, VideoinfoCommand, SummonCommand, PlayCommand, SkipCommand, PlaymodeCommand] address, port = "localhost", 1234 diff --git a/royalnet/audio/playmodes.py b/royalnet/audio/playmodes.py index 6220b0c5..93695a5c 100644 --- a/royalnet/audio/playmodes.py +++ b/royalnet/audio/playmodes.py @@ -1,11 +1,13 @@ import math import random +import typing +from .royalpcmaudio import RoyalPCMAudio class PlayMode: def __init__(self): - self.now_playing = None - self.generator = self._generator() + self.now_playing: typing.Optional[RoyalPCMAudio] = None + self.generator: typing.AsyncGenerator = self._generator() async def next(self): return await self.generator.__anext__() @@ -14,21 +16,27 @@ class PlayMode: raise NotImplementedError() async def _generator(self): - """Get the next video from the list and advance it.""" + """Get the next RPA from the list and advance it.""" + raise NotImplementedError() + # This is needed to make the coroutine an async generator + # noinspection PyUnreachableCode yield NotImplemented def add(self, item): - """Add a new video to the PlayMode.""" + """Add a new RPA to the PlayMode.""" raise NotImplementedError() + def delete(self): + """Delete all RPAs contained inside this PlayMode.""" + class Playlist(PlayMode): - """A video list. Videos played are removed from the list.""" - def __init__(self, starting_list=None): + """A video list. RPAs played are removed from the list.""" + def __init__(self, starting_list: typing.List[RoyalPCMAudio] = None): super().__init__() if starting_list is None: starting_list = [] - self.list = starting_list + self.list: typing.List[RoyalPCMAudio] = starting_list def videos_left(self): return len(self.list) @@ -42,26 +50,33 @@ class Playlist(PlayMode): else: self.now_playing = next_video yield self.now_playing + if self.now_playing is not None: + self.now_playing.delete() def add(self, item): self.list.append(item) + def delete(self): + while self.list: + self.list.pop(0).delete() + self.now_playing.delete() + 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): + """A RPA pool. RPAs played are played back in random order, and they are kept in the pool.""" + def __init__(self, starting_pool: typing.List[RoyalPCMAudio] = None): super().__init__() if starting_pool is None: starting_pool = [] - self.pool = starting_pool - self._pool_copy = [] + self.pool: typing.List[RoyalPCMAudio] = starting_pool + self._pool_copy: typing.List[RoyalPCMAudio] = [] def videos_left(self): return math.inf async def _generator(self): while True: - if self.pool: + if not self.pool: self.now_playing = None yield None continue @@ -74,5 +89,11 @@ class Pool(PlayMode): def add(self, item): self.pool.append(item) - self._pool_copy.append(self._pool_copy) + self._pool_copy.append(item) random.shuffle(self._pool_copy) + + def delete(self): + for item in self.pool: + item.delete() + self.pool = None + self._pool_copy = None diff --git a/royalnet/audio/royalpcmaudio.py b/royalnet/audio/royalpcmaudio.py index 3fca7711..c8381535 100644 --- a/royalnet/audio/royalpcmaudio.py +++ b/royalnet/audio/royalpcmaudio.py @@ -7,7 +7,7 @@ from .royalpcmfile import RoyalPCMFile class RoyalPCMAudio(AudioSource): def __init__(self, rpf: "RoyalPCMFile"): self.rpf: "RoyalPCMFile" = rpf - self._file = open(rpf.audio_filename, "rb") + self._file = open(self.rpf.audio_filename, "rb") @staticmethod def create_from_url(url) -> typing.List["RoyalPCMAudio"]: @@ -19,13 +19,20 @@ class RoyalPCMAudio(AudioSource): def read(self): data: bytes = self._file.read(OpusEncoder.FRAME_SIZE) + # If the file was externally closed, it means it was deleted + if self._file.closed: + return b"" if len(data) != OpusEncoder.FRAME_SIZE: + # Close the file as soon as the playback is finished + self._file.close() + # Reopen the file, so it can be reused + self._file = open(self.rpf.audio_filename, "rb") return b"" return data + def delete(self): + self._file.close() + self.rpf.delete_audio_file() + def __repr__(self): return f"" - - def __del__(self): - self._file.close() - del self.rpf diff --git a/royalnet/audio/royalpcmfile.py b/royalnet/audio/royalpcmfile.py index 272b3521..2450b846 100644 --- a/royalnet/audio/royalpcmfile.py +++ b/royalnet/audio/royalpcmfile.py @@ -28,7 +28,7 @@ class RoyalPCMFile(YtdlFile): log.info(f"Now downloading {info.webpage_url}") super().__init__(info, outtmpl=self._ytdl_filename, **self.ytdl_args) # Find the audio_filename with a regex (should be video.opus) - log.info(f"Preparing {self.video_filename}...") + log.info(f"Converting {self.video_filename}...") # Convert the video to pcm try: ffmpeg.input(f"./{self.video_filename}") \ @@ -58,6 +58,6 @@ class RoyalPCMFile(YtdlFile): def audio_filename(self): return f"./downloads/{safefilename(self.info.title)}-{safefilename(str(int(self._time)))}.pcm" - def __del__(self): + def delete_audio_file(self): log.info(f"Deleting {self.audio_filename}") os.remove(self.audio_filename) diff --git a/royalnet/audio/youtubedl.py b/royalnet/audio/youtubedl.py index 2ee38c38..723dbe6d 100644 --- a/royalnet/audio/youtubedl.py +++ b/royalnet/audio/youtubedl.py @@ -147,7 +147,3 @@ class YtdlInfo: if self.webpage_url: return self.webpage_url return self.id - - -if __name__ == "__main__": - f = YtdlFile.create_from_url("https://www.youtube.com/watch?v=BaW_jenozKc&v=UxxajLWwzqY", "./lovely.mp4") diff --git a/royalnet/bots/discord.py b/royalnet/bots/discord.py index 35d60c83..318a5ba8 100644 --- a/royalnet/bots/discord.py +++ b/royalnet/bots/discord.py @@ -102,7 +102,7 @@ class DiscordBot(GenericBot): log.debug(f"Creating music_data for {channel.guild}") self.music_data[channel.guild] = Playlist() - @staticmethod # Not really static because of the self reference + @staticmethod async def on_message(message: discord.Message): text = message.content # Skip non-text messages @@ -119,6 +119,10 @@ class DiscordBot(GenericBot): # Call the command await self.call(command_name, message.channel, parameters, message=message) + async def on_ready(cli): + log.debug("Connection successful, client is ready") + await cli.change_presence(status=discord.Status.online) + def find_guild_by_name(cli, name: str) -> discord.Guild: """Find the Guild with the specified name. Case-insensitive. Will raise a NoneFoundError if no channels are found, or a TooManyFoundError if more than one is found.""" @@ -212,10 +216,10 @@ class DiscordBot(GenericBot): async def advance_music_data(self, guild: discord.Guild): """Try to play the next song, while it exists. Otherwise, just return.""" - log.debug(f"Starting playback chain") guild_music_data = self.music_data[guild] voice_client = self.client.find_voice_client_by_guild(guild) next_source: RoyalPCMAudio = await guild_music_data.next() + await self.update_activity_with_source_title(next_source) if next_source is None: log.debug(f"Ending playback chain") return @@ -227,3 +231,19 @@ class DiscordBot(GenericBot): log.debug(f"Starting playback of {next_source}") voice_client.play(next_source, after=advance) + + async def update_activity_with_source_title(self, rpa: typing.Optional[RoyalPCMAudio] = None): + 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 + if rpa 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 {rpa.rpf.info.title}") + await self.client.change_presence(activity=discord.Activity(name=rpa.rpf.info.title, + type=discord.ActivityType.listening), + status=discord.Status.online) diff --git a/royalnet/commands/__init__.py b/royalnet/commands/__init__.py index 27219687..a7eb3d87 100644 --- a/royalnet/commands/__init__.py +++ b/royalnet/commands/__init__.py @@ -17,9 +17,10 @@ from .videoinfo import VideoinfoCommand from .summon import SummonCommand from .play import PlayCommand from .skip import SkipCommand +from .playmode import PlaymodeCommand __all__ = ["NullCommand", "PingCommand", "ShipCommand", "SmecdsCommand", "CiaoruoziCommand", "ColorCommand", "SyncCommand", "DiarioCommand", "RageCommand", "DateparserCommand", "AuthorCommand", "ReminderCommand", "KvactiveCommand", "KvCommand", "KvrollCommand", "VideoinfoCommand", "SummonCommand", "PlayCommand", - "SkipCommand"] + "SkipCommand", "PlaymodeCommand"] diff --git a/royalnet/commands/play.py b/royalnet/commands/play.py index 21509533..e44c127c 100644 --- a/royalnet/commands/play.py +++ b/royalnet/commands/play.py @@ -2,7 +2,7 @@ import typing import asyncio from ..utils import Command, Call, NetworkHandler from ..network import Message, RequestSuccessful -from ..error import TooManyFoundError +from ..error import TooManyFoundError, NoneFoundError if typing.TYPE_CHECKING: from ..bots import DiscordBot @@ -26,7 +26,9 @@ class PlayNH(NetworkHandler): if message.guild_name: guild = bot.client.find_guild(message.guild_name) else: - if len(bot.music_data) != 1: + if len(bot.music_data) == 0: + raise NoneFoundError("No voice clients active") + if len(bot.music_data) > 1: raise TooManyFoundError("Multiple guilds found") guild = list(bot.music_data)[0] # Ensure the guild has a PlayMode before adding the file to it diff --git a/royalnet/commands/playmode.py b/royalnet/commands/playmode.py new file mode 100644 index 00000000..5ff279ee --- /dev/null +++ b/royalnet/commands/playmode.py @@ -0,0 +1,59 @@ +import typing +import asyncio +from ..utils import Command, Call, NetworkHandler +from ..network import Message, RequestSuccessful +from ..error import NoneFoundError, TooManyFoundError +from ..audio import Playlist, Pool +if typing.TYPE_CHECKING: + from ..bots import DiscordBot + + +loop = asyncio.get_event_loop() + + +class PlaymodeMessage(Message): + def __init__(self, mode_name: str, guild_name: typing.Optional[str] = None): + self.mode_name: str = mode_name + self.guild_name: typing.Optional[str] = guild_name + + +class PlaymodeNH(NetworkHandler): + message_type = PlaymodeMessage + + @classmethod + async def discord(cls, bot: "DiscordBot", message: PlaymodeMessage): + """Handle a play Royalnet request. That is, add audio to a PlayMode.""" + # Find the matching guild + if message.guild_name: + guild = bot.client.find_guild(message.guild_name) + else: + if len(bot.music_data) == 0: + raise NoneFoundError("No voice clients active") + if len(bot.music_data) > 1: + raise TooManyFoundError("Multiple guilds found") + guild = list(bot.music_data)[0] + # Delete the previous PlayMode, if it exists + if bot.music_data[guild] is not None: + bot.music_data[guild].delete() + # Create the new PlayMode + if message.mode_name == "playlist": + bot.music_data[guild] = Playlist() + elif message.mode_name == "pool": + bot.music_data[guild] = Pool() + else: + raise ValueError("No such PlayMode") + return RequestSuccessful() + + +class PlaymodeCommand(Command): + command_name = "playmode" + command_description = "Cambia modalità di riproduzione per la chat vocale." + command_syntax = "[ [guild] ] (mode)" + + network_handlers = [PlaymodeNH] + + @classmethod + async def common(cls, call: Call): + guild, mode_name = call.args.match(r"(?:\[(.+)])?\s*(\S+)\s*") + await call.net_request(PlaymodeMessage(mode_name, guild), "discord") + await call.reply(f"✅ Richiesto di passare alla modalità di riproduzione [c]{mode_name}[/c].") diff --git a/royalnet/commands/skip.py b/royalnet/commands/skip.py index 8492ca51..c38c674b 100644 --- a/royalnet/commands/skip.py +++ b/royalnet/commands/skip.py @@ -21,7 +21,9 @@ class SkipNH(NetworkHandler): if message.guild_name: guild = bot.client.find_guild_by_name(message.guild_name) else: - if len(bot.music_data) != 1: + if len(bot.music_data) == 0: + raise NoneFoundError("No voice clients active") + if len(bot.music_data) > 1: raise TooManyFoundError("Multiple guilds found") guild = list(bot.music_data)[0] # Set the currently playing source as ended @@ -45,4 +47,4 @@ class SkipCommand(Command): async def common(cls, call: Call): guild, = call.args.match(r"(?:\[(.+)])?") await call.net_request(SkipMessage(guild), "discord") - await call.reply(f"✅ Richiesta lo skip della canzone attuale..") + await call.reply(f"✅ Richiesto lo skip della canzone attuale.") diff --git a/royalnet/error.py b/royalnet/error.py index 249011b4..747bf777 100644 --- a/royalnet/error.py +++ b/royalnet/error.py @@ -25,8 +25,8 @@ class InvalidConfigError(Exception): class RoyalnetError(Exception): """An error was raised while handling the Royalnet request. This exception contains the exception that was raised during the handling.""" - def __init__(self, exc): - self.exc = exc + def __init__(self, exc: Exception): + self.exc: Exception = exc class ExternalError(Exception):