feat: MyVibe settings and memory leak fix.

This commit is contained in:
Lemon4ksan
2025-01-30 20:08:46 +03:00
parent c353de429f
commit b3962c8928
9 changed files with 455 additions and 245 deletions

View File

@@ -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)

View File

@@ -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"
]

View File

@@ -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:

View File

@@ -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))
if ctx.guild_id in menu_views:
menu_views[ctx.guild_id].stop()
menu_views[ctx.guild_id] = await MenuView(ctx).init()
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})
self.db.update(ctx.guild_id, {'current_menu': response.id})
logging.info(f"[VC] New player menu {response.id} created in guild {ctx.guild.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

View File

@@ -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:
@@ -56,9 +61,13 @@ class Voice(Cog, VoiceExtension):
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}")
logging.info(f"[VOICE] Playback stopped in guild {ctx.guild.id}")
guild = self.db.get_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

View File

@@ -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:

View File

@@ -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']

View File

@@ -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]

View File

@@ -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,6 +346,10 @@ class MenuView(View, VoiceExtension):
self.add_item(self.like_button)
self.add_item(self.lyrics_button)
if self.guild['vibing']:
self.add_item(self.vibe_settings_button)
else:
self.add_item(self.vibe_button)
if disable:
@@ -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: