diff --git a/MusicBot/cogs/general.py b/MusicBot/cogs/general.py index b287d44..ad38cdb 100644 --- a/MusicBot/cogs/general.py +++ b/MusicBot/cogs/general.py @@ -19,7 +19,7 @@ def setup(bot): bot.add_cog(General(bot)) async def get_search_suggestions(ctx: discord.AutocompleteContext) -> list[str]: - if not ctx.interaction.user or not ctx.value: + if not ctx.interaction.user or not ctx.value or len(ctx.value) < 2: return [] users_db = BaseUsersDatabase() @@ -93,10 +93,11 @@ class General(Cog): if command == 'all': embed.description = ( - "Этот бот позволяет слушать музыку из вашего аккаунта Yandex Music.\n" + "Этот бот позволяет слушать музыку из вашего аккаунта Яндекс Музыки.\n" "Зарегистрируйте свой токен с помощью /login. Его можно получить [здесь](https://github.com/MarshalX/yandex-music-api/discussions/513).\n" "Для получения помощи по конкретной команде, введите /help <команда>.\n" "Для изменения настроек необходимо иметь права управления каналами на сервере.\n\n" + "Помните, что это **не замена Яндекс Музыки**, а лишь её дополнение. Не ожидайте безупречного звука.\n\n" "**Для дополнительной помощи, присоединяйтесь к [серверу любителей Яндекс Музыки](https://discord.gg/gkmFDaPMeC).**" ) diff --git a/MusicBot/cogs/utils/voice_extension.py b/MusicBot/cogs/utils/voice_extension.py index 75ec370..e0366d7 100644 --- a/MusicBot/cogs/utils/voice_extension.py +++ b/MusicBot/cogs/utils/voice_extension.py @@ -1,5 +1,7 @@ import asyncio +import aiofiles import logging +import io from typing import Any, Literal, cast from time import time @@ -228,6 +230,16 @@ class VoiceExtension: logging.debug(f"[VIBE] Radio started feedback: {feedback}") tracks = await client.rotor_station_tracks(f"{type}:{id}") self.db.update(gid, {'vibing': True}) + + if update_settings: + settings = user['vibe_settings'] + await client.rotor_station_settings2( + f"{type}:{id}", + mood_energy=settings['mood'], + diversity=settings['diversity'], + language=settings['lang'] + ) + elif guild['current_track']: if update_settings: settings = user['vibe_settings'] @@ -404,7 +416,12 @@ class VoiceExtension: await channel.send(f"😔 Не удалось загрузить трек. Попробуйте сбросить меню.", delete_after=15) return None - song = discord.FFmpegPCMAudio(f'music/{gid}.mp3', options='-vn -filter:a "volume=0.15"') + 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 -filter:a "volume=0.15"') + if not guild['current_menu']: + await asyncio.sleep(1) + vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.next_track(ctx, after=True), loop)) logging.info(f"[VC_EXT] Playing track '{track.title}'") @@ -422,13 +439,25 @@ class VoiceExtension: return track.title - async def stop_playing(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, vc: discord.VoiceClient | None = None) -> None: + async def stop_playing( + self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, + *, + vc: discord.VoiceClient | None = None, + full: bool = False + ) -> None: gid = ctx.guild_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.guild.id if ctx.guild else None - if not gid: + uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None + + if not gid or not uid: logging.warning("[VC_EXT] Guild ID not found in context") return + guild = self.db.get_guild(gid) + + if gid in menu_views: + menu_views[gid].stop() + del menu_views[gid] if not vc: vc = await self.get_voice_client(ctx) if vc: @@ -436,6 +465,49 @@ class VoiceExtension: self.db.update(gid, {'current_track': None, 'is_stopped': True}) vc.stop() + if full: + if guild['current_menu']: + menu = await self.get_menu_message(ctx, guild['current_menu']) + if menu: + await menu.delete() + + self.db.update(gid, { + 'current_menu': None, 'repeat': False, 'shuffle': False, 'previous_tracks': [], 'next_tracks': [], 'vibing': False + }) + logging.info(f"[VOICE] Playback stopped in guild {gid}") + + if guild['vibing']: + user = self.users_db.get_user(uid) + token = user['ym_token'] + if not token: + logging.info(f"[VOICE] User {uid} has no YM token") + if not isinstance(ctx, RawReactionActionEvent): + await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True) + return + + client = await self.init_ym_client(ctx, user['ym_token']) + if not client: + logging.info(f"[VOICE] Failed to init YM client for user {uid}") + if not isinstance(ctx, RawReactionActionEvent): + await ctx.respond("❌ Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True) + return + + track = guild['current_track'] + if not track: + logging.info(f"[VOICE] No current track in guild {gid}") + if not isinstance(ctx, RawReactionActionEvent): + await ctx.respond("❌ Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True) + return + + res = await client.rotor_station_feedback_track_finished( + f"{user['vibe_type']}:{user['vibe_id']}", + track['id'], + track['duration_ms'] // 1000, + cast(str, user['vibe_batch_id']), + time() + ) + logging.info(f"[VOICE] User {uid} finished vibing with result: {res}") + async def next_track( self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, @@ -543,7 +615,7 @@ class VoiceExtension: next_track, client=client # type: ignore # Async client can be used here. ) - await self.stop_playing(ctx, vc) + await self.stop_playing(ctx, vc=vc) title = await self.play_track( ctx, ym_track, # type: ignore # de_json should always work here. @@ -696,6 +768,34 @@ class VoiceExtension: await client.users_likes_tracks_remove(ym_track.id, client.me.account.uid) return 'TRACK REMOVED' + async def dislike_track(self, ctx: ApplicationContext | Interaction) -> bool: + """Dislike current track. Return track title on success. + + Args: + ctx (ApplicationContext | Interaction): Context. + + Returns: + str | None: Track title or None. + """ + if not ctx.guild or not ctx.user: + logging.warning("[VC_EXT] Guild or User not found in context inside 'dislike_track'") + return False + + current_track = self.db.get_track(ctx.guild.id, 'current') + if not current_track: + logging.debug("[VC_EXT] Current track not found in 'dislike_track'") + return False + + client = await self.init_ym_client(ctx) + if not client: + return False + + res = await client.users_dislikes_tracks_add( + current_track['id'], + client.me.account.uid # type: ignore + ) + return res + async def _retry_update_menu_embed( self, ctx: ApplicationContext | Interaction, diff --git a/MusicBot/cogs/voice.py b/MusicBot/cogs/voice.py index 7423720..e9edced 100644 --- a/MusicBot/cogs/voice.py +++ b/MusicBot/cogs/voice.py @@ -1,5 +1,4 @@ import logging -from time import time from typing import cast import discord @@ -241,11 +240,8 @@ class Voice(Cog, VoiceExtension): await ctx.respond("❌ У вас нет прав для выполнения этой команды.", delete_after=15, ephemeral=True) return - vc = await self.get_voice_client(ctx) - if await self.voice_check(ctx) and vc: - self.db.update(ctx.guild.id, {'previous_tracks': [], 'next_tracks': [], 'current_track': None, 'is_stopped': True}) - vc.stop() - await vc.disconnect(force=True) + if await self.voice_check(ctx): + await self.stop_playing(ctx, full=True) await ctx.respond("Отключение успешно!", delete_after=15, ephemeral=True) logging.info(f"[VOICE] Successfully disconnected from voice channel in guild {ctx.guild.id}") @@ -338,51 +334,7 @@ class Voice(Cog, VoiceExtension): await ctx.respond("❌ Вы не можете остановить воспроизведение, пока в канале находятся другие пользователи.", delete_after=15, ephemeral=True) elif await self.voice_check(ctx): - guild = self.db.get_guild(ctx.guild.id) - await self.stop_playing(ctx) - - if guild['current_menu']: - menu = await self.get_menu_message(ctx, guild['current_menu']) - if menu: - await menu.delete() - - self.db.update(ctx.guild.id, { - 'current_menu': None, 'repeat': False, 'shuffle': False, 'previous_tracks': [], 'next_tracks': [], 'vibing': False - }) - logging.info(f"[VOICE] Playback stopped in guild {ctx.guild.id}") - - if guild['vibing']: - user = self.users_db.get_user(ctx.user.id) - token = user['ym_token'] - if not token: - logging.info(f"[VOICE] User {ctx.user.id} has no YM token") - await ctx.respond("❌ Укажите токен через /account login.", delete_after=15, ephemeral=True) - return - - client = await self.init_ym_client(ctx, user['ym_token']) - if not client: - logging.info(f"[VOICE] Failed to init YM client for user {ctx.user.id}") - await ctx.respond("❌ Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True) - return - - track = guild['current_track'] - if not track: - logging.info(f"[VOICE] No current track in guild {ctx.guild.id}") - await ctx.respond("❌ Что-то пошло не так. Попробуйте позже.", delete_after=15, ephemeral=True) - return - - res = await client.rotor_station_feedback_track_finished( - f"{user['vibe_type']}:{user['vibe_id']}", - track['id'], - track['duration_ms'] // 1000, - cast(str, user['vibe_batch_id']), - time() - ) - logging.info(f"[VOICE] User {ctx.user.id} finished vibing with result: {res}") - - if ctx.guild.id in menu_views: - menu_views[ctx.guild.id].stop() - del menu_views[ctx.guild.id] + await self.stop_playing(ctx, full=True) await ctx.respond("Воспроизведение остановлено.", delete_after=15, ephemeral=True) @@ -453,7 +405,7 @@ class Voice(Cog, VoiceExtension): logging.info(f"[VOICE] Track added to favorites for user {ctx.author.id} in guild {ctx.guild.id}") await ctx.respond(f"Трек **{result}** был добавлен в избранное.", delete_after=15, ephemeral=True) - @track.command(name='vibe', description="Запустить мою волну по текущему треку.") + @track.command(name='vibe', description="Запустить Мою Волну по текущему треку.") async def track_vibe(self, ctx: discord.ApplicationContext) -> None: logging.info(f"[VOICE] Vibe (track) command invoked by user {ctx.author.id} in guild {ctx.guild.id}") if not await self.voice_check(ctx): diff --git a/MusicBot/database/extensions.py b/MusicBot/database/extensions.py index 18ee53a..af6c34c 100644 --- a/MusicBot/database/extensions.py +++ b/MusicBot/database/extensions.py @@ -129,7 +129,7 @@ class VoiceGuildsDatabase(BaseGuildsDatabase): tracks = self.get_tracks_list(gid, 'next') if not tracks: return None - track = tracks.pop() + track = tracks.pop(randint(0, len(tracks))) self.update(gid, {'next_tracks': tracks}) return track diff --git a/MusicBot/ui/menu.py b/MusicBot/ui/menu.py index 5a748fe..d05dae8 100644 --- a/MusicBot/ui/menu.py +++ b/MusicBot/ui/menu.py @@ -5,7 +5,7 @@ from discord.ui import View, Button, Item, Select from discord import VoiceChannel, ButtonStyle, Interaction, ApplicationContext, RawReactionActionEvent, Embed, ComponentType, SelectOption import yandex_music.exceptions -from yandex_music import Track, ClientAsync +from yandex_music import Track, Playlist, ClientAsync as YMClient from MusicBot.cogs.utils.voice_extension import VoiceExtension, menu_views class ToggleRepeatButton(Button, VoiceExtension): @@ -119,6 +119,27 @@ class LikeButton(Button, VoiceExtension): menu_views[gid] = await MenuView(interaction).init() await interaction.edit(view=menu_views[gid]) +class DislikeButton(Button, VoiceExtension): + def __init__(self, **kwargs): + Button.__init__(self, **kwargs) + VoiceExtension.__init__(self, None) + + async def callback(self, interaction: Interaction) -> None: + logging.info('[MENU] Dislike button callback...') + if not await self.voice_check(interaction): + return + + if not (vc := await self.get_voice_client(interaction)) or not vc.is_playing: + await interaction.respond("❌ Нет воспроизводимого трека.", delete_after=15, ephemeral=True) + + res = await self.dislike_track(interaction) + if res: + logging.debug("[VC_EXT] Disliked track") + await self.next_track(interaction, vc=vc, button_callback=True) + else: + logging.debug("[VC_EXT] Failed to dislike track") + await interaction.respond("❌ Не удалось поставить дизлайк. Попробуйте позже.") + class LyricsButton(Button, VoiceExtension): def __init__(self, **kwargs): Button.__init__(self, **kwargs) @@ -137,7 +158,7 @@ class LyricsButton(Button, VoiceExtension): track = cast(Track, Track.de_json( current_track, - ClientAsync(ym_token), # type: ignore # Async client can be used here + YMClient(ym_token), # type: ignore # Async client can be used here )) try: @@ -300,11 +321,85 @@ class MyVibeSettingsButton(Button, VoiceExtension): async def callback(self, interaction: Interaction) -> None: logging.info('[VIBE] My vibe settings button callback') - if not await self.voice_check(interaction) or not interaction.user: + if not await self.voice_check(interaction): return await interaction.respond('Настройки "Моей Волны"', view=MyVibeSettingsView(interaction), ephemeral=True) +class AddToPlaylistSelect(Select, VoiceExtension): + def __init__(self, ym_client: YMClient, *args, **kwargs): + super().__init__(*args, **kwargs) + VoiceExtension.__init__(self, None) + self.ym_client = ym_client + + async def callback(self, interaction: Interaction): + if not interaction.data or not interaction.guild_id: + return + if not interaction.data or 'values' not in interaction.data: + logging.warning('[MENU] No data in select callback') + return + + data = interaction.data['values'][0].split(';') + logging.debug(f"[MENU] Add to playlist select callback: {data}") + + playlist = cast(Playlist, await self.ym_client.users_playlists(kind=data[0], user_id=data[1])) + current_track = self.db.get_track(interaction.guild_id, 'current') + if not current_track: + return + + try: + 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, + user_id=f"{playlist.uid}" + ) + except yandex_music.exceptions.NetworkError: + res = None + + # value=f"{playlist.kind or "-1"};{current_track['id']};{current_track['albums'][0]['id']};{playlist.revision};{playlist.uid}" + + if res: + await interaction.respond('✅ Добавлено в плейлист', delete_after=15, ephemeral=True) + else: + await interaction.respond('❌ Что-то пошло не так. Попробуйте позже.', delete_after=15, ephemeral=True) + +class AddToPlaylistButton(Button, VoiceExtension): + def __init__(self, **kwargs): + Button.__init__(self, **kwargs) + VoiceExtension.__init__(self, None) + + async def callback(self, interaction: Interaction): + if not await self.voice_check(interaction) or not interaction.guild_id: + return + + client = await self.init_ym_client(interaction) + if not client or not client.me or not client.me.account or not client.me.account.uid: + await interaction.respond('❌ Что-то пошло не так. Попробуйте позже.', ephemeral=True) + return + + if not (vc := await self.get_voice_client(interaction)) or not vc.is_playing: + await interaction.respond("❌ Нет воспроизводимого трека.", delete_after=15, ephemeral=True) + return + + view = View( + AddToPlaylistSelect( + client, + ComponentType.string_select, + placeholder='Выберите плейлист', + options=[ + SelectOption( + label=playlist.title or "Без названия", + value=f"{playlist.kind or "-1"};{playlist.uid}" + ) for playlist in await client.users_playlists_list(client.me.account.uid) + ] + ) + ) + + await interaction.respond(view=view, ephemeral=True, delete_after=360) + + class MenuView(View, VoiceExtension): def __init__(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, *items: Item, timeout: float | None = 3600, disable_on_timeout: bool = False): @@ -322,7 +417,9 @@ class MenuView(View, VoiceExtension): self.prev_button = PrevTrackButton(style=ButtonStyle.primary, emoji='⏮', row=0) self.like_button = LikeButton(style=ButtonStyle.secondary, emoji='❤️', row=1) + self.dislike_button = DislikeButton(style=ButtonStyle.secondary, emoji='💔', row=1) self.lyrics_button = LyricsButton(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_settings_button = MyVibeSettingsButton(style=ButtonStyle.success, emoji='🛠', row=1) @@ -345,7 +442,9 @@ class MenuView(View, VoiceExtension): self.lyrics_button.disabled = True self.add_item(self.like_button) + self.add_item(self.dislike_button) self.add_item(self.lyrics_button) + self.add_item(self.add_to_playlist_button) if self.guild['vibing']: self.add_item(self.vibe_settings_button)