From cabc517861f6e15cac230283ef0d3d924bbaf716 Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Wed, 28 Nov 2018 14:47:08 +0000 Subject: [PATCH 01/12] WIP: new queue --- discordbot.py | 138 +++++++++++++++++++++++++++++--------------- template_config.ini | 1 + 2 files changed, 94 insertions(+), 45 deletions(-) diff --git a/discordbot.py b/discordbot.py index d4fbb07e..1ddee98b 100644 --- a/discordbot.py +++ b/discordbot.py @@ -20,6 +20,7 @@ import datetime import sqlalchemy.exc import coloredlogs import errors +import math logging.getLogger().disabled = True logger = logging.getLogger(__name__) @@ -51,10 +52,6 @@ zalgo_middle = ['̕', '̛', '̀', '́', '͘', '̡', '̢', '̧', '̨', '̴', '̵' # Init the event loop loop = asyncio.get_event_loop() -# Init the config reader -config = configparser.ConfigParser() -config.read("config.ini") - # Radio messages radio_messages = ["https://www.youtube.com/watch?v=3-yeK1Ck4yk", "https://youtu.be/YcR7du_A1Vc", @@ -141,20 +138,33 @@ else: class Video: + """A video to be played in the bot.""" + def __init__(self, url: str = None, file: str = None, info: dict = None, enqueuer: discord.Member = None): + # Url of the video if it has to be downloaded self.url = url + # Filename of the downloaded video if file is None and info is None: + # Get it from the url hash self.file = str(hash(url)) + ".opus" elif info is not None: + # Get it from the video title self.file = "./opusfiles/" + re.sub(r'[/\\?*"<>|!:]', "_", info["title"]) + ".opus" else: + # The filename was explicitly passed self.file = file - self.downloaded = False if file is None else True + # Was the file already downloaded? + self.downloaded = (file is not None) + # Do we already have info on the video? self.info = info + # Who added the video to the queue? self.enqueuer = enqueuer - self.duration = None + # How long is the video? + if info is not None: + self.duration = info.get("duration") def __str__(self): + """Format the title to be used on Discord using Markdown.""" if self.info is None or "title" not in self.info: return f"`{self.file}`" return f"_{self.info['title']}_" @@ -163,11 +173,12 @@ class Video: return f"" def plain_text(self): + """Format the video title without any Markdown.""" if self.info is None or "title" not in self.info: return self.file return self.info['title'] - async def download(self, progress_hooks: typing.List["function"] = None): + def download(self, progress_hooks: typing.List["function"] = None): # File already downloaded if self.downloaded: raise errors.AlreadyDownloadedError() @@ -176,10 +187,11 @@ class Video: progress_hooks = [] # Check if under max duration self.duration = datetime.timedelta(seconds=self.info.get("duration", 0)) - if self.info is not None and self.duration.total_seconds() > int(config["YouTube"]["max_duration"]): + # Refuse downloading if over YouTube max_duration + if self.info is not None and self.duration.total_seconds() > self.max_duration: raise errors.DurationError() # Download the file - logger.info(f"Now downloading {repr(self)}.") + logger.info(f"Downloading: {repr(self)}") with youtube_dl.YoutubeDL({"noplaylist": True, "format": "best", "postprocessors": [{ @@ -189,8 +201,8 @@ class Video: "outtmpl": self.file, "progress_hooks": progress_hooks, "quiet": True}) as ytdl: - await loop.run_in_executor(executor, functools.partial(ytdl.download, [self.url])) - logger.info(f"Download of {repr(self)} complete.") + ytdl.download(self.url) + logger.info(f"Download complete: {repr(self)}") self.downloaded = True def create_audio_source(self) -> discord.PCMVolumeTransformer: @@ -201,6 +213,7 @@ class Video: class SecretVideo(Video): + """A video to be played, but with a Zalgo'ed title.""" def __str__(self): final_string = "" @@ -220,12 +233,6 @@ class SecretVideo(Video): final_string += letter return final_string - def create_audio_source(self) -> discord.PCMVolumeTransformer: - # Check if the file has been downloaded - if not self.downloaded: - raise errors.FileNotDownloadedError() - return discord.PCMVolumeTransformer(discord.FFmpegPCMAudio(f"./opusfiles/{self.file}", **ffmpeg_settings)) - def escape(message: str): return message.replace("<", "<").replace(">", ">") @@ -335,22 +342,61 @@ class RoyalDiscordBot(discord.Client): "!resume": self.cmd_resume } self.video_queue: typing.List[Video] = [] - self.now_playing = None - self.radio_messages = True - self.next_radio_message_in = int(config["Discord"]["radio_messages_every"]) + self.now_playing: typing.Optional[Video] = None + self.load_config("config.ini") self.inactivity_timer = 0 + def load_config(self, filename): + # Init the config reader + config = configparser.ConfigParser() + config.read("config.ini") + # Token + try: + self.token = config["Discord"]["bot_token"] + except KeyError, ValueError: + raise errors.InvalidConfigError("Missing Discord bot token.") + # Main channels, will be fully loaded when ready + try: + self.main_guild_id = int(config["Discord"]["server_id"]) + self.main_channel_id = int(config["Discord"]["main_channel"] + except KeyError, ValueError: + raise errors.InvalidConfigError("Missing main guild and channel ids.") + # Max enqueable video duration + try: + self.max_duration = int(config["YouTube"].get("max_duration")) + except KeyError, ValueError: + logger.warning("Max video duration is not set, setting it to infinity.") + self.max_duration = math.inf + # Max videos to predownload + try: + self.max_videos_to_predownload = int(config["YouTube"]["predownload_videos"]) + except KeyError, ValueError: + logger.warning("Max videos to predownload is not set, setting it to infinity.") + self.max_videos_to_predownload = None + # Radio messages + try: + self.radio_messages_enabled = True if config["Discord"]["radio_messages_enabled"] == "True" else False + self.radio_messages_every = int(config["Discord"]["radio_messages_every"]) + self.radio_messages_next_in = self.radio_messages_every + except KeyError, ValueError: + logger.warning("Radio messages config error, disabling them.") + self.radio_messages_enabled = False + self.radio_messages_every = mathf.inf + self.radio_messages_next_in = mathf.inf + async def on_ready(self): - # Get the main channel - self.main_channel = self.get_channel(int(config["Discord"]["main_channel"])) - if not isinstance(self.main_channel, discord.TextChannel): - raise errors.InvalidConfigError("The main channel is not a TextChannel!") # Get the main guild - self.main_guild = self.get_guild(int(config["Discord"]["server_id"])) + self.main_guild = self.get_guild(self.main_guild_id) if not isinstance(self.main_guild, discord.Guild): raise errors.InvalidConfigError("The main guild does not exist!") + # Get the main channel + self.main_channel = self.get_channel(self.main_channel_id) + if not isinstance(self.main_channel, discord.TextChannel): + raise errors.InvalidConfigError("The main channel is not a TextChannel!") + # Show yourself! await self.change_presence(status=discord.Status.online, activity=None) logger.info("Bot is ready!") + # Start the bot tasks asyncio.ensure_future(self.queue_predownload_videos()) asyncio.ensure_future(self.queue_play_next_video()) asyncio.ensure_future(self.inactivity_countdown()) @@ -504,12 +550,12 @@ class RoyalDiscordBot(discord.Client): async def queue_predownload_videos(self): while True: - for index, video in enumerate(self.video_queue[:int(config["YouTube"]["predownload_videos"])].copy()): + for index, video in enumerate(self.video_queue[:(None if self.max_videos_to_predownload == math.inf else self.max_videos_to_predownload].copy()): if video.downloaded: continue try: with async_timeout.timeout(int(config["YouTube"]["download_timeout"])): - await video.download() + await loop.run_in_executor(executor, video.download) except asyncio.TimeoutError: logger.warning(f"Video download took more than {config['YouTube']['download_timeout']}s:" f" {video.plain_text()}") @@ -520,7 +566,7 @@ class RoyalDiscordBot(discord.Client): continue except DurationError: await self.main_channel.send(f"⚠️ {str(video)} dura più di" - f" {str(int(config['YouTube']['max_duration']) // 60)}" + f" {self.max_duration // 60}" f" minuti, quindi è stato rimosso dalla coda.") del self.video_queue[index] continue @@ -560,7 +606,7 @@ class RoyalDiscordBot(discord.Client): now_playing = self.video_queue[0] try: audio_source = now_playing.create_audio_source() - except FileNotDownloadedError: + except errors.FileNotDownloadedError: continue logger.info(f"Started playing {repr(now_playing)}.") voice_client.play(audio_source) @@ -750,15 +796,13 @@ class RoyalDiscordBot(discord.Client): "Sintassi: `!play `") return channel.typing() - # If the radio messages are enabled... - if self.radio_messages: - self.next_radio_message_in -= 1 - if self.next_radio_message_in <= 0: - radio_message = random.sample(radio_messages, 1)[0] - self.next_radio_message_in = int(config["Discord"]["radio_messages_every"]) - await self.add_video_from_url(radio_message) - await channel.send(f"📻 Aggiunto un messaggio radio, disattiva con `!radiomessages off`.") - logger.info(f"Radio message added to the queue.") + self.next_radio_message_in -= 1 + if self.next_radio_message_in <= 0: + radio_message = random.sample(radio_messages, 1)[0] + self.next_radio_message_in = self.radio_messages_every + await self.add_video_from_url(radio_message) + await channel.send(f"📻 Aggiunto un messaggio radio, disattiva con `!radiomessages off`.") + logger.info(f"Radio message added to the queue.") # Parse the parameter as URL url = re.match(r"(?:https?://|ytsearch[0-9]*:).*", " ".join(params[1:]).strip("<>")) if url is not None: @@ -931,19 +975,23 @@ class RoyalDiscordBot(discord.Client): @command async def cmd_radiomessages(self, channel: discord.TextChannel, author: discord.Member, params: typing.List[str]): + if not self.radio_messages_enabled: + await channel.send("⚠ I messaggi radio sono stati disabilitati dall'amministratore del bot.") + return if len(params) < 2: - self.radio_messages = not self.radio_messages + await channel.send("⚠ Sintassi del comando non valida.\n" + "Sintassi: `!radiomessages `") else: if params[1].lower() == "on": - self.radio_messages = True + self.radio_messages_next_in = self.radio_messages_every elif params[1].lower() == "off": - self.radio_messages = False + self.radio_messages_next_in = math.inf else: await channel.send("⚠ Sintassi del comando non valida.\n" - "Sintassi: `!radiomessages [on|off]`") + "Sintassi: `!radiomessages `") return - logger.info(f"Radio messages status toggled to {self.radio_messages}.") - await channel.send(f"📻 Messaggi radio **{'attivati' if self.radio_messages else 'disattivati'}**.") + logger.info(f"Radio messages status to {'enabled' if self.radio_messages.next_in < math.inf else 'disabled'}.") + await channel.send(f"📻 Messaggi radio **{'attivati' if self.radio_messages.next_in < math.inf else 'disattivati'}**.") @command @requires_connected_voice_client @@ -972,7 +1020,7 @@ def process(users_connection=None): logger.info("Initializing Telegram-Discord connection...") asyncio.ensure_future(bot.feed_pipe(users_connection)) logger.info("Logging in...") - loop.run_until_complete(bot.login(config["Discord"]["bot_token"], bot=True)) + loop.run_until_complete(bot.login(bot.token, bot=True)) logger.info("Connecting...") loop.run_until_complete(bot.connect()) logger.info("Now stopping...") diff --git a/template_config.ini b/template_config.ini index 8d7a40ff..1143a95b 100644 --- a/template_config.ini +++ b/template_config.ini @@ -15,6 +15,7 @@ bot_token = server_id = main_channel = afk_timer = 10 +radio_messages_enabled = True radio_messages_every = 5 [Telegram] From 6fbc9898ddecc82220aaf85fed6e48e562f3f713 Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Wed, 28 Nov 2018 15:44:34 +0000 Subject: [PATCH 02/12] WIP: new queue2 --- discordbot.py | 136 ++++++++++++++++++++++++++------------------------ 1 file changed, 70 insertions(+), 66 deletions(-) diff --git a/discordbot.py b/discordbot.py index 1ddee98b..e66840fb 100644 --- a/discordbot.py +++ b/discordbot.py @@ -159,9 +159,15 @@ class Video: self.info = info # Who added the video to the queue? self.enqueuer = enqueuer - # How long is the video? + # How long and what title has the video? if info is not None: self.duration = info.get("duration") + self.title = info.get("title") + else: + self.duration = None + self.title = None + # No audio source exists yet + self.audio_source = None def __str__(self): """Format the title to be used on Discord using Markdown.""" @@ -205,11 +211,56 @@ class Video: logger.info(f"Download complete: {repr(self)}") self.downloaded = True - def create_audio_source(self) -> discord.PCMVolumeTransformer: + def load(self) -> None: # Check if the file has been downloaded if not self.downloaded: raise errors.FileNotDownloadedError() - return discord.PCMVolumeTransformer(discord.FFmpegPCMAudio(f"{self.file}", **ffmpeg_settings)) + self.audio_source = discord.PCMVolumeTransformer(discord.FFmpegPCMAudio(f"{self.file}", **ffmpeg_settings)) + + +class VideoQueue(): + """The queue of videos to be played.""" + + def __init__(self): + self.list: typing.List[Video] = [] + self.now_playing: typing.Optional[Video] = None + + def __len__(self) -> int: + return len(self.list) + + def add(self, video: Video, position: int=None) -> None: + if position is None: + self.list.append(video) + return + self.list.insert(position, video) + + def next_video(self) -> typing.Optional[Video]: + if len(self.list) == 0: + return None + return self.list[0] + + def shuffle(self): + random.shuffle(self.list) + + def clear(self): + self.list = None + self.now_playing = None + + def forward(self) -> None: + self.now_playing = self.list.pop(0) + + def find_video(self, title: str) -> typing.Optional[Video]: + """Returns the first video with a certain title (or filename).""" + for video in self.list: + if title in video.title: + return video + elif title in video.file: + return video + return None + + def __getitem__(self, index: int) -> Video: + """Get an element from the list.""" + return self.list[index] class SecretVideo(Video): @@ -341,8 +392,7 @@ class RoyalDiscordBot(discord.Client): "!pause": self.cmd_pause, "!resume": self.cmd_resume } - self.video_queue: typing.List[Video] = [] - self.now_playing: typing.Optional[Video] = None + self.video_queue: VideoQueue = VideoQueue() self.load_config("config.ini") self.inactivity_timer = 0 @@ -550,7 +600,7 @@ class RoyalDiscordBot(discord.Client): async def queue_predownload_videos(self): while True: - for index, video in enumerate(self.video_queue[:(None if self.max_videos_to_predownload == math.inf else self.max_videos_to_predownload].copy()): + for index, video in enumerate(self.video_queue.list[:(None if self.max_videos_to_predownload == math.inf else self.max_videos_to_predownload].copy()): if video.downloaded: continue try: @@ -562,13 +612,13 @@ class RoyalDiscordBot(discord.Client): await self.main_channel.send(f"⚠️ Il download di {str(video)} ha richiesto più di" f" {config['YouTube']['download_timeout']} secondi, pertanto è stato" f" rimosso dalla coda.") - del self.video_queue[index] + del self.video_queue.list[index] continue except DurationError: await self.main_channel.send(f"⚠️ {str(video)} dura più di" f" {self.max_duration // 60}" f" minuti, quindi è stato rimosso dalla coda.") - del self.video_queue[index] + del self.video_queue.list[index] continue except Exception as e: sentry.user_context({ @@ -589,53 +639,15 @@ class RoyalDiscordBot(discord.Client): f"```python\n" f"{str(e)}" f"```") - del self.video_queue[index] + del self.video_queue.list[index] continue await asyncio.sleep(1) async def queue_play_next_video(self): await self.wait_until_ready() while True: - # Fun things will happen with multiple voice clients! - for voice_client in self.voice_clients: - if not voice_client.is_connected() or voice_client.is_playing() or voice_client.is_paused(): - continue - if len(self.video_queue) == 0: - self.now_playing = None - continue - now_playing = self.video_queue[0] - try: - audio_source = now_playing.create_audio_source() - except errors.FileNotDownloadedError: - continue - logger.info(f"Started playing {repr(now_playing)}.") - voice_client.play(audio_source) - del self.video_queue[0] - activity = discord.Activity(name=now_playing.plain_text(), - type=discord.ActivityType.listening) - logger.debug(f"Updated bot presence to {now_playing.plain_text()}.") - await self.change_presence(status=discord.Status.online, activity=activity) - if now_playing.enqueuer is not None: - try: - session = db.Session() - enqueuer = await loop.run_in_executor(executor, session.query(db.Discord) - .filter_by(discord_id=now_playing.enqueuer.id) - .one_or_none) - played_music = db.PlayedMusic(enqueuer=enqueuer, - filename=now_playing.plain_text(), - timestamp=datetime.datetime.now()) - session.add(played_music) - await loop.run_in_executor(executor, session.commit) - await loop.run_in_executor(executor, session.close) - except sqlalchemy.exc.OperationalError: - pass - for key in song_special_messages: - if key in now_playing.file.lower(): - await self.main_channel.send(song_special_messages[key].format(song=str(now_playing))) - break - else: - await self.main_channel.send(f":arrow_forward: Ora in riproduzione: {str(now_playing)}") - await asyncio.sleep(1) + # TODO + raise NotImplementedError("queue_play_next_video isn't done yet!") async def inactivity_countdown(self): while True: @@ -709,22 +721,13 @@ class RoyalDiscordBot(discord.Client): if "entries" in info: logger.debug(f"Playlist detected at {url}.") for entry in info["entries"]: - if index is not None: - self.video_queue.insert(index, Video(url=entry["webpage_url"], info=entry, enqueuer=enqueuer)) - else: - self.video_queue.append(Video(url=entry["webpage_url"], info=entry, enqueuer=enqueuer)) + self.video_queue.add(Video(url=entry["webpage_url"], info=entry, enqueuer=enqueuer), index) return logger.debug(f"Single video detected at {url}.") - if index is not None: - self.video_queue.insert(index, Video(url=url, info=info, enqueuer=enqueuer)) - else: - self.video_queue.append(Video(url=url, info=info, enqueuer=enqueuer)) + self.video_queue.add(Video(url=entry["webpage_url"], info=entry, enqueuer=enqueuer), index) async def add_video_from_file(self, file, index: typing.Optional[int] = None, enqueuer: discord.Member = None): - if index is not None: - self.video_queue.insert(index, Video(file=file, enqueuer=enqueuer)) - else: - self.video_queue.append(Video(file=file, enqueuer=enqueuer)) + self.video_queue.add(Video(file=file, enqueuer=enqueuer), index) @command async def null(self, channel: discord.TextChannel, author: discord.Member, params: typing.List[str]): @@ -865,7 +868,7 @@ class RoyalDiscordBot(discord.Client): await channel.send("⚠ Il numero inserito non corrisponde a nessun video nella playlist.\n" "Sintassi: `!remove [numerovideoiniziale] [numerovideofinale]`") return - video = self.video_queue.pop(index) + video = self.video_queue.list.pop(index) await channel.send(f":regional_indicator_x: {str(video)} è stato rimosso dalla coda.") logger.debug(f"Removed from queue: {video.plain_text()}") return @@ -895,7 +898,7 @@ class RoyalDiscordBot(discord.Client): await channel.send("⚠ Il numero iniziale è maggiore del numero finale.\n" "Sintassi: `!remove [numerovideoiniziale] [numerovideofinale]`") return - del self.video_queue[start:end] + del self.video_queue.list[start:end] await channel.send(f":regional_indicator_x: {end - start} video rimossi dalla coda.") logger.debug(f"Removed from queue {end - start} videos.") @@ -906,7 +909,7 @@ class RoyalDiscordBot(discord.Client): "nessuno") return msg = "**Video in coda:**\n" - for index, video in enumerate(self.video_queue[:10]): + for index, video in enumerate(self.video_queue.list[:10]): msg += f"{queue_emojis[index]} {str(video)}\n" if len(self.video_queue) > 10: msg += f"più altri {len(self.video_queue) - 10} video!" @@ -918,7 +921,7 @@ class RoyalDiscordBot(discord.Client): if len(self.video_queue) == 0: await channel.send("⚠ Non ci sono video in coda!") return - random.shuffle(self.video_queue) + random.shuffle(self.video_queue.list) logger.info(f"The queue was shuffled by {author.name}#{author.discriminator}.") await channel.send("♠️ ♦️ ♣️ ♥️ Shuffle completo!") @@ -928,7 +931,8 @@ class RoyalDiscordBot(discord.Client): if len(self.video_queue) == 0: await channel.send("⚠ Non ci sono video in coda!") return - self.video_queue = [] + # TODO + raise NotImplementedError() logger.info(f"The queue was cleared by {author.name}#{author.discriminator}.") await channel.send(":regional_indicator_x: Tutti i video in coda rimossi.") From 8556a2f0dcb0978c3343b92e9caee3e1aca96042 Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Mon, 3 Dec 2018 11:00:37 +0000 Subject: [PATCH 03/12] WIP: new video stuff --- discordbot.py | 119 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 2 deletions(-) diff --git a/discordbot.py b/discordbot.py index e66840fb..d3681c1b 100644 --- a/discordbot.py +++ b/discordbot.py @@ -21,6 +21,7 @@ import sqlalchemy.exc import coloredlogs import errors import math +import enum logging.getLogger().disabled = True logger = logging.getLogger(__name__) @@ -137,7 +138,7 @@ else: sentry = Succ() -class Video: +class OldVideo: """A video to be played in the bot.""" def __init__(self, url: str = None, file: str = None, info: dict = None, enqueuer: discord.Member = None): @@ -217,6 +218,92 @@ class Video: raise errors.FileNotDownloadedError() self.audio_source = discord.PCMVolumeTransformer(discord.FFmpegPCMAudio(f"{self.file}", **ffmpeg_settings)) + def suggestion(self) -> typing.Optional[str]: + """The suggested video to add to the queue after this one.""" + raise NotImplementedError() + + +class Video: + def __init__(self, enqueuer: typing.Optional[discord.Member]=None): + self.is_ready = False + self.name = None + self.enqueuer = enqueuer + self.audio_source = None + + def __str__(self): + return self.name + + def plain_text(self): + """Title without formatting to be printed on terminals.""" + return self.name + + def database_text(self): + """The text to be stored in the database for the stats. Usually the same as plain_text().""" + return self.name + + def __repr__(self): + return f"