From c49ff949cf2a5987007d29ea27573bf46060e81a Mon Sep 17 00:00:00 2001 From: Lemon4ksan Date: Sun, 26 Jan 2025 22:07:47 +0300 Subject: [PATCH] feat: Basic "My Vibe" implementation. --- MusicBot/cogs/general.py | 2 +- MusicBot/cogs/settings.py | 2 +- MusicBot/cogs/utils/embeds.py | 18 +- MusicBot/cogs/utils/voice_extension.py | 263 +++++++++++++++++++++---- MusicBot/cogs/voice.py | 111 +++++++---- MusicBot/database/base.py | 30 ++- MusicBot/database/extensions.py | 4 +- MusicBot/database/guild.py | 8 +- MusicBot/database/user.py | 8 +- MusicBot/ui/find.py | 10 +- MusicBot/ui/menu.py | 10 +- 11 files changed, 350 insertions(+), 116 deletions(-) diff --git a/MusicBot/cogs/general.py b/MusicBot/cogs/general.py index 3684ed8..ccac822 100644 --- a/MusicBot/cogs/general.py +++ b/MusicBot/cogs/general.py @@ -316,7 +316,7 @@ class General(Cog): if not result: logging.warning(f"Failed to search for '{name}' for user {ctx.user.id}") - await ctx.respond("❌ Что-то пошло не так. Повторите попытку позже", delete_after=15, ephemeral=True) + await ctx.respond("❌ Что-то пошло не так. Повторите попытку позже.", delete_after=15, ephemeral=True) return if content_type == 'Трек': diff --git a/MusicBot/cogs/settings.py b/MusicBot/cogs/settings.py index 463ffa0..92ae96a 100644 --- a/MusicBot/cogs/settings.py +++ b/MusicBot/cogs/settings.py @@ -10,7 +10,7 @@ def setup(bot): class Settings(Cog): - settings = discord.SlashCommandGroup("settings", "Команды для изменения настроек бота.", guild_ids=[1247100229535141899]) + settings = discord.SlashCommandGroup("settings", "Команды для изменения настроек бота.") def __init__(self, bot: discord.Bot): self.db = BaseGuildsDatabase() diff --git a/MusicBot/cogs/utils/embeds.py b/MusicBot/cogs/utils/embeds.py index a8925d5..0f9b614 100644 --- a/MusicBot/cogs/utils/embeds.py +++ b/MusicBot/cogs/utils/embeds.py @@ -10,7 +10,7 @@ from PIL import Image from yandex_music import Track, Album, Artist, Playlist, Label from discord import Embed -async def generate_item_embed(item: Track | Album | Artist | Playlist | list[Track]) -> Embed: +async def generate_item_embed(item: Track | Album | Artist | Playlist | list[Track], vibing: bool = False) -> Embed: """Generate item embed. Args: @@ -22,17 +22,23 @@ async def generate_item_embed(item: Track | Album | Artist | Playlist | list[Tra logging.debug(f"Generating embed for type: '{type(item).__name__}'") if isinstance(item, Track): - return await _generate_track_embed(item) + embed = await _generate_track_embed(item) elif isinstance(item, Album): - return await _generate_album_embed(item) + embed = await _generate_album_embed(item) elif isinstance(item, Artist): - return await _generate_artist_embed(item) + embed = await _generate_artist_embed(item) elif isinstance(item, Playlist): - return await _generate_playlist_embed(item) + embed = await _generate_playlist_embed(item) elif isinstance(item, list): - return _generate_likes_embed(item) + embed = _generate_likes_embed(item) else: raise ValueError(f"Unknown item type: {type(item).__name__}") + + if vibing: + embed.set_image( + url="https://media0.giphy.com/media/v1.Y2lkPTc5MGI3NjExbjd6M3VscnZnMXFlb3NtMHY2Zm5tbTVvMm8yY21nNXhpN214YzhyaCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/7HxhnYcJljc3ON77O3/giphy.gif" + ) # TODO: Get better gif + return embed def _generate_likes_embed(tracks: list[Track]) -> Embed: track_count = len(tracks) diff --git a/MusicBot/cogs/utils/voice_extension.py b/MusicBot/cogs/utils/voice_extension.py index 6e9159e..9255631 100644 --- a/MusicBot/cogs/utils/voice_extension.py +++ b/MusicBot/cogs/utils/voice_extension.py @@ -1,8 +1,10 @@ import asyncio import logging from typing import Any, Literal, cast +from time import time -from yandex_music import Track, TrackShort, ClientAsync +import yandex_music.exceptions +from yandex_music import Track, TrackShort, ClientAsync as YMClient import discord from discord import Interaction, ApplicationContext, RawReactionActionEvent @@ -17,22 +19,65 @@ class VoiceExtension: self.db = VoiceGuildsDatabase() self.users_db = BaseUsersDatabase() - async def update_menu_embed(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, player_mid: int) -> bool: + async def send_menu_message(self, ctx: ApplicationContext | Interaction) -> None: + from MusicBot.ui import MenuView + logging.info(f"Sending player menu") + + if not ctx.guild: + logging.warning("Guild not found in context inside 'create_menu'") + return + + guild = self.db.get_guild(ctx.guild.id) + embed = None + + if guild['current_track']: + embed = await generate_item_embed( + Track.de_json( + guild['current_track'], + client=YMClient() # type: ignore # Async client can be used here. + ), + guild['vibing'] + ) + vc = await self.get_voice_client(ctx) + if vc and vc.is_paused(): + embed.set_footer(text='Приостановлено') + else: + embed.remove_footer() + + if guild['current_menu']: + logging.info(f"Deleteing old player menu {guild['current_menu']} in guild {ctx.guild.id}") + message = await self.get_menu_message(ctx, guild['current_menu']) + if message: + await message.delete() + + interaction = cast(discord.Interaction, await ctx.respond(view=await MenuView(ctx).init(), embed=embed)) + response = await interaction.original_response() + self.db.update(ctx.guild.id, {'current_menu': response.id}) + + logging.info(f"New player menu {response.id} created in guild {ctx.guild.id}") + + async def update_menu_embed( + self, + ctx: ApplicationContext | Interaction | RawReactionActionEvent, + player_mid: int, + button_callback: bool = False + ) -> bool: """Update current player message by its id. Return True if updated, False if not. Args: ctx (ApplicationContext | Interaction): Context. player_mid (int): Id of the player message. There can only be only one player in the guild. + button_callback (bool, optional): If True, the interaction is a button interaction. Defaults to False. Returns: bool: True if updated, False if not. """ from MusicBot.ui import MenuView logging.debug( - f"Updating player embed using " + + f"Updating player embed using " + ( "interaction context" if isinstance(ctx, Interaction) else "application context" if isinstance(ctx, ApplicationContext) else - "raw reaction context" + "raw reaction context") ) gid = ctx.guild_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.guild.id if ctx.guild else None @@ -51,29 +96,114 @@ class VoiceExtension: logging.debug(f"No token found for user {uid}") return False - current_track = self.db.get_track(gid, 'current') + guild = self.db.get_guild(gid) + current_track = guild['current_track'] if not current_track: logging.debug("No current track found") return False track = cast(Track, Track.de_json( current_track, - client=ClientAsync(token) # type: ignore # Async client can be used here. + client=YMClient(token) # type: ignore # Async client can be used here. )) - embed = await generate_item_embed(track) + + embed = await generate_item_embed(track, guild['vibing']) - if isinstance(ctx, Interaction) and ctx.message and ctx.message.id == player_mid: - # If interaction from player buttons - await ctx.edit(embed=embed, view=await MenuView(ctx).init()) - else: - # If interaction from other buttons or commands. They should have their own response. - await player.edit(embed=embed, view=await MenuView(ctx).init()) + try: + if isinstance(ctx, Interaction) and button_callback: + # If interaction from player buttons + await ctx.edit(embed=embed, view=await MenuView(ctx).init()) + else: + # If interaction from other buttons or commands. They should have their own response. + await player.edit(embed=embed, view=await MenuView(ctx).init()) + except discord.NotFound: + return False return True + async def update_vibe( + self, ctx: ApplicationContext | Interaction, + type: Literal['track', 'album', 'artist', 'playlist', 'user'] | None = None, + id: str | int | None = None, + button_callback: bool = False + ) -> str | None: + """Get next vibe track. Return track title on success. If type or id is None, user's vibe will be used. + + Args: + ctx (ApplicationContext | Interaction): Context. + type (Literal['track', 'album', 'artist', 'playlist', 'user'] | None, optional): Type of the item. Defaults to None. + id (str | int | Literal['onyourwave'] | None, optional): ID of the item. Defaults to None. + button_callback (bool, optional): If the function is called from button callback. Defaults to False. + + Returns: + str | None: Track title or None. + """ + logging.info(f"Updating vibe for guild {ctx.guild_id} with type '{type}' and id '{id}'") + + gid = ctx.guild_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.guild.id if ctx.guild else None + uid = ctx.user_id if isinstance(ctx, discord.RawReactionActionEvent) else ctx.user.id if ctx.user else None + if not uid or not gid: + logging.warning("Guild ID or User ID not found in context inside 'vibe_update'") + return None + + token = self.users_db.get_ym_token(uid) + if not token: + logging.info(f"User {uid} has no YM token") + await ctx.respond("❌ Укажите токен через /account login.", ephemeral=True) + return + + try: + client = await YMClient(token).init() + except yandex_music.exceptions.UnauthorizedError: + logging.info(f"User {uid} provided invalid token") + await ctx.respond('❌ Недействительный токен.') + return + + if type and id: + self.users_db.update(uid, {'vibe_type': type, 'vibe_id': id}) + else: + logging.info(f"[VIBE] Using user's vibe for guild {gid}") + type = 'user' + id = 'onyourwave' + + guild = self.db.get_guild(gid) + if not guild['vibing']: + feedback = await client.rotor_station_feedback_radio_started( + f"{type}:{id}", + f"desktop-user-{client.me.account.uid}", # type: ignore + timestamp=time() + ) + logging.debug(f"[VIBE] Radio started feedback: {feedback}") + + tracks = await client.rotor_station_tracks( + f"{type}:{id}" + ) + self.db.update(gid, {'vibing': True}) + elif guild['current_track']: + tracks = await client.rotor_station_tracks( + f"{type}:{id}", + queue=guild['current_track']['id'] + ) + else: + tracks = None + + if not tracks: + logging.warning("[VIBE] Failed to get next vibe tracks") + await ctx.respond("❌ Что-то пошло не так. Повторите попытку позже.", ephemeral=True) + return + + logging.debug(f"[VIBE] Got next vibe tracks: {[track.track.title for track in tracks.sequence if track.track]}") + self.users_db.update(uid, {'vibe_batch_id': tracks.batch_id}) + + next_tracks = [cast(Track, track.track) for track in tracks.sequence] + + self.db.update(gid, {'next_tracks': [track.to_dict() for track in next_tracks[1:]]}) + await self.stop_playing(ctx) + return await self.play_track(ctx, next_tracks[0], button_callback=button_callback) + async def get_menu_message(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, player_mid: int) -> discord.Message | None: """Fetch the player message by its id. Return the message if found, None if not. - Reset `current_player` field in the database if not found. + Reset `current_menu` field in the database if not found. Args: ctx (ApplicationContext | Interaction): Context. @@ -101,14 +231,14 @@ class VoiceExtension: raise ValueError(f"Invalid context type: '{type(ctx).__name__}'.") except discord.DiscordException as e: logging.debug(f"Failed to get player message: {e}") - self.db.update(ctx.guild_id, {'current_player': None}) + self.db.update(ctx.guild_id, {'current_menu': None}) return None if player: logging.debug(f"Player message found") else: - logging.debug("Player message not found. Resetting current_player field.") - self.db.update(ctx.guild_id, {'current_player': None}) + logging.debug("Player message not found. Resetting current_menu field.") + self.db.update(ctx.guild_id, {'current_menu': None}) return player @@ -184,7 +314,9 @@ class VoiceExtension: self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, track: Track, - vc: discord.VoiceClient | None = None + *, + vc: discord.VoiceClient | None = None, + button_callback: bool = False ) -> str | None: """Download ``track`` by its id and play it in the voice channel. Return track title on success. If sound is already playing, add track id to the queue. There's no response to the context. @@ -193,6 +325,7 @@ class VoiceExtension: ctx (ApplicationContext | Interaction): Context track (Track): Track to play. vc (discord.VoiceClient | None): Voice client. + button_callback (bool): Whether the interaction is a button callback. Returns: str | None: Song title or None. @@ -220,8 +353,14 @@ class VoiceExtension: raise ValueError(f"Invalid context type: '{type(ctx).__name__}'.") guild = self.db.get_guild(gid) - await track.download_async(f'music/{gid}.mp3') - song = discord.FFmpegPCMAudio(f'music/{gid}.mp3', options='-vn -filter:a "volume=0.15"') + try: + await track.download_async(f'music/{gid}.mp3') + song = discord.FFmpegPCMAudio(f'music/{gid}.mp3', options='-vn -filter:a "volume=0.15"') + except yandex_music.exceptions.TimedOutError: # Not sure why that happens. Probably should add timeout for buttons. + if not isinstance(ctx, RawReactionActionEvent) and ctx.user and ctx.channel: + channel = cast(discord.VoiceChannel, ctx.channel) + await channel.send(f"😔 {ctx.user.mention}, не удалось загрузить трек. Яндекс Музыка не отвечает или блокирует запросы.") + return None vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.next_track(ctx, after=True), loop)) logging.info(f"Playing track '{track.title}'") @@ -229,9 +368,19 @@ class VoiceExtension: self.db.set_current_track(gid, track) self.db.update(gid, {'is_stopped': False}) - player = guild['current_player'] + player = guild['current_menu'] if player is not None: - await self.update_menu_embed(ctx, player) + await self.update_menu_embed(ctx, player, button_callback) + + if guild['vibing']: + user = self.users_db.get_user(uid) + feedback = await cast(YMClient, track.client).rotor_station_feedback_track_started( + f"{user['vibe_type']}:{user['vibe_id']}", + track.id, + user['vibe_batch_id'], # type: ignore # wrong typehints + time() + ) + logging.debug(f"[VIBE] Track started feedback: {feedback}") return track.title @@ -249,13 +398,13 @@ class VoiceExtension: self.db.update(gid, {'current_track': None, 'is_stopped': True}) vc.stop() - async def next_track( self, ctx: ApplicationContext | Interaction | RawReactionActionEvent, vc: discord.VoiceClient | None = None, *, - after: bool = False + after: bool = False, + button_callback: bool = False ) -> str | None: """Switch to the next track in the queue. Return track title on success. Doesn't change track if stopped. Stop playing if tracks list is empty. @@ -264,6 +413,7 @@ class VoiceExtension: ctx (ApplicationContext | Interaction): Context vc (discord.VoiceClient, optional): Voice client. after (bool, optional): Whether the function is being called by the after callback. Defaults to False. + button_interaction (bool, optional): Whether the function is being called by a button interaction. Defaults to False. Returns: str | None: Track title or None. @@ -275,13 +425,16 @@ class VoiceExtension: return None guild = self.db.get_guild(gid) + user = self.users_db.get_user(uid) token = self.users_db.get_ym_token(uid) if not token: logging.debug(f"No token found for user {uid}") return None + + client = await YMClient(token).init() - if guild['is_stopped']: - logging.debug("Playback is stopped, skipping...") + if guild['is_stopped'] and after: + logging.debug("Playback is stopped, skipping after callback...") return None if not vc: @@ -289,6 +442,28 @@ class VoiceExtension: if not vc: # Silently return if bot got kicked return None + if guild['vibing'] and not isinstance(ctx, RawReactionActionEvent): + if guild['current_track']: + if after: + res = await client.rotor_station_feedback_track_finished( + f'{user['vibe_type']}:{user['vibe_id']}', + guild['current_track']['id'], + guild['current_track']['duration_ms'] // 1000, + user['vibe_batch_id'], # type: ignore # Wrong typehints + time() + ) + logging.debug(f"[VIBE] Finished track: {res}") + else: + res = await client.rotor_station_feedback_skip( + f'{user['vibe_type']}:{user['vibe_id']}', + guild['current_track']['id'], + guild['current_track']['duration_ms'] // 1000, + user['vibe_batch_id'], # type: ignore # Wrong typehints + time() + ) + logging.debug(f"[VIBE] Skipped track: {res}") + return await self.update_vibe(ctx, user['vibe_type'], user['vibe_id'], button_callback) + if guild['repeat'] and after: logging.debug("Repeating current track") next_track = guild['current_track'] @@ -299,37 +474,42 @@ class VoiceExtension: logging.debug("Getting next track") next_track = self.db.get_track(gid, 'next') - if guild['current_track'] and guild['current_player'] and not guild['repeat']: + if guild['current_track'] and guild['current_menu'] and not guild['repeat']: logging.debug("Adding current track to history") self.db.modify_track(gid, guild['current_track'], 'previous', 'insert') if next_track: ym_track = Track.de_json( next_track, - client=ClientAsync(token) # type: ignore # Async client can be used here. + client=client # type: ignore # Async client can be used here. ) await self.stop_playing(ctx, vc) title = await self.play_track( ctx, ym_track, # type: ignore # de_json should always work here. - vc + vc=vc, + button_callback=button_callback ) - if after and not guild['current_player'] and not isinstance(ctx, discord.RawReactionActionEvent): + if after and not guild['current_menu'] and not isinstance(ctx, discord.RawReactionActionEvent): await ctx.respond(f"Сейчас играет: **{title}**!", delete_after=15) return title + elif guild['vibing'] and not isinstance(ctx, RawReactionActionEvent): + logging.debug("[VIBE] No next track found, updating vibe") + return await self.update_vibe(ctx, user['vibe_type'], user['vibe_id'], button_callback) logging.info("No next track found") self.db.update(gid, {'is_stopped': True, 'current_track': None}) return None - async def prev_track(self, ctx: ApplicationContext | Interaction) -> str | None: + async def prev_track(self, ctx: ApplicationContext | Interaction, button_callback: bool = False) -> str | None: """Switch to the previous track in the queue. Repeat curren the song if no previous tracks. Return track title on success. Args: ctx (ApplicationContext | Interaction): Context. + button_callback (bool, optional): Whether the command was called by a button interaction. Defaults to False. Returns: str | None: Track title or None. @@ -360,19 +540,19 @@ class VoiceExtension: if track: ym_track = Track.de_json( track, - client=ClientAsync(token) # type: ignore # Async client can be used here. + client=YMClient(token) # type: ignore # Async client can be used here. ) await self.stop_playing(ctx) return await self.play_track( ctx, - ym_track # type: ignore # de_json should always work here. + ym_track, # type: ignore # de_json should always work here. + button_callback=button_callback ) return None async def get_likes(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> list[TrackShort] | None: - """Get liked tracks. Return list of tracks on success. - Return None if no token found. + """Get liked tracks. Return list of tracks on success. Return None if no token found. Args: ctx (ApplicationContext | Interaction): Context. @@ -389,11 +569,14 @@ class VoiceExtension: current_track = self.db.get_track(gid, 'current') token = self.users_db.get_ym_token(uid) - if not current_track or not token: - logging.debug("Current track or token not found") + if not token: + logging.debug(f"No token found for user {uid}") + return None + if not current_track: + logging.debug("Current track not found in 'get_likes'") return None - client = await ClientAsync(token).init() + client = await YMClient(token).init() likes = await client.users_likes_tracks() if not likes: logging.debug("No likes found") @@ -417,10 +600,10 @@ class VoiceExtension: current_track = self.db.get_track(ctx.guild.id, 'current') token = self.users_db.get_ym_token(ctx.user.id) if not current_track or not token: - logging.debug("Current track or token not found") + logging.debug("Current track or token not found in 'like_track'") return None - client = await ClientAsync(token).init() + client = await YMClient(token).init() likes = await self.get_likes(ctx) if not likes: return None diff --git a/MusicBot/cogs/voice.py b/MusicBot/cogs/voice.py index c036447..ef68c63 100644 --- a/MusicBot/cogs/voice.py +++ b/MusicBot/cogs/voice.py @@ -1,13 +1,15 @@ import logging +from time import time from typing import cast import discord from discord.ext.commands import Cog -from yandex_music import Track, ClientAsync +import yandex_music.exceptions +from yandex_music import ClientAsync -from MusicBot.cogs.utils import VoiceExtension, generate_item_embed -from MusicBot.ui import MenuView, QueueView, generate_queue_embed +from MusicBot.cogs.utils import VoiceExtension +from MusicBot.ui import QueueView, generate_queue_embed def setup(bot: discord.Bot): bot.add_cog(Voice(bot)) @@ -29,7 +31,7 @@ class Voice(Cog, VoiceExtension): gid = member.guild.id guild = self.db.get_guild(gid) discord_guild = await self.typed_bot.fetch_guild(gid) - current_player = self.db.get_current_player(gid) + current_menu = self.db.get_current_menu(gid) channel = after.channel or before.channel if not channel: @@ -43,12 +45,12 @@ class Voice(Cog, VoiceExtension): self.db.update(gid, {'previous_tracks': [], 'next_tracks': [], 'current_track': None, 'is_stopped': True}) vc.stop() elif len(channel.members) > 2 and not guild['always_allow_menu']: - if current_player: + if current_menu: logging.info(f"Disabling current player for guild {gid} due to multiple members") - self.db.update(gid, {'current_player': None, 'repeat': False, 'shuffle': False}) + self.db.update(gid, {'current_menu': None, 'repeat': False, 'shuffle': False}) try: - message = await channel.fetch_message(current_player) + message = await channel.fetch_message(current_menu) await message.delete() await channel.send("Меню отключено из-за большого количества участников.", delete_after=15) except (discord.NotFound, discord.Forbidden): @@ -191,42 +193,16 @@ class Voice(Cog, VoiceExtension): @voice.command(name="menu", description="Создать меню проигрывателя. Доступно только если вы единственный в голосовом канале.") async def menu(self, ctx: discord.ApplicationContext) -> None: logging.info(f"Menu command invoked by user {ctx.author.id} in guild {ctx.guild.id}") - if not await self.voice_check(ctx): - return guild = self.db.get_guild(ctx.guild.id) channel = cast(discord.VoiceChannel, ctx.channel) - embed = None if len(channel.members) > 2 and not guild['always_allow_menu']: logging.info(f"Action declined: other members are present in the voice channel") await ctx.respond("❌ Вы не единственный в голосовом канале.", ephemeral=True) return - if guild['current_track']: - embed = await generate_item_embed( - Track.de_json( - guild['current_track'], - client=ClientAsync() # type: ignore # Async client can be used here. - ) - ) - vc = await self.get_voice_client(ctx) - if vc and vc.is_paused(): - embed.set_footer(text='Приостановлено') - else: - embed.remove_footer() - - if guild['current_player']: - logging.info(f"Deleteing old player menu {guild['current_player']} in guild {ctx.guild.id}") - message = await self.get_menu_message(ctx, guild['current_player']) - if message: - await message.delete() - - interaction = cast(discord.Interaction, await ctx.respond(view=await MenuView(ctx).init(), embed=embed, delete_after=3600)) - response = await interaction.original_response() - self.db.update(ctx.guild.id, {'current_player': response.id}) - - logging.info(f"New player menu {response.id} created in guild {ctx.guild.id}") + await self.send_menu_message(ctx) @voice.command(name="join", description="Подключиться к голосовому каналу, в котором вы сейчас находитесь.") async def join(self, ctx: discord.ApplicationContext) -> None: @@ -308,7 +284,7 @@ class Voice(Cog, VoiceExtension): if not vc.is_paused(): vc.pause() - player = self.db.get_current_player(ctx.guild.id) + player = self.db.get_current_menu(ctx.guild.id) if player: await self.update_menu_embed(ctx, player) @@ -332,7 +308,7 @@ class Voice(Cog, VoiceExtension): elif await self.voice_check(ctx) and (vc := await self.get_voice_client(ctx)): if vc.is_paused(): vc.resume() - player = self.db.get_current_player(ctx.guild.id) + player = self.db.get_current_menu(ctx.guild.id) if player: await self.update_menu_embed(ctx, player) logging.info(f"Track resumed in guild {ctx.guild.id}") @@ -353,18 +329,48 @@ class Voice(Cog, VoiceExtension): await ctx.respond("❌ Вы не можете остановить воспроизведение, пока в канале находятся другие пользователи.", delete_after=15, ephemeral=True) elif await self.voice_check(ctx): - self.db.update(ctx.guild.id, {'previous_tracks': [], 'next_tracks': []}) await self.stop_playing(ctx) - - current_player = self.db.get_current_player(ctx.guild.id) - if current_player: - player = await self.get_menu_message(ctx, current_player) + + current_menu = self.db.get_current_menu(ctx.guild.id) + if current_menu: + player = await self.get_menu_message(ctx, current_menu) if player: await player.delete() + self.db.update(ctx.guild.id, { + 'current_menu': None, 'repeat': False, 'shuffle': False, 'previous_tracks': [], 'next_tracks': [], 'vibing': False + }) logging.info(f"Playback stopped in guild {ctx.guild.id}") + + guild = self.db.get_guild(ctx.guild_id) + if guild['vibing']: + user = self.users_db.get_user(ctx.user.id) + token = user['ym_token'] + if not token: + logging.info(f"User {ctx.user.id} has no YM token") + await ctx.respond("❌ Укажите токен через /account login.", ephemeral=True) + return - self.db.update(ctx.guild.id, {'current_player': None, 'repeat': False, 'shuffle': False}) + try: + client = await ClientAsync(token).init() + except yandex_music.exceptions.UnauthorizedError: + logging.info(f"User {ctx.user.id} provided invalid token") + await ctx.respond('❌ Недействительный токен.') + return + + track = guild['current_track'] + if not track: + 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"User {ctx.user.id} finished vibing with result: {res}") + await ctx.respond("Воспроизведение остановлено.", delete_after=15, ephemeral=True) @track.command(description="Переключиться на следующую песню в очереди.") @@ -433,3 +439,24 @@ class Voice(Cog, VoiceExtension): else: logging.info(f"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(description="Запустить мою волну по текущему треку.") + async def vibe(self, ctx: discord.ApplicationContext) -> None: + logging.info(f"Vibe command invoked by user {ctx.author.id} in guild {ctx.guild.id}") + if not await self.voice_check(ctx): + return + + guild = self.db.get_guild(ctx.guild.id) + channel = cast(discord.VoiceChannel, ctx.channel) + + if len(channel.members) > 2 and not guild['always_allow_menu']: + logging.info(f"Action declined: other members are present in the voice channel") + await ctx.respond("❌ Вы не единственный в голосовом канале.", ephemeral=True) + return + if not guild['current_track']: + logging.info(f"No current track in {ctx.guild.id}") + await ctx.respond("❌ Нет воспроизводимого трека.", ephemeral=True) + return + + await self.send_menu_message(ctx) + await self.update_vibe(ctx, 'track', guild['current_track']['id']) diff --git a/MusicBot/database/base.py b/MusicBot/database/base.py index 58b123c..9fd1e4d 100644 --- a/MusicBot/database/base.py +++ b/MusicBot/database/base.py @@ -26,7 +26,10 @@ class BaseUsersDatabase: ym_token=None, playlists=[], playlists_page=0, - queue_page=0 + queue_page=0, + vibe_batch_id=None, + vibe_type=None, + vibe_id=None )) def update(self, uid: int, data: User) -> None: @@ -54,14 +57,18 @@ class BaseUsersDatabase: user = users.find_one({'_id': uid}) user = cast(ExplicitUser, user) existing_fields = user.keys() - fields: User = User( + fields: ExplicitUser = ExplicitUser( + _id=0, ym_token=None, playlists=[], playlists_page=0, - queue_page=0 + queue_page=0, + vibe_batch_id=None, + vibe_type=None, + vibe_id=None ) for field, default_value in fields.items(): - if field not in existing_fields and field != '_id': + if field not in existing_fields: user[field] = default_value users.update_one({'_id': uid}, {"$set": {field: default_value}}) @@ -87,7 +94,7 @@ class BaseGuildsDatabase: next_tracks=[], previous_tracks=[], current_track=None, - current_player=None, + current_menu=None, is_stopped=True, allow_explicit=True, always_allow_menu=False, @@ -98,7 +105,8 @@ class BaseGuildsDatabase: vote_add_playlist=True, shuffle=False, repeat=False, - votes={} + votes={}, + vibing=False )) def update(self, gid: int, data: Guild) -> None: @@ -127,11 +135,12 @@ class BaseGuildsDatabase: guild = cast(ExplicitGuild, guild) existing_fields = guild.keys() - fields = Guild( + fields = ExplicitGuild( + _id=0, next_tracks=[], previous_tracks=[], current_track=None, - current_player=None, + current_menu=None, is_stopped=True, allow_explicit=True, always_allow_menu=False, @@ -142,10 +151,11 @@ class BaseGuildsDatabase: vote_add_playlist=True, shuffle=False, repeat=False, - votes={} + votes={}, + vibing=False ) for field, default_value in fields.items(): - if field not in existing_fields and field != '_id': + if field not in existing_fields: guild[field] = default_value guilds.update_one({'_id': gid}, {"$set": {field: default_value}}) diff --git a/MusicBot/database/extensions.py b/MusicBot/database/extensions.py index 81a3e78..452532d 100644 --- a/MusicBot/database/extensions.py +++ b/MusicBot/database/extensions.py @@ -144,7 +144,7 @@ class VoiceGuildsDatabase(BaseGuildsDatabase): track = track.to_dict() self.update(gid, {'current_track': track}) - def get_current_player(self, gid: int) -> int | None: + def get_current_menu(self, gid: int) -> int | None: """Get current player. Args: @@ -153,4 +153,4 @@ class VoiceGuildsDatabase(BaseGuildsDatabase): Returns: int | None: Player message id or None if not present. """ guild = self.get_guild(gid) - return guild['current_player'] \ No newline at end of file + return guild['current_menu'] \ No newline at end of file diff --git a/MusicBot/database/guild.py b/MusicBot/database/guild.py index 10dfa0b..dbf8481 100644 --- a/MusicBot/database/guild.py +++ b/MusicBot/database/guild.py @@ -11,7 +11,7 @@ class Guild(TypedDict, total=False): next_tracks: list[dict[str, Any]] previous_tracks: list[dict[str, Any]] current_track: dict[str, Any] | None - current_player: int | None + current_menu: int | None is_stopped: bool allow_explicit: bool always_allow_menu: bool @@ -23,13 +23,14 @@ class Guild(TypedDict, total=False): shuffle: bool repeat: bool votes: dict[str, MessageVotes] + vibing: bool class ExplicitGuild(TypedDict): _id: int next_tracks: list[dict[str, Any]] previous_tracks: list[dict[str, Any]] current_track: dict[str, Any] | None - current_player: int | None + current_menu: int | None is_stopped: bool # Prevents the `after` callback of play_track allow_explicit: bool always_allow_menu: bool @@ -40,4 +41,5 @@ class ExplicitGuild(TypedDict): vote_add_playlist: bool shuffle: bool repeat: bool - votes: dict[str, MessageVotes] \ No newline at end of file + votes: dict[str, MessageVotes] + vibing: bool \ No newline at end of file diff --git a/MusicBot/database/user.py b/MusicBot/database/user.py index 27adb1b..eb4ed11 100644 --- a/MusicBot/database/user.py +++ b/MusicBot/database/user.py @@ -1,10 +1,13 @@ -from typing import TypedDict +from typing import TypedDict, Literal class User(TypedDict, total=False): ym_token: str | None playlists: list[tuple[str, int]] playlists_page: int queue_page: int + vibe_batch_id: str | None + vibe_type: Literal['track', 'album', 'artist', 'playlist', 'user'] | None + vibe_id: str | int | None class ExplicitUser(TypedDict): _id: int @@ -12,3 +15,6 @@ class ExplicitUser(TypedDict): playlists: list[tuple[str, int]] # name / tracks count playlists_page: int queue_page: int + vibe_batch_id: str | None + vibe_type: Literal['track', 'album', 'artist', 'playlist', 'user'] | None + vibe_id: str | int | None diff --git a/MusicBot/ui/find.py b/MusicBot/ui/find.py index eb16789..0fd5bab 100644 --- a/MusicBot/ui/find.py +++ b/MusicBot/ui/find.py @@ -119,12 +119,12 @@ class PlayButton(Button, VoiceExtension): await self.play_track(interaction, track) response_message = f"Сейчас играет: **{track.title}**!" - current_player = None - if guild['current_player']: - current_player = await self.get_menu_message(interaction, guild['current_player']) + current_menu = None + if guild['current_menu']: + current_menu = await self.get_menu_message(interaction, guild['current_menu']) - if current_player and interaction.message: - logging.debug(f"Deleting interaction message {interaction.message.id}: current player {current_player.id} found") + if current_menu and interaction.message: + logging.debug(f"Deleting interaction message {interaction.message.id}: current player {current_menu.id} found") await interaction.message.delete() else: await interaction.respond(response_message, delete_after=15) diff --git a/MusicBot/ui/menu.py b/MusicBot/ui/menu.py index 1c5f0bc..d07f986 100644 --- a/MusicBot/ui/menu.py +++ b/MusicBot/ui/menu.py @@ -69,7 +69,7 @@ class NextTrackButton(Button, VoiceExtension): logging.info('Next track button callback...') if not await self.voice_check(interaction): return - title = await self.next_track(interaction) + title = await self.next_track(interaction, button_callback=True) if not title: await interaction.respond(f"Нет треков в очереди.", delete_after=15, ephemeral=True) @@ -82,7 +82,7 @@ class PrevTrackButton(Button, VoiceExtension): logging.info('Previous track button callback...') if not await self.voice_check(interaction): return - title = await self.prev_track(interaction) + title = await self.prev_track(interaction, button_callback=True) if not title: await interaction.respond(f"Нет треков в истории.", delete_after=15, ephemeral=True) @@ -185,9 +185,9 @@ class MenuView(View, VoiceExtension): if not self.ctx.guild_id: return - if self.guild['current_player']: - self.db.update(self.ctx.guild_id, {'current_player': None, 'previous_tracks': []}) - message = await self.get_menu_message(self.ctx, self.guild['current_player']) + if self.guild['current_menu']: + self.db.update(self.ctx.guild_id, {'current_menu': None, 'previous_tracks': []}) + message = await self.get_menu_message(self.ctx, self.guild['current_menu']) if message: await message.delete() logging.debug('Successfully deleted menu message')