feat: Basic "My Vibe" implementation.

This commit is contained in:
Lemon4ksan
2025-01-26 22:07:47 +03:00
parent 85f7ee6c6c
commit c49ff949cf
11 changed files with 350 additions and 116 deletions

View File

@@ -316,7 +316,7 @@ class General(Cog):
if not result: if not result:
logging.warning(f"Failed to search for '{name}' for user {ctx.user.id}") 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 return
if content_type == 'Трек': if content_type == 'Трек':

View File

@@ -10,7 +10,7 @@ def setup(bot):
class Settings(Cog): class Settings(Cog):
settings = discord.SlashCommandGroup("settings", "Команды для изменения настроек бота.", guild_ids=[1247100229535141899]) settings = discord.SlashCommandGroup("settings", "Команды для изменения настроек бота.")
def __init__(self, bot: discord.Bot): def __init__(self, bot: discord.Bot):
self.db = BaseGuildsDatabase() self.db = BaseGuildsDatabase()

View File

@@ -10,7 +10,7 @@ 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
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. """Generate item embed.
Args: 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__}'") logging.debug(f"Generating embed for type: '{type(item).__name__}'")
if isinstance(item, Track): if isinstance(item, Track):
return await _generate_track_embed(item) embed = await _generate_track_embed(item)
elif isinstance(item, Album): elif isinstance(item, Album):
return await _generate_album_embed(item) embed = await _generate_album_embed(item)
elif isinstance(item, Artist): elif isinstance(item, Artist):
return await _generate_artist_embed(item) embed = await _generate_artist_embed(item)
elif isinstance(item, Playlist): elif isinstance(item, Playlist):
return await _generate_playlist_embed(item) embed = await _generate_playlist_embed(item)
elif isinstance(item, list): elif isinstance(item, list):
return _generate_likes_embed(item) embed = _generate_likes_embed(item)
else: else:
raise ValueError(f"Unknown item type: {type(item).__name__}") 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: def _generate_likes_embed(tracks: list[Track]) -> Embed:
track_count = len(tracks) track_count = len(tracks)

View File

@@ -1,8 +1,10 @@
import asyncio import asyncio
import logging import logging
from typing import Any, Literal, cast 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 import discord
from discord import Interaction, ApplicationContext, RawReactionActionEvent from discord import Interaction, ApplicationContext, RawReactionActionEvent
@@ -17,22 +19,65 @@ class VoiceExtension:
self.db = VoiceGuildsDatabase() self.db = VoiceGuildsDatabase()
self.users_db = BaseUsersDatabase() 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. """Update current player message by its id. Return True if updated, False if not.
Args: Args:
ctx (ApplicationContext | Interaction): Context. ctx (ApplicationContext | Interaction): Context.
player_mid (int): Id of the player message. There can only be only one player in the guild. 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: Returns:
bool: True if updated, False if not. bool: True if updated, False if not.
""" """
from MusicBot.ui import MenuView from MusicBot.ui import MenuView
logging.debug( logging.debug(
f"Updating player embed using " + f"Updating player embed using " + (
"interaction context" if isinstance(ctx, Interaction) else "interaction context" if isinstance(ctx, Interaction) else
"application context" if isinstance(ctx, ApplicationContext) 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 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}") logging.debug(f"No token found for user {uid}")
return False 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: if not current_track:
logging.debug("No current track found") logging.debug("No current track found")
return False return False
track = cast(Track, Track.de_json( track = cast(Track, Track.de_json(
current_track, 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: try:
# If interaction from player buttons if isinstance(ctx, Interaction) and button_callback:
await ctx.edit(embed=embed, view=await MenuView(ctx).init()) # If interaction from player buttons
else: await ctx.edit(embed=embed, view=await MenuView(ctx).init())
# If interaction from other buttons or commands. They should have their own response. else:
await player.edit(embed=embed, view=await MenuView(ctx).init()) # 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 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: 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. """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: Args:
ctx (ApplicationContext | Interaction): Context. ctx (ApplicationContext | Interaction): Context.
@@ -101,14 +231,14 @@ class VoiceExtension:
raise ValueError(f"Invalid context type: '{type(ctx).__name__}'.") raise ValueError(f"Invalid context type: '{type(ctx).__name__}'.")
except discord.DiscordException as e: except discord.DiscordException as e:
logging.debug(f"Failed to get player message: {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 return None
if player: if player:
logging.debug(f"Player message found") logging.debug(f"Player message found")
else: else:
logging.debug("Player message not found. Resetting current_player field.") logging.debug("Player message not found. Resetting current_menu field.")
self.db.update(ctx.guild_id, {'current_player': None}) self.db.update(ctx.guild_id, {'current_menu': None})
return player return player
@@ -184,7 +314,9 @@ class VoiceExtension:
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
track: Track, track: Track,
vc: discord.VoiceClient | None = None *,
vc: discord.VoiceClient | None = None,
button_callback: bool = False
) -> str | None: ) -> str | None:
"""Download ``track`` by its id and play it in the voice channel. Return track title on success. """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. 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 ctx (ApplicationContext | Interaction): Context
track (Track): Track to play. track (Track): Track to play.
vc (discord.VoiceClient | None): Voice client. vc (discord.VoiceClient | None): Voice client.
button_callback (bool): Whether the interaction is a button callback.
Returns: Returns:
str | None: Song title or None. str | None: Song title or None.
@@ -220,8 +353,14 @@ class VoiceExtension:
raise ValueError(f"Invalid context type: '{type(ctx).__name__}'.") raise ValueError(f"Invalid context type: '{type(ctx).__name__}'.")
guild = self.db.get_guild(gid) guild = self.db.get_guild(gid)
await track.download_async(f'music/{gid}.mp3') try:
song = discord.FFmpegPCMAudio(f'music/{gid}.mp3', options='-vn -filter:a "volume=0.15"') 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)) vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.next_track(ctx, after=True), loop))
logging.info(f"Playing track '{track.title}'") logging.info(f"Playing track '{track.title}'")
@@ -229,9 +368,19 @@ class VoiceExtension:
self.db.set_current_track(gid, track) self.db.set_current_track(gid, track)
self.db.update(gid, {'is_stopped': False}) self.db.update(gid, {'is_stopped': False})
player = guild['current_player'] player = guild['current_menu']
if player is not None: 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 return track.title
@@ -249,13 +398,13 @@ class VoiceExtension:
self.db.update(gid, {'current_track': None, 'is_stopped': True}) self.db.update(gid, {'current_track': None, 'is_stopped': True})
vc.stop() vc.stop()
async def next_track( async def next_track(
self, self,
ctx: ApplicationContext | Interaction | RawReactionActionEvent, ctx: ApplicationContext | Interaction | RawReactionActionEvent,
vc: discord.VoiceClient | None = None, vc: discord.VoiceClient | None = None,
*, *,
after: bool = False after: bool = False,
button_callback: bool = False
) -> str | None: ) -> str | None:
"""Switch to the next track in the queue. Return track title on success. """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. Doesn't change track if stopped. Stop playing if tracks list is empty.
@@ -264,6 +413,7 @@ class VoiceExtension:
ctx (ApplicationContext | Interaction): Context ctx (ApplicationContext | Interaction): 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.
button_interaction (bool, optional): Whether the function is being called by a button interaction. Defaults to False.
Returns: Returns:
str | None: Track title or None. str | None: Track title or None.
@@ -275,13 +425,16 @@ class VoiceExtension:
return None return None
guild = self.db.get_guild(gid) guild = self.db.get_guild(gid)
user = self.users_db.get_user(uid)
token = self.users_db.get_ym_token(uid) token = self.users_db.get_ym_token(uid)
if not token: if not token:
logging.debug(f"No token found for user {uid}") logging.debug(f"No token found for user {uid}")
return None return None
client = await YMClient(token).init()
if guild['is_stopped']: if guild['is_stopped'] and after:
logging.debug("Playback is stopped, skipping...") logging.debug("Playback is stopped, skipping after callback...")
return None return None
if not vc: if not vc:
@@ -289,6 +442,28 @@ class VoiceExtension:
if not vc: # Silently return if bot got kicked if not vc: # Silently return if bot got kicked
return None 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: if guild['repeat'] and after:
logging.debug("Repeating current track") logging.debug("Repeating current track")
next_track = guild['current_track'] next_track = guild['current_track']
@@ -299,37 +474,42 @@ class VoiceExtension:
logging.debug("Getting next track") logging.debug("Getting next track")
next_track = self.db.get_track(gid, 'next') 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") logging.debug("Adding current track to history")
self.db.modify_track(gid, guild['current_track'], 'previous', 'insert') self.db.modify_track(gid, guild['current_track'], 'previous', 'insert')
if next_track: if next_track:
ym_track = Track.de_json( ym_track = Track.de_json(
next_track, 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) await self.stop_playing(ctx, vc)
title = await self.play_track( title = await self.play_track(
ctx, ctx,
ym_track, # type: ignore # de_json should always work here. 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) await ctx.respond(f"Сейчас играет: **{title}**!", delete_after=15)
return title 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") logging.info("No next track found")
self.db.update(gid, {'is_stopped': True, 'current_track': None}) self.db.update(gid, {'is_stopped': True, 'current_track': None})
return 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. """Switch to the previous track in the queue. Repeat curren the song if no previous tracks.
Return track title on success. Return track title on success.
Args: Args:
ctx (ApplicationContext | Interaction): Context. ctx (ApplicationContext | Interaction): Context.
button_callback (bool, optional): Whether the command was called by a button interaction. Defaults to False.
Returns: Returns:
str | None: Track title or None. str | None: Track title or None.
@@ -360,19 +540,19 @@ class VoiceExtension:
if track: if track:
ym_track = Track.de_json( ym_track = Track.de_json(
track, 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) await self.stop_playing(ctx)
return await self.play_track( return await self.play_track(
ctx, 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 return None
async def get_likes(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> list[TrackShort] | None: async def get_likes(self, ctx: ApplicationContext | Interaction | RawReactionActionEvent) -> list[TrackShort] | None:
"""Get liked tracks. Return list of tracks on success. """Get liked tracks. Return list of tracks on success. Return None if no token found.
Return None if no token found.
Args: Args:
ctx (ApplicationContext | Interaction): Context. ctx (ApplicationContext | Interaction): Context.
@@ -389,11 +569,14 @@ class VoiceExtension:
current_track = self.db.get_track(gid, 'current') current_track = self.db.get_track(gid, 'current')
token = self.users_db.get_ym_token(uid) token = self.users_db.get_ym_token(uid)
if not current_track or not token: if not token:
logging.debug("Current track or token not found") 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 return None
client = await ClientAsync(token).init() client = await YMClient(token).init()
likes = await client.users_likes_tracks() likes = await client.users_likes_tracks()
if not likes: if not likes:
logging.debug("No likes found") logging.debug("No likes found")
@@ -417,10 +600,10 @@ class VoiceExtension:
current_track = self.db.get_track(ctx.guild.id, 'current') current_track = self.db.get_track(ctx.guild.id, 'current')
token = self.users_db.get_ym_token(ctx.user.id) token = self.users_db.get_ym_token(ctx.user.id)
if not current_track or not token: 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 return None
client = await ClientAsync(token).init() client = await YMClient(token).init()
likes = await self.get_likes(ctx) likes = await self.get_likes(ctx)
if not likes: if not likes:
return None return None

View File

@@ -1,13 +1,15 @@
import logging import logging
from time import time
from typing import cast from typing import cast
import discord import discord
from discord.ext.commands import Cog 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.cogs.utils import VoiceExtension
from MusicBot.ui import MenuView, QueueView, generate_queue_embed from MusicBot.ui import QueueView, generate_queue_embed
def setup(bot: discord.Bot): def setup(bot: discord.Bot):
bot.add_cog(Voice(bot)) bot.add_cog(Voice(bot))
@@ -29,7 +31,7 @@ class Voice(Cog, VoiceExtension):
gid = member.guild.id gid = member.guild.id
guild = self.db.get_guild(gid) guild = self.db.get_guild(gid)
discord_guild = await self.typed_bot.fetch_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 channel = after.channel or before.channel
if not 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}) self.db.update(gid, {'previous_tracks': [], 'next_tracks': [], 'current_track': None, 'is_stopped': True})
vc.stop() vc.stop()
elif len(channel.members) > 2 and not guild['always_allow_menu']: 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") 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: try:
message = await channel.fetch_message(current_player) message = await channel.fetch_message(current_menu)
await message.delete() await message.delete()
await channel.send("Меню отключено из-за большого количества участников.", delete_after=15) await channel.send("Меню отключено из-за большого количества участников.", delete_after=15)
except (discord.NotFound, discord.Forbidden): except (discord.NotFound, discord.Forbidden):
@@ -191,42 +193,16 @@ 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"Menu command invoked by user {ctx.author.id} in guild {ctx.guild.id}") 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) guild = self.db.get_guild(ctx.guild.id)
channel = cast(discord.VoiceChannel, ctx.channel) channel = cast(discord.VoiceChannel, ctx.channel)
embed = None
if len(channel.members) > 2 and not guild['always_allow_menu']: if len(channel.members) > 2 and not guild['always_allow_menu']:
logging.info(f"Action declined: other members are present in the voice channel") logging.info(f"Action declined: other members are present in the voice channel")
await ctx.respond("❌ Вы не единственный в голосовом канале.", ephemeral=True) await ctx.respond("❌ Вы не единственный в голосовом канале.", ephemeral=True)
return return
if guild['current_track']: await self.send_menu_message(ctx)
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}")
@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:
@@ -308,7 +284,7 @@ class Voice(Cog, VoiceExtension):
if not vc.is_paused(): if not vc.is_paused():
vc.pause() vc.pause()
player = self.db.get_current_player(ctx.guild.id) player = self.db.get_current_menu(ctx.guild.id)
if player: if player:
await self.update_menu_embed(ctx, 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)): elif await self.voice_check(ctx) and (vc := await self.get_voice_client(ctx)):
if vc.is_paused(): if vc.is_paused():
vc.resume() vc.resume()
player = self.db.get_current_player(ctx.guild.id) player = self.db.get_current_menu(ctx.guild.id)
if player: if player:
await self.update_menu_embed(ctx, player) await self.update_menu_embed(ctx, player)
logging.info(f"Track resumed in guild {ctx.guild.id}") 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) await ctx.respond("❌ Вы не можете остановить воспроизведение, пока в канале находятся другие пользователи.", delete_after=15, ephemeral=True)
elif await self.voice_check(ctx): elif await self.voice_check(ctx):
self.db.update(ctx.guild.id, {'previous_tracks': [], 'next_tracks': []})
await self.stop_playing(ctx) await self.stop_playing(ctx)
current_player = self.db.get_current_player(ctx.guild.id) current_menu = self.db.get_current_menu(ctx.guild.id)
if current_player: if current_menu:
player = await self.get_menu_message(ctx, current_player) player = await self.get_menu_message(ctx, current_menu)
if player: if player:
await player.delete() 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}") 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) await ctx.respond("Воспроизведение остановлено.", delete_after=15, ephemeral=True)
@track.command(description="Переключиться на следующую песню в очереди.") @track.command(description="Переключиться на следующую песню в очереди.")
@@ -433,3 +439,24 @@ class Voice(Cog, VoiceExtension):
else: else:
logging.info(f"Track added to favorites for user {ctx.author.id} in guild {ctx.guild.id}") 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) 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'])

View File

@@ -26,7 +26,10 @@ class BaseUsersDatabase:
ym_token=None, ym_token=None,
playlists=[], playlists=[],
playlists_page=0, 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: def update(self, uid: int, data: User) -> None:
@@ -54,14 +57,18 @@ class BaseUsersDatabase:
user = users.find_one({'_id': uid}) user = users.find_one({'_id': uid})
user = cast(ExplicitUser, user) user = cast(ExplicitUser, user)
existing_fields = user.keys() existing_fields = user.keys()
fields: User = User( fields: ExplicitUser = ExplicitUser(
_id=0,
ym_token=None, ym_token=None,
playlists=[], playlists=[],
playlists_page=0, 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(): 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 user[field] = default_value
users.update_one({'_id': uid}, {"$set": {field: default_value}}) users.update_one({'_id': uid}, {"$set": {field: default_value}})
@@ -87,7 +94,7 @@ class BaseGuildsDatabase:
next_tracks=[], next_tracks=[],
previous_tracks=[], previous_tracks=[],
current_track=None, current_track=None,
current_player=None, current_menu=None,
is_stopped=True, is_stopped=True,
allow_explicit=True, allow_explicit=True,
always_allow_menu=False, always_allow_menu=False,
@@ -98,7 +105,8 @@ class BaseGuildsDatabase:
vote_add_playlist=True, vote_add_playlist=True,
shuffle=False, shuffle=False,
repeat=False, repeat=False,
votes={} votes={},
vibing=False
)) ))
def update(self, gid: int, data: Guild) -> None: def update(self, gid: int, data: Guild) -> None:
@@ -127,11 +135,12 @@ class BaseGuildsDatabase:
guild = cast(ExplicitGuild, guild) guild = cast(ExplicitGuild, guild)
existing_fields = guild.keys() existing_fields = guild.keys()
fields = Guild( fields = ExplicitGuild(
_id=0,
next_tracks=[], next_tracks=[],
previous_tracks=[], previous_tracks=[],
current_track=None, current_track=None,
current_player=None, current_menu=None,
is_stopped=True, is_stopped=True,
allow_explicit=True, allow_explicit=True,
always_allow_menu=False, always_allow_menu=False,
@@ -142,10 +151,11 @@ class BaseGuildsDatabase:
vote_add_playlist=True, vote_add_playlist=True,
shuffle=False, shuffle=False,
repeat=False, repeat=False,
votes={} votes={},
vibing=False
) )
for field, default_value in fields.items(): 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 guild[field] = default_value
guilds.update_one({'_id': gid}, {"$set": {field: default_value}}) guilds.update_one({'_id': gid}, {"$set": {field: default_value}})

View File

@@ -144,7 +144,7 @@ class VoiceGuildsDatabase(BaseGuildsDatabase):
track = track.to_dict() track = track.to_dict()
self.update(gid, {'current_track': track}) 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. """Get current player.
Args: Args:
@@ -153,4 +153,4 @@ class VoiceGuildsDatabase(BaseGuildsDatabase):
Returns: int | None: Player message id or None if not present. Returns: int | None: Player message id or None if not present.
""" """
guild = self.get_guild(gid) guild = self.get_guild(gid)
return guild['current_player'] return guild['current_menu']

View File

@@ -11,7 +11,7 @@ class Guild(TypedDict, total=False):
next_tracks: list[dict[str, Any]] next_tracks: list[dict[str, Any]]
previous_tracks: list[dict[str, Any]] previous_tracks: list[dict[str, Any]]
current_track: dict[str, Any] | None current_track: dict[str, Any] | None
current_player: int | None current_menu: int | None
is_stopped: bool is_stopped: bool
allow_explicit: bool allow_explicit: bool
always_allow_menu: bool always_allow_menu: bool
@@ -23,13 +23,14 @@ class Guild(TypedDict, total=False):
shuffle: bool shuffle: bool
repeat: bool repeat: bool
votes: dict[str, MessageVotes] votes: dict[str, MessageVotes]
vibing: bool
class ExplicitGuild(TypedDict): class ExplicitGuild(TypedDict):
_id: int _id: int
next_tracks: list[dict[str, Any]] next_tracks: list[dict[str, Any]]
previous_tracks: list[dict[str, Any]] previous_tracks: list[dict[str, Any]]
current_track: dict[str, Any] | None current_track: dict[str, Any] | None
current_player: int | None current_menu: int | None
is_stopped: bool # Prevents the `after` callback of play_track is_stopped: bool # Prevents the `after` callback of play_track
allow_explicit: bool allow_explicit: bool
always_allow_menu: bool always_allow_menu: bool
@@ -40,4 +41,5 @@ class ExplicitGuild(TypedDict):
vote_add_playlist: bool vote_add_playlist: bool
shuffle: bool shuffle: bool
repeat: bool repeat: bool
votes: dict[str, MessageVotes] votes: dict[str, MessageVotes]
vibing: bool

View File

@@ -1,10 +1,13 @@
from typing import TypedDict from typing import TypedDict, Literal
class User(TypedDict, total=False): class User(TypedDict, total=False):
ym_token: str | None ym_token: str | None
playlists: list[tuple[str, int]] playlists: list[tuple[str, int]]
playlists_page: int playlists_page: int
queue_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): class ExplicitUser(TypedDict):
_id: int _id: int
@@ -12,3 +15,6 @@ class ExplicitUser(TypedDict):
playlists: list[tuple[str, int]] # name / tracks count playlists: list[tuple[str, int]] # name / tracks count
playlists_page: int playlists_page: int
queue_page: int queue_page: int
vibe_batch_id: str | None
vibe_type: Literal['track', 'album', 'artist', 'playlist', 'user'] | None
vibe_id: str | int | None

View File

@@ -119,12 +119,12 @@ class PlayButton(Button, VoiceExtension):
await self.play_track(interaction, track) await self.play_track(interaction, track)
response_message = f"Сейчас играет: **{track.title}**!" response_message = f"Сейчас играет: **{track.title}**!"
current_player = None current_menu = None
if guild['current_player']: if guild['current_menu']:
current_player = await self.get_menu_message(interaction, guild['current_player']) current_menu = await self.get_menu_message(interaction, guild['current_menu'])
if current_player and interaction.message: if current_menu and interaction.message:
logging.debug(f"Deleting interaction message {interaction.message.id}: current player {current_player.id} found") logging.debug(f"Deleting interaction message {interaction.message.id}: current player {current_menu.id} found")
await interaction.message.delete() await interaction.message.delete()
else: else:
await interaction.respond(response_message, delete_after=15) await interaction.respond(response_message, delete_after=15)

View File

@@ -69,7 +69,7 @@ class NextTrackButton(Button, VoiceExtension):
logging.info('Next track button callback...') logging.info('Next track button callback...')
if not await self.voice_check(interaction): if not await self.voice_check(interaction):
return return
title = await self.next_track(interaction) title = await self.next_track(interaction, button_callback=True)
if not title: if not title:
await interaction.respond(f"Нет треков в очереди.", delete_after=15, ephemeral=True) await interaction.respond(f"Нет треков в очереди.", delete_after=15, ephemeral=True)
@@ -82,7 +82,7 @@ class PrevTrackButton(Button, VoiceExtension):
logging.info('Previous track button callback...') logging.info('Previous track button callback...')
if not await self.voice_check(interaction): if not await self.voice_check(interaction):
return return
title = await self.prev_track(interaction) title = await self.prev_track(interaction, button_callback=True)
if not title: if not title:
await interaction.respond(f"Нет треков в истории.", delete_after=15, ephemeral=True) await interaction.respond(f"Нет треков в истории.", delete_after=15, ephemeral=True)
@@ -185,9 +185,9 @@ class MenuView(View, VoiceExtension):
if not self.ctx.guild_id: if not self.ctx.guild_id:
return return
if self.guild['current_player']: if self.guild['current_menu']:
self.db.update(self.ctx.guild_id, {'current_player': None, 'previous_tracks': []}) self.db.update(self.ctx.guild_id, {'current_menu': None, 'previous_tracks': []})
message = await self.get_menu_message(self.ctx, self.guild['current_player']) message = await self.get_menu_message(self.ctx, self.guild['current_menu'])
if message: if message:
await message.delete() await message.delete()
logging.debug('Successfully deleted menu message') logging.debug('Successfully deleted menu message')