feat: Add /account likes and user playlists search.

This commit is contained in:
Lemon4ksan
2025-01-17 13:56:35 +03:00
parent c2feeec158
commit b9ea63b4d5
7 changed files with 188 additions and 54 deletions

View File

@@ -1,5 +1,5 @@
from math import ceil
from typing import cast from typing import cast
from asyncio import gather
import discord import discord
from discord.ext.commands import Cog from discord.ext.commands import Cog
@@ -13,7 +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 from MusicBot.cogs.utils.misc import MyPlalists, ListenLikesPlaylist, generate_playlist_embed, generate_likes_embed
def setup(bot): def setup(bot):
bot.add_cog(General(bot)) bot.add_cog(General(bot))
@@ -51,9 +51,10 @@ class General(Cog):
embed.add_field( embed.add_field(
name='__Основные команды__', name='__Основные команды__',
value=""" value="""
`account`
`find` `find`
`help` `help`
`account` `like`
`queue` `queue`
`track` `track`
`voice` `voice`
@@ -62,16 +63,20 @@ class General(Cog):
embed.set_author(name='YandexMusic') embed.set_author(name='YandexMusic')
embed.set_footer(text='©️ Bananchiki') embed.set_footer(text='©️ Bananchiki')
elif command == 'account':
embed.description += ("Ввести токен от Яндекс Музыки. Его можно получить [здесь](https://github.com/MarshalX/yandex-music-api/discussions/513).\n"
"```/account login <token>```\n"
"Удалить токен из датабазы бота.\n```/account remove```\n"
"Получить ваши плейлисты. Чтобы добавить плейлист в очередь, используйте команду /find.\n```/account playlists```\n"
"Получить плейлист «Мне нравится». \n```/account likes```\n")
elif command == 'find': elif command == 'find':
embed.description += ("Вывести информацию о треке (по умолчанию), альбоме, авторе или плейлисте. Позволяет добавить музыку в очередь. " embed.description += ("Вывести информацию о треке (по умолчанию), альбоме, авторе или плейлисте. Позволяет добавить музыку в очередь. "
"В названии можно уточнить автора или версию. Возвращается лучшее совпадение.\n```/find <название> <тип>```") "В названии можно уточнить автора или версию. Возвращается лучшее совпадение.\n```/find <название> <тип>```")
elif command == 'help': elif command == 'help':
embed.description += ("Вывести список всех команд.\n```/help```\n" embed.description += ("Вывести список всех команд.\n```/help```\n"
"Получить информацию о конкретной команде.\n```/help <команда>```") "Получить информацию о конкретной команде.\n```/help <команда>```")
elif command == 'account': elif command == 'like':
embed.description += ("Ввести токен от Яндекс Музыки. Его можно получить [здесь](https://github.com/MarshalX/yandex-music-api/discussions/513).\n" embed.description += "Добавить трек в плейлист «Мне нравится».\n```/like```"
"```/account login <token>```\n"
"Удалить токен из датабазы бота.\n```/account remove```")
elif command == 'queue': elif command == 'queue':
embed.description += ("Получить очередь треков. По 15 элементов на страницу.\n```/queue get```\n" embed.description += ("Получить очередь треков. По 15 элементов на страницу.\n```/queue get```\n"
"Очистить очередь треков и историю прослушивания. Требует согласия части слушателей.\n```/queue clear```\n" "Очистить очередь треков и историю прослушивания. Требует согласия части слушателей.\n```/queue clear```\n"
@@ -111,7 +116,27 @@ 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="Получить плейлисты пользователя.") @account.command(description="Получить плейлист «Мне нравится»")
async def likes(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
likes = await client.users_likes_tracks()
if not likes:
await ctx.respond('❌ Что-то пошло не так. Повторите попытку позже.', delete_after=15, ephemeral=True)
return
real_tracks = await gather(*[track_short.fetch_track_async() for track_short in likes.tracks], return_exceptions=True)
tracks = [track for track in real_tracks if not isinstance(track, BaseException)] # Can't fetch user tracks
embed = generate_likes_embed(tracks)
await ctx.respond(embed=embed, view=ListenLikesPlaylist(tracks))
@account.command(description="Получить ваши плейлисты.")
async def playlists(self, ctx: discord.ApplicationContext) -> None: async def playlists(self, ctx: discord.ApplicationContext) -> None:
token = self.db.get_ym_token(ctx.user.id) token = self.db.get_ym_token(ctx.user.id)
if not token: if not token:
@@ -125,7 +150,7 @@ class General(Cog):
playlists: list[tuple[str, int]] = [(playlist.title, playlist.track_count) for playlist in playlists_list] # type: ignore 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}) self.db.update(ctx.user.id, {'playlists': playlists, 'playlists_page': 0})
embed = generate_playlist_embed(0, playlists) embed = generate_playlist_embed(0, playlists)
await ctx.respond(embed=embed, view=MyPlalistsView(ctx), ephemeral=True) await ctx.respond(embed=embed, view=MyPlalists(ctx), ephemeral=True)
@discord.slash_command(description="Найти контент и отправить информацию о нём. Возвращается лучшее совпадение.") @discord.slash_command(description="Найти контент и отправить информацию о нём. Возвращается лучшее совпадение.")
@discord.option( @discord.option(
@@ -137,7 +162,7 @@ class General(Cog):
"content_type", "content_type",
description="Тип искомого контента.", description="Тип искомого контента.",
type=discord.SlashCommandOptionType.string, type=discord.SlashCommandOptionType.string,
choices=['Artist', 'Album', 'Track', 'Playlist'], choices=['Artist', 'Album', 'Track', 'Playlist', 'User Playlist'],
default='Track' default='Track'
) )
async def find( async def find(
@@ -146,10 +171,9 @@ class General(Cog):
name: str, name: str,
content_type: str = 'Track' content_type: str = 'Track'
) -> None: ) -> None:
if content_type not in ['Artist', 'Album', 'Track', 'Playlist']: if content_type not in ['Artist', 'Album', 'Track', 'Playlist', 'User Playlist']:
await ctx.respond("❌ Недопустимый тип.", delete_after=15, ephemeral=True) await ctx.respond("❌ Недопустимый тип.", delete_after=15, ephemeral=True)
return return
content_type = content_type.lower()
token = self.db.get_ym_token(ctx.user.id) token = self.db.get_ym_token(ctx.user.id)
if not token: if not token:
@@ -161,28 +185,45 @@ class General(Cog):
await ctx.respond("❌ Недействительный токен. Если это не так, попробуйте ещё раз.", delete_after=15, ephemeral=True) await ctx.respond("❌ Недействительный токен. Если это не так, попробуйте ещё раз.", delete_after=15, ephemeral=True)
return return
result = await client.search(name, True, content_type) if content_type == 'User Playlist':
if not client.me or not client.me.account or not client.me.account.uid:
if not result: await ctx.respond("Не удалось получить информацию о пользователе.", delete_after=15, ephemeral=True)
await ctx.respond("❌ Что-то пошло не так. Повторите попытку позже", delete_after=15, ephemeral=True) return
return playlists = await client.users_playlists_list(client.me.account.uid)
result = None
if content_type == 'album' and result.albums: for playlist in playlists:
album = result.albums.results[0] if playlist.title == name:
embed = await process_album(album) result = playlist
await ctx.respond(embed=embed, view=ListenAlbum(album)) break
elif content_type == 'track' and result.tracks: else:
track: yandex_music.Track = result.tracks.results[0] await ctx.respond("❌ Плейлист не найден.", delete_after=15, ephemeral=True)
album_id = cast(int, track.albums[0].id) return
embed = await process_track(track)
await ctx.respond(embed=embed, view=ListenTrack(track, album_id)) embed = await process_playlist(result)
elif content_type == 'artist' and result.artists: await ctx.respond(embed=embed, view=ListenPlaylist(result))
artist = result.artists.results[0]
embed = await process_artist(artist)
await ctx.respond(embed=embed, view=ListenArtist(artist))
elif content_type == 'playlist' and result.playlists:
playlist = result.playlists.results[0]
embed = await process_playlist(playlist)
await ctx.respond(embed=embed, view=ListenPlaylist(playlist))
else: else:
await ctx.respond("❌ По запросу ничего не найдено.", delete_after=15, ephemeral=True) result = await client.search(name, True, content_type.lower())
if not result:
await ctx.respond("❌ Что-то пошло не так. Повторите попытку позже", delete_after=15, ephemeral=True)
return
if content_type == 'Album' and result.albums:
album = result.albums.results[0]
embed = await process_album(album)
await ctx.respond(embed=embed, view=ListenAlbum(album))
elif content_type == 'Track' and result.tracks:
track: yandex_music.Track = result.tracks.results[0]
album_id = cast(int, track.albums[0].id)
embed = await process_track(track)
await ctx.respond(embed=embed, view=ListenTrack(track, album_id))
elif content_type == 'Artist' and result.artists:
artist = result.artists.results[0]
embed = await process_artist(artist)
await ctx.respond(embed=embed, view=ListenArtist(artist))
elif content_type == 'Playlist' and result.playlists:
playlist = result.playlists.results[0]
embed = await process_playlist(playlist)
await ctx.respond(embed=embed, view=ListenPlaylist(playlist))
else:
await ctx.respond("❌ По запросу ничего не найдено.", delete_after=15, ephemeral=True)

View File

@@ -1,3 +1,4 @@
from asyncio import gather
from os import getenv from os import getenv
from math import ceil from math import ceil
from typing import cast from typing import cast
@@ -8,7 +9,7 @@ from yandex_music import Track, Album, Artist, Playlist, Label
from discord.ui import View, Button, Item from discord.ui import View, Button, Item
from discord import ButtonStyle, Interaction, Embed from discord import ButtonStyle, Interaction, Embed
from MusicBot.cogs.utils.voice import VoiceExtension, get_average_color_from_url from MusicBot.cogs.utils.voice_extension import VoiceExtension, get_average_color_from_url
class PlayTrackButton(Button, VoiceExtension): class PlayTrackButton(Button, VoiceExtension):
@@ -120,7 +121,8 @@ class PlayPlaylistButton(Button, VoiceExtension):
gid = interaction.guild.id gid = interaction.guild.id
guild = self.db.get_guild(gid) guild = self.db.get_guild(gid)
tracks: list[Track] = [cast(Track, short_track.track) for short_track in short_tracks] real_tracks = await gather(*[track_short.fetch_track_async() for track_short in self.playlist.tracks], return_exceptions=True)
tracks = [track for track in real_tracks if not isinstance(track, BaseException)] # Can't fetch user tracks
if guild['current_track'] is not None: if guild['current_track'] is not None:
self.db.modify_track(gid, tracks, 'next', 'extend') self.db.modify_track(gid, tracks, 'next', 'extend')
@@ -418,8 +420,23 @@ async def process_playlist(playlist: Playlist) -> Embed:
duration = playlist.duration_ms duration = playlist.duration_ms
likes_count = playlist.likes_count likes_count = playlist.likes_count
cover_url = f"https://{playlist.cover.uri.replace('%%', '400x400')}" # type: ignore color = 0x000
color = await get_average_color_from_url(cover_url) cover_url = None
if playlist.cover and playlist.cover.uri:
cover_url = f"https://{playlist.cover.uri.replace('%%', '400x400')}"
else:
tracks = await playlist.fetch_tracks_async()
for i in range(len(tracks)):
track = tracks[i].track
try:
cover_url = f"https://{track.albums[0].cover_uri.replace('%%', '400x400')}" # type: ignore
break
except TypeError:
continue
if cover_url:
color = await get_average_color_from_url(cover_url)
embed = discord.Embed( embed = discord.Embed(
title=title, title=title,

View File

@@ -1,10 +1,36 @@
from math import ceil from math import ceil
from typing import Any from typing import Any
from yandex_music import Track
from discord.ui import View, Button, Item from discord.ui import View, Button, Item
from discord import ButtonStyle, Interaction, ApplicationContext, Embed from discord import ButtonStyle, Interaction, ApplicationContext, Embed
from MusicBot.cogs.utils.voice import VoiceExtension from MusicBot.cogs.utils.voice_extension import VoiceExtension
def generate_likes_embed(tracks: list[Track]) -> Embed:
track_count = len(tracks)
cover_url = "https://avatars.yandex.net/get-music-user-playlist/11418140/favorit-playlist-cover.bb48fdb9b9f4/300x300"
embed = Embed(
title="Мне нравится",
description="Треки, которые вам понравились.",
color=0xce3a26,
)
embed.set_thumbnail(url=cover_url)
duration = 0
for track in tracks:
if track.duration_ms:
duration += track.duration_ms
duration_m = duration // 60000
duration_s = ceil(duration / 1000) - duration_m * 60
embed.add_field(name="Длительность", value=f"{duration_m}:{duration_s:02}")
if track_count is not None:
embed.add_field(name="Треки", value=str(track_count))
return embed
def generate_playlist_embed(page: int, playlists: list[tuple[str, int]]) -> Embed: def generate_playlist_embed(page: int, playlists: list[tuple[str, int]]) -> Embed:
count = 15 * page count = 15 * page
@@ -35,6 +61,38 @@ def generate_queue_embed(page: int, tracks_list: list[dict[str, Any]]) -> Embed:
embed.add_field(name=f"{i} - {track['title']} - {duration_m}:{duration_s:02d}", value="", inline=False) embed.add_field(name=f"{i} - {track['title']} - {duration_m}:{duration_s:02d}", value="", inline=False)
return embed return embed
class PlayLikesButton(Button, VoiceExtension):
def __init__(self, playlist: list[Track], **kwargs):
Button.__init__(self, **kwargs)
VoiceExtension.__init__(self)
self.playlist = playlist
async def callback(self, interaction: Interaction):
if not interaction.guild or not await self.voice_check(interaction):
return
gid = interaction.guild.id
guild = self.db.get_guild(gid)
if guild['current_track'] is not None:
self.db.modify_track(gid, self.playlist, 'next', 'extend')
response_message = f"Плейлист **«Мне нравится»** был добавлен в очередь."
else:
track = self.playlist.pop(0)
self.db.modify_track(gid, self.playlist, 'next', 'extend')
await self.play_track(interaction, track)
response_message = f"Сейчас играет плейлист **«Мне нравится»**!"
if guild['current_player'] is not None and interaction.message:
await interaction.message.delete()
await interaction.respond(response_message, delete_after=15)
class ListenLikesPlaylist(View):
def __init__(self, playlist: list[Track], *items: Item, timeout: float | None = None, disable_on_timeout: bool = False):
super().__init__(*items, timeout=timeout, disable_on_timeout=disable_on_timeout)
self.add_item(PlayLikesButton(playlist, label="Слушать в голосовом канале", style=ButtonStyle.gray))
class MPNextButton(Button, VoiceExtension): class MPNextButton(Button, VoiceExtension):
def __init__(self, **kwargs): def __init__(self, **kwargs):
Button.__init__(self, **kwargs) Button.__init__(self, **kwargs)
@@ -47,7 +105,7 @@ class MPNextButton(Button, VoiceExtension):
page = user['playlists_page'] + 1 page = user['playlists_page'] + 1
self.users_db.update(interaction.user.id, {'playlists_page': page}) self.users_db.update(interaction.user.id, {'playlists_page': page})
embed = generate_playlist_embed(page, user['playlists']) embed = generate_playlist_embed(page, user['playlists'])
await interaction.edit(embed=embed, view=MyPlalistsView(interaction)) await interaction.edit(embed=embed, view=MyPlalists(interaction))
class MPPrevButton(Button, VoiceExtension): class MPPrevButton(Button, VoiceExtension):
def __init__(self, **kwargs): def __init__(self, **kwargs):
@@ -61,9 +119,9 @@ class MPPrevButton(Button, VoiceExtension):
page = user['playlists_page'] - 1 page = user['playlists_page'] - 1
self.users_db.update(interaction.user.id, {'playlists_page': page}) self.users_db.update(interaction.user.id, {'playlists_page': page})
embed = generate_playlist_embed(page, user['playlists']) embed = generate_playlist_embed(page, user['playlists'])
await interaction.edit(embed=embed, view=MyPlalistsView(interaction)) await interaction.edit(embed=embed, view=MyPlalists(interaction))
class MyPlalistsView(View, VoiceExtension): class MyPlalists(View, VoiceExtension):
def __init__(self, ctx: ApplicationContext | Interaction, *items: Item, timeout: float | None = 3600, disable_on_timeout: bool = True): 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) View.__init__(self, *items, timeout=timeout, disable_on_timeout=disable_on_timeout)
VoiceExtension.__init__(self) VoiceExtension.__init__(self)
@@ -83,7 +141,6 @@ class MyPlalistsView(View, VoiceExtension):
self.add_item(prev_button) self.add_item(prev_button)
self.add_item(next_button) self.add_item(next_button)
class QNextButton(Button, VoiceExtension): class QNextButton(Button, VoiceExtension):
def __init__(self, **kwargs): def __init__(self, **kwargs):
Button.__init__(self, **kwargs) Button.__init__(self, **kwargs)

View File

@@ -1,7 +1,7 @@
from discord.ui import View, Button, Item from discord.ui import View, Button, Item
from discord import ButtonStyle, Interaction, ApplicationContext from discord import ButtonStyle, Interaction, ApplicationContext
from MusicBot.cogs.utils.voice import VoiceExtension from MusicBot.cogs.utils.voice_extension import VoiceExtension
class ToggleRepeatButton(Button, VoiceExtension): class ToggleRepeatButton(Button, VoiceExtension):
def __init__(self, **kwargs): def __init__(self, **kwargs):
@@ -77,6 +77,24 @@ class PrevTrackButton(Button, VoiceExtension):
if not title: if not title:
await interaction.respond(f"Нет треков в истории.", delete_after=15, ephemeral=True) await interaction.respond(f"Нет треков в истории.", delete_after=15, ephemeral=True)
class LikeButton(Button, VoiceExtension):
def __init__(self, **kwargs):
Button.__init__(self, **kwargs)
VoiceExtension.__init__(self)
async def callback(self, interaction: Interaction) -> None:
if await self.voice_check(interaction):
vc = self.get_voice_client(interaction)
if not vc or not vc.is_playing:
await interaction.respond("Нет воспроизводимого трека.", delete_after=15, ephemeral=True)
result = await self.like_track(interaction)
if not result:
await interaction.respond("❌ Операция не удалась.", delete_after=15, ephemeral=True)
elif result == 'TRACK REMOVED':
await interaction.respond("Трек был удалён из избранного.", delete_after=15, ephemeral=True)
else:
await interaction.respond(f"Трек **{result}** был добавлен в избранное.", delete_after=15, ephemeral=True)
class Player(View, VoiceExtension): class Player(View, VoiceExtension):
def __init__(self, ctx: ApplicationContext | Interaction, *items: Item, timeout: float | None = 3600, disable_on_timeout: bool = True): def __init__(self, ctx: ApplicationContext | Interaction, *items: Item, timeout: float | None = 3600, disable_on_timeout: bool = True):

View File

@@ -2,7 +2,7 @@ import aiohttp
import asyncio import asyncio
from os import getenv from os import getenv
from math import ceil from math import ceil
from typing import cast from typing import Literal, cast
from io import BytesIO from io import BytesIO
from PIL import Image from PIL import Image
@@ -359,7 +359,7 @@ class VoiceExtension:
return None return None
async def like_track(self, ctx: ApplicationContext | Interaction) -> str | None: async def like_track(self, ctx: ApplicationContext | Interaction) -> str | Literal['TRACK REMOVED'] | None:
"""Like current track. Return track title on success. """Like current track. Return track title on success.
Args: Args:
@@ -385,6 +385,7 @@ class VoiceExtension:
if ym_track.id not in [track.id for track in likes.tracks]: if ym_track.id not in [track.id for track in likes.tracks]:
await ym_track.like_async() await ym_track.like_async()
return ym_track.title return ym_track.title
else:
return None await client.users_likes_tracks_remove(ym_track.id, client.me.account.uid) # type: ignore
return 'TRACK REMOVED'

View File

@@ -1,4 +1,3 @@
from math import ceil
from typing import cast from typing import cast
import discord import discord
@@ -6,7 +5,7 @@ from discord.ext.commands import Cog
from yandex_music import Track, ClientAsync from yandex_music import Track, ClientAsync
from MusicBot.cogs.utils.voice import VoiceExtension, generate_player_embed from MusicBot.cogs.utils.voice_extension 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 from MusicBot.cogs.utils.misc import QueueView, generate_queue_embed
@@ -136,6 +135,8 @@ class Voice(Cog, VoiceExtension):
await ctx.respond("Нет воспроизводимого трека.", delete_after=15, ephemeral=True) await ctx.respond("Нет воспроизводимого трека.", delete_after=15, ephemeral=True)
result = await self.like_track(ctx) result = await self.like_track(ctx)
if not result: if not result:
await ctx.respond("Трек уже добавлен в избранное.", delete_after=15, ephemeral=True) await ctx.respond("❌ Операция не удалась.", delete_after=15, ephemeral=True)
elif result == 'TRACK REMOVED':
await ctx.respond("Трек был удалён из избранного.", delete_after=15, ephemeral=True)
else: else:
await ctx.respond(f"Трек **{result}** был добавлен в избранное.", delete_after=15, ephemeral=True) await ctx.respond(f"Трек **{result}** был добавлен в избранное.", delete_after=15, ephemeral=True)

View File

@@ -2,7 +2,6 @@ import os
import logging import logging
import discord import discord
from discord.errors import NotFound
from discord.ext.commands import Bot from discord.ext.commands import Bot
try: try: