feat: Add account playlists view and ability to like current track. Update queue embed.

This commit is contained in:
Lemon4ksan
2025-01-14 17:05:12 +03:00
parent eb6c9b4665
commit c2feeec158
7 changed files with 229 additions and 54 deletions

View File

@@ -1,4 +1,5 @@
from typing import cast, TypeAlias from math import ceil
from typing import cast
import discord import discord
from discord.ext.commands import Cog from discord.ext.commands import Cog
@@ -12,6 +13,7 @@ from MusicBot.cogs.utils.find import (
process_album, process_track, process_artist, process_playlist, process_album, process_track, process_artist, process_playlist,
ListenAlbum, ListenTrack, ListenArtist, ListenPlaylist ListenAlbum, ListenTrack, ListenArtist, ListenPlaylist
) )
from MusicBot.cogs.utils.misc import MyPlalistsView, generate_playlist_embed
def setup(bot): def setup(bot):
bot.add_cog(General(bot)) bot.add_cog(General(bot))
@@ -71,7 +73,7 @@ class General(Cog):
"```/account login <token>```\n" "```/account login <token>```\n"
"Удалить токен из датабазы бота.\n```/account remove```") "Удалить токен из датабазы бота.\n```/account remove```")
elif command == 'queue': elif command == 'queue':
embed.description += ("Получить очередь треков. По 25 элементов на страницу.\n```/queue get```\n" embed.description += ("Получить очередь треков. По 15 элементов на страницу.\n```/queue get```\n"
"Очистить очередь треков и историю прослушивания. Требует согласия части слушателей.\n```/queue clear```\n" "Очистить очередь треков и историю прослушивания. Требует согласия части слушателей.\n```/queue clear```\n"
"`Примечание`: Если вы один в голосовом канале или имеете роль администратора бота, голосование не требуется.") "`Примечание`: Если вы один в голосовом канале или имеете роль администратора бота, голосование не требуется.")
elif command == 'track': elif command == 'track':
@@ -109,6 +111,22 @@ class General(Cog):
self.db.update(ctx.user.id, {'ym_token': None}) self.db.update(ctx.user.id, {'ym_token': None})
await ctx.respond(f'Токен был удалён.', delete_after=15, ephemeral=True) 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.slash_command(description="Найти контент и отправить информацию о нём. Возвращается лучшее совпадение.")
@discord.option( @discord.option(
"name", "name",
@@ -117,7 +135,7 @@ class General(Cog):
) )
@discord.option( @discord.option(
"content_type", "content_type",
description="Тип искомого контента (track, album, artist, playlist).", description="Тип искомого контента.",
type=discord.SlashCommandOptionType.string, type=discord.SlashCommandOptionType.string,
choices=['Artist', 'Album', 'Track', 'Playlist'], choices=['Artist', 'Album', 'Track', 'Playlist'],
default='Track' default='Track'

137
MusicBot/cogs/utils/misc.py Normal file
View File

@@ -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)

View File

@@ -86,32 +86,16 @@ class Player(View, VoiceExtension):
return return
guild = self.db.get_guild(ctx.guild.id) guild = self.db.get_guild(ctx.guild.id)
self.ctx = ctx 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.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.play_pause_button = PlayPauseButton(style=ButtonStyle.primary, 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.next_button = NextTrackButton(style=ButtonStyle.primary, emoji='', row=0)
self.prev_button = PrevTrackButton(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) self.queue_button = Button(style=ButtonStyle.primary, emoji='📋', row=1)
if guild['repeat']: self.add_item(self.repeat_button)
self.add_item(self.repeat_button_on)
else:
self.add_item(self.repeat_button_off)
self.add_item(self.prev_button) self.add_item(self.prev_button)
self.add_item(self.play_pause_button) self.add_item(self.play_pause_button)
self.add_item(self.next_button) self.add_item(self.next_button)
self.add_item(self.shuffle_button)
if guild['shuffle']:
self.add_item(self.shuffle_button_on)
else:
self.add_item(self.shuffle_button_off)

View File

@@ -178,7 +178,7 @@ class VoiceExtension:
token = self.users_db.get_ym_token(ctx.user.id) token = self.users_db.get_ym_token(ctx.user.id)
if not token: if not token:
await ctx.respond("❌ Необходимо указать свой токен доступа с помощью комманды /login.", delete_after=15, ephemeral=True) await ctx.respond("❌ Необходимо указать свой токен доступа с помощью команды /login.", delete_after=15, ephemeral=True)
return False return False
channel = ctx.channel channel = ctx.channel
@@ -239,10 +239,10 @@ class VoiceExtension:
gid = ctx.guild.id gid = ctx.guild.id
guild = self.db.get_guild(gid) guild = self.db.get_guild(gid)
await track.download_async(f'music/{ctx.guild_id}.mp3') 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"') 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.set_current_track(gid, track)
self.db.update(gid, {'is_stopped': False}) self.db.update(gid, {'is_stopped': False})
@@ -253,18 +253,6 @@ class VoiceExtension:
return track.title 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: def stop_playing(self, ctx: ApplicationContext | Interaction) -> None:
if not ctx.guild: if not ctx.guild:
return return
@@ -275,12 +263,13 @@ class VoiceExtension:
vc.stop() vc.stop()
return 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. """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.
Args: Args:
ctx (ApplicationContext | Interaction): Context ctx (ApplicationContext | Interaction): Context
after (bool, optional): Whether the function was called by the after callback. Defaults to False.
Returns: Returns:
str | None: Track title or None. str | None: Track title or None.
@@ -300,7 +289,7 @@ class VoiceExtension:
current_track = guild['current_track'] current_track = guild['current_track']
ym_track = None ym_track = None
if guild['repeat'] and current_track: if guild['repeat'] and after:
return await self.repeat_current_track(ctx) return await self.repeat_current_track(ctx)
elif guild['shuffle']: elif guild['shuffle']:
next_track = self.db.get_random_track(gid) 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 await self.play_track(ctx, ym_track) # type: ignore
return None 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

View File

@@ -1,3 +1,4 @@
from math import ceil
from typing import cast from typing import cast
import discord 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.voice import VoiceExtension, generate_player_embed
from MusicBot.cogs.utils.player import Player from MusicBot.cogs.utils.player import Player
from MusicBot.cogs.utils.misc import QueueView, generate_queue_embed
def setup(bot: discord.Bot): def setup(bot: discord.Bot):
bot.add_cog(Voice()) bot.add_cog(Voice())
@@ -74,23 +76,17 @@ class Voice(Cog, VoiceExtension):
async def get(self, ctx: discord.ApplicationContext) -> None: async def get(self, ctx: discord.ApplicationContext) -> None:
if not await self.voice_check(ctx): if not await self.voice_check(ctx):
return return
tracks_list = self.db.get_tracks_list(ctx.guild.id, 'next') tracks = self.db.get_tracks_list(ctx.guild.id, 'next')
embed = discord.Embed( self.users_db.update(ctx.user.id, {'queue_page': 0})
title='Список треков', embed = generate_queue_embed(0, tracks)
color=discord.Color.dark_purple() await ctx.respond(embed=embed, view=QueueView(ctx), ephemeral=True)
)
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)
@track.command(description="Приостановить текущий трек.") @track.command(description="Приостановить текущий трек.")
async def pause(self, ctx: discord.ApplicationContext) -> None: async def pause(self, ctx: discord.ApplicationContext) -> None:
vc = self.get_voice_client(ctx) vc = self.get_voice_client(ctx)
if await self.voice_check(ctx) and vc is not None: if await self.voice_check(ctx) and vc is not None:
if not vc.is_paused(): if not vc.is_paused():
self.pause_playing(ctx) vc.pause()
await ctx.respond("Воспроизведение приостановлено.", delete_after=15, ephemeral=True) await ctx.respond("Воспроизведение приостановлено.", delete_after=15, ephemeral=True)
else: else:
await ctx.respond("Воспроизведение уже приостановлено.", delete_after=15, ephemeral=True) await ctx.respond("Воспроизведение уже приостановлено.", delete_after=15, ephemeral=True)
@@ -100,7 +96,7 @@ class Voice(Cog, VoiceExtension):
vc = self.get_voice_client(ctx) vc = self.get_voice_client(ctx)
if await self.voice_check(ctx) and vc is not None: if await self.voice_check(ctx) and vc is not None:
if vc.is_paused(): if vc.is_paused():
self.resume_playing(ctx) vc.resume()
await ctx.respond("Воспроизведение восстановлено.", delete_after=15, ephemeral=True) await ctx.respond("Воспроизведение восстановлено.", delete_after=15, ephemeral=True)
else: else:
await ctx.respond("Воспроизведение не на паузе.", delete_after=15, ephemeral=True) await ctx.respond("Воспроизведение не на паузе.", delete_after=15, ephemeral=True)
@@ -131,3 +127,15 @@ class Voice(Cog, VoiceExtension):
await ctx.respond(f"Сейчас играет: **{title}**!", delete_after=15) await ctx.respond(f"Сейчас играет: **{title}**!", delete_after=15)
else: else:
await ctx.respond(f"Нет треков в очереди.", delete_after=15, ephemeral=True) 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)

View File

@@ -23,7 +23,10 @@ class BaseUsersDatabase:
uid = uid uid = uid
users.insert_one(ExplicitUser( users.insert_one(ExplicitUser(
_id=uid, _id=uid,
ym_token=None ym_token=None,
playlists=[],
playlists_page=0,
queue_page=0
)) ))
def update(self, uid: int, data: User) -> None: def update(self, uid: int, data: User) -> None:

View File

@@ -2,7 +2,13 @@ from typing import TypedDict
class User(TypedDict, total=False): class User(TypedDict, total=False):
ym_token: str | None ym_token: str | None
playlists: list[tuple[str, int]]
playlists_page: int
queue_page: int
class ExplicitUser(TypedDict): class ExplicitUser(TypedDict):
_id: int _id: int
ym_token: str | None ym_token: str | None
playlists: list[tuple[str, int]] # name / tracks count
playlists_page: int
queue_page: int