From c2feeec15884b88f9d28596841ec07cdcc51a560 Mon Sep 17 00:00:00 2001 From: Lemon4ksan Date: Tue, 14 Jan 2025 17:05:12 +0300 Subject: [PATCH] feat: Add account playlists view and ability to like current track. Update queue embed. --- MusicBot/cogs/general.py | 24 +++++- MusicBot/cogs/utils/misc.py | 137 ++++++++++++++++++++++++++++++++++ MusicBot/cogs/utils/player.py | 24 +----- MusicBot/cogs/utils/voice.py | 55 +++++++++----- MusicBot/cogs/voice.py | 32 +++++--- MusicBot/database/base.py | 5 +- MusicBot/database/user.py | 6 ++ 7 files changed, 229 insertions(+), 54 deletions(-) create mode 100644 MusicBot/cogs/utils/misc.py diff --git a/MusicBot/cogs/general.py b/MusicBot/cogs/general.py index 84d814f..1995a43 100644 --- a/MusicBot/cogs/general.py +++ b/MusicBot/cogs/general.py @@ -1,4 +1,5 @@ -from typing import cast, TypeAlias +from math import ceil +from typing import cast import discord from discord.ext.commands import Cog @@ -12,6 +13,7 @@ from MusicBot.cogs.utils.find import ( process_album, process_track, process_artist, process_playlist, ListenAlbum, ListenTrack, ListenArtist, ListenPlaylist ) +from MusicBot.cogs.utils.misc import MyPlalistsView, generate_playlist_embed def setup(bot): bot.add_cog(General(bot)) @@ -71,7 +73,7 @@ class General(Cog): "```/account login ```\n" "Удалить токен из датабазы бота.\n```/account remove```") elif command == 'queue': - embed.description += ("Получить очередь треков. По 25 элементов на страницу.\n```/queue get```\n" + embed.description += ("Получить очередь треков. По 15 элементов на страницу.\n```/queue get```\n" "Очистить очередь треков и историю прослушивания. Требует согласия части слушателей.\n```/queue clear```\n" "`Примечание`: Если вы один в голосовом канале или имеете роль администратора бота, голосование не требуется.") elif command == 'track': @@ -109,6 +111,22 @@ class General(Cog): self.db.update(ctx.user.id, {'ym_token': None}) await ctx.respond(f'Токен был удалён.', delete_after=15, ephemeral=True) + @account.command(description="Получить плейлисты пользователя.") + async def playlists(self, ctx: discord.ApplicationContext) -> None: + token = self.db.get_ym_token(ctx.user.id) + if not token: + await ctx.respond('❌ Необходимо указать свой токен доступа с помощью команды /login.', delete_after=15, ephemeral=True) + return + client = await YMClient(token).init() + if not client.me or not client.me.account or not client.me.account.uid: + await ctx.respond('❌ Что-то пошло не так. Повторите попытку позже.', delete_after=15, ephemeral=True) + return + playlists_list = await client.users_playlists_list(client.me.account.uid) + playlists: list[tuple[str, int]] = [(playlist.title, playlist.track_count) for playlist in playlists_list] # type: ignore + self.db.update(ctx.user.id, {'playlists': playlists, 'playlists_page': 0}) + embed = generate_playlist_embed(0, playlists) + await ctx.respond(embed=embed, view=MyPlalistsView(ctx), ephemeral=True) + @discord.slash_command(description="Найти контент и отправить информацию о нём. Возвращается лучшее совпадение.") @discord.option( "name", @@ -117,7 +135,7 @@ class General(Cog): ) @discord.option( "content_type", - description="Тип искомого контента (track, album, artist, playlist).", + description="Тип искомого контента.", type=discord.SlashCommandOptionType.string, choices=['Artist', 'Album', 'Track', 'Playlist'], default='Track' diff --git a/MusicBot/cogs/utils/misc.py b/MusicBot/cogs/utils/misc.py new file mode 100644 index 0000000..2e0695a --- /dev/null +++ b/MusicBot/cogs/utils/misc.py @@ -0,0 +1,137 @@ +from math import ceil +from typing import Any + +from discord.ui import View, Button, Item +from discord import ButtonStyle, Interaction, ApplicationContext, Embed + +from MusicBot.cogs.utils.voice import VoiceExtension + +def generate_playlist_embed(page: int, playlists: list[tuple[str, int]]) -> Embed: + count = 15 * page + length = len(playlists) + embed = Embed( + title=f"Всего плейлистов: {length}", + color=0xfed42b + ) + embed.set_author(name="Ваши плейлисты") + embed.set_footer(text=f"Страница {page + 1} из {ceil(length / 10)}") + for playlist in playlists[count:count + 10]: + embed.add_field(name=playlist[0], value=f"{playlist[1]} треков", inline=False) + return embed + +def generate_queue_embed(page: int, tracks_list: list[dict[str, Any]]) -> Embed: + count = 15 * page + length = len(tracks_list) + embed = Embed( + title=f"Всего: {length}", + color=0xfed42b, + ) + embed.set_author(name="Очередь треков") + embed.set_footer(text=f"Страница {page + 1} из {ceil(length / 15)}") + for i, track in enumerate(tracks_list[count:count + 15], start=1 + count): + duration = track['duration_ms'] + duration_m = duration // 60000 + duration_s = ceil(duration / 1000) - duration_m * 60 + embed.add_field(name=f"{i} - {track['title']} - {duration_m}:{duration_s:02d}", value="", inline=False) + return embed + +class MPNextButton(Button, VoiceExtension): + def __init__(self, **kwargs): + Button.__init__(self, **kwargs) + VoiceExtension.__init__(self) + + async def callback(self, interaction: Interaction) -> None: + if not interaction.user: + return + user = self.users_db.get_user(interaction.user.id) + page = user['playlists_page'] + 1 + self.users_db.update(interaction.user.id, {'playlists_page': page}) + embed = generate_playlist_embed(page, user['playlists']) + await interaction.edit(embed=embed, view=MyPlalistsView(interaction)) + +class MPPrevButton(Button, VoiceExtension): + def __init__(self, **kwargs): + Button.__init__(self, **kwargs) + VoiceExtension.__init__(self) + + async def callback(self, interaction: Interaction) -> None: + if not interaction.user: + return + user = self.users_db.get_user(interaction.user.id) + page = user['playlists_page'] - 1 + self.users_db.update(interaction.user.id, {'playlists_page': page}) + embed = generate_playlist_embed(page, user['playlists']) + await interaction.edit(embed=embed, view=MyPlalistsView(interaction)) + +class MyPlalistsView(View, VoiceExtension): + def __init__(self, ctx: ApplicationContext | Interaction, *items: Item, timeout: float | None = 3600, disable_on_timeout: bool = True): + View.__init__(self, *items, timeout=timeout, disable_on_timeout=disable_on_timeout) + VoiceExtension.__init__(self) + if not ctx.user: + return + user = self.users_db.get_user(ctx.user.id) + count = 10 * user['playlists_page'] + + next_button = MPNextButton(style=ButtonStyle.primary, emoji='▶️') + prev_button = MPPrevButton(style=ButtonStyle.primary, emoji='◀️') + + if not user['playlists'][count + 10:]: + next_button.disabled = True + if not user['playlists'][:count]: + prev_button.disabled = True + + self.add_item(prev_button) + self.add_item(next_button) + + +class QNextButton(Button, VoiceExtension): + def __init__(self, **kwargs): + Button.__init__(self, **kwargs) + VoiceExtension.__init__(self) + + async def callback(self, interaction: Interaction) -> None: + if not interaction.user or not interaction.guild: + return + user = self.users_db.get_user(interaction.user.id) + page = user['queue_page'] + 1 + self.users_db.update(interaction.user.id, {'queue_page': page}) + tracks = self.db.get_tracks_list(interaction.guild.id, 'next') + embed = generate_queue_embed(page, tracks) + await interaction.edit(embed=embed, view=QueueView(interaction)) + +class QPrevButton(Button, VoiceExtension): + def __init__(self, **kwargs): + Button.__init__(self, **kwargs) + VoiceExtension.__init__(self) + + async def callback(self, interaction: Interaction) -> None: + if not interaction.user or not interaction.guild: + return + user = self.users_db.get_user(interaction.user.id) + page = user['queue_page'] - 1 + self.users_db.update(interaction.user.id, {'queue_page': page}) + tracks = self.db.get_tracks_list(interaction.guild.id, 'next') + embed = generate_queue_embed(page, tracks) + await interaction.edit(embed=embed, view=QueueView(interaction)) + +class QueueView(View, VoiceExtension): + def __init__(self, ctx: ApplicationContext | Interaction, *items: Item, timeout: float | None = 3600, disable_on_timeout: bool = True): + View.__init__(self, *items, timeout=timeout, disable_on_timeout=disable_on_timeout) + VoiceExtension.__init__(self) + if not ctx.user or not ctx.guild: + return + + tracks = self.db.get_tracks_list(ctx.guild.id, 'next') + user = self.users_db.get_user(ctx.user.id) + count = 15 * user['queue_page'] + + next_button = QNextButton(style=ButtonStyle.primary, emoji='▶️') + prev_button = QPrevButton(style=ButtonStyle.primary, emoji='◀️') + + if not tracks[count + 15:]: + next_button.disabled = True + if not tracks[:count]: + prev_button.disabled = True + + self.add_item(prev_button) + self.add_item(next_button) \ No newline at end of file diff --git a/MusicBot/cogs/utils/player.py b/MusicBot/cogs/utils/player.py index 1984b35..3e91ab5 100644 --- a/MusicBot/cogs/utils/player.py +++ b/MusicBot/cogs/utils/player.py @@ -86,32 +86,16 @@ class Player(View, VoiceExtension): return guild = self.db.get_guild(ctx.guild.id) - self.ctx = ctx - - self.repeat_button_off = ToggleRepeatButton(style=ButtonStyle.secondary, emoji='🔂', row=0) - self.repeat_button_on = ToggleRepeatButton(style=ButtonStyle.success, emoji='🔂', row=0) - - self.shuffle_button_off = ToggleShuffleButton(style=ButtonStyle.secondary, emoji='🔀', row=0) - self.shuffle_button_on = ToggleShuffleButton(style=ButtonStyle.success, emoji='🔀', row=0) - + self.repeat_button = ToggleRepeatButton(style=ButtonStyle.success if guild['repeat'] else ButtonStyle.secondary, emoji='🔂', row=0) + self.shuffle_button = ToggleShuffleButton(style=ButtonStyle.success if guild['shuffle'] else ButtonStyle.secondary, emoji='🔀', row=0) self.play_pause_button = PlayPauseButton(style=ButtonStyle.primary, emoji='⏯', row=0) - self.next_button = NextTrackButton(style=ButtonStyle.primary, emoji='⏭', row=0) self.prev_button = PrevTrackButton(style=ButtonStyle.primary, emoji='⏮', row=0) - self.queue_button = Button(style=ButtonStyle.primary, emoji='📋', row=1) - - if guild['repeat']: - self.add_item(self.repeat_button_on) - else: - self.add_item(self.repeat_button_off) + self.add_item(self.repeat_button) self.add_item(self.prev_button) self.add_item(self.play_pause_button) self.add_item(self.next_button) - - if guild['shuffle']: - self.add_item(self.shuffle_button_on) - else: - self.add_item(self.shuffle_button_off) + self.add_item(self.shuffle_button) \ No newline at end of file diff --git a/MusicBot/cogs/utils/voice.py b/MusicBot/cogs/utils/voice.py index 163d274..2912654 100644 --- a/MusicBot/cogs/utils/voice.py +++ b/MusicBot/cogs/utils/voice.py @@ -178,7 +178,7 @@ class VoiceExtension: token = self.users_db.get_ym_token(ctx.user.id) if not token: - await ctx.respond("❌ Необходимо указать свой токен доступа с помощью комманды /login.", delete_after=15, ephemeral=True) + await ctx.respond("❌ Необходимо указать свой токен доступа с помощью команды /login.", delete_after=15, ephemeral=True) return False channel = ctx.channel @@ -239,10 +239,10 @@ class VoiceExtension: gid = ctx.guild.id guild = self.db.get_guild(gid) - await track.download_async(f'music/{ctx.guild_id}.mp3') - song = discord.FFmpegPCMAudio(f'music/{ctx.guild_id}.mp3', options='-vn -filter:a "volume=0.15"') + await track.download_async(f'music/{ctx.guild.id}.mp3') + song = discord.FFmpegPCMAudio(f'music/{ctx.guild.id}.mp3', options='-vn -filter:a "volume=0.15"') - vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.next_track(ctx), loop)) + vc.play(song, after=lambda exc: asyncio.run_coroutine_threadsafe(self.next_track(ctx, after=True), loop)) self.db.set_current_track(gid, track) self.db.update(gid, {'is_stopped': False}) @@ -253,18 +253,6 @@ class VoiceExtension: return track.title - def pause_playing(self, ctx: ApplicationContext | Interaction) -> None: - vc = self.get_voice_client(ctx) - if vc: - vc.pause() - return - - def resume_playing(self, ctx: ApplicationContext | Interaction) -> None: - vc = self.get_voice_client(ctx) - if vc: - vc.resume() - return - def stop_playing(self, ctx: ApplicationContext | Interaction) -> None: if not ctx.guild: return @@ -275,12 +263,13 @@ class VoiceExtension: vc.stop() return - async def next_track(self, ctx: ApplicationContext | Interaction) -> str | None: + async def next_track(self, ctx: ApplicationContext | Interaction, *, after: 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. Args: ctx (ApplicationContext | Interaction): Context + after (bool, optional): Whether the function was called by the after callback. Defaults to False. Returns: str | None: Track title or None. @@ -300,7 +289,7 @@ class VoiceExtension: current_track = guild['current_track'] ym_track = None - if guild['repeat'] and current_track: + if guild['repeat'] and after: return await self.repeat_current_track(ctx) elif guild['shuffle']: next_track = self.db.get_random_track(gid) @@ -369,3 +358,33 @@ class VoiceExtension: return await self.play_track(ctx, ym_track) # type: ignore return None + + async def like_track(self, ctx: ApplicationContext | Interaction) -> str | None: + """Like 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: + return None + + current_track = self.db.get_track(ctx.guild.id, 'current') + token = self.users_db.get_ym_token(ctx.user.id) + if not current_track or not token: + return None + + client = await ClientAsync(token).init() + likes = await client.users_likes_tracks() + if not likes: + return None + + ym_track = cast(Track, Track.de_json(current_track, client=client)) # type: ignore + if ym_track.id not in [track.id for track in likes.tracks]: + await ym_track.like_async() + return ym_track.title + + return None + \ No newline at end of file diff --git a/MusicBot/cogs/voice.py b/MusicBot/cogs/voice.py index 53ec34f..3bd4292 100644 --- a/MusicBot/cogs/voice.py +++ b/MusicBot/cogs/voice.py @@ -1,3 +1,4 @@ +from math import ceil from typing import cast import discord @@ -7,6 +8,7 @@ from yandex_music import Track, ClientAsync from MusicBot.cogs.utils.voice import VoiceExtension, generate_player_embed from MusicBot.cogs.utils.player import Player +from MusicBot.cogs.utils.misc import QueueView, generate_queue_embed def setup(bot: discord.Bot): bot.add_cog(Voice()) @@ -74,23 +76,17 @@ class Voice(Cog, VoiceExtension): async def get(self, ctx: discord.ApplicationContext) -> None: if not await self.voice_check(ctx): return - tracks_list = self.db.get_tracks_list(ctx.guild.id, 'next') - embed = discord.Embed( - title='Список треков', - color=discord.Color.dark_purple() - ) - for i, track in enumerate(tracks_list, start=1): - embed.add_field(name=f"{i} - {track.get('title')}", value="", inline=False) - if i == 25: - break - await ctx.respond("", embed=embed, ephemeral=True) + tracks = self.db.get_tracks_list(ctx.guild.id, 'next') + self.users_db.update(ctx.user.id, {'queue_page': 0}) + embed = generate_queue_embed(0, tracks) + await ctx.respond(embed=embed, view=QueueView(ctx), ephemeral=True) @track.command(description="Приостановить текущий трек.") async def pause(self, ctx: discord.ApplicationContext) -> None: vc = self.get_voice_client(ctx) if await self.voice_check(ctx) and vc is not None: if not vc.is_paused(): - self.pause_playing(ctx) + vc.pause() await ctx.respond("Воспроизведение приостановлено.", delete_after=15, ephemeral=True) else: await ctx.respond("Воспроизведение уже приостановлено.", delete_after=15, ephemeral=True) @@ -100,7 +96,7 @@ class Voice(Cog, VoiceExtension): vc = self.get_voice_client(ctx) if await self.voice_check(ctx) and vc is not None: if vc.is_paused(): - self.resume_playing(ctx) + vc.resume() await ctx.respond("Воспроизведение восстановлено.", delete_after=15, ephemeral=True) else: await ctx.respond("Воспроизведение не на паузе.", delete_after=15, ephemeral=True) @@ -131,3 +127,15 @@ class Voice(Cog, VoiceExtension): await ctx.respond(f"Сейчас играет: **{title}**!", delete_after=15) else: await ctx.respond(f"Нет треков в очереди.", delete_after=15, ephemeral=True) + + @voice.command(description="Добавить трек в избранное.") + async def like(self, ctx: discord.ApplicationContext) -> None: + if await self.voice_check(ctx): + vc = self.get_voice_client(ctx) + if not vc or not vc.is_playing: + await ctx.respond("Нет воспроизводимого трека.", delete_after=15, ephemeral=True) + result = await self.like_track(ctx) + if not result: + await ctx.respond("Трек уже добавлен в избранное.", delete_after=15, ephemeral=True) + else: + await ctx.respond(f"Трек **{result}** был добавлен в избранное.", delete_after=15, ephemeral=True) diff --git a/MusicBot/database/base.py b/MusicBot/database/base.py index 57eb74b..9f6fc2d 100644 --- a/MusicBot/database/base.py +++ b/MusicBot/database/base.py @@ -23,7 +23,10 @@ class BaseUsersDatabase: uid = uid users.insert_one(ExplicitUser( _id=uid, - ym_token=None + ym_token=None, + playlists=[], + playlists_page=0, + queue_page=0 )) def update(self, uid: int, data: User) -> None: diff --git a/MusicBot/database/user.py b/MusicBot/database/user.py index e03befd..27adb1b 100644 --- a/MusicBot/database/user.py +++ b/MusicBot/database/user.py @@ -2,7 +2,13 @@ from typing import TypedDict class User(TypedDict, total=False): ym_token: str | None + playlists: list[tuple[str, int]] + playlists_page: int + queue_page: int class ExplicitUser(TypedDict): _id: int ym_token: str | None + playlists: list[tuple[str, int]] # name / tracks count + playlists_page: int + queue_page: int