Merge pull request #3 from Lemon4ksan/dev

Обновление бота #3
This commit is contained in:
Bananchiki
2025-02-28 22:22:29 +03:00
committed by GitHub
8 changed files with 582 additions and 586 deletions

View File

@@ -18,7 +18,7 @@ def setup(bot):
bot.add_cog(General(bot)) bot.add_cog(General(bot))
async def get_search_suggestions(ctx: discord.AutocompleteContext) -> list[str]: async def get_search_suggestions(ctx: discord.AutocompleteContext) -> list[str]:
if not ctx.interaction.user or not ctx.value or len(ctx.value) < 2: if not ctx.interaction.user or not ctx.value or not (100 > len(ctx.value) > 2):
return [] return []
uid = ctx.interaction.user.id uid = ctx.interaction.user.id
@@ -41,6 +41,10 @@ async def get_search_suggestions(ctx: discord.AutocompleteContext) -> list[str]:
logging.debug(f"[GENERAL] Searching for '{ctx.value}' for user {uid}") logging.debug(f"[GENERAL] Searching for '{ctx.value}' for user {uid}")
if content_type not in ('Трек', 'Альбом', 'Артист', 'Плейлист'):
logging.error(f"[GENERAL] Invalid content type '{content_type}' for user {uid}")
return []
if content_type == 'Трек' and search.tracks is not None: if content_type == 'Трек' and search.tracks is not None:
res = [f"{item.title} {f"({item.version})" if item.version else ''} - {", ".join(item.artists_name())}" for item in search.tracks.results] res = [f"{item.title} {f"({item.version})" if item.version else ''} - {", ".join(item.artists_name())}" for item in search.tracks.results]
elif content_type == 'Альбом' and search.albums is not None: elif content_type == 'Альбом' and search.albums is not None:
@@ -50,13 +54,13 @@ async def get_search_suggestions(ctx: discord.AutocompleteContext) -> list[str]:
elif content_type == 'Плейлист' and search.playlists is not None: elif content_type == 'Плейлист' and search.playlists is not None:
res = [f"{item.title}" for item in search.playlists.results] res = [f"{item.title}" for item in search.playlists.results]
else: else:
logging.warning(f"[GENERAL] Invalid content type '{content_type}' for user {uid}") logging.info(f"[GENERAL] Failed to get content type '{content_type}' with name '{ctx.value}' for user {uid}")
return [] return []
return res[:100] return res[:100]
async def get_user_playlists_suggestions(ctx: discord.AutocompleteContext) -> list[str]: async def get_user_playlists_suggestions(ctx: discord.AutocompleteContext) -> list[str]:
if not ctx.interaction.user or not ctx.value or len(ctx.value) < 2: if not ctx.interaction.user or not ctx.value or not (100 > len(ctx.value) > 2):
return [] return []
uid = ctx.interaction.user.id uid = ctx.interaction.user.id
@@ -70,21 +74,25 @@ async def get_user_playlists_suggestions(ctx: discord.AutocompleteContext) -> li
except UnauthorizedError: except UnauthorizedError:
logging.info(f"[GENERAL] User {uid} provided invalid token") logging.info(f"[GENERAL] User {uid} provided invalid token")
return [] return []
logging.debug(f"[GENERAL] Searching for '{ctx.value}' for user {uid}")
playlists_list = await client.users_playlists_list() logging.debug(f"[GENERAL] Searching for '{ctx.value}' for user {uid}")
try:
playlists_list = await client.users_playlists_list()
except Exception as e:
logging.error(f"[GENERAL] Failed to get playlists for user {uid}: {e}")
return []
return [playlist.title for playlist in playlists_list if playlist.title and ctx.value in playlist.title][:100] return [playlist.title for playlist in playlists_list if playlist.title and ctx.value in playlist.title][:100]
class General(Cog): class General(Cog):
def __init__(self, bot: discord.Bot): def __init__(self, bot: discord.Bot):
self.bot = bot self.bot = bot
self.db = BaseGuildsDatabase() self.db = BaseGuildsDatabase()
self.users_db = users_db self.users_db = users_db
account = discord.SlashCommandGroup("account", "Команды, связанные с аккаунтом.") account = discord.SlashCommandGroup("account", "Команды, связанные с аккаунтом.")
@discord.slash_command(description="Получить информацию о командах YandexMusic.") @discord.slash_command(description="Получить информацию о командах YandexMusic.")
@discord.option( @discord.option(
"command", "command",
@@ -208,10 +216,11 @@ class General(Cog):
await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True) await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True)
return return
client = await YMClient(token).init() try:
if not client.me or not client.me.account or not client.me.account.uid: client = await YMClient(token).init()
logging.warning(f"Failed to fetch user info for user {ctx.user.id}") except UnauthorizedError:
await ctx.respond('❌ Что-то пошло не так. Повторите попытку позже.', delete_after=15, ephemeral=True) logging.info(f"[GENERAL] Invalid token for user {ctx.user.id}")
await ctx.respond('❌ Неверный токен. Укажите новый через /account login.', delete_after=15, ephemeral=True)
return return
likes = await client.users_likes_tracks() likes = await client.users_likes_tracks()
@@ -256,7 +265,7 @@ class General(Cog):
client = await YMClient(token).init() client = await YMClient(token).init()
except UnauthorizedError: except UnauthorizedError:
logging.info(f"[GENERAL] 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) await ctx.respond('❌ Неверный токен. Укажите новый через /account login.', delete_after=15, ephemeral=True)
return return
search = await client.search(content_type, type_='playlist') search = await client.search(content_type, type_='playlist')
@@ -287,7 +296,7 @@ class General(Cog):
autocomplete=discord.utils.basic_autocomplete(get_user_playlists_suggestions) autocomplete=discord.utils.basic_autocomplete(get_user_playlists_suggestions)
) )
async def playlist(self, ctx: discord.ApplicationContext, name: str) -> None: async def playlist(self, ctx: discord.ApplicationContext, name: str) -> None:
logging.info(f"[GENERAL] Playlists command invoked by user {ctx.user.id} in guild {ctx.guild_id}") logging.info(f"[GENERAL] Playlist command invoked by user {ctx.user.id} in guild {ctx.guild_id}")
token = await self.users_db.get_ym_token(ctx.user.id) token = await self.users_db.get_ym_token(ctx.user.id)
if not token: if not token:
@@ -299,10 +308,15 @@ class General(Cog):
client = await YMClient(token).init() client = await YMClient(token).init()
except UnauthorizedError: except UnauthorizedError:
logging.info(f"[GENERAL] 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) await ctx.respond('❌ Неверный токен. Укажите новый через /account login.', delete_after=15, ephemeral=True)
return return
playlists = await client.users_playlists_list() try:
playlists = await client.users_playlists_list()
except UnauthorizedError:
logging.warning(f"[GENERAL] Unknown token error for user {ctx.user.id}")
await ctx.respond("❌ Произошла неизвестная ошибка при попытке получения плейлистов. Пожалуйста, сообщите об этом разработчику.", delete_after=15, ephemeral=True)
return
playlist = next((playlist for playlist in playlists if playlist.title == name), None) playlist = next((playlist for playlist in playlists if playlist.title == name), None)
if not playlist: if not playlist:

View File

@@ -1,5 +1,5 @@
import logging import logging
from typing import cast from typing import cast, Final
from math import ceil from math import ceil
from os import getenv from os import getenv
@@ -10,29 +10,35 @@ from PIL import Image
from yandex_music import Track, Album, Artist, Playlist, Label from yandex_music import Track, Album, Artist, Playlist, Label
from discord import Embed from discord import Embed
explicit_eid: Final[str | None] = getenv('EXPLICIT_EID')
if not explicit_eid:
raise ValueError('You must specify explicit emoji id in your enviroment (EXPLICIT_EID).')
async def generate_item_embed(item: Track | Album | Artist | Playlist | list[Track], vibing: bool = False) -> Embed: async def generate_item_embed(item: Track | Album | Artist | Playlist | list[Track], vibing: bool = False) -> Embed:
"""Generate item embed. list[Track] is used for likes. If vibing is True, add vibing image. """Generate item embed. list[Track] is used for likes. If vibing is True, add vibing image.
Args: Args:
item (yandex_music.Track | yandex_music.Album | yandex_music.Artist | yandex_music.Playlist): Item to be processed. item (Track | Album | Artist | Playlist | list[Track]): Item to be processed.
vibing (bool, optional): Add vibing image. Defaults to False.
Returns: Returns:
discord.Embed: Item embed. discord.Embed: Item embed.
""" """
logging.debug(f"[EMBEDS] Generating embed for type: '{type(item).__name__}'") logging.debug(f"[EMBEDS] Generating embed for type: '{type(item).__name__}'")
if isinstance(item, Track): match item:
embed = await _generate_track_embed(item) case Track():
elif isinstance(item, Album): embed = await _generate_track_embed(item)
embed = await _generate_album_embed(item) case Album():
elif isinstance(item, Artist): embed = await _generate_album_embed(item)
embed = await _generate_artist_embed(item) case Artist():
elif isinstance(item, Playlist): embed = await _generate_artist_embed(item)
embed = await _generate_playlist_embed(item) case Playlist():
elif isinstance(item, list): embed = await _generate_playlist_embed(item)
embed = _generate_likes_embed(item) case list():
else: embed = _generate_likes_embed(item)
raise ValueError(f"Unknown item type: {type(item).__name__}") case _:
raise ValueError(f"Unknown item type: {type(item).__name__}")
if vibing: if vibing:
embed.set_image( embed.set_image(
@@ -41,13 +47,12 @@ async def generate_item_embed(item: Track | Album | Artist | Playlist | list[Tra
return embed return embed
def _generate_likes_embed(tracks: list[Track]) -> Embed: def _generate_likes_embed(tracks: list[Track]) -> Embed:
track_count = len(tracks)
cover_url = "https://avatars.yandex.net/get-music-user-playlist/11418140/favorit-playlist-cover.bb48fdb9b9f4/300x300" cover_url = "https://avatars.yandex.net/get-music-user-playlist/11418140/favorit-playlist-cover.bb48fdb9b9f4/300x300"
embed = Embed( embed = Embed(
title="Мне нравится", title="Мне нравится",
description="Треки, которые вам понравились.", description="Треки, которые вам понравились.",
color=0xce3a26, color=0xce3a26
) )
embed.set_thumbnail(url=cover_url) embed.set_thumbnail(url=cover_url)
@@ -56,203 +61,143 @@ def _generate_likes_embed(tracks: list[Track]) -> Embed:
if track.duration_ms: if track.duration_ms:
duration += track.duration_ms duration += track.duration_ms
duration_m = duration // 60000 embed.add_field(name="Длительность", value=_format_duration(duration))
duration_s = ceil(duration / 1000) - duration_m * 60 embed.add_field(name="Треки", value=str(len(tracks)))
if duration_s == 60:
duration_m += 1
duration_s = 0
embed.add_field(name="Длительность", value=f"{duration_m}:{duration_s:02}")
if track_count is not None:
embed.add_field(name="Треки", value=str(track_count))
return embed return embed
async def _generate_track_embed(track: Track) -> Embed: async def _generate_track_embed(track: Track) -> Embed:
title = cast(str, track.title) title = track.title
avail = cast(bool, track.available)
artists = track.artists_name()
albums = [cast(str, album.title) for album in track.albums] albums = [cast(str, album.title) for album in track.albums]
lyrics = cast(bool, track.lyrics_available)
duration = cast(int, track.duration_ms)
explicit = track.explicit or track.content_warning explicit = track.explicit or track.content_warning
bg_video = track.background_video_uri year = track.albums[0].year if track.albums else None
metadata = track.meta_data artist = track.artists[0] if track.artists else None
year = track.albums[0].year
artist = track.artists[0]
cover_url = track.get_cover_url('400x400') if track.cover_uri:
color = await _get_average_color_from_url(cover_url) cover_url = track.get_cover_url('400x400')
color = await _get_average_color_from_url(cover_url)
else:
cover_url = None
color = 0x000
if explicit: if explicit and title:
explicit_eid = getenv('EXPLICIT_EID')
if not explicit_eid:
raise ValueError('You must specify explicit emoji id in your enviroment (EXPLICIT_EID).')
title += ' <:explicit:' + explicit_eid + '>' title += ' <:explicit:' + explicit_eid + '>'
duration_m = duration // 60000 if artist:
duration_s = ceil(duration / 1000) - duration_m * 60 artist_url = f"https://music.yandex.ru/artist/{artist.id}"
if duration_s == 60: artist_cover = artist.cover
duration_m += 1
duration_s = 0
artist_url = f"https://music.yandex.ru/artist/{artist.id}" if not artist_cover and artist.op_image:
artist_cover = artist.cover artist_cover_url = artist.get_op_image_url()
elif artist_cover:
if not artist_cover and artist.op_image: artist_cover_url = artist_cover.get_url()
artist_cover_url = artist.get_op_image_url() else:
elif artist_cover: artist_cover_url = None
artist_cover_url = artist_cover.get_url()
else: else:
artist_url = None
artist_cover_url = None artist_cover_url = None
embed = Embed( embed = Embed(
title=title, title=title,
description=", ".join(albums), description=", ".join(albums),
color=color, color=color
) )
embed.set_thumbnail(url=cover_url) embed.set_thumbnail(url=cover_url)
embed.set_author(name=", ".join(artists), url=artist_url, icon_url=artist_cover_url) embed.set_author(name=", ".join(track.artists_name()), url=artist_url, icon_url=artist_cover_url)
embed.add_field(name="Текст песни", value="Есть" if lyrics else "Нет") embed.add_field(name="Текст песни", value="Есть" if track.lyrics_available else "Нет")
embed.add_field(name="Длительность", value=f"{duration_m}:{duration_s:02}")
if isinstance(track.duration_ms, int):
embed.add_field(name="Длительность", value=_format_duration(track.duration_ms))
if year: if year:
embed.add_field(name="Год выпуска", value=str(year)) embed.add_field(name="Год выпуска", value=str(year))
if metadata: if track.background_video_uri:
if metadata.year: embed.add_field(name="Видеофон", value=f"[Ссылка]({track.background_video_uri})")
embed.add_field(name="Год выхода", value=str(metadata.year))
if metadata.number: if not (track.available or track.available_for_premium_users):
embed.add_field(name="Позиция", value=str(metadata.number))
if metadata.composer:
embed.add_field(name="Композитор", value=metadata.composer)
if metadata.version:
embed.add_field(name="Версия", value=metadata.version)
if bg_video:
embed.add_field(name="Видеофон", value=f"[Ссылка]({bg_video})")
if not avail:
embed.set_footer(text=f"Трек в данный момент недоступен.") embed.set_footer(text=f"Трек в данный момент недоступен.")
return embed return embed
async def _generate_album_embed(album: Album) -> Embed: async def _generate_album_embed(album: Album) -> Embed:
title = cast(str, album.title) title = album.title
track_count = album.track_count
artists = album.artists_name()
avail = cast(bool, album.available)
description = album.short_description
year = album.year
version = album.version
bests = album.bests
duration = album.duration_ms
explicit = album.explicit or album.content_warning explicit = album.explicit or album.content_warning
likes_count = album.likes_count
artist = album.artists[0] artist = album.artists[0]
cover_url = album.get_cover_url('400x400') cover_url = album.get_cover_url('400x400')
color = await _get_average_color_from_url(cover_url)
if isinstance(album.labels[0], Label): if isinstance(album.labels[0], Label):
labels = [cast(Label, label).name for label in album.labels] labels = [cast(Label, label).name for label in album.labels]
else: else:
labels = [cast(str, label) for label in album.labels] labels = [cast(str, label) for label in album.labels]
if version: if album.version and title:
title += f' *{version}*' title += f' *{album.version}*'
if explicit: if explicit and title:
explicit_eid = getenv('EXPLICIT_EID')
if not explicit_eid:
raise ValueError('You must specify explicit emoji id in your enviroment.')
title += ' <:explicit:' + explicit_eid + '>' title += ' <:explicit:' + explicit_eid + '>'
artist_url = f"https://music.yandex.ru/artist/{artist.id}" artist_url = f"https://music.yandex.ru/artist/{artist.id}"
artist_cover = artist.cover artist_cover = artist.cover
if not artist_cover and artist.op_image: if not artist_cover and artist.op_image:
artist_cover_url = artist.get_op_image_url() artist_cover_url = artist.get_op_image_url('400x400')
elif artist_cover: elif artist_cover:
artist_cover_url = artist_cover.get_url() artist_cover_url = artist_cover.get_url(size='400x400')
else: else:
artist_cover_url = None artist_cover_url = None
embed = Embed( embed = Embed(
title=title, title=title,
description=description, description=album.short_description,
color=color, color=await _get_average_color_from_url(cover_url)
) )
embed.set_thumbnail(url=cover_url) embed.set_thumbnail(url=cover_url)
embed.set_author(name=", ".join(artists), url=artist_url, icon_url=artist_cover_url) embed.set_author(name=", ".join(album.artists_name()), url=artist_url, icon_url=artist_cover_url)
if year: if album.year:
embed.add_field(name="Год выпуска", value=str(year)) embed.add_field(name="Год выпуска", value=str(album.year))
if duration: if isinstance(album.duration_ms, int):
duration_m = duration // 60000 embed.add_field(name="Длительность", value=_format_duration(album.duration_ms))
duration_s = ceil(duration / 1000) - duration_m * 60
if duration_s == 60:
duration_m += 1
duration_s = 0
embed.add_field(name="Длительность", value=f"{duration_m}:{duration_s:02}")
if track_count is not None: if album.track_count is not None:
if track_count > 1: embed.add_field(name="Треки", value=str(album.track_count) if album.track_count > 1 else "Сингл")
embed.add_field(name="Треки", value=str(track_count))
else:
embed.add_field(name="Треки", value="Сингл")
if likes_count: if album.likes_count is not None:
embed.add_field(name="Лайки", value=str(likes_count)) embed.add_field(name="Лайки", value=str(album.likes_count))
if len(labels) > 1: embed.add_field(name="Лейблы" if len(labels) > 1 else "Лейбл", value=", ".join(labels))
embed.add_field(name="Лейблы", value=", ".join(labels))
else:
embed.add_field(name="Лейбл", value=", ".join(labels))
if not avail: if not (album.available or album.available_for_premium_users):
embed.set_footer(text=f"Альбом в данный момент недоступен.") embed.set_footer(text=f"Альбом в данный момент недоступен.")
return embed return embed
async def _generate_artist_embed(artist: Artist) -> Embed: async def _generate_artist_embed(artist: Artist) -> Embed:
name = cast(str, artist.name)
likes_count = artist.likes_count
avail = cast(bool, artist.available)
counts = artist.counts
description = artist.description
ratings = artist.ratings
popular_tracks = artist.popular_tracks
if not artist.cover: if not artist.cover:
cover_url = artist.get_op_image_url('400x400') cover_url = artist.get_op_image_url('400x400')
else: else:
cover_url = artist.cover.get_url(size='400x400') cover_url = artist.cover.get_url(size='400x400')
color = await _get_average_color_from_url(cover_url)
embed = Embed( embed = Embed(
title=name, title=artist.name,
description=description.text if description else None, description=artist.description.text if artist.description else None,
color=color, color=await _get_average_color_from_url(cover_url)
) )
embed.set_thumbnail(url=cover_url) embed.set_thumbnail(url=cover_url)
if likes_count: if artist.likes_count:
embed.add_field(name="Лайки", value=str(likes_count)) embed.add_field(name="Лайки", value=str(artist.likes_count))
# if ratings: # if ratings:
# embed.add_field(name="Слушателей за месяц", value=str(ratings.month)) # Wrong numbers? # embed.add_field(name="Слушателей за месяц", value=str(ratings.month)) # Wrong numbers
if counts: if artist.counts:
embed.add_field(name="Треки", value=str(counts.tracks)) embed.add_field(name="Треки", value=str(artist.counts.tracks))
embed.add_field(name="Альбомы", value=str(counts.direct_albums)) embed.add_field(name="Альбомы", value=str(artist.counts.direct_albums))
if artist.genres: if artist.genres:
genres = [genre.capitalize() for genre in artist.genres] genres = [genre.capitalize() for genre in artist.genres]
@@ -261,23 +206,12 @@ async def _generate_artist_embed(artist: Artist) -> Embed:
else: else:
embed.add_field(name="Жанр", value=", ".join(genres)) embed.add_field(name="Жанр", value=", ".join(genres))
if not avail: if not artist.available or artist.reason:
embed.set_footer(text=f"Артист в данный момент недоступен.") embed.set_footer(text=f"Артист в данный момент недоступен.")
return embed return embed
async def _generate_playlist_embed(playlist: Playlist) -> Embed: async def _generate_playlist_embed(playlist: Playlist) -> Embed:
title = cast(str, playlist.title)
track_count = playlist.track_count
avail = cast(bool, playlist.available)
description = playlist.description
year = playlist.created
modified = playlist.modified
duration = playlist.duration_ms
likes_count = playlist.likes_count
cover_url = None
if playlist.cover and playlist.cover.uri: if playlist.cover and playlist.cover.uri:
cover_url = f"https://{playlist.cover.uri.replace('%%', '400x400')}" cover_url = f"https://{playlist.cover.uri.replace('%%', '400x400')}"
else: else:
@@ -287,6 +221,8 @@ async def _generate_playlist_embed(playlist: Playlist) -> Embed:
if track and track.albums and track.albums[0].cover_uri: if track and track.albums and track.albums[0].cover_uri:
cover_url = f"https://{track.albums[0].cover_uri.replace('%%', '400x400')}" cover_url = f"https://{track.albums[0].cover_uri.replace('%%', '400x400')}"
break break
else:
cover_url = None
if cover_url: if cover_url:
color = await _get_average_color_from_url(cover_url) color = await _get_average_color_from_url(cover_url)
@@ -294,33 +230,28 @@ async def _generate_playlist_embed(playlist: Playlist) -> Embed:
color = 0x000 color = 0x000
embed = Embed( embed = Embed(
title=title, title=playlist.title,
description=description, description=playlist.description,
color=color, color=color
) )
embed.set_thumbnail(url=cover_url) embed.set_thumbnail(url=cover_url)
if year: if playlist.created:
embed.add_field(name="Год создания", value=str(year).split('-')[0]) embed.add_field(name="Год создания", value=str(playlist.created).split('-')[0])
if modified: if playlist.modified:
embed.add_field(name="Изменён", value=str(modified).split('-')[0]) embed.add_field(name="Изменён", value=str(playlist.modified).split('-')[0])
if duration: if playlist.duration_ms:
duration_m = duration // 60000 embed.add_field(name="Длительность", value=_format_duration(playlist.duration_ms))
duration_s = ceil(duration / 1000) - duration_m * 60
if duration_s == 60:
duration_m += 1
duration_s = 0
embed.add_field(name="Длительность", value=f"{duration_m}:{duration_s:02}")
if track_count is not None: if playlist.track_count is not None:
embed.add_field(name="Треки", value=str(track_count)) embed.add_field(name="Треки", value=str(playlist.track_count))
if likes_count: if playlist.likes_count:
embed.add_field(name="Лайки", value=str(likes_count)) embed.add_field(name="Лайки", value=str(playlist.likes_count))
if not avail: if not playlist.available:
embed.set_footer(text=f"Плейлист в данный момент недоступен.") embed.set_footer(text=f"Плейлист в данный момент недоступен.")
return embed return embed
@@ -358,5 +289,13 @@ async def _get_average_color_from_url(url: str) -> int:
b = b_total // count b = b_total // count
return (r << 16) + (g << 8) + b return (r << 16) + (g << 8) + b
except Exception: except (aiohttp.ClientError, IOError, ValueError):
return 0x000 return 0x000
def _format_duration(duration_ms: int) -> str:
duration_m = duration_ms // 60000
duration_s = ceil(duration_ms / 1000) - duration_m * 60
if duration_s == 60:
duration_m += 1
duration_s = 0
return f"{duration_m}:{duration_s:02}"

View File

@@ -12,7 +12,7 @@ from discord.ui import View
from discord import Interaction, ApplicationContext, RawReactionActionEvent, VoiceChannel from discord import Interaction, ApplicationContext, RawReactionActionEvent, VoiceChannel
from MusicBot.cogs.utils import generate_item_embed from MusicBot.cogs.utils import generate_item_embed
from MusicBot.database import VoiceGuildsDatabase, BaseUsersDatabase, ExplicitGuild, ExplicitUser, MessageVotes from MusicBot.database import VoiceGuildsDatabase, BaseUsersDatabase, ExplicitGuild, MessageVotes
menu_views: dict[int, View] = {} # Store menu views and delete them when needed to prevent memory leaks for after callbacks. menu_views: dict[int, View] = {} # Store menu views and delete them when needed to prevent memory leaks for after callbacks.
@@ -24,11 +24,11 @@ class VoiceExtension:
self.users_db = BaseUsersDatabase() self.users_db = BaseUsersDatabase()
async def send_menu_message(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, *, disable: bool = False) -> bool: async def send_menu_message(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, *, disable: bool = False) -> bool:
"""Send menu message to the channel and delete old menu message if exists. Return True if sent. """Send menu message to the channel and delete old one if exists. Return True if sent.
Args: Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
disable (bool, optional): Disable menu message. Defaults to False. disable (bool, optional): Disable menu message buttons. Defaults to False.
Raises: Raises:
ValueError: If bot instance is not set and ctx is RawReactionActionEvent. ValueError: If bot instance is not set and ctx is RawReactionActionEvent.
@@ -44,10 +44,11 @@ class VoiceExtension:
guild = await self.db.get_guild(ctx.guild_id, projection={'current_track': 1, 'current_menu': 1, 'vibing': 1}) guild = await self.db.get_guild(ctx.guild_id, projection={'current_track': 1, 'current_menu': 1, 'vibing': 1})
if guild['current_track']: if not guild['current_track']:
if not (vc := await self.get_voice_client(ctx)): embed = None
return False elif not (vc := await self.get_voice_client(ctx)):
return False
else:
track = cast(Track, Track.de_json( track = cast(Track, Track.de_json(
guild['current_track'], guild['current_track'],
client=YMClient() # type: ignore client=YMClient() # type: ignore
@@ -58,32 +59,29 @@ class VoiceExtension:
embed.set_footer(text='Приостановлено') embed.set_footer(text='Приостановлено')
else: else:
embed.remove_footer() embed.remove_footer()
else:
embed = None
if guild['current_menu']: if guild['current_menu']:
logging.info(f"[VC_EXT] Deleting old menu message {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 self.get_menu_message(ctx, guild['current_menu'])):
if message:
await message.delete() await message.delete()
await self._update_menu_views_dict(ctx, disable=disable) await self._update_menu_views_dict(ctx, disable=disable)
if isinstance(ctx, (ApplicationContext, Interaction)): if isinstance(ctx, (ApplicationContext, Interaction)):
interaction = await ctx.respond(view=menu_views[ctx.guild_id], embed=embed) interaction = await ctx.respond(view=menu_views[ctx.guild_id], embed=embed)
else: elif not self.bot:
if not self.bot: raise ValueError("Bot instance is not set.")
raise ValueError("Bot instance is not set.") elif not (channel := self.bot.get_channel(ctx.channel_id)):
logging.warning(f"[VC_EXT] Channel {ctx.channel_id} not found in guild {ctx.guild_id}")
channel = cast(VoiceChannel, self.bot.get_channel(ctx.channel_id)) return False
if not channel: elif isinstance(channel, discord.VoiceChannel):
logging.warning(f"[VC_EXT] Channel {ctx.channel_id} not found in guild {ctx.guild_id}")
return False
interaction = await channel.send( interaction = await channel.send(
view=menu_views[ctx.guild_id], view=menu_views[ctx.guild_id],
embed=embed # type: ignore # Wrong typehints. embed=embed # type: ignore # Wrong typehints.
) )
else:
logging.warning(f"[VC_EXT] Channel {ctx.channel_id} is not a voice channel in guild {ctx.guild_id}")
return False
response = await interaction.original_response() if isinstance(interaction, discord.Interaction) else interaction response = await interaction.original_response() if isinstance(interaction, discord.Interaction) else interaction
await self.db.update(ctx.guild_id, {'current_menu': response.id}) await self.db.update(ctx.guild_id, {'current_menu': response.id})
@@ -113,12 +111,10 @@ class VoiceExtension:
menu = await ctx.fetch_message(menu_mid) menu = await ctx.fetch_message(menu_mid)
elif isinstance(ctx, Interaction): elif isinstance(ctx, Interaction):
menu = ctx.client.get_message(menu_mid) menu = ctx.client.get_message(menu_mid)
elif isinstance(ctx, RawReactionActionEvent): elif not self.bot:
if not self.bot: raise ValueError("Bot instance is not set.")
raise ValueError("Bot instance is not set.")
menu = self.bot.get_message(menu_mid)
else: else:
raise ValueError(f"Invalid context type: '{type(ctx).__name__}'.") menu = self.bot.get_message(menu_mid)
except discord.DiscordException as e: except discord.DiscordException as e:
logging.debug(f"[VC_EXT] Failed to get menu message: {e}") logging.debug(f"[VC_EXT] Failed to get menu message: {e}")
await self.db.update(ctx.guild_id, {'current_menu': None}) await self.db.update(ctx.guild_id, {'current_menu': None})
@@ -167,6 +163,7 @@ class VoiceExtension:
guild = await self.db.get_guild(gid, projection={'vibing': 1, 'current_menu': 1, 'current_track': 1}) guild = await self.db.get_guild(gid, projection={'vibing': 1, 'current_menu': 1, 'current_track': 1})
if not guild['current_menu']: if not guild['current_menu']:
logging.debug("[VC_EXT] No current menu found")
return False return False
menu_message = await self.get_menu_message(ctx, guild['current_menu']) if not menu_message else menu_message menu_message = await self.get_menu_message(ctx, guild['current_menu']) if not menu_message else menu_message
@@ -285,7 +282,7 @@ class VoiceExtension:
uid = viber_id if viber_id else ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None uid = viber_id if viber_id else ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
if not uid or not gid: if not uid or not gid:
logging.warning("[VC_EXT] 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")
return False return False
user = await self.users_db.get_user(uid, projection={'ym_token': 1, 'vibe_settings': 1}) user = await self.users_db.get_user(uid, projection={'ym_token': 1, 'vibe_settings': 1})
@@ -307,27 +304,23 @@ class VoiceExtension:
) )
if not guild['vibing']: if not guild['vibing']:
feedback = await client.rotor_station_feedback_radio_started( try:
f"{type}:{id}", feedback = await client.rotor_station_feedback_radio_started(
f"desktop-user-{client.me.account.uid}", # type: ignore # That's made up, but it doesn't do much anyway. f"{type}:{id}",
) f"desktop-user-{client.me.account.uid}", # type: ignore # That's made up, but it doesn't do much anyway.
)
except yandex_music.exceptions.BadRequestError as e:
logging.info(f"[VIBE] Bad request error while starting radio: {e}")
return False
if not feedback: if not feedback:
logging.warning(f"[VIBE] Failed to start radio '{type}:{id}'") logging.warning(f"[VIBE] Failed to start radio '{type}:{id}'")
return False return False
logging.debug(f"[VIBE] Successfully started radio '{type}:{id}'")
if guild['current_track']: tracks = await client.rotor_station_tracks(
logging.debug("[VIBE] Getting next vibe tracks") f"{type}:{id}",
queue=guild['current_track']['id'] if guild['current_track'] else None # type: ignore
# Current track here is either the track used to start vibe or the last vibe track played. )
# So we always set the current track as the last track in the queue.
tracks = await client.rotor_station_tracks(
f"{type}:{id}",
queue=guild['current_track']['id']
)
else:
tracks = await client.rotor_station_tracks(f"{type}:{id}")
if not tracks: if not tracks:
logging.warning("[VIBE] Failed to get next vibe tracks") logging.warning("[VIBE] Failed to get next vibe tracks")
@@ -430,91 +423,46 @@ class VoiceExtension:
async def play_track( async def play_track(
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
track: Track, track: Track | dict[str, Any],
*, *,
client: YMClient | None = None,
vc: discord.VoiceClient | None = None, vc: discord.VoiceClient | None = None,
menu_message: discord.Message | None = None, menu_message: discord.Message | None = None,
button_callback: bool = False, button_callback: bool = False,
retry: bool = False
) -> str | None: ) -> str | None:
"""Download ``track`` by its id and play it in the voice channel. Return track title on success. """Play `track` in the voice channel. Avoids additional vibe feedback used in `next_track` and `previous_track`.
Send vibe feedback for playing track if vibing. Should be called when voice requirements are met. Forms ym_track and stops playback if needed. Returns track title on success.
Args: Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
track (Track): Track to play. track (dict[str, Any]): Track to play.
vc (discord.VoiceClient | None): Voice client. vc (discord.VoiceClient | None, optional): Voice client. Defaults to None.
menu_message (discord.Message | None): Menu message. If None, fetches menu from channel using message id from database. Defaults to None. menu_message (discord.Message | None, optional): Menu message to update. Defaults to None.
button_callback (bool): Should be True if the function is being called from button callback. Defaults to False. button_callback (bool, optional): Should be True if the function is being called from button callback. Defaults to False.
retry (bool): Whether the function is called again.
Returns: Returns:
(str | None): Song title or None. (str | None): Song title or None.
""" """
gid = ctx.guild_id
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("Guild ID or User ID not found in context")
return None
guild = await self.db.get_guild(gid, projection={'current_menu': 1, 'vibing': 1, 'current_track': 1})
vc = await self.get_voice_client(ctx) if not vc else vc
if not vc: if not vc:
vc = await self.get_voice_client(ctx)
if not await self.stop_playing(ctx, vc=vc):
return None return None
try: if isinstance(track, dict):
if not guild['current_track'] or track.id != guild['current_track']['id']: track = cast(Track, Track.de_json(
await self._download_track(gid, track) track,
except yandex_music.exceptions.TimedOutError: client=await self.init_ym_client(ctx) if not client else client # type: ignore # Async client can be used here.
logging.warning(f"[VC_EXT] Timed out while downloading track '{track.title}'") ))
if not isinstance(ctx, RawReactionActionEvent) and ctx.channel: return await self._play_track(
channel = cast(discord.VoiceChannel, ctx.channel) ctx,
elif self.bot and isinstance(ctx, RawReactionActionEvent): track,
channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id)) vc=vc,
menu_message=menu_message,
if not retry: button_callback=button_callback
return await self.play_track(ctx, track, vc=vc, button_callback=button_callback, retry=True) )
logging.error(f"[VC_EXT] Failed to download track '{track.title}'")
await channel.send(f"😔 Не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15)
return None
async with aiofiles.open(f'music/{gid}.mp3', "rb") as f:
track_bytes = io.BytesIO(await f.read())
song = discord.FFmpegPCMAudio(track_bytes, pipe=True, options='-vn -b:a 64k -filter:a "volume=0.15"')
await self.db.set_current_track(gid, track)
if menu_message or guild['current_menu']:
# Updating menu message before playing to prevent delay and avoid FFMPEG lags.
await self.update_menu_full(ctx, menu_message=menu_message, button_callback=button_callback)
if not guild['vibing']:
# Giving FFMPEG enough time to process the audio file
await asyncio.sleep(1)
loop = self._get_current_event_loop(ctx)
try:
vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.next_track(ctx, after=True), loop))
except discord.errors.ClientException as e:
logging.error(f"[VC_EXT] Error while playing track '{track.title}': {e}")
if not isinstance(ctx, RawReactionActionEvent):
await ctx.respond(f"Не удалось проиграть трек. Попробуйте сбросить меню.", delete_after=15, ephemeral=True)
elif self.bot:
channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id))
await channel.send(f"Не удалось проиграть трек. Попробуйте сбросить меню.", delete_after=15)
return None
logging.info(f"[VC_EXT] Playing track '{track.title}'")
await self.db.update(gid, {'is_stopped': False})
if guild['vibing']:
await self._my_vibe_start_feedback(ctx, track, uid)
return track.title
async def stop_playing( async def stop_playing(
self, self,
@@ -523,12 +471,13 @@ class VoiceExtension:
vc: discord.VoiceClient | None = None, vc: discord.VoiceClient | None = None,
full: bool = False full: bool = False
) -> bool: ) -> bool:
"""Stop playing music in the voice channel. Required to play next one. Returns True on success. """Stop playing music in the voice channel and send vibe feedback.
Required to play next track. Returns True on success.
Args: Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
vc (discord.VoiceClient | None, optional): Voice client. Defaults to None. vc (discord.VoiceClient | None, optional): Voice client. Defaults to None.
full (bool, optional): Full check includes menu deletion and vibe feedback. Defaults to False. full (bool, optional): Full check includes menu deletion. Defaults to False.
Returns: Returns:
bool: Whether the playback was stopped. bool: Whether the playback was stopped.
@@ -543,7 +492,6 @@ class VoiceExtension:
return False return False
guild = await self.db.get_guild(gid, projection={'current_menu': 1, 'current_track': 1, 'vibing': 1}) guild = await self.db.get_guild(gid, projection={'current_menu': 1, 'current_track': 1, 'vibing': 1})
user = await self.users_db.get_user(uid, projection={'vibe_type': 1, 'vibe_id': 1, 'vibe_batch_id': 1, 'ym_token': 1})
vc = await self.get_voice_client(ctx) if not vc else vc vc = await self.get_voice_client(ctx) if not vc else vc
if not vc: if not vc:
@@ -553,12 +501,13 @@ class VoiceExtension:
vc.stop() vc.stop()
if full: if full:
if not await self._full_stop(ctx, guild, gid):
return False
if guild['vibing'] and guild['current_track']: if guild['vibing'] and guild['current_track']:
if not await self._my_vibe_stop_feedback(ctx, guild, user): await self.send_vibe_feedback(ctx, 'trackFinished', guild['current_track'])
return False
if not guild['current_menu']:
return True
return await self._full_stop(ctx, guild['current_menu'], gid)
return True return True
@@ -568,6 +517,7 @@ class VoiceExtension:
vc: discord.VoiceClient | None = None, vc: discord.VoiceClient | None = None,
*, *,
after: bool = False, after: bool = False,
client: YMClient | None = None,
menu_message: discord.Message | None = None, menu_message: discord.Message | None = None,
button_callback: bool = False button_callback: bool = False
) -> str | None: ) -> str | None:
@@ -578,8 +528,9 @@ class VoiceExtension:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context
vc (discord.VoiceClient, optional): Voice client. vc (discord.VoiceClient, optional): Voice client.
after (bool, optional): Whether the function is being called by the after callback. Defaults to False. after (bool, optional): Whether the function is being called by the after callback. Defaults to False.
client (YMClient | None, optional): Yandex Music client. Defaults to None.
menu_message (discord.Message | None): Menu message. If None, fetches menu from channel using message id from database. Defaults to None. menu_message (discord.Message | None): Menu message. If None, fetches menu from channel using message id from database. Defaults to None.
button_interaction (bool, optional): Should be True if the function is being called from button callback. Defaults to False. button_callback (bool, optional): Should be True if the function is being called from button callback. Defaults to False.
Returns: Returns:
(str | None): Track title or None. (str | None): Track title or None.
@@ -595,18 +546,9 @@ class VoiceExtension:
guild = await self.db.get_guild(gid, projection={'shuffle': 1, 'repeat': 1, 'is_stopped': 1, 'current_menu': 1, 'vibing': 1, 'current_track': 1}) guild = await self.db.get_guild(gid, projection={'shuffle': 1, 'repeat': 1, 'is_stopped': 1, 'current_menu': 1, 'vibing': 1, 'current_track': 1})
user = await self.users_db.get_user(uid) user = await self.users_db.get_user(uid)
client = await self.init_ym_client(ctx, user['ym_token'])
vc = await self.get_voice_client(ctx) if not vc else vc
if guild['is_stopped'] and after: if guild['is_stopped'] and after:
logging.debug("[VC_EXT] Playback is stopped, skipping after callback...") logging.debug("[VC_EXT] Playback is stopped, skipping after callback.")
return None
if not client:
return None
if not vc: # Silently return if bot got kicked
logging.debug("[VC_EXT] Voice client not found in 'next_track'")
return None return None
if guild['current_track'] and guild['current_menu'] and not guild['repeat']: if guild['current_track'] and guild['current_menu'] and not guild['repeat']:
@@ -617,14 +559,12 @@ class VoiceExtension:
await self.update_menu_view(ctx, menu_message=menu_message, disable=True) await self.update_menu_view(ctx, menu_message=menu_message, disable=True)
if guild['vibing'] and guild['current_track']: if guild['vibing'] and guild['current_track']:
if not await self._my_vibe_feedback(ctx, guild, user, client, after=after): if not await self.send_vibe_feedback(ctx, 'trackFinished' if after else 'skip', guild['current_track']):
if not isinstance(ctx, RawReactionActionEvent): if not isinstance(ctx, RawReactionActionEvent):
await ctx.respond("Что-то пошло не так. Попробуйте снова.", ephemeral=True) await ctx.respond("Не удалось отправить отчёт об оконачнии Моей Волны.", ephemeral=True, delete_after=15)
elif self.bot: elif self.bot:
channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id)) channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id))
await channel.send("Что-то пошло не так. Попробуйте снова.", delete_after=15) await channel.send("Не удалось отправить отчёт об оконачнии Моей Волны.", delete_after=15)
return None
if guild['repeat'] and after: if guild['repeat'] and after:
logging.debug("[VC_EXT] Repeating current track") logging.debug("[VC_EXT] Repeating current track")
@@ -635,7 +575,7 @@ class VoiceExtension:
else: else:
logging.debug("[VC_EXT] Getting next track from queue") logging.debug("[VC_EXT] Getting next track from queue")
next_track = await self.db.get_track(gid, 'next') next_track = await self.db.get_track(gid, 'next')
if not next_track and guild['vibing']: if not next_track and guild['vibing']:
logging.debug("[VC_EXT] No next track found, generating new vibe") logging.debug("[VC_EXT] No next track found, generating new vibe")
if not user['vibe_type'] or not user['vibe_id']: if not user['vibe_type'] or not user['vibe_id']:
@@ -646,7 +586,7 @@ class VoiceExtension:
next_track = await self.db.get_track(gid, 'next') next_track = await self.db.get_track(gid, 'next')
if next_track: if next_track:
title = await self._play_track(ctx, next_track, client=client, vc=vc, button_callback=button_callback) title = await self.play_track(ctx, next_track, client=client, vc=vc, button_callback=button_callback)
if after and not guild['current_menu']: if after and not guild['current_menu']:
if isinstance(ctx, discord.RawReactionActionEvent): if isinstance(ctx, discord.RawReactionActionEvent):
@@ -668,7 +608,7 @@ class VoiceExtension:
async def previous_track(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, button_callback: bool = False) -> str | None: async def previous_track(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, button_callback: bool = False) -> str | None:
"""Switch to the previous track in the queue. Repeat current track if no previous one found. """Switch to the previous track in the queue. Repeat current track if no previous one found.
Return track title on success. Return track title on success. Should be called only if there's already track playing.
Args: Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
@@ -700,7 +640,7 @@ class VoiceExtension:
track = None track = None
if track: if track:
return await self._play_track(ctx, track, button_callback=button_callback) return await self.play_track(ctx, track, button_callback=button_callback)
return None return None
@@ -713,17 +653,15 @@ class VoiceExtension:
Returns: Returns:
(list[Track] | None): List of tracks or None. (list[Track] | None): List of tracks or None.
""" """
gid = ctx.guild_id logging.info("[VC_EXT] Getting liked tracks")
uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
if not gid or not uid: if not ctx.guild_id:
logging.warning("Guild ID or User ID not found in context inside 'play_track'") logging.warning("Guild ID not found in context inside 'get_likes'")
return None return None
current_track = await self.db.get_track(gid, 'current') client = await self.init_ym_client(ctx)
client = await self.init_ym_client(ctx, await self.users_db.get_ym_token(uid))
if not current_track: if not await self.db.get_track(ctx.guild_id, 'current'):
logging.debug("[VC_EXT] Current track not found in 'get_likes'") logging.debug("[VC_EXT] Current track not found in 'get_likes'")
return None return None
@@ -732,7 +670,7 @@ class VoiceExtension:
likes = await client.users_likes_tracks() likes = await client.users_likes_tracks()
if not likes: if not likes:
logging.debug("[VC_EXT] No likes found") logging.info("[VC_EXT] No likes found")
return None return None
return likes.tracks return likes.tracks
@@ -797,7 +735,8 @@ class VoiceExtension:
Returns: Returns:
(YMClient | None): Client or None. (YMClient | None): Client or None.
""" """
logging.debug("[VC_EXT] Initializing Yandex Music client")
if not token: if not token:
uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
token = await self.users_db.get_ym_token(uid) if uid else None token = await self.users_db.get_ym_token(uid) if uid else None
@@ -807,19 +746,22 @@ class VoiceExtension:
if not isinstance(ctx, discord.RawReactionActionEvent): if not isinstance(ctx, discord.RawReactionActionEvent):
await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True) await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True)
return None return None
if not hasattr(self, '_ym_clients'): if not hasattr(self, '_ym_clients'):
self._ym_clients = {} self._ym_clients: dict[str, YMClient] = {}
if token in self._ym_clients: if token in self._ym_clients:
return self._ym_clients[token] client = self._ym_clients[token]
try:
await client.account_status()
return client
except yandex_music.exceptions.UnauthorizedError:
del self._ym_clients[token]
return None
try: try:
client = await YMClient(token).init() client = await YMClient(token).init()
except yandex_music.exceptions.UnauthorizedError: except yandex_music.exceptions.UnauthorizedError:
logging.debug("UnauthorizedError in 'init_ym_client'") logging.debug("UnauthorizedError in 'init_ym_client'")
if not isinstance(ctx, discord.RawReactionActionEvent):
await ctx.respond("❌ Недействительный токен. Если это не так, попробуйте ещё раз.", delete_after=15, ephemeral=True)
return None return None
self._ym_clients[token] = client self._ym_clients[token] = client
@@ -843,8 +785,8 @@ class VoiceExtension:
logging.warning("[VOICE] Guild not found") logging.warning("[VOICE] Guild not found")
return False return False
if not guild['current_menu']: if not guild['current_menu'] and not await self.send_menu_message(ctx):
await self.send_menu_message(ctx) await channel.send(content=f"Не удалось отправить меню! Попробуйте ещё раз.", delete_after=15)
if vote_data['action'] in ('next', 'previous'): if vote_data['action'] in ('next', 'previous'):
if not guild.get(f'{vote_data['action']}_tracks'): if not guild.get(f'{vote_data['action']}_tracks'):
@@ -916,29 +858,86 @@ class VoiceExtension:
await channel.send("❌ Произошла ошибка при обновлении станции.", delete_after=15) await channel.send("❌ Произошла ошибка при обновлении станции.", delete_after=15)
return False return False
feedback = await self.update_vibe(ctx, _type, _id, viber_id=viber_id) if not await self.update_vibe(ctx, _type, _id, viber_id=viber_id):
if not feedback:
await channel.send("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15) await channel.send("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15)
return False return False
next_track = await self.db.get_track(ctx.guild_id, 'next') next_track = await self.db.get_track(ctx.guild_id, 'next')
if next_track: if next_track:
await self._play_track(ctx, next_track) await self.play_track(ctx, next_track)
else:
await channel.send("Не удалось воспроизвести трек.", delete_after=15)
return False
else: else:
logging.error(f"[VOICE] Unknown action '{vote_data['action']}' for message {ctx.message_id}") logging.error(f"[VOICE] Unknown action '{vote_data['action']}' for message {ctx.message_id}")
return False return False
return True return True
async def send_vibe_feedback(
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
feedback_type: Literal['radioStarted', 'trackStarted', 'trackFinished', 'skip'],
track: Track | dict[str, Any]
) -> bool:
"""Send vibe feedback to Yandex Music. Return True on success.
Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
feedback_type (str): Type of feedback. Can be 'radioStarted', 'trackStarted', 'trackFinished', 'skip'.
track (Track | dict[str, Any]): Track data.
Returns:
bool: True on success, False otherwise.
"""
logging.debug(f"[VC_EXT] Sending vibe feedback, type: {feedback_type}")
uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
if not uid:
logging.warning("[VC_EXT] User id not found")
return False
user = await self.users_db.get_user(uid, projection={'ym_token': 1, 'vibe_batch_id': 1, 'vibe_type': 1, 'vibe_id': 1})
if not user['ym_token']:
logging.warning(f"[VC_EXT] No YM token for user {user['_id']}.")
return False
client = await self.init_ym_client(ctx, user['ym_token'])
if not client:
logging.info(f"[VC_EXT] Failed to init YM client for user {user['_id']}")
if not isinstance(ctx, RawReactionActionEvent):
await ctx.respond("❌ Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True)
elif self.bot:
channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id))
await channel.send("❌ Что-то пошло не так. Попробуйте позже.", delete_after=15)
return False
total_play_seconds = track['duration_ms'] // 1000 if feedback_type not in ('radioStarted', 'trackStarted') and track['duration_ms'] else None
try:
feedback = await client.rotor_station_feedback(
f'{user['vibe_type']}:{user['vibe_id']}',
feedback_type,
track_id=track['id'],
total_played_seconds=total_play_seconds, # type: ignore
batch_id=user['vibe_batch_id'] # type: ignore
)
except yandex_music.exceptions.BadRequestError as e:
logging.error(f"[VC_EXT] Failed to send vibe feedback, type: {feedback_type}, track: {track['title']} error: {e}")
return False
logging.info(f"[VC_EXT] Sent vibe feedback type '{feedback_type}' with result: {feedback}")
return feedback
async def _update_menu_views_dict( async def _update_menu_views_dict(
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
*, *,
disable: bool = False disable: bool = False
) -> None: ) -> None:
"""Update menu views in `menu_views` dict. This prevents creating multiple menu views for the same guild. """Genereate a new menu view and update the `menu_views` dict. This prevents creating multiple menu views for the same guild.
Use guild id as a key to access menu view. Use guild id as a key to access menu view.
Args: Args:
@@ -968,10 +967,10 @@ class VoiceExtension:
try: try:
await track.download_async(f'music/{gid}.mp3') await track.download_async(f'music/{gid}.mp3')
except yandex_music.exceptions.TimedOutError: except yandex_music.exceptions.TimedOutError:
logging.warning(f"[VC_EXT] Timeout downloading {track.title}") logging.warning(f"[VC_EXT] Timed out while downloading track '{track.title}'")
raise raise
async def _full_stop(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, guild: ExplicitGuild, gid: int) -> Literal[True]: async def _full_stop(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, current_menu: int, gid: int) -> Literal[True]:
"""Stop all actions and delete menu. Return True on success. """Stop all actions and delete menu. Return True on success.
Args: Args:
@@ -986,195 +985,106 @@ class VoiceExtension:
if gid in menu_views: if gid in menu_views:
menu_views[gid].stop() menu_views[gid].stop()
del menu_views[gid] del menu_views[gid]
if guild['current_menu']: if (menu := await self.get_menu_message(ctx, current_menu)):
menu = await self.get_menu_message(ctx, guild['current_menu']) await menu.delete()
if menu:
await menu.delete()
await self.db.update(gid, { await self.db.update(gid, {
'current_menu': None, 'repeat': False, 'shuffle': False, 'previous_tracks': [], 'next_tracks': [], 'votes': {}, 'vibing': False 'current_menu': None, 'repeat': False, 'shuffle': False, 'previous_tracks': [], 'next_tracks': [], 'votes': {}, 'vibing': False
}) })
return True return True
async def _my_vibe_start_feedback(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, track: Track, uid: int):
"""Send vibe start feedback to Yandex Music. Return True on success.
Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
track (Track): Track.
uid (int): User ID.
Returns:
bool: True on success, False otherwise.
"""
user = await self.users_db.get_user(uid)
client = await self.init_ym_client(ctx, user['ym_token']) if not track.client else track.client
if not client:
logging.warning(f"[VOICE] No YM client for user {uid}.")
return False
feedback = await client.rotor_station_feedback_track_started(
f"{user['vibe_type']}:{user['vibe_id']}",
track.id,
user['vibe_batch_id'] # type: ignore # Wrong typehints
)
logging.debug(f"[VIBE] Track started feedback: {feedback}")
return True
async def _my_vibe_stop_feedback(
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
guild: ExplicitGuild,
user: ExplicitUser
) -> bool:
"""Send vibe stop feedback to Yandex Music. Return True on success.
Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
guild (ExplicitGuild): Guild.
user (ExplicitUser): User.
Returns:
bool: True on success, False otherwise.
"""
logging.debug("[VC_EXT] Sending vibe stop feedback")
if not user['ym_token']:
logging.warning(f"[VOICE] No YM token for user {user['_id']}.")
return False
client = await self.init_ym_client(ctx, user['ym_token'])
if not client:
logging.info(f"[VOICE] Failed to init YM client for user {user['_id']}")
if not isinstance(ctx, RawReactionActionEvent):
await ctx.respond("❌ Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True)
elif self.bot:
channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id))
await channel.send("❌ Что-то пошло не так. Попробуйте позже.", delete_after=15)
return False
track = guild['current_track']
if not track:
logging.info(f"[VOICE] No current track in guild {guild['_id']}")
return False
res = await client.rotor_station_feedback_track_finished(
f"{user['vibe_type']}:{user['vibe_id']}",
track['id'],
track['duration_ms'] // 1000,
user['vibe_batch_id'] # type: ignore # Wrong typehints
)
logging.info(f"[VOICE] User {user['_id']} finished vibing with result: {res}")
return True
async def _my_vibe_feedback(
self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent,
guild: ExplicitGuild,
user: ExplicitUser,
client: YMClient,
*,
after: bool
) -> bool:
"""Send vibe feedback to Yandex Music. If the track was skipped, call `update_vibe` to get next tracks.
This is called when a user skips a track or when a track finishes and not when a user stops the player.
Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
guild (ExplicitGuild): Guild.
user (ExplicitUser): User.
client (YMClient): Yandex Music client.
after (bool): Whether the track finished or was skipped. If True, the track finished.
Returns:
bool: True on success, False otherwise.
"""
# TODO: Should be refactored to prevent duplication with `_my_vibe_stop_feedback` and `_my_vibe_start_feedback`
logging.debug(f"[VC_EXT] Sending vibe feedback, after: {after}")
if not user['vibe_type'] or not user['vibe_id']:
logging.warning("[VIBE] No vibe type or id found")
return False
if not guild['current_track']:
logging.warning("[VIBE] No current track found")
return False
if after:
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
)
logging.debug(f"[VIBE] Finished track feeedback: {feedback}")
else:
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
)
if not feedback:
logging.warning("[VIBE] Failed to send vibe feedback")
return False
logging.debug(f"[VIBE] Skipped track feeedback: {feedback}")
feedback = await self.update_vibe(
ctx,
user['vibe_type'],
user['vibe_id']
)
return feedback
async def _play_track( async def _play_track(
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
track: dict[str, Any], track: Track,
*, *,
client: YMClient | None = None,
vc: discord.VoiceClient | None = None, vc: discord.VoiceClient | None = None,
menu_message: discord.Message | None = None, menu_message: discord.Message | None = None,
button_callback: bool = False, button_callback: bool = False,
retry: bool = False
) -> str | None: ) -> str | None:
"""Play `track` in the voice channel. Avoids additional vibe checks used in `next_track` and `previous_track`. """Download ``track`` by its id and play it in the voice channel. Return track title on success.
Send vibe feedback for playing track if vibing. Should be called when voice requirements are met.
Args: Args:
ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context. ctx (ApplicationContext | Interaction | RawReactionActionEvent): Context.
track (dict[str, Any]): Track to play. track (Track): Track to play.
vc (discord.VoiceClient | None, optional): Voice client. Defaults to None. vc (discord.VoiceClient | None): Voice client.
menu_message (discord.Message | None, optional): Menu message to update. Defaults to None. menu_message (discord.Message | None): Menu message. If None, fetches menu from channel using message id from database. Defaults to None.
button_callback (bool, optional): Should be True if the function is being called from button callback. Defaults to False. button_callback (bool): Should be True if the function is being called from button callback. Defaults to False.
retry (bool): Whether the function is called again.
Returns: Returns:
str | None: Song title or None. (str | None): Song title or None.
""" """
# TODO: This should be refactored to avoid code duplication with `next_track` and `previous_track`. gid = ctx.guild_id
client = await self.init_ym_client(ctx) if not client else client uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None
if not client: if not gid or not uid:
logging.warning("Guild ID or User ID not found in context")
return None return None
if not vc: guild = await self.db.get_guild(gid, projection={'current_menu': 1, 'vibing': 1, 'current_track': 1})
vc = await self.get_voice_client(ctx)
if not await self.stop_playing(ctx, vc=vc): if not (vc := await self.get_voice_client(ctx) if not vc else vc):
return None return None
ym_track = cast(Track, Track.de_json( try:
track, if not guild['current_track'] or track.id != guild['current_track']['id']:
client=client # type: ignore # Async client can be used here. await self._download_track(gid, track)
)) except yandex_music.exceptions.TimedOutError:
return await self.play_track( if not isinstance(ctx, RawReactionActionEvent) and ctx.channel:
ctx, channel = cast(discord.VoiceChannel, ctx.channel)
ym_track, elif not retry:
vc=vc, return await self._play_track(ctx, track, vc=vc, menu_message=menu_message, button_callback=button_callback, retry=True)
menu_message=menu_message, elif self.bot and isinstance(ctx, RawReactionActionEvent):
button_callback=button_callback channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id))
) logging.error(f"[VC_EXT] Failed to download track '{track.title}'")
await channel.send(f"😔 Не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15)
return None
async with aiofiles.open(f'music/{gid}.mp3', "rb") as f:
track_bytes = io.BytesIO(await f.read())
song = discord.FFmpegPCMAudio(track_bytes, pipe=True, options='-vn -b:a 64k -filter:a "volume=0.15"')
await self.db.set_current_track(gid, track)
if menu_message or guild['current_menu']:
# Updating menu message before playing to prevent delay and avoid FFMPEG lags.
await self.update_menu_full(ctx, menu_message=menu_message, button_callback=button_callback)
if not guild['vibing']:
# Giving FFMPEG enough time to process the audio file
await asyncio.sleep(1)
loop = self._get_current_event_loop(ctx)
try:
vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.next_track(ctx, after=True), loop))
except discord.errors.ClientException as e:
logging.error(f"[VC_EXT] Error while playing track '{track.title}': {e}")
if not isinstance(ctx, RawReactionActionEvent):
await ctx.respond(f"Не удалось проиграть трек. Попробуйте сбросить меню.", delete_after=15, ephemeral=True)
elif self.bot:
channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id))
await channel.send(f"Не удалось проиграть трек. Попробуйте сбросить меню.", delete_after=15)
return None
except yandex_music.exceptions.InvalidBitrateError:
logging.error(f"[VC_EXT] Invalid bitrate while playing track '{track.title}'")
if not isinstance(ctx, RawReactionActionEvent):
await ctx.respond(f"У трека отсутствует необходимый битрейт. Его проигрывание невозможно.", delete_after=15, ephemeral=True)
elif self.bot:
channel = cast(discord.VoiceChannel, self.bot.get_channel(ctx.channel_id))
await channel.send(f"У трека отсутствует необходимый битрейт. Его проигрывание невозможно.", delete_after=15)
return None
logging.info(f"[VC_EXT] Playing track '{track.title}'")
await self.db.update(gid, {'is_stopped': False})
if guild['vibing']:
await self.send_vibe_feedback(ctx, 'trackStarted', track)
return track.title
def _get_current_event_loop(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> asyncio.AbstractEventLoop: def _get_current_event_loop(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> asyncio.AbstractEventLoop:
"""Get the current event loop. If the context is a RawReactionActionEvent, get the loop from the self.bot instance. """Get the current event loop. If the context is a RawReactionActionEvent, get the loop from the self.bot instance.

View File

@@ -204,8 +204,8 @@ class Voice(Cog, VoiceExtension):
@voice.command(name="menu", description="Создать или обновить меню проигрывателя.") @voice.command(name="menu", description="Создать или обновить меню проигрывателя.")
async def menu(self, ctx: discord.ApplicationContext) -> None: async def menu(self, ctx: discord.ApplicationContext) -> None:
logging.info(f"[VOICE] 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}")
if await self.voice_check(ctx): if await self.voice_check(ctx) and not await self.send_menu_message(ctx):
await self.send_menu_message(ctx) await ctx.respond("Не удалось создать меню.", ephemeral=True)
@voice.command(name="join", description="Подключиться к голосовому каналу, в котором вы сейчас находитесь.") @voice.command(name="join", description="Подключиться к голосовому каналу, в котором вы сейчас находитесь.")
async def join(self, ctx: discord.ApplicationContext) -> None: async def join(self, ctx: discord.ApplicationContext) -> None:
@@ -213,17 +213,17 @@ class Voice(Cog, VoiceExtension):
member = cast(discord.Member, ctx.author) member = cast(discord.Member, ctx.author)
guild = await self.db.get_guild(ctx.guild.id, projection={'allow_change_connect': 1}) guild = await self.db.get_guild(ctx.guild.id, projection={'allow_change_connect': 1})
vc = await self.get_voice_client(ctx)
await ctx.defer(ephemeral=True)
if not member.guild_permissions.manage_channels and not guild['allow_change_connect']: if not member.guild_permissions.manage_channels and not guild['allow_change_connect']:
response_message = "У вас нет прав для выполнения этой команды." response_message = "У вас нет прав для выполнения этой команды."
elif vc and vc.is_connected():
response_message = "❌ Бот уже находится в голосовом канале. Выключите его с помощью команды /voice leave."
elif isinstance(ctx.channel, discord.VoiceChannel): elif isinstance(ctx.channel, discord.VoiceChannel):
try: try:
await ctx.channel.connect() await ctx.channel.connect()
except TimeoutError: except TimeoutError:
response_message = "Не удалось подключиться к голосовому каналу." response_message = "Не удалось подключиться к голосовому каналу."
except discord.ClientException:
response_message = "❌ Бот уже находится в голосовом канале. Выключите его с помощью команды /voice leave."
else: else:
response_message = "✅ Подключение успешно!" response_message = "✅ Подключение успешно!"
else: else:
@@ -302,6 +302,10 @@ class Voice(Cog, VoiceExtension):
await self.users_db.update(ctx.user.id, {'queue_page': 0}) await self.users_db.update(ctx.user.id, {'queue_page': 0})
tracks = await self.db.get_tracks_list(ctx.guild.id, 'next') tracks = await self.db.get_tracks_list(ctx.guild.id, 'next')
if len(tracks) == 0:
await ctx.respond("❌ Очередь пуста.", ephemeral=True)
return
embed = generate_queue_embed(0, tracks) embed = generate_queue_embed(0, tracks)
await ctx.respond(embed=embed, view=await QueueView(ctx).init(), ephemeral=True) await ctx.respond(embed=embed, view=await QueueView(ctx).init(), ephemeral=True)
@@ -340,6 +344,7 @@ class Voice(Cog, VoiceExtension):
) )
return return
await ctx.defer(ephemeral=True)
res = await self.stop_playing(ctx, full=True) res = await self.stop_playing(ctx, full=True)
if res: if res:
await ctx.respond("✅ Воспроизведение остановлено.", delete_after=15, ephemeral=True) await ctx.respond("✅ Воспроизведение остановлено.", delete_after=15, ephemeral=True)
@@ -435,18 +440,16 @@ class Voice(Cog, VoiceExtension):
} }
) )
return return
feedback = await self.update_vibe(ctx, _type, _id)
if not feedback: if not await self.update_vibe(ctx, _type, _id):
await ctx.respond("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15, ephemeral=True) await ctx.respond("❌ Операция не удалась. Возможно, у вес нет подписки на Яндекс Музыку.", delete_after=15, ephemeral=True)
return return
if guild['current_menu']: if guild['current_menu']:
await ctx.respond("✅ Моя Волна включена.", delete_after=15, ephemeral=True) await ctx.respond("✅ Моя Волна включена.", delete_after=15, ephemeral=True)
else: elif not await self.send_menu_message(ctx, disable=True):
await self.send_menu_message(ctx, disable=True) await ctx.respond("Не удалось отправить меню. Попробуйте позже.", delete_after=15, ephemeral=True)
next_track = await self.db.get_track(ctx.guild_id, 'next') next_track = await self.db.get_track(ctx.guild_id, 'next')
if next_track: if next_track:
await self._play_track(ctx, next_track) await self.play_track(ctx, next_track)

View File

@@ -1,11 +1,12 @@
import os import os
import logging import logging
from aiohttp import ClientSession
import discord import discord
from discord.ext.commands import Bot from discord.ext.commands import Bot
from discord.ext import tasks
intents = discord.Intents.default() intents = discord.Intents.default()
intents.message_content = True
bot = Bot(intents=intents) bot = Bot(intents=intents)
cogs_list = [ cogs_list = [
@@ -19,6 +20,22 @@ async def on_ready():
logging.info("Bot's ready!") logging.info("Bot's ready!")
await bot.change_presence(activity=discord.Activity(type=discord.ActivityType.listening, name="/voice vibe")) await bot.change_presence(activity=discord.Activity(type=discord.ActivityType.listening, name="/voice vibe"))
@tasks.loop(seconds=3600)
async def update_server_count():
# Don't update server count in debug mode
if os.getenv('DEBUG') == 'True':
return
async with ClientSession() as session:
if token := os.getenv('PROMO_TOKEN_1'):
res = await session.post(
'https://api.server-discord.com/v2/bots/1325795708019806250/stats',
headers={'Authorization': token},
data={'servers': len(bot.guilds), 'shards': bot.shard_count or 1}
)
if not res.ok:
logging.error(f'Failed to update server count 1: {res.status} {await res.text()}')
if __name__ == '__main__': if __name__ == '__main__':
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()

View File

@@ -19,15 +19,18 @@ class PlayButton(Button, VoiceExtension):
logging.debug(f"[FIND] Callback triggered for type: '{type(self.item).__name__}'") logging.debug(f"[FIND] Callback triggered for type: '{type(self.item).__name__}'")
if not interaction.guild: if not interaction.guild:
logging.warning("[FIND] No guild found in PlayButton callback") logging.info("[FIND] No guild found in PlayButton callback")
await interaction.respond("❌ Эта команда доступна только на серверах.", ephemeral=True, delete_after=15)
return return
if not await self.voice_check(interaction): if not await self.voice_check(interaction):
logging.debug("[FIND] Voice check failed in PlayButton callback")
return return
gid = interaction.guild.id guild = await self.db.get_guild(interaction.guild.id, projection={'current_track': 1, 'current_menu': 1, 'vote_add': 1, 'vibing': 1})
guild = await self.db.get_guild(gid, projection={'current_track': 1, 'current_menu': 1, 'vote_add': 1}) if guild['vibing']:
await interaction.respond("❌ Нельзя добавлять треки в очередь, пока запущена волна.", ephemeral=True, delete_after=15)
return
channel = cast(discord.VoiceChannel, interaction.channel) channel = cast(discord.VoiceChannel, interaction.channel)
member = cast(discord.Member, interaction.user) member = cast(discord.Member, interaction.user)
@@ -41,7 +44,7 @@ class PlayButton(Button, VoiceExtension):
album = await self.item.with_tracks_async() album = await self.item.with_tracks_async()
if not album or not album.volumes: if not album or not album.volumes:
logging.debug("[FIND] Failed to fetch album tracks in PlayButton callback") logging.debug("[FIND] Failed to fetch album tracks in PlayButton callback")
await interaction.respond("Не удалось получить треки альбома.", ephemeral=True) await interaction.respond("Не удалось получить треки альбома.", ephemeral=True, delete_after=15)
return return
tracks = [track for volume in album.volumes for track in volume] tracks = [track for volume in album.volumes for track in volume]
@@ -53,7 +56,7 @@ class PlayButton(Button, VoiceExtension):
artist_tracks = await self.item.get_tracks_async() artist_tracks = await self.item.get_tracks_async()
if not artist_tracks: if not artist_tracks:
logging.debug("[FIND] Failed to fetch artist tracks in PlayButton callback") logging.debug("[FIND] Failed to fetch artist tracks in PlayButton callback")
await interaction.respond("Не удалось получить треки артиста.", ephemeral=True) await interaction.respond("Не удалось получить треки артиста.", ephemeral=True, delete_after=15)
return return
tracks = artist_tracks.tracks.copy() tracks = artist_tracks.tracks.copy()
@@ -65,7 +68,7 @@ class PlayButton(Button, VoiceExtension):
short_tracks = await self.item.fetch_tracks_async() short_tracks = await self.item.fetch_tracks_async()
if not short_tracks: if not short_tracks:
logging.debug("[FIND] Failed to fetch playlist tracks in PlayButton callback") logging.debug("[FIND] Failed to fetch playlist tracks in PlayButton callback")
await interaction.respond("Не удалось получить треки из плейлиста.", delete_after=15) await interaction.respond("Не удалось получить треки из плейлиста.", ephemeral=True, delete_after=15)
return return
tracks = [cast(Track, short_track.track) for short_track in short_tracks] tracks = [cast(Track, short_track.track) for short_track in short_tracks]
@@ -77,7 +80,7 @@ class PlayButton(Button, VoiceExtension):
tracks = self.item.copy() tracks = self.item.copy()
if not tracks: if not tracks:
logging.debug("[FIND] Empty tracks list in PlayButton callback") logging.debug("[FIND] Empty tracks list in PlayButton callback")
await interaction.respond("Не удалось получить треки.", delete_after=15) await interaction.respond("Не удалось получить треки.", ephemeral=True, delete_after=15)
return return
action = 'add_playlist' action = 'add_playlist'
@@ -97,7 +100,7 @@ class PlayButton(Button, VoiceExtension):
await response.add_reaction('') await response.add_reaction('')
await self.db.update_vote( await self.db.update_vote(
gid, interaction.guild.id,
response.id, response.id,
{ {
'positive_votes': list(), 'positive_votes': list(),
@@ -109,21 +112,20 @@ class PlayButton(Button, VoiceExtension):
) )
return return
logging.debug(f"[FIND] Skipping vote for '{action}'")
if guild['current_menu']: if guild['current_menu']:
await interaction.respond(response_message, delete_after=15) await interaction.respond(response_message, delete_after=15)
else: elif not await self.send_menu_message(interaction, disable=True):
await self.send_menu_message(interaction, disable=True) await interaction.respond('Не удалось отправить сообщение.', ephemeral=True, delete_after=15)
if guild['current_track'] is not None: if guild['current_track']:
logging.debug(f"[FIND] Adding tracks to queue") logging.debug(f"[FIND] Adding tracks to queue")
await self.db.modify_track(gid, tracks, 'next', 'extend') await self.db.modify_track(interaction.guild.id, tracks, 'next', 'extend')
else: else:
logging.debug(f"[FIND] Playing track") logging.debug(f"[FIND] Playing track")
track = tracks.pop(0) track = tracks.pop(0)
await self.db.modify_track(gid, tracks, 'next', 'extend') await self.db.modify_track(interaction.guild.id, tracks, 'next', 'extend')
await self.play_track(interaction, track) if not await self.play_track(interaction, track):
await interaction.respond('Не удалось воспроизвести трек.', ephemeral=True, delete_after=15)
if interaction.message: if interaction.message:
await interaction.message.delete() await interaction.message.delete()
@@ -138,12 +140,17 @@ class MyVibeButton(Button, VoiceExtension):
async def callback(self, interaction: discord.Interaction): async def callback(self, interaction: discord.Interaction):
logging.debug(f"[VIBE] Button callback for '{type(self.item).__name__}'") logging.debug(f"[VIBE] Button callback for '{type(self.item).__name__}'")
if not await self.voice_check(interaction): if not await self.voice_check(interaction):
return return
gid = interaction.guild_id if not interaction.guild_id or not interaction.user:
if not gid: logging.warning(f"[VIBE] Guild ID or user is None in button callback")
logging.warning(f"[VIBE] Guild ID is None in button callback") return
guild = await self.db.get_guild(interaction.guild_id, projection={'current_menu': 1, 'vibing': 1})
if guild['vibing']:
await interaction.respond('❌ Волна уже запущена. Остановите её с помощью команды /voice stop.', ephemeral=True, delete_after=15)
return return
track_type_map = { track_type_map = {
@@ -153,7 +160,7 @@ class MyVibeButton(Button, VoiceExtension):
if isinstance(self.item, Playlist): if isinstance(self.item, Playlist):
if not self.item.owner: if not self.item.owner:
logging.warning(f"[VIBE] Playlist owner is None") logging.warning(f"[VIBE] Playlist owner is None")
await interaction.respond("Не удалось получить информацию о плейлисте.", ephemeral=True) await interaction.respond("Не удалось получить информацию о плейлисте. Отсутствует владелец.", ephemeral=True, delete_after=15)
return return
_id = self.item.owner.login + '_' + str(self.item.kind) _id = self.item.owner.login + '_' + str(self.item.kind)
@@ -162,16 +169,50 @@ class MyVibeButton(Button, VoiceExtension):
else: else:
_id = 'onyourwave' _id = 'onyourwave'
await self.send_menu_message(interaction, disable=True) member = cast(discord.Member, interaction.user)
await self.update_vibe( channel = cast(discord.VoiceChannel, interaction.channel)
interaction,
track_type_map[type(self.item)], if len(channel.members) > 2 and not member.guild_permissions.manage_channels:
_id logging.info(f"Starting vote for starting vibe in guild {interaction.guild_id}")
)
next_track = await self.db.get_track(gid, 'next') match self.item:
if next_track: case Track():
await self._play_track(interaction, next_track) response_message = f"{member.mention} хочет запустить волну по треку **{self.item['title']}**.\n\n Выполнить действие?"
case Album():
response_message = f"{member.mention} хочет запустить волну по альбому **{self.item['title']}**.\n\n Выполнить действие?"
case Artist():
response_message = f"{member.mention} хочет запустить волну по исполнителю **{self.item['name']}**.\n\n Выполнить действие?"
case Playlist():
response_message = f"{member.mention} хочет запустить волну по плейлисту **{self.item['title']}**.\n\n Выполнить действие?"
case list():
response_message = f"{member.mention} хочет запустить станцию **Моя Волна**.\n\n Выполнить действие?"
message = cast(discord.Interaction, await interaction.respond(response_message))
response = await message.original_response()
await response.add_reaction('')
await response.add_reaction('')
await self.db.update_vote(
interaction.guild_id,
response.id,
{
'positive_votes': list(),
'negative_votes': list(),
'total_members': len(channel.members),
'action': 'vibe_station',
'vote_content': [track_type_map[type(self.item)], _id, interaction.user.id]
}
)
return
if not guild['current_menu'] and not await self.send_menu_message(interaction, disable=True):
await interaction.respond('Не удалось отправить сообщение.', ephemeral=True, delete_after=15)
await self.update_vibe(interaction, track_type_map[type(self.item)], _id)
if (next_track := await self.db.get_track(interaction.guild_id, 'next')):
await self.play_track(interaction, next_track)
class ListenView(View): class ListenView(View):
def __init__(self, item: Track | Album | Artist | Playlist | list[Track], *items: Item, timeout: float | None = 360, disable_on_timeout: bool = True): def __init__(self, item: Track | Album | Artist | Playlist | list[Track], *items: Item, timeout: float | None = 360, disable_on_timeout: bool = True):
@@ -192,6 +233,7 @@ class ListenView(View):
link_web = f"https://music.yandex.ru/playlist/{item.playlist_uuid}" link_web = f"https://music.yandex.ru/playlist/{item.playlist_uuid}"
elif isinstance(item, list): # Can't open other person's likes elif isinstance(item, list): # Can't open other person's likes
self.add_item(PlayButton(item, label="Слушать в голосовом канале", style=ButtonStyle.gray)) self.add_item(PlayButton(item, label="Слушать в голосовом канале", style=ButtonStyle.gray))
self.add_item(MyVibeButton(item, label="Моя Волна", style=ButtonStyle.gray, emoji="🌊", row=1))
return return
self.button1: Button = Button(label="Слушать в приложении", style=ButtonStyle.gray, url=link_app, row=0) self.button1: Button = Button(label="Слушать в приложении", style=ButtonStyle.gray, url=link_app, row=0)
@@ -208,6 +250,6 @@ class ListenView(View):
async def on_timeout(self) -> None: async def on_timeout(self) -> None:
try: try:
return await super().on_timeout() return await super().on_timeout()
except discord.NotFound: except discord.HTTPException:
pass pass
self.stop() self.stop()

View File

@@ -2,7 +2,10 @@ import logging
from typing import Self, cast from typing import Self, cast
from discord.ui import View, Button, Item, Select from discord.ui import View, Button, Item, Select
from discord import VoiceChannel, ButtonStyle, Interaction, ApplicationContext, RawReactionActionEvent, Embed, ComponentType, SelectOption, Member from discord import (
Interaction, ApplicationContext, RawReactionActionEvent,
VoiceChannel, ButtonStyle, Embed, ComponentType, SelectOption, Member, HTTPException
)
import yandex_music.exceptions import yandex_music.exceptions
from yandex_music import TrackLyrics, Playlist, ClientAsync as YMClient from yandex_music import TrackLyrics, Playlist, ClientAsync as YMClient
@@ -275,11 +278,45 @@ class MyVibeButton(Button, VoiceExtension):
if not await self.voice_check(interaction): if not await self.voice_check(interaction):
return return
if not interaction.guild_id: if not interaction.guild_id or not interaction.user:
logging.warning('[MENU] No guild id in button callback') logging.warning('[MENU] No guild id or user in button callback')
return
member = cast(Member, interaction.user)
channel = cast(VoiceChannel, interaction.channel)
track = await self.db.get_track(interaction.guild_id, 'current')
if len(channel.members) > 2 and not member.guild_permissions.manage_channels:
logging.info(f"Starting vote for starting vibe in guild {interaction.guild_id}")
if track:
response_message = f"{member.mention} хочет запустить волну по треку **{track['title']}**.\n\n Выполнить действие?"
_type = 'track'
_id = track['id']
else:
response_message = f"{member.mention} хочет запустить станцию **Моя Волна**.\n\n Выполнить действие?"
_type = 'user'
_id = 'onyourwave'
message = cast(Interaction, await interaction.respond(response_message))
response = await message.original_response()
await response.add_reaction('')
await response.add_reaction('')
await self.db.update_vote(
interaction.guild_id,
response.id,
{
'positive_votes': list(),
'negative_votes': list(),
'total_members': len(channel.members),
'action': 'vibe_station',
'vote_content': [_type, _id, interaction.user.id]
}
)
return return
track = await self.db.get_track(interaction.guild_id, 'current')
if track: if track:
logging.info(f"[MENU] Playing vibe for track '{track["id"]}'") logging.info(f"[MENU] Playing vibe for track '{track["id"]}'")
res = await self.update_vibe( res = await self.update_vibe(
@@ -296,14 +333,12 @@ class MyVibeButton(Button, VoiceExtension):
) )
if not res: if not res:
logging.warning('[MENU] Failed to start the vibe') logging.info('[MENU] Failed to start the vibe')
await interaction.respond('Не удалось запустить "Мою Волну". Попробуйте позже.', ephemeral=True) await interaction.respond('Не удалось запустить "Мою Волну". Возможно, у вас нет подписки на Яндекс Музыку.', ephemeral=True)
next_track = await self.db.get_track(interaction.guild_id, 'next') next_track = await self.db.get_track(interaction.guild_id, 'next')
if next_track: if next_track:
# Need to avoid additional feedback. await self.play_track(interaction, next_track, button_callback=True)
# TODO: Make it more elegant
await self._play_track(interaction, next_track, button_callback=True)
class MyVibeSelect(Select, VoiceExtension): class MyVibeSelect(Select, VoiceExtension):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -350,7 +385,7 @@ class MyVibeSelect(Select, VoiceExtension):
await interaction.edit(view=view) await interaction.edit(view=view)
class MyVibeSettingsView(View, VoiceExtension): class MyVibeSettingsView(View, VoiceExtension):
def __init__(self, interaction: Interaction, *items: Item, timeout: float | None = None, disable_on_timeout: bool = True): def __init__(self, interaction: Interaction, *items: Item, timeout: float | None = 360, disable_on_timeout: bool = True):
View.__init__(self, *items, timeout=timeout, disable_on_timeout=disable_on_timeout) View.__init__(self, *items, timeout=timeout, disable_on_timeout=disable_on_timeout)
VoiceExtension.__init__(self, None) VoiceExtension.__init__(self, None)
self.interaction = interaction self.interaction = interaction
@@ -412,6 +447,13 @@ class MyVibeSettingsView(View, VoiceExtension):
self.add_item(select) self.add_item(select)
return self return self
async def on_timeout(self) -> None:
try:
return await super().on_timeout()
except HTTPException:
pass
self.stop()
class MyVibeSettingsButton(Button, VoiceExtension): class MyVibeSettingsButton(Button, VoiceExtension):
def __init__(self, **kwargs): def __init__(self, **kwargs):
@@ -455,18 +497,32 @@ class AddToPlaylistSelect(Select, VoiceExtension):
if not current_track: if not current_track:
return return
res = await self.ym_client.users_playlists_insert_track( tracks = [track.id for track in playlist.tracks]
kind=f"{playlist.kind}", track_in_playlist = current_track['id'] in tracks
track_id=current_track['id'],
album_id=current_track['albums'][0]['id'], if track_in_playlist:
revision=playlist.revision or 1, index = tracks.index(current_track['id'])
user_id=f"{playlist.uid}" res = await self.ym_client.users_playlists_delete_track(
) kind=f"{playlist.kind}",
from_=index,
if res: to=index + 1,
await interaction.respond('✅ Добавлено в плейлист', delete_after=15, ephemeral=True) revision=playlist.revision or 1
)
else: else:
res = await self.ym_client.users_playlists_insert_track(
kind=f"{playlist.kind}",
track_id=current_track['id'],
album_id=current_track['albums'][0]['id'],
revision=playlist.revision or 1
)
if not res:
await interaction.respond('❌ Что-то пошло не так. Попробуйте позже.', delete_after=15, ephemeral=True) await interaction.respond('❌ Что-то пошло не так. Попробуйте позже.', delete_after=15, ephemeral=True)
elif track_in_playlist:
await interaction.respond('🗑 Трек был удалён из плейлиста.', delete_after=15, ephemeral=True)
else:
await interaction.respond('📩 Трек был добавлен в плейлист.', delete_after=15, ephemeral=True)
class AddToPlaylistButton(Button, VoiceExtension): class AddToPlaylistButton(Button, VoiceExtension):
def __init__(self, **kwargs): def __init__(self, **kwargs):
@@ -477,6 +533,11 @@ class AddToPlaylistButton(Button, VoiceExtension):
if not await self.voice_check(interaction) or not interaction.guild_id: if not await self.voice_check(interaction) or not interaction.guild_id:
return return
current_track = await self.db.get_track(interaction.guild_id, 'current')
if not current_track:
await interaction.respond('❌ Нет воспроизводимого трека.', delete_after=15, ephemeral=True)
return
client = await self.init_ym_client(interaction) client = await self.init_ym_client(interaction)
if not client: if not client:
await interaction.respond('❌ Что-то пошло не так. Попробуйте позже.', delete_after=15, ephemeral=True) await interaction.respond('❌ Что-то пошло не так. Попробуйте позже.', delete_after=15, ephemeral=True)
@@ -527,12 +588,12 @@ class MenuView(View, VoiceExtension):
self.add_to_playlist_button = AddToPlaylistButton(style=ButtonStyle.secondary, emoji='📁', row=1) self.add_to_playlist_button = AddToPlaylistButton(style=ButtonStyle.secondary, emoji='📁', row=1)
self.vibe_button = MyVibeButton(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) self.vibe_settings_button = MyVibeSettingsButton(style=ButtonStyle.success, emoji='🛠', row=1)
async def init(self, *, disable: bool = False) -> Self: async def init(self, *, disable: bool = False) -> Self:
if not self.ctx.guild_id: if not self.ctx.guild_id:
return self return self
self.guild = await self.db.get_guild(self.ctx.guild_id) self.guild = await self.db.get_guild(self.ctx.guild_id, projection={'repeat': 1, 'shuffle': 1, 'current_track': 1, 'vibing': 1})
if self.guild['repeat']: if self.guild['repeat']:
self.repeat_button.style = ButtonStyle.success self.repeat_button.style = ButtonStyle.success
@@ -583,10 +644,12 @@ class MenuView(View, VoiceExtension):
if self.guild['current_menu']: if self.guild['current_menu']:
await self.stop_playing(self.ctx) await self.stop_playing(self.ctx)
await self.db.update(self.ctx.guild_id, {'current_menu': None, 'previous_tracks': [], 'vibing': False}) await 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']) message = await self.get_menu_message(self.ctx, self.guild['current_menu'])
if message: if message:
await message.delete() await message.delete()
logging.debug('[MENU] Successfully deleted menu message') logging.debug('[MENU] Successfully deleted menu message')
else: else:
logging.debug('[MENU] No menu message found') logging.debug('[MENU] No menu message found')
self.stop() self.stop()

View File

@@ -2,7 +2,7 @@ from math import ceil
from typing import Self, Any from typing import Self, Any
from discord.ui import View, Button, Item from discord.ui import View, Button, Item
from discord import ApplicationContext, ButtonStyle, Interaction, Embed from discord import ApplicationContext, ButtonStyle, Interaction, Embed, HTTPException
from MusicBot.cogs.utils.voice_extension import VoiceExtension from MusicBot.cogs.utils.voice_extension import VoiceExtension
@@ -27,10 +27,11 @@ class QueueNextButton(Button, VoiceExtension):
def __init__(self, **kwargs): def __init__(self, **kwargs):
Button.__init__(self, **kwargs) Button.__init__(self, **kwargs)
VoiceExtension.__init__(self, None) VoiceExtension.__init__(self, None)
async def callback(self, interaction: Interaction) -> None: async def callback(self, interaction: Interaction) -> None:
if not interaction.user or not interaction.guild: if not interaction.user or not interaction.guild:
return return
user = await self.users_db.get_user(interaction.user.id) user = await self.users_db.get_user(interaction.user.id)
page = user['queue_page'] + 1 page = user['queue_page'] + 1
await self.users_db.update(interaction.user.id, {'queue_page': page}) await self.users_db.update(interaction.user.id, {'queue_page': page})
@@ -42,10 +43,11 @@ class QueuePrevButton(Button, VoiceExtension):
def __init__(self, **kwargs): def __init__(self, **kwargs):
Button.__init__(self, **kwargs) Button.__init__(self, **kwargs)
VoiceExtension.__init__(self, None) VoiceExtension.__init__(self, None)
async def callback(self, interaction: Interaction) -> None: async def callback(self, interaction: Interaction) -> None:
if not interaction.user or not interaction.guild: if not interaction.user or not interaction.guild:
return return
user = await self.users_db.get_user(interaction.user.id) user = await self.users_db.get_user(interaction.user.id)
page = user['queue_page'] - 1 page = user['queue_page'] - 1
await self.users_db.update(interaction.user.id, {'queue_page': page}) await self.users_db.update(interaction.user.id, {'queue_page': page})
@@ -54,30 +56,36 @@ class QueuePrevButton(Button, VoiceExtension):
await interaction.edit(embed=embed, view=await QueueView(interaction).init()) await interaction.edit(embed=embed, view=await QueueView(interaction).init())
class QueueView(View, VoiceExtension): class QueueView(View, VoiceExtension):
def __init__(self, ctx: ApplicationContext | Interaction, *items: Item, timeout: float | None = 360, disable_on_timeout: bool = True): def __init__(self, ctx: ApplicationContext | Interaction, *items: Item, timeout: float | None = 360, disable_on_timeout: bool = False):
View.__init__(self, *items, timeout=timeout, disable_on_timeout=disable_on_timeout) View.__init__(self, *items, timeout=timeout, disable_on_timeout=disable_on_timeout)
VoiceExtension.__init__(self, None) VoiceExtension.__init__(self, None)
self.ctx = ctx self.ctx = ctx
self.next_button = QueueNextButton(style=ButtonStyle.primary, emoji='▶️') self.next_button = QueueNextButton(style=ButtonStyle.primary, emoji='▶️')
self.prev_button = QueuePrevButton(style=ButtonStyle.primary, emoji='◀️') self.prev_button = QueuePrevButton(style=ButtonStyle.primary, emoji='◀️')
async def init(self) -> Self: async def init(self) -> Self:
if not self.ctx.user or not self.ctx.guild: if not self.ctx.user or not self.ctx.guild:
return self return self
tracks = await self.db.get_tracks_list(self.ctx.guild.id, 'next') tracks = await self.db.get_tracks_list(self.ctx.guild.id, 'next')
user = await self.users_db.get_user(self.ctx.user.id) user = await self.users_db.get_user(self.ctx.user.id)
count = 15 * user['queue_page'] count = 15 * user['queue_page']
if not tracks[count + 15:]: if not tracks[count + 15:]:
self.next_button.disabled = True self.next_button.disabled = True
if not tracks[:count]: if not tracks[:count]:
self.prev_button.disabled = True self.prev_button.disabled = True
self.add_item(self.prev_button) self.add_item(self.prev_button)
self.add_item(self.next_button) self.add_item(self.next_button)
return self return self
async def on_timeout(self) -> None:
try:
await super().on_timeout()
except HTTPException:
pass
self.stop()