Initial commit

This commit is contained in:
Lemon4ksan
2025-01-08 21:55:34 +03:00
commit 7df90b48df
14 changed files with 989 additions and 0 deletions

64
.gitignore vendored Normal file
View File

@@ -0,0 +1,64 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# IDE Stuff
.idea/
.venv/
# Downloaded music
music/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Bananchiki
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

80
MusicBot/cogs/general.py Normal file
View File

@@ -0,0 +1,80 @@
from typing import cast
import discord
from discord.ext.commands import Cog
import yandex_music
import yandex_music.exceptions
from yandex_music import ClientAsync as YMClient
from MusicBot.database.base import get_ym_token, update
from MusicBot.cogs.utils.find import (
proccess_album, process_track, process_artist,
ListenAlbum, ListenTrack, ListenArtist
)
def setup(bot):
bot.add_cog(General(bot))
class General(Cog):
def __init__(self, bot):
self.bot = bot
@discord.slash_command(description="Login to Yandex Music using access token.", guild_ids=[1247100229535141899])
@discord.option("token", type=discord.SlashCommandOptionType.string)
async def login(self, ctx: discord.ApplicationContext, token: str) -> None:
try:
client = await YMClient(token).init()
except yandex_music.exceptions.UnauthorizedError:
await ctx.respond('❌ Недействительный токен.', delete_after=15, ephemeral=True)
return
about = cast(yandex_music.Status, client.me).to_dict()
uid = ctx.author.id
update(uid, {'ym_token': token})
await ctx.respond(f'Привет, {about['account']['first_name']}!', ephemeral=True)
@discord.slash_command(description="Find the content type by its name and send info about it. The best match is returned.", guild_ids=[1247100229535141899])
@discord.option(
"name",
description="Name of the content to find",
type=discord.SlashCommandOptionType.string
)
@discord.option(
"content_type",
description="Type of the conent to find (artist, album, track, playlist).",
type=discord.SlashCommandOptionType.string,
default='track'
)
async def find(self, ctx: discord.ApplicationContext, name: str, content_type: str = 'track') -> None:
if content_type not in ('artist', 'album', 'track', 'playlist'):
await ctx.respond('❌ Недопустимый тип.')
return
token = get_ym_token(ctx.user.id)
if not token:
await ctx.respond('❌ Необходимо указать свой токен доступа с помощью комманды /login.', delete_after=15, ephemeral=True)
return
try:
client = await YMClient(token).init()
except yandex_music.exceptions.UnauthorizedError:
await ctx.respond('❌ Недействительный токен. Если это не так, попробуйте ещё раз.', delete_after=15, ephemeral=True)
return
result = await client.search(name, True, content_type)
if content_type == 'album':
album = result.albums.results[0] # type: ignore
embed = await proccess_album(album)
await ctx.respond("", embed=embed, view=ListenAlbum(album))
elif content_type == 'track':
track: yandex_music.Track = result.tracks.results[0] # type: ignore
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':
artist = result.artists.results[0] # type: ignore
embed = await process_artist(artist)
await ctx.respond("", embed=embed, view=ListenArtist(artist.id))

360
MusicBot/cogs/utils/find.py Normal file
View File

@@ -0,0 +1,360 @@
from math import ceil
from typing import cast
from io import BytesIO
import aiohttp
import discord
import yandex_music
from PIL import Image
from discord.ui import View, Button, Item
from discord import ButtonStyle, Interaction
from MusicBot.cogs.utils.voice import VoiceExtension
from MusicBot.database.base import add_track, pop_track
class PlayTrackButton(Button, VoiceExtension):
def __init__(
self,
track: yandex_music.Track,
*,
style: ButtonStyle = ButtonStyle.secondary,
label: str | None = None,
disabled: bool = False,
custom_id: str | None = None,
url: str | None = None,
emoji: str | discord.Emoji | discord.PartialEmoji | None = None,
sku_id: int | None = None,
row: int | None = None
):
Button.__init__(self, style=style, label=label, disabled=disabled, custom_id=custom_id, url=url, emoji=emoji, sku_id=sku_id, row=row)
VoiceExtension.__init__(self)
self.track = track
async def callback(self, interaction: Interaction) -> None:
if interaction.channel is None or not isinstance(interaction.channel, discord.VoiceChannel):
await interaction.respond("Вы должны отправить команду в голосовом канале.", ephemeral=True)
return
title = await self.play_track(interaction, self.track)
if title:
await interaction.respond(f"Сейчас играет: **{title}**!", delete_after=15)
class PlayAlbumButton(Button, VoiceExtension):
def __init__(
self,
album: yandex_music.Album,
*,
style: ButtonStyle = ButtonStyle.secondary,
label: str | None = None,
disabled: bool = False,
custom_id: str | None = None,
url: str | None = None,
emoji: str | discord.Emoji | discord.PartialEmoji | None = None,
sku_id: int | None = None,
row: int | None = None
):
Button.__init__(self, style=style, label=label, disabled=disabled, custom_id=custom_id, url=url, emoji=emoji, sku_id=sku_id, row=row)
VoiceExtension.__init__(self)
self.album = album
async def callback(self, interaction: Interaction) -> None:
if not interaction.user:
return
album = cast(yandex_music.Album, await self.album.with_tracks_async())
if not album or not album.volumes:
return
for volume in album.volumes:
for track in volume:
add_track(interaction.user.id, track)
track = pop_track(interaction.user.id)
ym_track = yandex_music.Track(id=track['track_id'], title=track['title'], client=album.client) # type: ignore
title = await self.play_track(interaction, ym_track)
if title:
await interaction.respond(f"Сейчас играет: **{album.title}**!", delete_after=15)
else:
await interaction.respond("Добавьте бота в голосовой канал при помощи команды /voice join.", delete_after=15, ephemeral=True)
class ListenTrack(View):
def __init__(self, track: yandex_music.Track, album_id: int, *items: Item, timeout: float | None = 3600, disable_on_timeout: bool = False):
super().__init__(*items, timeout=timeout, disable_on_timeout=disable_on_timeout)
link_app = f"yandexmusic://album/{album_id}/track/{track.id}"
link_web = f"https://music.yandex.ru/album/{album_id}/track/{track.id}"
self.button1 = Button(label="Слушать в приложении", style=ButtonStyle.gray, url=link_app)
self.button2 = Button(label="Слушать в браузере", style=ButtonStyle.gray, url=link_web)
self.button3 = PlayTrackButton(track, label="Слушать в голосовом канале", style=ButtonStyle.gray)
# self.add_item(self.button1) # Discord doesn't allow well formed URLs in buttons for some reason.
self.add_item(self.button2)
self.add_item(self.button3)
class ListenAlbum(View):
def __init__(self, album: yandex_music.Album, *items: Item, timeout: float | None = 180, disable_on_timeout: bool = False):
super().__init__(*items, timeout=timeout, disable_on_timeout=disable_on_timeout)
link_app = f"yandexmusic://album/{album.id}"
link_web = f"https://music.yandex.ru/album/{album.id}"
self.button1 = Button(label="Слушать в приложении", style=ButtonStyle.gray, url=link_app)
self.button2 = Button(label="Слушать в браузере", style=ButtonStyle.gray, url=link_web)
self.button3 = PlayAlbumButton(album, label="Слушать в голосовом канале", style=ButtonStyle.gray)
# self.add_item(self.button1) # Discord doesn't allow well formed URLs in buttons for some reason.
self.add_item(self.button2)
self.add_item(self.button3)
class ListenArtist(View):
def __init__(self, artist_id, *items: Item, timeout: float | None = 180, disable_on_timeout: bool = False):
super().__init__(*items, timeout=timeout, disable_on_timeout=disable_on_timeout)
link_app = f"yandexmusic://artist/{artist_id}"
link_web = f"https://music.yandex.ru/artist/{artist_id}"
self.button1 = Button(label="Слушать в приложении", style=ButtonStyle.gray, url=link_app)
self.button2 = Button(label="Слушать в браузере", style=ButtonStyle.gray, url=link_web)
# self.add_item(self.button1) # Discord doesn't allow well formed URLs in buttons for some reason.
self.add_item(self.button2)
async def get_average_color_from_url(url: str) -> int:
"""Get image from url and calculate its average color to use in embeds.
Args:
url (str): Image url.
Returns:
int: RGB Hex code. 0x000 if failed.
"""
try:
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
response.raise_for_status()
response = await response.read()
img = Image.open(BytesIO(response))
img = img.convert('RGB')
width, height = img.size
r_total, g_total, b_total = 0, 0, 0
for y in range(height):
for x in range(width):
r, g, b = cast(tuple, img.getpixel((x, y)))
r_total += r
g_total += g
b_total += b
count = width * height
r = r_total // count
g = g_total // count
b = b_total // count
return (r << 16) + (g << 8) + b
except Exception:
return 0x000
async def proccess_album(album: yandex_music.Album) -> discord.Embed:
"""Generate album embed.
Args:
album (yandex_music.Album): Album to process.
Returns:
discord.Embed: Album embed.
"""
title = cast(str, album.title)
track_count = album.track_count
artists = album.artists_name()
avail = cast(bool, album.available)
description = album.short_description
year = album.year
version = album.version
bests = album.bests
duration = album.duration_ms
explicit = album.explicit or album.content_warning
likes_count = album.likes_count
artist = album.artists[0]
cover_url = album.get_cover_url('400x400')
color = await get_average_color_from_url(cover_url)
if isinstance(album.labels[0], yandex_music.Label):
labels = [cast(yandex_music.Label, label).name for label in album.labels]
else:
labels = [cast(str, label) for label in album.labels]
if version:
title += f' *{version}*'
if explicit:
title += ' <:explicit:1325879701117472869>'
artist_url = f"https://music.yandex.ru/artist/{artist.id}"
artist_cover = artist.cover
if not artist_cover:
artist_cover_url = artist.get_op_image_url()
else:
artist_cover_url = artist_cover.get_url()
embed = discord.Embed(
title=title,
description=description,
color=color,
)
embed.set_thumbnail(url=cover_url)
embed.set_author(name=", ".join(artists), url=artist_url, icon_url=artist_cover_url)
if year:
embed.add_field(name="Год выпуска", value=str(year))
if duration:
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:
if track_count > 1:
embed.add_field(name="Треки", value=str(track_count))
else:
embed.add_field(name="Треки", value="Сингл")
if likes_count:
embed.add_field(name="Лайки", value=str(likes_count))
if len(labels) > 1:
embed.add_field(name="Лейблы", value=", ".join(labels))
else:
embed.add_field(name="Лейбл", value=", ".join(labels))
if not avail:
embed.set_footer(text=f"Трек в данный момент недоступен.")
return embed
async def process_track(track: yandex_music.Track) -> discord.Embed:
"""Generate track embed.
Args:
track (yandex_music.Track): Track to be processed.
Returns:
discord.Embed: Track embed.
"""
title = cast(str, track.title) # casted types are always there, blame JS for that
avail = cast(bool, track.available)
artists = track.artists_name()
albums = [cast(str, album.title) for album in track.albums]
lyrics = cast(bool, track.lyrics_available)
duration = cast(int, track.duration_ms)
explicit = track.explicit or track.content_warning
bg_video = track.background_video_uri
metadata = track.meta_data
year = track.albums[0].year
artist = track.artists[0]
cover_url = track.get_cover_url('400x400')
color = await get_average_color_from_url(cover_url)
if explicit:
title += ' <:explicit:1325879701117472869>'
duration_m = duration // 60000
duration_s = ceil(duration / 1000) - duration_m * 60
artist_url = f"https://music.yandex.ru/artist/{artist.id}"
artist_cover = artist.cover
if not artist_cover:
artist_cover_url = artist.get_op_image_url()
else:
artist_cover_url = artist_cover.get_url()
embed = discord.Embed(
title=title,
description=", ".join(albums),
color=color,
)
embed.set_thumbnail(url=cover_url)
embed.set_author(name=", ".join(artists), url=artist_url, icon_url=artist_cover_url)
embed.add_field(name="Текст песни", value="Есть" if lyrics else "Нет")
embed.add_field(name="Длительность", value=f"{duration_m}:{duration_s:02}")
if year:
embed.add_field(name="Год выпуска", value=str(year))
if metadata:
if metadata.year:
embed.add_field(name="Год выхода", value=str(metadata.year))
if metadata.number:
embed.add_field(name="Позиция", value=str(metadata.number))
if metadata.composer:
embed.add_field(name="Композитор", value=metadata.composer)
if metadata.version:
embed.add_field(name="Версия", value=metadata.version)
if bg_video:
embed.add_field(name="Видеофон", value=f"[Ссылка]({bg_video})")
if not avail:
embed.set_footer(text=f"Трек в данный момент недоступен.")
return embed
async def process_artist(artist: yandex_music.Artist) -> discord.Embed:
"""Generate artist embed.
Args:
artist (yandex_music.Artist): Artist to process.
Returns:
discord.Embed: Artist embed.
"""
name = cast(str, artist.name)
likes_count = artist.likes_count
avail = cast(bool, artist.available)
counts = artist.counts
description = artist.description
ratings = artist.ratings
popular_tracks = artist.popular_tracks
if not artist.cover:
cover_url = artist.get_op_image_url('400x400')
else:
cover_url = artist.cover.get_url(size='400x400')
color = await get_average_color_from_url(cover_url)
embed = discord.Embed(
title=name,
description=description.text if description else None,
color=color,
)
embed.set_thumbnail(url=cover_url)
if likes_count:
embed.add_field(name="Лайки", value=str(likes_count))
# if ratings:
# embed.add_field(name="Слушателей за месяц", value=str(ratings.month)) # Wrong numbers?
if counts:
embed.add_field(name="Треки", value=str(counts.tracks))
embed.add_field(name="Альбомы", value=str(counts.direct_albums))
if artist.genres:
genres = [genre.capitalize() for genre in artist.genres]
if len(genres) > 1:
embed.add_field(name="Жанры", value=", ".join(genres))
else:
embed.add_field(name="Жанр", value=", ".join(genres))
if not avail:
embed.set_footer(text=f"Артист в данный момент недоступен.")
return embed

View File

@@ -0,0 +1,43 @@
from typing import cast
from discord.ui import View, Button, Item
from discord import ButtonStyle, Interaction, ApplicationContext
from MusicBot.cogs.utils.voice import VoiceExtension
class PlayPauseButton(Button, VoiceExtension):
async def callback(self, interaction: Interaction) -> None:
vc = self.get_voice_client(interaction)
if vc is not None:
if not vc.is_paused():
self.pause_playing(interaction)
await interaction.edit(content="Результат паузы.")
else:
self.resume_playing(interaction)
await interaction.edit(content="Результат возобновления.")
class NextTrackButton(Button, VoiceExtension):
async def callback(self, interaction: Interaction) -> None:
await self.next_track(interaction)
await interaction.edit(content='Результат переключения >.')
class Player(View):
def __init__(self, ctx: ApplicationContext, *items: Item, timeout: float | None = 3600, disable_on_timeout: bool = False):
super().__init__(*items, timeout=timeout, disable_on_timeout=disable_on_timeout)
self.ctx = ctx
self.repeat_button = Button(style=ButtonStyle.secondary, emoji='🔂', row=0)
self.shuffle_button = Button(style=ButtonStyle.secondary, emoji='🔀', row=0)
self.queue_button = Button(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.prev_button = Button(style=ButtonStyle.primary, emoji='', row=0)
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)
self.add_item(self.shuffle_button)

View File

@@ -0,0 +1,127 @@
import asyncio
from typing import cast
from yandex_music import Track, ClientAsync
import discord
from discord import Interaction, ApplicationContext
from MusicBot.database.base import update, get_user, pop_track, add_track, set_current_track
class VoiceExtension:
def clear_queue(self, ctx: ApplicationContext | Interaction):
if ctx.user:
update(ctx.user.id, {'tracks_list': []})
def get_voice_client(self, ctx: ApplicationContext | Interaction) -> discord.VoiceClient | None:
"""Return voice client for the given guild id. Return None if not present.
Args:
ctx (ApplicationContext | Interaction): Command context.
Returns:
discord.VoiceClient | None: Voice client.
"""
if isinstance(ctx, Interaction):
voice_chat = discord.utils.get(ctx.client.voice_clients, guild=ctx.guild)
else:
voice_chat = discord.utils.get(ctx.bot.voice_clients, guild=ctx.guild)
return cast(discord.VoiceClient, voice_chat)
async def play_track(self, ctx: ApplicationContext | Interaction, track: Track) -> str | None:
"""Download ``track`` by its id and play it in the voice channel. Return track title on success and don't respond.
If sound is already playing, add track id to the queue and respond.
Args:
ctx (ApplicationContext | Interaction): Context
track (Track): Track class with id and title specified.
Returns:
str | None: Song title or None.
"""
if not ctx.user:
return
vc = self.get_voice_client(ctx)
if not vc:
await ctx.respond("Добавьте бота в голосовой канал при помощи команды /voice join.", delete_after=15, ephemeral=True)
return
if isinstance(ctx, Interaction):
loop = ctx.client.loop
else:
loop = ctx.bot.loop
uid = ctx.user.id
user = get_user(uid)
if user.get('current_track') is not None:
add_track(uid, track)
await ctx.respond(f"Трек **{track.title}** был добавлен в очередь.", delete_after=15)
else:
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))
set_current_track(uid)
update(uid, {'is_stopped': False})
return track.title
def pause_playing(self, ctx: ApplicationContext | Interaction) -> None:
if not ctx.user:
return
vc = self.get_voice_client(ctx)
if vc:
vc.pause()
def resume_playing(self, ctx: ApplicationContext | Interaction) -> None:
if not ctx.user:
return
vc = self.get_voice_client(ctx)
if vc:
vc.resume()
def stop_playing(self, ctx: ApplicationContext | Interaction) -> None:
if not ctx.user:
return
vc = self.get_voice_client(ctx)
if vc:
update(ctx.user.id, {'current_track': None, 'is_stopped': True})
vc.stop()
async def next_track(self, ctx: ApplicationContext | Interaction) -> str | None:
"""Switch to the next track in the queue. Return track title on success.
Stop playing if tracks list is empty.
Args:
ctx (ApplicationContext | Interaction): Context
Returns:
str | None: Track title or None.
"""
if not ctx.user:
return
uid = ctx.user.id
user = get_user(uid)
if user.get('is_stopped'):
return
if not self.get_voice_client(ctx): # Silently return if bot got kicked
return
tracks_list = user.get('tracks_list')
if tracks_list:
track = pop_track(uid)
ym_track = Track(id=track['track_id'], title=track['title'], client=ClientAsync(user.get('ym_token'))) # type: ignore
self.stop_playing(ctx)
return await self.play_track(ctx, ym_track)
else:
self.stop_playing(ctx)

123
MusicBot/cogs/voice.py Normal file
View File

@@ -0,0 +1,123 @@
import discord
from discord.ext.commands import Cog
from MusicBot.cogs.utils.voice import VoiceExtension
from MusicBot.cogs.utils.player import Player
from MusicBot.database.base import update, get_user, get_tracks_list
def setup(bot: discord.Bot):
bot.add_cog(Voice())
class Voice(Cog, VoiceExtension):
toggle = discord.SlashCommandGroup("toggle", "Команды, связанные с переключением опций.", [1247100229535141899])
voice = discord.SlashCommandGroup("voice", "Команды, связанные с голосовым каналом.", [1247100229535141899])
queue = discord.SlashCommandGroup("queue", "Команды, связанные с очередью треков.", [1247100229535141899])
track = discord.SlashCommandGroup("track", "Команды, связанные с текущим треком.", [1247100229535141899])
async def voice_check(self, ctx: discord.ApplicationContext) -> bool:
"""Check if bot can perform voice tasks and respond if failed.
Args:
ctx (discord.ApplicationContext): Command context.
Returns:
bool: Check result.
"""
channel = ctx.channel
if not isinstance(channel, discord.VoiceChannel):
await ctx.respond("Вы должны отправить команду в голосовом канале.", delete_after=15, ephemeral=True)
return False
channels = ctx.bot.voice_clients
voice_chat = discord.utils.get(channels, guild=ctx.guild)
if not voice_chat:
await ctx.respond("Добавьте бота в голосовой канал при помощи команды /voice join.", delete_after=15, ephemeral=True)
return False
return True
@toggle.command(name="menu", description="Toggle player menu.")
async def menu(self, ctx: discord.ApplicationContext) -> None:
if self.voice_check:
await ctx.respond("Меню", view=Player(ctx))
@voice.command(name="join", description="Join the voice channel you're currently in.")
async def join(self, ctx: discord.ApplicationContext) -> None:
vc = self.get_voice_client(ctx)
if vc is not None and vc.is_playing():
await ctx.respond("Бот уже находится в голосовом канале. Выключите его с помощью команды /voice leave.", delete_after=15, ephemeral=True)
elif ctx.channel is not None and isinstance(ctx.channel, discord.VoiceChannel):
await ctx.channel.connect(timeout=15)
await ctx.respond("Подключение успешно!", delete_after=15, ephemeral=True)
else:
await ctx.respond("Вы должны отправить команду в голосовом канале.", delete_after=15, ephemeral=True)
@voice.command(description="Force the bot to leave the voice channel.")
async def leave(self, ctx: discord.ApplicationContext) -> None:
vc = self.get_voice_client(ctx)
if await self.voice_check(ctx) and vc is not None:
await vc.disconnect()
await ctx.respond("Отключение успешно!", delete_after=15, ephemeral=True)
@queue.command(description="Clear tracks queue.")
async def clear(self, ctx: discord.ApplicationContext) -> None:
self.clear_queue(ctx)
await ctx.respond("Очередь сброшена.", delete_after=15, ephemeral=True)
@queue.command(description="Get tracks queue.")
async def get(self, ctx: discord.ApplicationContext) -> None:
if await self.voice_check(ctx):
user = get_user(ctx.user.id)
tracks_list = user.get('tracks_list')
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)
@track.command(description="Pause the current track.")
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)
await ctx.respond("Воспроизведение приостановлено.", delete_after=15, ephemeral=True)
else:
await ctx.respond("Воспроизведение уже приостановлено.", delete_after=15, ephemeral=True)
@track.command(description="Resume the current track.")
async def resume(self, ctx: discord.ApplicationContext) -> None:
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)
await ctx.respond("Воспроизведение восстановлено.", delete_after=15, ephemeral=True)
else:
await ctx.respond("Воспроизведение не на паузе.", delete_after=15, ephemeral=True)
@track.command(description="Stop the current track and clear the queue.")
async def stop(self, ctx: discord.ApplicationContext) -> None:
if await self.voice_check(ctx):
self.clear_queue(ctx)
self.stop_playing(ctx)
await ctx.respond("Воспроизведение остановлено.", delete_after=15, ephemeral=True)
@track.command(description="Switch to the next song in the queue.")
async def next(self, ctx: discord.ApplicationContext) -> None:
if await self.voice_check(ctx):
uid = ctx.user.id
tracks_list = get_tracks_list(uid)
if not tracks_list:
await ctx.respond("Нет песенен в очереди.", delete_after=15, ephemeral=True)
return
update(uid, {'is_stopped': False})
title = await self.next_track(ctx)
if title is not None:
await ctx.respond(f"Сейчас играет: **{title}**!", delete_after=15)

89
MusicBot/database/base.py Normal file
View File

@@ -0,0 +1,89 @@
"""This documents initialises databse and contains methods to access it."""
from typing import Any, cast
from pymongo import MongoClient
from pymongo.collection import Collection
from yandex_music import Track
from MusicBot.database.user import User, ExplicitUser, TrackInfo
client: MongoClient = MongoClient("mongodb://localhost:27017/")
users: Collection[User] = client.YandexMusicBot.users
def create_record(uid: int | str) -> None:
"""Create user database record.
Args:
uid (int | str): User id.
"""
uid = str(uid)
users.insert_one(ExplicitUser(
_id=uid,
ym_token=None,
tracks_list=[],
current_track=None,
is_stopped=True
))
def update(uid: int | str, data: dict[Any, Any]) -> None:
"""Update user record.
Args:
uid (int | str): User id.
data (dict[Any, Any]): Updated data.
"""
get_user(uid)
users.update_one({'_id': str(uid)}, {"$set": data})
def get_user(uid: int | str) -> User:
"""Get user record from database. Create new entry if not present.
Args:
uid (int | str): User id.
Returns:
User: User record.
"""
user = users.find_one({'_id': str(uid)})
if not user:
create_record(uid)
user = users.find_one({'_id': str(uid)})
return cast(User, user)
def get_ym_token(uid: int | str) -> str | None:
user = users.find_one({'_id': str(uid)})
if not user:
create_record(uid)
user = cast(User, users.find_one({'_id': str(uid)}))
return user['ym_token']
def get_tracks_list(uid: int | str) -> list[TrackInfo]:
user = get_user(uid)
return user.get('tracks_list')
def pop_track(uid: int | str) -> TrackInfo:
tracks_list = get_tracks_list(uid)
track = tracks_list.pop(0)
update(uid, {'tracks_list': tracks_list})
return track
def add_track(uid: int | str, track: Track | TrackInfo) -> None:
tracks_list = get_tracks_list(uid)
if isinstance(track, Track):
track = TrackInfo(
track_id=str(track.id),
title=track.title, # type: ignore
avail=track.available, # type: ignore
artists=", ".join(track.artists_name()),
albums=", ".join([album.title for album in track.albums]), # type: ignore
duration=track.duration_ms, # type: ignore
explicit=track.explicit or bool(track.content_warning),
bg_video=track.background_video_uri
)
tracks_list.append(track)
update(uid, {'tracks_list': tracks_list})
def set_current_track(uid: int | str) -> None:
update(uid, {'current_track': str(uid)})

View File

@@ -0,0 +1,10 @@
from typing import TypedDict
class Guild(TypedDict):
allow_explicit: bool
allow_menu: bool
class ExplicitGuild(TypedDict):
_id: str
allow_explicit: bool
allow_menu: bool

25
MusicBot/database/user.py Normal file
View File

@@ -0,0 +1,25 @@
from typing import TypedDict
class TrackInfo(TypedDict):
track_id: str
title: str
avail: bool
artists: str
albums: str
duration: int
explicit: bool
bg_video: str | None
class User(TypedDict):
ym_token: str | None
tracks_list: list[TrackInfo]
current_track: int | None
is_stopped: bool
class ExplicitUser(TypedDict):
_id: str
ym_token: str | None
tracks_list: list[TrackInfo]
current_track: int | None
is_stopped: bool # Prevents callback of play_track

39
MusicBot/main.py Normal file
View File

@@ -0,0 +1,39 @@
import os
import logging
from dotenv import load_dotenv
import discord
from discord.ext.commands import Bot
try:
import coloredlogs
coloredlogs.install()
except ImportError:
pass
intents = discord.Intents.all()
bot = Bot(intents=intents)
cogs_list = [
'general',
'voice'
]
for cog in cogs_list:
bot.load_extension(f'MusicBot.cogs.{cog}')
@bot.event
async def on_ready():
logging.info("Bot's ready!")
if __name__ == '__main__':
load_dotenv()
if not os.path.exists('music'):
os.mkdir('music')
token = os.getenv('TOKEN')
if not token:
raise ValueError('You must specify the bot TOKEN in your enviroment')
logging.basicConfig(format='%(asctime)s:%(levelname)s:%(name)s: %(message)s')
logging.getLogger('discord').setLevel(logging.INFO)
bot.run(token)

BIN
assets/Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
assets/explicit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
py-cord
audioop-lts
PyNaCl
pymongo
yandex-music
pillow
python-dotenv
wavelink