From b3962c8928d43054ae243de68b1096161b772cb8 Mon Sep 17 00:00:00 2001 From: Lemon4ksan Date: Thu, 30 Jan 2025 20:08:46 +0300 Subject: [PATCH] feat: MyVibe settings and memory leak fix. --- MusicBot/cogs/general.py | 54 +++--- MusicBot/cogs/utils/__init__.py | 3 +- MusicBot/cogs/utils/embeds.py | 4 +- MusicBot/cogs/utils/voice_extension.py | 248 +++++++++++++++---------- MusicBot/cogs/voice.py | 173 +++++++++-------- MusicBot/database/base.py | 20 +- MusicBot/database/extensions.py | 15 +- MusicBot/database/user.py | 10 +- MusicBot/ui/menu.py | 173 +++++++++++++++-- 9 files changed, 455 insertions(+), 245 deletions(-) diff --git a/MusicBot/cogs/general.py b/MusicBot/cogs/general.py index c0a5618..88a7edb 100644 --- a/MusicBot/cogs/general.py +++ b/MusicBot/cogs/general.py @@ -30,7 +30,7 @@ async def get_search_suggestions(ctx: discord.AutocompleteContext) -> list[str]: try: client = await YMClient(token).init() except yandex_music.exceptions.UnauthorizedError: - logging.info(f"User {ctx.interaction.user.id} provided invalid token") + logging.info(f"[GENERAL] User {ctx.interaction.user.id} provided invalid token") return ['❌ Недействительный токен.'] content_type = ctx.options['тип'] @@ -81,7 +81,7 @@ class General(Cog): default='all' ) async def help(self, ctx: discord.ApplicationContext, command: str) -> None: - logging.info(f"Help command invoked by {ctx.user.id} for command '{command}'") + logging.info(f"[GENERAL] Help command invoked by {ctx.user.id} for command '{command}'") response_message = None embed = discord.Embed( @@ -171,32 +171,32 @@ class General(Cog): @account.command(description="Ввести токен от Яндекс Музыки.") @discord.option("token", type=discord.SlashCommandOptionType.string, description="Токен.") async def login(self, ctx: discord.ApplicationContext, token: str) -> None: - logging.info(f"Login command invoked by user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[GENERAL] Login command invoked by user {ctx.author.id} in guild {ctx.guild.id}") try: client = await YMClient(token).init() except yandex_music.exceptions.UnauthorizedError: - logging.info(f"Invalid token provided by user {ctx.author.id}") + logging.info(f"[GENERAL] Invalid token provided by user {ctx.author.id}") await ctx.respond('❌ Недействительный токен.', delete_after=15, ephemeral=True) return about = cast(yandex_music.Status, client.me).to_dict() uid = ctx.author.id self.users_db.update(uid, {'ym_token': token}) - logging.info(f"Token saved for user {ctx.author.id}") + logging.info(f"[GENERAL] Token saved for user {ctx.author.id}") await ctx.respond(f'Привет, {about['account']['first_name']}!', delete_after=15, ephemeral=True) @account.command(description="Удалить токен из датабазы бота.") async def remove(self, ctx: discord.ApplicationContext) -> None: - logging.info(f"Remove command invoked by user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[GENERAL] Remove command invoked by user {ctx.author.id} in guild {ctx.guild.id}") self.users_db.update(ctx.user.id, {'ym_token': None}) await ctx.respond(f'Токен был удалён.', delete_after=15, ephemeral=True) @account.command(description="Получить плейлист «Мне нравится»") async def likes(self, ctx: discord.ApplicationContext) -> None: - logging.info(f"Likes command invoked by user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[GENERAL] Likes command invoked by user {ctx.author.id} in guild {ctx.guild.id}") token = self.users_db.get_ym_token(ctx.user.id) if not token: - logging.info(f"No token found for user {ctx.user.id}") + logging.info(f"[GENERAL] No token found for user {ctx.user.id}") await ctx.respond('❌ Необходимо указать свой токен доступа с помощью команды /login.', delete_after=15, ephemeral=True) return client = await YMClient(token).init() @@ -206,23 +206,23 @@ class General(Cog): return likes = await client.users_likes_tracks() if likes is None: - logging.info(f"Failed to fetch likes for user {ctx.user.id}") + logging.info(f"[GENERAL] Failed to fetch likes for user {ctx.user.id}") await ctx.respond('❌ Что-то пошло не так. Повторите попытку позже.', delete_after=15, ephemeral=True) return elif not likes: - logging.info(f"Empty likes for user {ctx.user.id}") + logging.info(f"[GENERAL] Empty likes for user {ctx.user.id}") await ctx.respond('❌ У вас нет треков в плейлисте «Мне нравится».', delete_after=15, ephemeral=True) return real_tracks = await gather(*[track_short.fetch_track_async() for track_short in likes.tracks], return_exceptions=True) tracks = [track for track in real_tracks if not isinstance(track, BaseException)] # Can't fetch user tracks embed = await generate_item_embed(tracks) - logging.info(f"Successfully fetched likes for user {ctx.user.id}") + logging.info(f"[GENERAL] Successfully fetched likes for user {ctx.user.id}") await ctx.respond(embed=embed, view=ListenView(tracks)) @account.command(description="Получить ваши плейлисты.") async def playlists(self, ctx: discord.ApplicationContext) -> None: - logging.info(f"Playlists command invoked by user {ctx.user.id} in guild {ctx.guild_id}") + logging.info(f"[GENERAL] Playlists command invoked by user {ctx.user.id} in guild {ctx.guild_id}") token = self.users_db.get_ym_token(ctx.user.id) if not token: @@ -242,7 +242,7 @@ class General(Cog): self.users_db.update(ctx.user.id, {'playlists': playlists, 'playlists_page': 0}) embed = generate_playlists_embed(0, playlists) - logging.info(f"Successfully fetched playlists for user {ctx.user.id}") + logging.info(f"[GENERAL] Successfully fetched playlists for user {ctx.user.id}") await ctx.respond(embed=embed, view=MyPlaylists(ctx), ephemeral=True) @discord.slash_command(description="Найти контент и отправить информацию о нём. Возвращается лучшее совпадение.") @@ -266,19 +266,19 @@ class General(Cog): content_type: Literal['Трек', 'Альбом', 'Артист', 'Плейлист', 'Свой плейлист'], name: str ) -> None: - logging.info(f"Find command invoked by user {ctx.user.id} in guild {ctx.guild_id} for '{content_type}' with name '{name}'") + logging.info(f"[GENERAL] Find command invoked by user {ctx.user.id} in guild {ctx.guild_id} for '{content_type}' with name '{name}'") guild = self.db.get_guild(ctx.guild_id) token = self.users_db.get_ym_token(ctx.user.id) if not token: - logging.info(f"No token found for user {ctx.user.id}") - await ctx.respond("❌ Необходимо указать свой токен доступа с помощью команды /login.", delete_after=15, ephemeral=True) + logging.info(f"[GENERAL] No token found for user {ctx.user.id}") + await ctx.respond("❌ Укажите токен через /account login.", ephemeral=True) return try: client = await YMClient(token).init() except yandex_music.exceptions.UnauthorizedError: - logging.info(f"User {ctx.user.id} provided invalid token") + logging.info(f"[GENERAL] User {ctx.user.id} provided invalid token") await ctx.respond("❌ Недействительный токен. Если это не так, попробуйте ещё раз.", delete_after=15, ephemeral=True) return @@ -291,20 +291,20 @@ class General(Cog): playlists = await client.users_playlists_list(client.me.account.uid) result = next((playlist for playlist in playlists if playlist.title == name), None) if not result: - logging.info(f"User {ctx.user.id} playlist '{name}' not found") + logging.info(f"[GENERAL] User {ctx.user.id} playlist '{name}' not found") await ctx.respond("❌ Плейлист не найден.", delete_after=15, ephemeral=True) return tracks = await result.fetch_tracks_async() if not tracks: - logging.info(f"User {ctx.user.id} playlist '{name}' is empty") + logging.info(f"[GENERAL] User {ctx.user.id} playlist '{name}' is empty") await ctx.respond("❌ Плейлист пуст.", delete_after=15, ephemeral=True) return for track_short in tracks: track = cast(Track, track_short.track) if (track.explicit or track.content_warning) and not guild['allow_explicit']: - logging.info(f"User {ctx.user.id} playlist '{name}' contains explicit content and is not allowed on this server") + logging.info(f"[GENERAL] User {ctx.user.id} playlist '{name}' contains explicit content and is not allowed on this server") await ctx.respond("❌ Explicit контент запрещён на этом сервере.", delete_after=15, ephemeral=True) return @@ -328,7 +328,7 @@ class General(Cog): content = result.playlists if not content: - logging.info(f"User {ctx.user.id} search for '{name}' returned no results") + logging.info(f"[GENERAL] User {ctx.user.id} search for '{name}' returned no results") await ctx.respond("❌ По запросу ничего не найдено.", delete_after=15, ephemeral=True) return content = content.results[0] @@ -337,35 +337,35 @@ class General(Cog): view = ListenView(content) if isinstance(content, (Track, Album)) and (content.explicit or content.content_warning) and not guild['allow_explicit']: - logging.info(f"User {ctx.user.id} search for '{name}' returned explicit content and is not allowed on this server") + logging.info(f"[GENERAL] User {ctx.user.id} search for '{name}' returned explicit content and is not allowed on this server") await ctx.respond("❌ Explicit контент запрещён на этом сервере.", delete_after=15, ephemeral=True) return elif isinstance(content, Artist): tracks = await content.get_tracks_async() if not tracks: - logging.info(f"User {ctx.user.id} search for '{name}' returned no tracks") + logging.info(f"[GENERAL] User {ctx.user.id} search for '{name}' returned no tracks") await ctx.respond("❌ Треки от этого исполнителя не найдены.", delete_after=15, ephemeral=True) return for track in tracks: if (track.explicit or track.content_warning) and not guild['allow_explicit']: - logging.info(f"User {ctx.user.id} search for '{name}' returned explicit content and is not allowed on this server") + logging.info(f"[GENERAL] User {ctx.user.id} search for '{name}' returned explicit content and is not allowed on this server") view = None embed.set_footer(text="Воспроизведение недоступно, так как у автора присутствуют Explicit треки") break elif isinstance(content, Playlist): tracks = await content.fetch_tracks_async() if not tracks: - logging.info(f"User {ctx.user.id} search for '{name}' returned no tracks") + logging.info(f"[GENERAL] User {ctx.user.id} search for '{name}' returned no tracks") await ctx.respond("❌ Пустой плейлист.", delete_after=15, ephemeral=True) return for track_short in content.tracks: track = cast(Track, track_short.track) if (track.explicit or track.content_warning) and not guild['allow_explicit']: - logging.info(f"User {ctx.user.id} search for '{name}' returned explicit content and is not allowed on this server") + logging.info(f"[GENERAL] User {ctx.user.id} search for '{name}' returned explicit content and is not allowed on this server") view = None embed.set_footer(text="Воспроизведение недоступно, так как у автора присутствуют Explicit треки") break - logging.info(f"Successfully generated '{content_type}' message for user {ctx.author.id}") + logging.info(f"[GENERAL] Successfully generated '{content_type}' message for user {ctx.author.id}") await ctx.respond(embed=embed, view=view) diff --git a/MusicBot/cogs/utils/__init__.py b/MusicBot/cogs/utils/__init__.py index baec22e..59224b3 100644 --- a/MusicBot/cogs/utils/__init__.py +++ b/MusicBot/cogs/utils/__init__.py @@ -1,7 +1,8 @@ from .embeds import generate_item_embed -from .voice_extension import VoiceExtension +from .voice_extension import VoiceExtension, menu_views __all__ = [ "generate_item_embed", "VoiceExtension", + "menu_views" ] \ No newline at end of file diff --git a/MusicBot/cogs/utils/embeds.py b/MusicBot/cogs/utils/embeds.py index 67c5e55..617a532 100644 --- a/MusicBot/cogs/utils/embeds.py +++ b/MusicBot/cogs/utils/embeds.py @@ -36,8 +36,8 @@ async def generate_item_embed(item: Track | Album | Artist | Playlist | list[Tra if vibing: embed.set_image( - url="https://media0.giphy.com/media/v1.Y2lkPTc5MGI3NjExbjd6M3VscnZnMXFlb3NtMHY2Zm5tbTVvMm8yY21nNXhpN214YzhyaCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/7HxhnYcJljc3ON77O3/giphy.gif" - ) # TODO: Get better gif + url="https://media4.giphy.com/media/v1.Y2lkPTc5MGI3NjExaWN5dG50YWtxeDcwNnZpaDdqY3A3bHBsYXkyb29rdXoyajNjdWMxYiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/IilXmX8tjwfXgSwjBr/giphy.gif" + ) return embed def _generate_likes_embed(tracks: list[Track]) -> Embed: diff --git a/MusicBot/cogs/utils/voice_extension.py b/MusicBot/cogs/utils/voice_extension.py index 8495d71..7f9ea7d 100644 --- a/MusicBot/cogs/utils/voice_extension.py +++ b/MusicBot/cogs/utils/voice_extension.py @@ -7,6 +7,7 @@ import yandex_music.exceptions from yandex_music import Track, TrackShort, ClientAsync as YMClient import discord +from discord.ui import View from discord import Interaction, ApplicationContext, RawReactionActionEvent from MusicBot.cogs.utils import generate_item_embed @@ -14,6 +15,8 @@ from MusicBot.database import VoiceGuildsDatabase, BaseUsersDatabase # TODO: RawReactionActionEvent is poorly supported. +menu_views: dict[int, View] = {} # Store menu views and delete them when needed to prevent memory leaks for after callbacks. + class VoiceExtension: def __init__(self, bot: discord.Bot | None) -> None: @@ -23,13 +26,13 @@ class VoiceExtension: async def send_menu_message(self, ctx: ApplicationContext | Interaction) -> None: from MusicBot.ui import MenuView - logging.info("[VC] Sending player menu") + logging.info("[VC_EXT] Sending menu message") - if not ctx.guild: - logging.warning("[VC] Guild not found in context inside 'create_menu'") + if not ctx.guild_id: + logging.warning("[VC_EXT] Guild id not found in context inside 'create_menu'") return - guild = self.db.get_guild(ctx.guild.id) + guild = self.db.get_guild(ctx.guild_id) embed = None if guild['current_track']: @@ -45,57 +48,61 @@ class VoiceExtension: embed.remove_footer() if guild['current_menu']: - logging.info(f"[VC] Deleting old player menu {guild['current_menu']} in guild {ctx.guild.id}") + logging.info(f"[VC_EXT] Deleting old menu message {guild['current_menu']} in guild {ctx.guild_id}") message = await self.get_menu_message(ctx, guild['current_menu']) if message: await message.delete() - interaction = cast(discord.Interaction, await ctx.respond(view=await MenuView(ctx).init(), embed=embed)) - response = await interaction.original_response() - self.db.update(ctx.guild.id, {'current_menu': response.id}) + if ctx.guild_id in menu_views: + menu_views[ctx.guild_id].stop() + menu_views[ctx.guild_id] = await MenuView(ctx).init() - logging.info(f"[VC] New player menu {response.id} created in guild {ctx.guild.id}") + interaction = cast(discord.Interaction, await ctx.respond(view=menu_views[ctx.guild_id], embed=embed)) + response = await interaction.original_response() + self.db.update(ctx.guild_id, {'current_menu': response.id}) + + logging.info(f"[VC_EXT] New menu message {response.id} created in guild {ctx.guild_id}") - async def get_menu_message(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, player_mid: int) -> discord.Message | None: - """Fetch the player message by its id. Return the message if found, None if not. + async def get_menu_message(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, menu_mid: int) -> discord.Message | None: + """Fetch the menu message by its id. Return the message if found, None if not. Reset `current_menu` field in the database if not found. Args: ctx (ApplicationContext | Interaction): Context. - player_mid (int): Id of the player message. + menu_mid (int): Id of the menu message. Returns: - discord.Message | None: Player message or None. + discord.Message | None: Menu message or None. """ - logging.debug(f"[VC] Fetching player message {player_mid}...") + logging.debug(f"[VC_EXT] Fetching menu message {menu_mid}...") if not ctx.guild_id: - logging.warning("[VC] Guild ID not found in context") + logging.warning("[VC_EXT] Guild ID not found in context") return None try: if isinstance(ctx, Interaction): - player = ctx.client.get_message(player_mid) + menu = ctx.client.get_message(menu_mid) elif isinstance(ctx, RawReactionActionEvent): if not self.bot: raise ValueError("Bot instance is not set.") - player = self.bot.get_message(player_mid) + menu = self.bot.get_message(menu_mid) elif isinstance(ctx, ApplicationContext): - player = await ctx.fetch_message(player_mid) + menu = await ctx.fetch_message(menu_mid) else: raise ValueError(f"Invalid context type: '{type(ctx).__name__}'.") except discord.DiscordException as e: - logging.debug(f"[VC] Failed to get player message: {e}") + logging.debug(f"[VC_EXT] Failed to get menu message: {e}") self.db.update(ctx.guild_id, {'current_menu': None}) return None - if player: - logging.debug("[VC] Player message found") + if menu: + logging.debug("[VC_EXT] Menu message found") else: - logging.debug("[VC] Player message not found. Resetting current_menu field.") + logging.debug("[VC_EXT] Menu message not found. Resetting current_menu field.") self.db.update(ctx.guild_id, {'current_menu': None}) - return player + return menu async def update_menu_embed( self, @@ -103,11 +110,11 @@ class VoiceExtension: menu_mid: int, button_callback: bool = False ) -> bool: - """Update current player message by its id. Return True if updated, False if not. + """Update current menu message by its id. Return True if updated, False if not. Args: ctx (ApplicationContext | Interaction): Context. - menu_mid (int): Id of the player message. There can only be only one player in the guild. + menu_mid (int): Id of the menu message. There can only be only one menu in the guild. button_callback (bool, optional): If True, the interaction is a button interaction. Defaults to False. Returns: @@ -115,7 +122,7 @@ class VoiceExtension: """ from MusicBot.ui import MenuView logging.debug( - f"[VC] Updating player embed using " + ( + f"[VC_EXT] Updating menu embed using " + ( "interaction context" if isinstance(ctx, Interaction) else "application context" if isinstance(ctx, ApplicationContext) else "raw reaction context" @@ -126,22 +133,22 @@ class VoiceExtension: uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None if not gid or not uid: - logging.warning("[VC] Guild ID or User ID not found in context inside 'update_player_embed'") + logging.warning("[VC_EXT] Guild ID or User ID not found in context inside 'update_menu_embed'") return False - player = await self.get_menu_message(ctx, menu_mid) - if not player: + menu = await self.get_menu_message(ctx, menu_mid) + if not menu: return False token = self.users_db.get_ym_token(uid) if not token: - logging.debug(f"[VC] No token found for user {uid}") + logging.debug(f"[VC_EXT] No token found for user {uid}") return False guild = self.db.get_guild(gid) current_track = guild['current_track'] if not current_track: - logging.debug("[VC] No current track found") + logging.debug("[VC_EXT] No current track found") return False track = cast(Track, Track.de_json( @@ -152,16 +159,23 @@ class VoiceExtension: embed = await generate_item_embed(track, guild['vibing']) try: + if gid in menu_views: + menu_views[gid].stop() + menu_views[gid] = await MenuView(ctx).init() if isinstance(ctx, Interaction) and button_callback: - # If interaction from player buttons - await ctx.edit(embed=embed, view=await MenuView(ctx).init()) + # If interaction from menu buttons + await ctx.edit(embed=embed, view=menu_views[gid]) else: # If interaction from other buttons or commands. They should have their own response. - await player.edit(embed=embed, view=await MenuView(ctx).init()) + await menu.edit(embed=embed, view=menu_views[gid]) except discord.NotFound: - logging.warning("[VC] Player message not found") + logging.warning("[VC_EXT] Menu message not found") + if gid in menu_views: + menu_views[gid].stop() + del menu_views[gid] return False + logging.debug("[VC_EXT] Menu embed updated") return True async def update_vibe( @@ -170,6 +184,7 @@ class VoiceExtension: type: Literal['track', 'album', 'artist', 'playlist', 'user'], id: str | int, *, + update_settings: bool = False, button_callback: bool = False ) -> str | None: """Update vibe state. Return track title on success. @@ -183,25 +198,22 @@ class VoiceExtension: Returns: str | None: Track title or None. """ - logging.info(f"[VC] Updating vibe for guild {ctx.guild_id} with type '{type}' and id '{id}'") + logging.info(f"[VC_EXT] Updating vibe for guild {ctx.guild_id} with type '{type}' and id '{id}'") gid = ctx.guild_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.guild.id if ctx.guild else None uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None if not uid or not gid: - logging.warning("[VC] Guild ID or User ID not found in context inside 'vibe_update'") + logging.warning("[VC_EXT] Guild ID or User ID not found in context inside 'vibe_update'") return None - token = self.users_db.get_ym_token(uid) - if not token: - logging.info(f"[VC] User {uid} has no YM token") + user = self.users_db.get_user(uid) + if not user['ym_token']: + logging.info(f"[VC_EXT] User {uid} has no YM token") await ctx.respond("❌ Укажите токен через /account login.", ephemeral=True) return - try: - client = await YMClient(token).init() - except yandex_music.exceptions.UnauthorizedError: - logging.info(f"[VC] User {uid} provided invalid token") - await ctx.respond('❌ Недействительный токен.') + client = await self.init_ym_client(ctx, user['ym_token']) + if not client: return self.users_db.update(uid, {'vibe_type': type, 'vibe_id': id}) @@ -214,10 +226,17 @@ class VoiceExtension: timestamp=time() ) logging.debug(f"[VIBE] Radio started feedback: {feedback}") - tracks = await client.rotor_station_tracks(f"{type}:{id}") self.db.update(gid, {'vibing': True}) elif guild['current_track']: + if update_settings: + settings = user['vibe_settings'] + await client.rotor_station_settings2( + f"{type}:{id}", + mood_energy=settings['mood'], + diversity=settings['diversity'], + language=settings['lang'] + ) tracks = await client.rotor_station_tracks( f"{type}:{id}", queue=guild['current_track']['id'] @@ -252,24 +271,29 @@ class VoiceExtension: bool: Check result. """ if not ctx.user or not ctx.guild: - logging.warning("[VC] User or guild not found in context inside 'voice_check'") + logging.warning("[VC_EXT] User or guild not found in context inside 'voice_check'") return False token = self.users_db.get_ym_token(ctx.user.id) if not token: - logging.debug(f"[VC] No token found for user {ctx.user.id}") + logging.debug(f"[VC_EXT] No token found for user {ctx.user.id}") await ctx.respond("❌ Необходимо указать свой токен доступа с помощью команды /login.", delete_after=15, ephemeral=True) return False if not isinstance(ctx.channel, discord.VoiceChannel): - logging.debug("[VC] User is not in a voice channel") + logging.debug("[VC_EXT] User is not in a voice channel") await ctx.respond("❌ Вы должны отправить команду в голосовом канале.", delete_after=15, ephemeral=True) return False + + if ctx.user.id not in ctx.channel.voice_states: + logging.debug("[VC_EXT] User is not connected to the voice channel") + await ctx.respond("❌ Вы должны находиться в голосовом канале.", delete_after=15, ephemeral=True) + return False voice_clients = ctx.client.voice_clients if isinstance(ctx, Interaction) else ctx.bot.voice_clients voice_chat = discord.utils.get(voice_clients, guild=ctx.guild) if not voice_chat: - logging.debug("[VC] Voice client not found") + logging.debug("[VC_EXT] Voice client not found") await ctx.respond("❌ Добавьте бота в голосовой канал при помощи команды /voice join.", delete_after=15, ephemeral=True) return False @@ -280,7 +304,7 @@ class VoiceExtension: await ctx.respond("❌ Вы не можете взаимодействовать с чужой волной!", delete_after=15, ephemeral=True) return False - logging.debug("[VC] Voice requirements met") + logging.debug("[VC_EXT] Voice requirements met") return True async def get_voice_client(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> discord.VoiceClient | None: @@ -292,29 +316,26 @@ class VoiceExtension: Returns: discord.VoiceClient | None: Voice client or None. """ - if isinstance(ctx, Interaction): - voice_clients = ctx.client.voice_clients + if isinstance(ctx, (Interaction, ApplicationContext)): + voice_clients = ctx.client.voice_clients if isinstance(ctx, Interaction) else ctx.bot.voice_clients guild = ctx.guild elif isinstance(ctx, RawReactionActionEvent): if not self.bot: raise ValueError("Bot instance is not set.") if not ctx.guild_id: - logging.warning("[VC] Guild ID not found in context inside get_voice_client") + logging.warning("[VC_EXT] Guild ID not found in context inside get_voice_client") return None voice_clients = self.bot.voice_clients guild = await self.bot.fetch_guild(ctx.guild_id) - elif isinstance(ctx, ApplicationContext): - voice_clients = ctx.bot.voice_clients - guild = ctx.guild else: raise ValueError(f"Invalid context type: '{type(ctx).__name__}'.") voice_chat = discord.utils.get(voice_clients, guild=guild) if voice_chat: - logging.debug("[VC] Voice client found") + logging.debug("[VC_EXT] Voice client found") else: - logging.debug("[VC] Voice client not found") + logging.debug("[VC_EXT] Voice client not found") return cast(discord.VoiceClient | None, voice_chat) @@ -347,7 +368,7 @@ class VoiceExtension: gid = ctx.guild_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.guild.id if ctx.guild else None uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None if not gid or not uid: - logging.warning("[VC] Guild ID or User ID not found in context inside 'play_track'") + logging.warning("Guild ID or User ID not found in context") return None if not vc: @@ -366,14 +387,17 @@ class VoiceExtension: else: raise ValueError(f"Invalid context type: '{type(ctx).__name__}'.") - self.db.set_current_track(gid, track) + self.db.update(gid, {'current_track': track.to_dict()}) guild = self.db.get_guild(gid) if guild['current_menu'] and not isinstance(ctx, RawReactionActionEvent): if menu_message: try: - await menu_message.edit(embed=await generate_item_embed(track, guild['vibing']), view=await MenuView(ctx).init()) + if gid in menu_views: + menu_views[gid].stop() + menu_views[gid] = await MenuView(ctx).init() + await menu_message.edit(embed=await generate_item_embed(track, guild['vibing']), view=menu_views[gid]) except discord.errors.NotFound: - logging.warning("[VC] Menu message not found. Using 'update_menu_embed' instead.") + logging.warning("[VC_EXT] Menu message not found. Using 'update_menu_embed' instead.") await self._retry_update_menu_embed(ctx, guild['current_menu'], button_callback) else: await self._retry_update_menu_embed(ctx, guild['current_menu'], button_callback) @@ -382,18 +406,17 @@ class VoiceExtension: await track.download_async(f'music/{gid}.mp3') song = discord.FFmpegPCMAudio(f'music/{gid}.mp3', options='-vn -filter:a "volume=0.15"') except yandex_music.exceptions.TimedOutError: # sometimes track takes too long to download. - logging.warning(f"[VC] Timed out while downloading track '{track.title}'") + logging.warning(f"[VC_EXT] Timed out while downloading track '{track.title}'") if not isinstance(ctx, RawReactionActionEvent) and ctx.user and ctx.channel: channel = cast(discord.VoiceChannel, ctx.channel) if not retry: channel = cast(discord.VoiceChannel, ctx.channel) - await channel.send(f"Не удалось загрузить трек. Пробуем заного...", delete_after=5) return await self.play_track(ctx, track, vc=vc, button_callback=button_callback, retry=True) - await channel.send(f"😔 Снова не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15) + await channel.send(f"😔 Не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15) return None vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.next_track(ctx, after=True), loop)) - logging.info(f"[VC] Playing track '{track.title}'") + logging.info(f"[VC_EXT] Playing track '{track.title}'") self.db.update(gid, {'is_stopped': False}) @@ -413,13 +436,13 @@ class VoiceExtension: gid = ctx.guild_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.guild.id if ctx.guild else None if not gid: - logging.warning("[VC] Guild ID not found in context") + logging.warning("[VC_EXT] Guild ID not found in context") return if not vc: vc = await self.get_voice_client(ctx) if vc: - logging.debug("[VC] Stopping playback") + logging.debug("[VC_EXT] Stopping playback") self.db.update(gid, {'current_track': None, 'is_stopped': True}) vc.stop() @@ -450,20 +473,21 @@ class VoiceExtension: menu_message = None if not gid or not uid: - logging.warning("Guild ID or User ID not found in context inside 'next_track'") + logging.warning("[VC_EXT] Guild ID or User ID not found in context inside 'next_track'") return None guild = self.db.get_guild(gid) user = self.users_db.get_user(uid) - token = self.users_db.get_ym_token(uid) - if not token: - logging.debug(f"No token found for user {uid}") + if not user['ym_token']: + logging.debug(f"[VC_EXT] No token found for user {uid}") return None - client = await YMClient(token).init() + client = await self.init_ym_client(ctx, user['ym_token']) + if not client: + return None if guild['is_stopped'] and after: - logging.debug("Playback is stopped, skipping after callback...") + logging.debug("[VC_EXT] Playback is stopped, skipping after callback...") return None if not vc: @@ -474,7 +498,10 @@ class VoiceExtension: if after and guild['current_menu']: menu_message = await self.get_menu_message(ctx, guild['current_menu']) if menu_message: - await menu_message.edit(view=await MenuView(ctx).init(disable=True)) + if gid in menu_views: + menu_views[gid].stop() + menu_views[gid] = await MenuView(ctx).init(disable=True) + await menu_message.edit(view=menu_views[gid]) if guild['vibing'] and not isinstance(ctx, RawReactionActionEvent): if not user['vibe_type'] or not user['vibe_id']: @@ -483,23 +510,23 @@ class VoiceExtension: if guild['current_track']: if after: - res = await client.rotor_station_feedback_track_finished( + feedback = await client.rotor_station_feedback_track_finished( f'{user['vibe_type']}:{user['vibe_id']}', guild['current_track']['id'], guild['current_track']['duration_ms'] // 1000, user['vibe_batch_id'], # type: ignore # Wrong typehints time() ) - logging.debug(f"[VIBE] Finished track: {res}") + logging.debug(f"[VIBE] Finished track: {feedback}") else: - res = await client.rotor_station_feedback_skip( + feedback = await client.rotor_station_feedback_skip( f'{user['vibe_type']}:{user['vibe_id']}', guild['current_track']['id'], guild['current_track']['duration_ms'] // 1000, user['vibe_batch_id'], # type: ignore # Wrong typehints time() ) - logging.debug(f"[VIBE] Skipped track: {res}") + logging.debug(f"[VIBE] Skipped track: {feedback}") return await self.update_vibe( ctx, user['vibe_type'], @@ -508,17 +535,17 @@ class VoiceExtension: ) if guild['repeat'] and after: - logging.debug("Repeating current track") + logging.debug("[VC_EXT] Repeating current track") next_track = guild['current_track'] elif guild['shuffle']: - logging.debug("Shuffling tracks") + logging.debug("[VC_EXT] Shuffling tracks") next_track = self.db.get_random_track(gid) else: - logging.debug("Getting next track") + logging.debug("[VC_EXT] Getting next track") next_track = self.db.get_track(gid, 'next') if guild['current_track'] and guild['current_menu'] and not guild['repeat']: - logging.debug("Adding current track to history") + logging.debug("[VC_EXT] Adding current track to history") self.db.modify_track(gid, guild['current_track'], 'previous', 'insert') if next_track: @@ -578,17 +605,17 @@ class VoiceExtension: prev_track = self.db.get_track(gid, 'previous') if not token: - logging.debug(f"No token found for user {ctx.user.id}") + logging.debug(f"[VC_EXT] No token found for user {ctx.user.id}") return None if prev_track: - logging.debug("Previous track found") + logging.debug("[VC_EXT] Previous track found") track: dict[str, Any] | None = prev_track elif current_track: - logging.debug("No previous track found. Repeating current track") + logging.debug("[VC_EXT] No previous track found. Repeating current track") track = self.db.get_track(gid, 'current') else: - logging.debug("No previous or current track found") + logging.debug("[VC_EXT] No previous or current track found") track = None if track: @@ -624,16 +651,16 @@ class VoiceExtension: current_track = self.db.get_track(gid, 'current') token = self.users_db.get_ym_token(uid) if not token: - logging.debug(f"No token found for user {uid}") + logging.debug(f"[VC_EXT] No token found for user {uid}") return None if not current_track: - logging.debug("Current track not found in 'get_likes'") + logging.debug("[VC_EXT] Current track not found in 'get_likes'") return None client = await YMClient(token).init() likes = await client.users_likes_tracks() if not likes: - logging.debug("No likes found") + logging.debug("[VC_EXT] No likes found") return None return likes.tracks @@ -648,13 +675,13 @@ class VoiceExtension: str | None: Track title or None. """ if not ctx.guild or not ctx.user: - logging.warning("Guild or User not found in context inside 'like_track'") + logging.warning("[VC_EXT] Guild or User not found in context inside 'like_track'") return None current_track = self.db.get_track(ctx.guild.id, 'current') token = self.users_db.get_ym_token(ctx.user.id) if not current_track or not token: - logging.debug("Current track or token not found in 'like_track'") + logging.debug("[VC_EXT] Current track or token not found in 'like_track'") return None client = await YMClient(token).init() @@ -668,11 +695,11 @@ class VoiceExtension: ) ) if str(ym_track.id) not in [str(track.id) for track in likes]: - logging.debug("Track not found in likes. Adding...") + logging.debug("[VC_EXT] Track not found in likes. Adding...") await ym_track.like_async() return ym_track.title else: - logging.debug("Track found in likes. Removing...") + logging.debug("[VC_EXT] Track found in likes. Removing...") if not client.me or not client.me.account or not client.me.account.uid: logging.debug("Client account not found") return None @@ -689,4 +716,35 @@ class VoiceExtension: for _ in range(10): if update: break + await asyncio.sleep(0.25) update = await self.update_menu_embed(ctx, menu_mid, button_callback) + + async def init_ym_client(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, token: str | None = None) -> YMClient | None: + """Initialize Yandex Music client. Return client on success. Return None if no token found and respond to the context. + + Args: + ctx (ApplicationContext | Interaction): Context. + token (str | None, optional): Token. Defaults to None. + + Returns: + YMClient | None: Client or None. + """ + + if not token: + uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None + token = self.users_db.get_ym_token(uid) if uid else None + + if not token: + logging.debug("No token found in 'init_ym_client'") + if not isinstance(ctx, discord.RawReactionActionEvent): + await ctx.respond("❌ Укажите токен через /account login.", ephemeral=True) + return None + + try: + client = await YMClient(token).init() + except yandex_music.exceptions.UnauthorizedError: + logging.debug("UnauthorizedError in 'init_ym_client'") + if not isinstance(ctx, discord.RawReactionActionEvent): + await ctx.respond("❌ Недействительный токен. Если это не так, попробуйте ещё раз.", delete_after=15, ephemeral=True) + return None + return client \ No newline at end of file diff --git a/MusicBot/cogs/voice.py b/MusicBot/cogs/voice.py index 2b5d62d..8018eb5 100644 --- a/MusicBot/cogs/voice.py +++ b/MusicBot/cogs/voice.py @@ -5,10 +5,7 @@ from typing import cast import discord from discord.ext.commands import Cog -import yandex_music.exceptions -from yandex_music import ClientAsync - -from MusicBot.cogs.utils import VoiceExtension +from MusicBot.cogs.utils import VoiceExtension, menu_views from MusicBot.ui import QueueView, generate_queue_embed def setup(bot: discord.Bot): @@ -22,31 +19,39 @@ class Voice(Cog, VoiceExtension): def __init__(self, bot: discord.Bot): VoiceExtension.__init__(self, bot) - self.typed_bot: discord.Bot = bot + self.typed_bot: discord.Bot = bot # should be removed later @Cog.listener() async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState) -> None: - logging.info(f"Voice state update for member {member.id} in guild {member.guild.id}") + logging.info(f"[VOICE] Voice state update for member {member.id} in guild {member.guild.id}") gid = member.guild.id guild = self.db.get_guild(gid) discord_guild = await self.typed_bot.fetch_guild(gid) - current_menu = self.db.get_current_menu(gid) + current_menu = guild['current_menu'] channel = after.channel or before.channel if not channel: - logging.info(f"No channel found for member {member.id}") + logging.info(f"[VOICE] No channel found for member {member.id}") return vc = cast(discord.VoiceClient | None, discord.utils.get(self.typed_bot.voice_clients, guild=discord_guild)) if len(channel.members) == 1 and vc: - logging.info(f"Clearing history and stopping playback for guild {gid}") - self.db.update(gid, {'previous_tracks': [], 'next_tracks': [], 'current_track': None, 'is_stopped': True}) + logging.info(f"[VOICE] Clearing history and stopping playback for guild {gid}") + if guild['current_menu']: + message = self.typed_bot.get_message(guild['current_menu']) + if message: + await message.delete() + if member.guild.id in menu_views: + menu_views[member.guild.id].stop() + del menu_views[member.guild.id] + + self.db.update(gid, {'previous_tracks': [], 'next_tracks': [], 'vibing': False, 'current_menu': None, 'repeat': False, 'shuffle': False}) vc.stop() elif len(channel.members) > 2 and not guild['always_allow_menu']: if current_menu: - logging.info(f"Disabling current player for guild {gid} due to multiple members") + logging.info(f"[VOICE] Disabling current menu for guild {gid} due to multiple members") self.db.update(gid, {'current_menu': None, 'repeat': False, 'shuffle': False}) try: @@ -55,10 +60,14 @@ class Voice(Cog, VoiceExtension): await channel.send("Меню отключено из-за большого количества участников.", delete_after=15) except (discord.NotFound, discord.Forbidden): pass + + if member.guild.id in menu_views: + menu_views[member.guild.id].stop() + del menu_views[member.guild.id] @Cog.listener() async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None: - logging.info(f"Reaction added by user {payload.user_id} in channel {payload.channel_id}") + logging.info(f"[VOICE] Reaction added by user {payload.user_id} in channel {payload.channel_id}") if not self.typed_bot.user or not payload.member: return @@ -86,24 +95,24 @@ class Voice(Cog, VoiceExtension): votes = guild['votes'] if payload.message_id not in votes: - logging.info(f"Message {payload.message_id} not found in votes") + logging.info(f"[VOICE] Message {payload.message_id} not found in votes") return vote_data = votes[str(payload.message_id)] if payload.emoji.name == '✅': - logging.info(f"User {payload.user_id} voted positively for message {payload.message_id}") + logging.info(f"[VOICE] User {payload.user_id} voted positively for message {payload.message_id}") vote_data['positive_votes'].append(payload.user_id) elif payload.emoji.name == '❌': - logging.info(f"User {payload.user_id} voted negatively for message {payload.message_id}") + logging.info(f"[VOICE] User {payload.user_id} voted negatively for message {payload.message_id}") vote_data['negative_votes'].append(payload.user_id) total_members = len(channel.members) required_votes = 2 if total_members <= 5 else 4 if total_members <= 10 else 6 if total_members <= 15 else 9 if len(vote_data['positive_votes']) >= required_votes: - logging.info(f"Enough positive votes for message {payload.message_id}") + logging.info(f"[VOICE] Enough positive votes for message {payload.message_id}") if vote_data['action'] == 'next': - logging.info(f"Skipping track for message {payload.message_id}") + logging.info(f"[VOICE] Skipping track for message {payload.message_id}") self.db.update(guild_id, {'is_stopped': False}) title = await self.next_track(payload) @@ -112,12 +121,12 @@ class Voice(Cog, VoiceExtension): del votes[str(payload.message_id)] elif vote_data['action'] == 'add_track': - logging.info(f"Adding track for message {payload.message_id}") + logging.info(f"[VOICE] Adding track for message {payload.message_id}") await message.clear_reactions() track = vote_data['vote_content'] if not track: - logging.info(f"Recieved empty vote context for message {payload.message_id}") + logging.info(f"[VOICE] Recieved empty vote context for message {payload.message_id}") return self.db.update(guild_id, {'is_stopped': False}) @@ -132,13 +141,13 @@ class Voice(Cog, VoiceExtension): del votes[str(payload.message_id)] elif vote_data['action'] in ('add_album', 'add_artist', 'add_playlist'): - logging.info(f"Performing '{vote_data['action']}' action for message {payload.message_id}") + logging.info(f"[VOICE] Performing '{vote_data['action']}' action for message {payload.message_id}") await message.clear_reactions() tracks = vote_data['vote_content'] if not tracks: - logging.info(f"Recieved empty vote context for message {payload.message_id}") + logging.info(f"[VOICE] Recieved empty vote context for message {payload.message_id}") return self.db.update(guild_id, {'is_stopped': False}) @@ -153,7 +162,7 @@ class Voice(Cog, VoiceExtension): del votes[str(payload.message_id)] elif len(vote_data['negative_votes']) >= required_votes: - logging.info(f"Enough negative votes for message {payload.message_id}") + logging.info(f"[VOICE] Enough negative votes for message {payload.message_id}") await message.clear_reactions() await message.edit(content='Запрос был отклонён.', delete_after=15) del votes[str(payload.message_id)] @@ -162,7 +171,7 @@ class Voice(Cog, VoiceExtension): @Cog.listener() async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent) -> None: - logging.info(f"Reaction removed by user {payload.user_id} in channel {payload.channel_id}") + logging.info(f"[VOICE] Reaction removed by user {payload.user_id} in channel {payload.channel_id}") if not self.typed_bot.user: return @@ -182,23 +191,23 @@ class Voice(Cog, VoiceExtension): vote_data = votes[str(payload.message_id)] if payload.emoji.name == '✔️': - logging.info(f"User {payload.user_id} removed positive vote for message {payload.message_id}") + logging.info(f"[VOICE] User {payload.user_id} removed positive vote for message {payload.message_id}") del vote_data['positive_votes'][payload.user_id] elif payload.emoji.name == '❌': - logging.info(f"User {payload.user_id} removed negative vote for message {payload.message_id}") + logging.info(f"[VOICE] User {payload.user_id} removed negative vote for message {payload.message_id}") del vote_data['negative_votes'][payload.user_id] self.db.update(guild_id, {'votes': votes}) @voice.command(name="menu", description="Создать меню проигрывателя. Доступно только если вы единственный в голосовом канале.") async def menu(self, ctx: discord.ApplicationContext) -> None: - logging.info(f"Menu command invoked by user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[VOICE] Menu command invoked by user {ctx.author.id} in guild {ctx.guild.id}") guild = self.db.get_guild(ctx.guild.id) channel = cast(discord.VoiceChannel, ctx.channel) if len(channel.members) > 2 and not guild['always_allow_menu']: - logging.info(f"Action declined: other members are present in the voice channel") + logging.info(f"[VOICE] Action declined: other members are present in the voice channel") await ctx.respond("❌ Вы не единственный в голосовом канале.", ephemeral=True) return @@ -206,7 +215,7 @@ class Voice(Cog, VoiceExtension): @voice.command(name="join", description="Подключиться к голосовому каналу, в котором вы сейчас находитесь.") async def join(self, ctx: discord.ApplicationContext) -> None: - logging.info(f"Join command invoked by user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[VOICE] Join command invoked by user {ctx.author.id} in guild {ctx.guild.id}") member = cast(discord.Member, ctx.author) if not member.guild_permissions.manage_channels: @@ -219,16 +228,16 @@ class Voice(Cog, VoiceExtension): else: response_message = "❌ Вы должны отправить команду в голосовом канале." - logging.info(f"Join command response: {response_message}") + logging.info(f"[VOICE] Join command response: {response_message}") await ctx.respond(response_message, delete_after=15, ephemeral=True) @voice.command(description="Заставить бота покинуть голосовой канал.") async def leave(self, ctx: discord.ApplicationContext) -> None: - logging.info(f"Leave command invoked by user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[VOICE] Leave command invoked by user {ctx.author.id} in guild {ctx.guild.id}") member = cast(discord.Member, ctx.author) if not member.guild_permissions.manage_channels: - logging.info(f"User {ctx.author.id} does not have permissions to execute leave command in guild {ctx.guild.id}") + logging.info(f"[VOICE] User {ctx.author.id} does not have permissions to execute leave command in guild {ctx.guild.id}") await ctx.respond("❌ У вас нет прав для выполнения этой команды.", delete_after=15, ephemeral=True) return @@ -238,26 +247,26 @@ class Voice(Cog, VoiceExtension): vc.stop() await vc.disconnect(force=True) await ctx.respond("Отключение успешно!", delete_after=15, ephemeral=True) - logging.info(f"Successfully disconnected from voice channel in guild {ctx.guild.id}") + logging.info(f"[VOICE] Successfully disconnected from voice channel in guild {ctx.guild.id}") @queue.command(description="Очистить очередь треков и историю прослушивания.") async def clear(self, ctx: discord.ApplicationContext) -> None: - logging.info(f"Clear queue command invoked by user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[VOICE] Clear queue command invoked by user {ctx.author.id} in guild {ctx.guild.id}") member = cast(discord.Member, ctx.author) channel = cast(discord.VoiceChannel, ctx.channel) if len(channel.members) > 2 and not member.guild_permissions.manage_channels: - logging.info(f"User {ctx.author.id} does not have permissions to execute leave command in guild {ctx.guild.id}") + logging.info(f"[VOICE] User {ctx.author.id} does not have permissions to execute leave command in guild {ctx.guild.id}") await ctx.respond("❌ У вас нет прав для выполнения этой команды.", delete_after=15, ephemeral=True) elif await self.voice_check(ctx): self.db.update(ctx.guild.id, {'previous_tracks': [], 'next_tracks': []}) await ctx.respond("Очередь и история сброшены.", delete_after=15, ephemeral=True) - logging.info(f"Queue and history cleared in guild {ctx.guild.id}") + logging.info(f"[VOICE] Queue and history cleared in guild {ctx.guild.id}") @queue.command(description="Получить очередь треков.") async def get(self, ctx: discord.ApplicationContext) -> None: - logging.info(f"Get queue command invoked by user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[VOICE] Get queue command invoked by user {ctx.author.id} in guild {ctx.guild.id}") if not await self.voice_check(ctx): return @@ -267,95 +276,91 @@ class Voice(Cog, VoiceExtension): embed = generate_queue_embed(0, tracks) await ctx.respond(embed=embed, view=QueueView(ctx), ephemeral=True) - logging.info(f"Queue embed sent to user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[VOICE] Queue embed sent to user {ctx.author.id} in guild {ctx.guild.id}") @track.command(description="Приостановить текущий трек.") async def pause(self, ctx: discord.ApplicationContext) -> None: - logging.info(f"Pause command invoked by user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[VOICE] Pause command invoked by user {ctx.author.id} in guild {ctx.guild.id}") member = cast(discord.Member, ctx.author) channel = cast(discord.VoiceChannel, ctx.channel) if len(channel.members) > 2 and not member.guild_permissions.manage_channels: - logging.info(f"User {ctx.author.id} does not have permissions to pause the track in guild {ctx.guild.id}") + logging.info(f"[VOICE] User {ctx.author.id} does not have permissions to pause the track in guild {ctx.guild.id}") await ctx.respond("❌ Вы не можете остановить воспроизведение, пока в канале находятся другие пользователи.", delete_after=15, ephemeral=True) elif await self.voice_check(ctx) and (vc := await self.get_voice_client(ctx)) is not None: if not vc.is_paused(): vc.pause() - player = self.db.get_current_menu(ctx.guild.id) - if player: - await self.update_menu_embed(ctx, player) + menu = self.db.get_current_menu(ctx.guild.id) + if menu: + await self.update_menu_embed(ctx, menu) - logging.info(f"Track paused in guild {ctx.guild.id}") + logging.info(f"[VOICE] Track paused in guild {ctx.guild.id}") await ctx.respond("Воспроизведение приостановлено.", delete_after=15, ephemeral=True) else: - logging.info(f"Track already paused in guild {ctx.guild.id}") + logging.info(f"[VOICE] Track already paused in guild {ctx.guild.id}") await ctx.respond("Воспроизведение уже приостановлено.", delete_after=15, ephemeral=True) @track.command(description="Возобновить текущий трек.") async def resume(self, ctx: discord.ApplicationContext) -> None: - logging.info(f"Resume command invoked by user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[VOICE] Resume command invoked by user {ctx.author.id} in guild {ctx.guild.id}") member = cast(discord.Member, ctx.author) channel = cast(discord.VoiceChannel, ctx.channel) if len(channel.members) > 2 and not member.guild_permissions.manage_channels: - logging.info(f"User {ctx.author.id} does not have permissions to resume the track in guild {ctx.guild.id}") + logging.info(f"[VOICE] User {ctx.author.id} does not have permissions to resume the track in guild {ctx.guild.id}") await ctx.respond("❌ Вы не можете остановить воспроизведение, пока в канале находятся другие пользователи.", delete_after=15, ephemeral=True) elif await self.voice_check(ctx) and (vc := await self.get_voice_client(ctx)): if vc.is_paused(): vc.resume() - player = self.db.get_current_menu(ctx.guild.id) - if player: - await self.update_menu_embed(ctx, player) - logging.info(f"Track resumed in guild {ctx.guild.id}") + menu = self.db.get_current_menu(ctx.guild.id) + if menu: + await self.update_menu_embed(ctx, menu) + logging.info(f"[VOICE] Track resumed in guild {ctx.guild.id}") await ctx.respond("Воспроизведение восстановлено.", delete_after=15, ephemeral=True) else: - logging.info(f"Track is not paused in guild {ctx.guild.id}") + logging.info(f"[VOICE] Track is not paused in guild {ctx.guild.id}") await ctx.respond("Воспроизведение не на паузе.", delete_after=15, ephemeral=True) @track.command(description="Прервать проигрывание, удалить историю, очередь и текущий плеер.") async def stop(self, ctx: discord.ApplicationContext) -> None: - logging.info(f"Stop command invoked by user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[VOICE] Stop command invoked by user {ctx.author.id} in guild {ctx.guild.id}") member = cast(discord.Member, ctx.author) channel = cast(discord.VoiceChannel, ctx.channel) if len(channel.members) > 2 and not member.guild_permissions.manage_channels: - logging.info(f"User {ctx.author.id} tried to stop playback in guild {ctx.guild.id} but there are other users in the channel") + logging.info(f"[VOICE] User {ctx.author.id} tried to stop playback in guild {ctx.guild.id} but there are other users in the channel") await ctx.respond("❌ Вы не можете остановить воспроизведение, пока в канале находятся другие пользователи.", delete_after=15, ephemeral=True) elif await self.voice_check(ctx): + guild = self.db.get_guild(ctx.guild.id) await self.stop_playing(ctx) - current_menu = self.db.get_current_menu(ctx.guild.id) - if current_menu: - player = await self.get_menu_message(ctx, current_menu) - if player: - await player.delete() + if guild['current_menu']: + menu = await self.get_menu_message(ctx, guild['current_menu']) + if menu: + await menu.delete() self.db.update(ctx.guild.id, { 'current_menu': None, 'repeat': False, 'shuffle': False, 'previous_tracks': [], 'next_tracks': [], 'vibing': False }) - logging.info(f"Playback stopped in guild {ctx.guild.id}") - - guild = self.db.get_guild(ctx.guild_id) + logging.info(f"[VOICE] Playback stopped in guild {ctx.guild.id}") + if guild['vibing']: user = self.users_db.get_user(ctx.user.id) token = user['ym_token'] if not token: - logging.info(f"User {ctx.user.id} has no YM token") + logging.info(f"[VOICE] User {ctx.user.id} has no YM token") await ctx.respond("❌ Укажите токен через /account login.", ephemeral=True) return - try: - client = await ClientAsync(token).init() - except yandex_music.exceptions.UnauthorizedError: - logging.info(f"User {ctx.user.id} provided invalid token") - await ctx.respond('❌ Недействительный токен.') + client = await self.init_ym_client(ctx, user['ym_token']) + if not client: return track = guild['current_track'] @@ -369,20 +374,24 @@ class Voice(Cog, VoiceExtension): cast(str, user['vibe_batch_id']), time() ) - logging.info(f"User {ctx.user.id} finished vibing with result: {res}") - + logging.info(f"[VOICE] User {ctx.user.id} finished vibing with result: {res}") + + if ctx.guild.id in menu_views: + menu_views[ctx.guild.id].stop() + del menu_views[ctx.guild.id] + await ctx.respond("Воспроизведение остановлено.", delete_after=15, ephemeral=True) @track.command(description="Переключиться на следующую песню в очереди.") async def next(self, ctx: discord.ApplicationContext) -> None: - logging.info(f"Next command invoked by user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[VOICE] Next command invoked by user {ctx.author.id} in guild {ctx.guild.id}") if not await self.voice_check(ctx): return gid = ctx.guild.id guild = self.db.get_guild(gid) if not guild['next_tracks']: - logging.info(f"No tracks in queue in guild {ctx.guild.id}") + logging.info(f"[VOICE] No tracks in queue in guild {ctx.guild.id}") await ctx.respond("❌ Нет песенен в очереди.", delete_after=15, ephemeral=True) return @@ -390,7 +399,7 @@ class Voice(Cog, VoiceExtension): channel = cast(discord.VoiceChannel, ctx.channel) if guild['vote_next_track'] and len(channel.members) > 2 and not member.guild_permissions.manage_channels: - logging.info(f"User {ctx.author.id} started vote to skip track in guild {ctx.guild.id}") + logging.info(f"[VOICE] User {ctx.author.id} started vote to skip track in guild {ctx.guild.id}") message = cast(discord.Interaction, await ctx.respond(f"{member.mention} хочет пропустить текущий трек.\n\nВыполнить переход?", delete_after=30)) response = await message.original_response() @@ -410,7 +419,7 @@ class Voice(Cog, VoiceExtension): } ) else: - logging.info(f"Skipping vote for user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[VOICE] Skipping vote for user {ctx.author.id} in guild {ctx.guild.id}") self.db.update(gid, {'is_stopped': False}) title = await self.next_track(ctx) @@ -418,14 +427,14 @@ class Voice(Cog, VoiceExtension): @track.command(description="Добавить трек в избранное или убрать, если он уже там.") async def like(self, ctx: discord.ApplicationContext) -> None: - logging.info(f"Like command invoked by user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[VOICE] Like command invoked by user {ctx.author.id} in guild {ctx.guild.id}") if not await self.voice_check(ctx): return vc = await self.get_voice_client(ctx) if not vc or not vc.is_playing: - logging.info(f"No current track in {ctx.guild.id}") + logging.info(f"[VOICE] No current track in {ctx.guild.id}") await ctx.respond("Нет воспроизводимого трека.", delete_after=15, ephemeral=True) return @@ -434,15 +443,15 @@ class Voice(Cog, VoiceExtension): logging.warning(f"Like command failed for user {ctx.author.id} in guild {ctx.guild.id}") await ctx.respond("❌ Операция не удалась.", delete_after=15, ephemeral=True) elif result == 'TRACK REMOVED': - logging.info(f"Track removed from favorites for user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[VOICE] Track removed from favorites for user {ctx.author.id} in guild {ctx.guild.id}") await ctx.respond("Трек был удалён из избранного.", delete_after=15, ephemeral=True) else: - logging.info(f"Track added to favorites for user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[VOICE] Track added to favorites for user {ctx.author.id} in guild {ctx.guild.id}") await ctx.respond(f"Трек **{result}** был добавлен в избранное.", delete_after=15, ephemeral=True) @track.command(name='vibe', description="Запустить мою волну по текущему треку.") async def track_vibe(self, ctx: discord.ApplicationContext) -> None: - logging.info(f"Vibe (track) command invoked by user {ctx.author.id} in guild {ctx.guild.id}") + logging.info(f"[VOICE] Vibe (track) command invoked by user {ctx.author.id} in guild {ctx.guild.id}") if not await self.voice_check(ctx): return @@ -450,11 +459,11 @@ class Voice(Cog, VoiceExtension): channel = cast(discord.VoiceChannel, ctx.channel) if len(channel.members) > 2 and not guild['always_allow_menu']: - logging.info(f"Action declined: other members are present in the voice channel") + logging.info(f"[VOICE] Action declined: other members are present in the voice channel") await ctx.respond("❌ Вы не единственный в голосовом канале.", ephemeral=True) return if not guild['current_track']: - logging.info(f"No current track in {ctx.guild.id}") + logging.info(f"[VOICE] No current track in {ctx.guild.id}") await ctx.respond("❌ Нет воспроизводимого трека.", ephemeral=True) return @@ -463,7 +472,7 @@ class Voice(Cog, VoiceExtension): @discord.slash_command(name='vibe', description="Запустить Мою Волну.") async def user_vibe(self, ctx: discord.ApplicationContext) -> None: - logging.info(f"Vibe (user) command invoked by user {ctx.user.id} in guild {ctx.guild_id}") + logging.info(f"[VOICE] Vibe (user) command invoked by user {ctx.user.id} in guild {ctx.guild_id}") if not await self.voice_check(ctx): return @@ -471,7 +480,7 @@ class Voice(Cog, VoiceExtension): channel = cast(discord.VoiceChannel, ctx.channel) if len(channel.members) > 2 and not guild['always_allow_menu']: - logging.info(f"Action declined: other members are present in the voice channel") + logging.info(f"[VOICE] Action declined: other members are present in the voice channel") await ctx.respond("❌ Вы не единственный в голосовом канале.", ephemeral=True) return diff --git a/MusicBot/database/base.py b/MusicBot/database/base.py index 3584ad9..9466e28 100644 --- a/MusicBot/database/base.py +++ b/MusicBot/database/base.py @@ -1,6 +1,6 @@ """This documents initialises databse and contains methods to access it.""" -from typing import cast +from typing import Any, cast from pymongo import MongoClient from pymongo.collection import Collection @@ -29,15 +29,20 @@ class BaseUsersDatabase: queue_page=0, vibe_batch_id=None, vibe_type=None, - vibe_id=None + vibe_id=None, + vibe_settings={ + 'mood': 'all', + 'diversity': 'default', + 'lang': 'any' + } )) - def update(self, uid: int, data: User) -> None: + def update(self, uid: int, data: User | dict[Any, Any]) -> None: """Update user record. Args: uid (int): User id. - data (dict[Any, Any]): Updated data. + data (User | dict[Any, Any]): Updated data. """ self.get_user(uid) users.update_one({'_id': uid}, {"$set": data}) @@ -65,7 +70,12 @@ class BaseUsersDatabase: queue_page=0, vibe_batch_id=None, vibe_type=None, - vibe_id=None + vibe_id=None, + vibe_settings={ + 'mood': 'all', + 'diversity': 'default', + 'lang': 'any' + } ) for field, default_value in fields.items(): if field not in existing_fields: diff --git a/MusicBot/database/extensions.py b/MusicBot/database/extensions.py index 452532d..18ee53a 100644 --- a/MusicBot/database/extensions.py +++ b/MusicBot/database/extensions.py @@ -133,24 +133,13 @@ class VoiceGuildsDatabase(BaseGuildsDatabase): self.update(gid, {'next_tracks': tracks}) return track - def set_current_track(self, gid: int, track: Track | dict[str, Any]) -> None: - """Set current track. - - Args: - gid (int): Guild id. - track (Track | dict[str, Any]): Track or dictionary covertable to yandex_music.Track. - """ - if isinstance(track, Track): - track = track.to_dict() - self.update(gid, {'current_track': track}) - def get_current_menu(self, gid: int) -> int | None: - """Get current player. + """Get current menu. Args: gid (int): Guild id. - Returns: int | None: Player message id or None if not present. + Returns: int | None: Menu message id or None if not present. """ guild = self.get_guild(gid) return guild['current_menu'] \ No newline at end of file diff --git a/MusicBot/database/user.py b/MusicBot/database/user.py index eb4ed11..9ba049c 100644 --- a/MusicBot/database/user.py +++ b/MusicBot/database/user.py @@ -1,4 +1,10 @@ -from typing import TypedDict, Literal +from typing import TypedDict, TypeAlias, Literal + +VibeSettingsOptions: TypeAlias = Literal[ + 'active', 'fun', 'calm', 'sad', 'all', + 'favorite', 'discover', 'popular', 'default', + 'russian', 'not-russian', 'without-words', 'any', +] class User(TypedDict, total=False): ym_token: str | None @@ -8,6 +14,7 @@ class User(TypedDict, total=False): vibe_batch_id: str | None vibe_type: Literal['track', 'album', 'artist', 'playlist', 'user'] | None vibe_id: str | int | None + vibe_settings: dict[Literal['mood', 'diversity', 'lang'], VibeSettingsOptions] class ExplicitUser(TypedDict): _id: int @@ -18,3 +25,4 @@ class ExplicitUser(TypedDict): vibe_batch_id: str | None vibe_type: Literal['track', 'album', 'artist', 'playlist', 'user'] | None vibe_id: str | int | None + vibe_settings: dict[Literal['mood', 'diversity', 'lang'], VibeSettingsOptions] diff --git a/MusicBot/ui/menu.py b/MusicBot/ui/menu.py index a81e0c5..cc89dad 100644 --- a/MusicBot/ui/menu.py +++ b/MusicBot/ui/menu.py @@ -1,12 +1,12 @@ import logging from typing import Self, cast -from discord.ui import View, Button, Item, Modal, Select +from discord.ui import View, Button, Item, Select from discord import VoiceChannel, ButtonStyle, Interaction, ApplicationContext, RawReactionActionEvent, Embed, ComponentType, SelectOption import yandex_music.exceptions from yandex_music import Track, ClientAsync -from MusicBot.cogs.utils.voice_extension import VoiceExtension +from MusicBot.cogs.utils.voice_extension import VoiceExtension, menu_views class ToggleRepeatButton(Button, VoiceExtension): def __init__(self, **kwargs): @@ -14,13 +14,17 @@ class ToggleRepeatButton(Button, VoiceExtension): VoiceExtension.__init__(self, None) async def callback(self, interaction: Interaction) -> None: - logging.info('Repeat button callback...') - if not interaction.guild: + logging.info('[MENU] Repeat button callback...') + if not await self.voice_check(interaction) or not interaction.guild: return gid = interaction.guild.id guild = self.db.get_guild(gid) self.db.update(gid, {'repeat': not guild['repeat']}) - await interaction.edit(view=await MenuView(interaction).init()) + + if gid in menu_views: + menu_views[gid].stop() + menu_views[gid] = await MenuView(interaction).init() + await interaction.edit(view=menu_views[gid]) class ToggleShuffleButton(Button, VoiceExtension): def __init__(self, **kwargs): @@ -28,13 +32,17 @@ class ToggleShuffleButton(Button, VoiceExtension): VoiceExtension.__init__(self, None) async def callback(self, interaction: Interaction) -> None: - logging.info('Shuffle button callback...') - if not interaction.guild: + logging.info('[MENU] Shuffle button callback...') + if not await self.voice_check(interaction) or not interaction.guild: return gid = interaction.guild.id guild = self.db.get_guild(gid) self.db.update(gid, {'shuffle': not guild['shuffle']}) - await interaction.edit(view=await MenuView(interaction).init()) + + if gid in menu_views: + menu_views[gid].stop() + menu_views[gid] = await MenuView(interaction).init() + await interaction.edit(view=menu_views[gid]) class PlayPauseButton(Button, VoiceExtension): def __init__(self, **kwargs): @@ -42,7 +50,7 @@ class PlayPauseButton(Button, VoiceExtension): VoiceExtension.__init__(self, None) async def callback(self, interaction: Interaction) -> None: - logging.info('Play/Pause button callback...') + logging.info('[MENU] Play/Pause button callback...') if not await self.voice_check(interaction): return @@ -67,7 +75,7 @@ class NextTrackButton(Button, VoiceExtension): VoiceExtension.__init__(self, None) async def callback(self, interaction: Interaction) -> None: - logging.info('Next track button callback...') + logging.info('[MENU] Next track button callback...') if not await self.voice_check(interaction): return title = await self.next_track(interaction, button_callback=True) @@ -80,7 +88,7 @@ class PrevTrackButton(Button, VoiceExtension): VoiceExtension.__init__(self, None) async def callback(self, interaction: Interaction) -> None: - logging.info('Previous track button callback...') + logging.info('[MENU] Previous track button callback...') if not await self.voice_check(interaction): return title = await self.prev_track(interaction, button_callback=True) @@ -93,15 +101,23 @@ class LikeButton(Button, VoiceExtension): VoiceExtension.__init__(self, None) async def callback(self, interaction: Interaction) -> None: - logging.info('Like button callback...') + logging.info('[MENU] Like button callback...') if not await self.voice_check(interaction): return - + + if not interaction.guild: + return + gid = interaction.guild.id + if not (vc := await self.get_voice_client(interaction)) or not vc.is_playing: await interaction.respond("❌ Нет воспроизводимого трека.", delete_after=15, ephemeral=True) await self.like_track(interaction) - await interaction.edit(view=await MenuView(interaction).init()) + + if gid in menu_views: + menu_views[gid].stop() + menu_views[gid] = await MenuView(interaction).init() + await interaction.edit(view=menu_views[gid]) class LyricsButton(Button, VoiceExtension): def __init__(self, **kwargs): @@ -109,7 +125,7 @@ class LyricsButton(Button, VoiceExtension): VoiceExtension.__init__(self, None) async def callback(self, interaction: Interaction) -> None: - logging.info('Lyrics button callback...') + logging.info('[MENU] Lyrics button callback...') if not await self.voice_check(interaction) or not interaction.guild_id or not interaction.user: return @@ -127,12 +143,12 @@ class LyricsButton(Button, VoiceExtension): try: lyrics = await track.get_lyrics_async() except yandex_music.exceptions.NotFoundError: - logging.debug('Lyrics not found') + logging.debug('[MENU] Lyrics not found') await interaction.respond("❌ Текст песни не найден. Яндекс нам соврал (опять)!", delete_after=15, ephemeral=True) return if not lyrics: - logging.debug('Lyrics not found') + logging.debug('[MENU] Lyrics not found') return embed = Embed( @@ -160,7 +176,7 @@ class MyVibeButton(Button, VoiceExtension): track = self.db.get_track(interaction.guild_id, 'current') if track: - logging.info(f"[VIBE] Playing vibe for track '{track["id"]}'") + logging.info(f"[MENU] Playing vibe for track '{track["id"]}'") await self.update_vibe( interaction, 'track', @@ -176,6 +192,119 @@ class MyVibeButton(Button, VoiceExtension): button_callback=True ) +class MyVibeSelect(Select, VoiceExtension): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + VoiceExtension.__init__(self, None) + + async def callback(self, interaction: Interaction) -> None: + logging.info('[VIBE] My vibe select callback') + if not interaction.user: + logging.warning('[VIBE] No user in select callback') + return + + custom_id = interaction.custom_id + if custom_id not in ('diversity', 'mood', 'lang'): + logging.warning(f'[VIBE] Unknown custom_id: {custom_id}') + return + + data = interaction.data + if not data or 'values' not in data: + logging.warning('[VIBE] No data in select callback') + return + + data_value = data['values'][0] + if data_value not in ( + 'fun', 'active', 'calm', 'sad', 'all', + 'favorite', 'popular', 'discover', 'default', + 'not-russian', 'russian', 'without-words', 'any' + ): + logging.warning(f'[VIBE] Unknown data_value: {data_value}') + return + + logging.info(f"[VIBE] Settings option '{custom_id}' updated to {data_value}") + self.users_db.update(interaction.user.id, {f'vibe_settings.{custom_id}': data_value}) + + view = MyVibeSettingsView(interaction) + view.disable_all_items() + await interaction.edit(view=view) + + await self.update_vibe(interaction, 'user', 'onyourwave', update_settings=True) + view.enable_all_items() + await interaction.edit(view=view) + +class MyVibeSettingsView(View, VoiceExtension): + def __init__(self, interaction: Interaction, *items: Item, timeout: float | None = 360, disable_on_timeout: bool = False): + View.__init__(self, *items, timeout=timeout, disable_on_timeout=disable_on_timeout) + VoiceExtension.__init__(self, None) + + if not interaction.user: + logging.warning('[VIBE] No user in settings view') + return + + settings = self.users_db.get_user(interaction.user.id)['vibe_settings'] + + diversity_settings = settings['diversity'] + diversity = [ + SelectOption(label='Любое', value='default'), + SelectOption(label='Любимое', value='favorite', default=diversity_settings == 'favorite'), + SelectOption(label='Незнакомое', value='discover', default=diversity_settings == 'discover'), + SelectOption(label='Популярное', value='popular', default=diversity_settings == 'popular') + ] + + mood_settings = settings['mood'] + mood = [ + SelectOption(label='Любое', value='all'), + SelectOption(label='Бодрое', value='active', default=mood_settings == 'active'), + SelectOption(label='Весёлое', value='fun', default=mood_settings == 'fun'), + SelectOption(label='Спокойное', value='calm', default=mood_settings == 'calm'), + SelectOption(label='Грустное', value='sad', default=mood_settings == 'sad') + ] + + lang_settings = settings['lang'] + lang = [ + SelectOption(label='Любое', value='any'), + SelectOption(label='Русский', value='russian', default=lang_settings == 'russian'), + SelectOption(label='Иностранный', value='not-russian', default=lang_settings == 'not-russian'), + SelectOption(label='Без слов', value='without-words', default=lang_settings == 'without-words') + ] + + feel_select = MyVibeSelect( + ComponentType.string_select, + placeholder='По характеру', + options=diversity, + row=0, + custom_id='diversity' + ) + mood_select = MyVibeSelect( + ComponentType.string_select, + placeholder='По настроению', + options=mood, + row=1, + custom_id='mood' + ) + lang_select = MyVibeSelect( + ComponentType.string_select, + placeholder='По языку', + options=lang, + row=2, + custom_id='lang' + ) + for select in [feel_select, mood_select, lang_select]: + self.add_item(select) + +class MyVibeSettingsButton(Button, VoiceExtension): + def __init__(self, **kwargs): + Button.__init__(self, **kwargs) + VoiceExtension.__init__(self, None) + + async def callback(self, interaction: Interaction) -> None: + logging.info('[VIBE] My vibe settings button callback') + if not await self.voice_check(interaction) or not interaction.user: + return + + await interaction.respond('Настройки "Моей Волны"', view=MyVibeSettingsView(interaction), ephemeral=True) + class MenuView(View, VoiceExtension): def __init__(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, *items: Item, timeout: float | None = 3600, disable_on_timeout: bool = False): @@ -195,6 +324,7 @@ class MenuView(View, VoiceExtension): self.like_button = LikeButton(style=ButtonStyle.secondary, emoji='❤️', row=1) self.lyrics_button = LyricsButton(style=ButtonStyle.secondary, emoji='📋', row=1) self.vibe_button = MyVibeButton(style=ButtonStyle.secondary, emoji='🌊', row=1) + self.vibe_settings_button = MyVibeSettingsButton(style=ButtonStyle.success, emoji='🛠', row=1) async def init(self, *, disable: bool = False) -> Self: current_track = self.guild['current_track'] @@ -216,7 +346,11 @@ class MenuView(View, VoiceExtension): self.add_item(self.like_button) self.add_item(self.lyrics_button) - self.add_item(self.vibe_button) + + if self.guild['vibing']: + self.add_item(self.vibe_settings_button) + else: + self.add_item(self.vibe_button) if disable: self.disable_all_items() @@ -229,6 +363,7 @@ class MenuView(View, VoiceExtension): return if self.guild['current_menu']: + await self.stop_playing(self.ctx) self.db.update(self.ctx.guild_id, {'current_menu': None, 'previous_tracks': [], 'vibing': False}) message = await self.get_menu_message(self.ctx, self.guild['current_menu']) if message: