From 7df90b48df69d35469be95c19b5c8319063ce2a8 Mon Sep 17 00:00:00 2001 From: Lemon4ksan Date: Wed, 8 Jan 2025 21:55:34 +0300 Subject: [PATCH] Initial commit --- .gitignore | 64 ++++++ LICENSE | 21 ++ MusicBot/cogs/general.py | 80 ++++++++ MusicBot/cogs/utils/find.py | 360 ++++++++++++++++++++++++++++++++++ MusicBot/cogs/utils/player.py | 43 ++++ MusicBot/cogs/utils/voice.py | 127 ++++++++++++ MusicBot/cogs/voice.py | 123 ++++++++++++ MusicBot/database/base.py | 89 +++++++++ MusicBot/database/guild.py | 10 + MusicBot/database/user.py | 25 +++ MusicBot/main.py | 39 ++++ assets/Logo.png | Bin 0 -> 15308 bytes assets/explicit.png | Bin 0 -> 7118 bytes requirements.txt | 8 + 14 files changed, 989 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 MusicBot/cogs/general.py create mode 100644 MusicBot/cogs/utils/find.py create mode 100644 MusicBot/cogs/utils/player.py create mode 100644 MusicBot/cogs/utils/voice.py create mode 100644 MusicBot/cogs/voice.py create mode 100644 MusicBot/database/base.py create mode 100644 MusicBot/database/guild.py create mode 100644 MusicBot/database/user.py create mode 100644 MusicBot/main.py create mode 100644 assets/Logo.png create mode 100644 assets/explicit.png create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02dd7e4 --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b002498 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/MusicBot/cogs/general.py b/MusicBot/cogs/general.py new file mode 100644 index 0000000..186bc08 --- /dev/null +++ b/MusicBot/cogs/general.py @@ -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)) diff --git a/MusicBot/cogs/utils/find.py b/MusicBot/cogs/utils/find.py new file mode 100644 index 0000000..3914dd7 --- /dev/null +++ b/MusicBot/cogs/utils/find.py @@ -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 \ No newline at end of file diff --git a/MusicBot/cogs/utils/player.py b/MusicBot/cogs/utils/player.py new file mode 100644 index 0000000..9f71939 --- /dev/null +++ b/MusicBot/cogs/utils/player.py @@ -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) + \ No newline at end of file diff --git a/MusicBot/cogs/utils/voice.py b/MusicBot/cogs/utils/voice.py new file mode 100644 index 0000000..e0cdba1 --- /dev/null +++ b/MusicBot/cogs/utils/voice.py @@ -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) diff --git a/MusicBot/cogs/voice.py b/MusicBot/cogs/voice.py new file mode 100644 index 0000000..30ee9e2 --- /dev/null +++ b/MusicBot/cogs/voice.py @@ -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) + \ No newline at end of file diff --git a/MusicBot/database/base.py b/MusicBot/database/base.py new file mode 100644 index 0000000..05fc6f9 --- /dev/null +++ b/MusicBot/database/base.py @@ -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)}) \ No newline at end of file diff --git a/MusicBot/database/guild.py b/MusicBot/database/guild.py new file mode 100644 index 0000000..48c1cbc --- /dev/null +++ b/MusicBot/database/guild.py @@ -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 \ No newline at end of file diff --git a/MusicBot/database/user.py b/MusicBot/database/user.py new file mode 100644 index 0000000..98feb7b --- /dev/null +++ b/MusicBot/database/user.py @@ -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 + diff --git a/MusicBot/main.py b/MusicBot/main.py new file mode 100644 index 0000000..4402a63 --- /dev/null +++ b/MusicBot/main.py @@ -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) diff --git a/assets/Logo.png b/assets/Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..cd65dde77ab5b78464aeead67276b4360b6d2982 GIT binary patch literal 15308 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 literal 0 HcmV?d00001 diff --git a/assets/explicit.png b/assets/explicit.png new file mode 100644 index 0000000000000000000000000000000000000000..8a4251b40d6accfc92b9fee6549a3c35b80be911 GIT binary patch literal 7118 zcmc&(c{r5syMKwuUQI~KBxO_vV;}n#m1T-7+0qzW7@4t*y)?84WvT3wZ^@ReEFmrS z7+=X2#!f-_#WXRd3^`#$ge-uwM|?)z~AW3123A;bYe5cf#~ z-LnwH1Y>;Jw}X|>UQHzMv%|~4iU2`8wTv%Q!ZDtG5X3@vHM1mI8X2Oocn_q5BmN=| z>F?nMs3AyI!{5sRdkIH`U&J}Pda8-iD{DmIu8wM=7V<{YMqWBN7gvJ-Z=7j>u^Bet z5?0w!R6`xE>W>BtJa9w@xW9+HCjsrRCi;Uf8eB7$r9|OBNQjrzM70jrKJ4){E&XKNW8bRl#H@6V1SZBp(Fu?B!T2fbnut-Bpmw9K^I5B zdb@fNUGbi921kdBcpsvgDA4t%5guND*m@FvISRN;%HP3DN(L#-FzN@PBlZuSmyfsm z58;kjDV#gb1LsL309u(pv|cWFBA(!a|8H3TdHfFofOCzE{>b>Jx_Ef}5kVm8`2sV3 zIpm*G6U<0nIH|KZ0^Y|Pi_`N3Y93-(e$s*<92n$zF zN4y_F;y=BB({&)?)I>ql7S9J zAUb$raVK@vM1jpnS64?gN?KM{9)(qwlu>e!lXOy$laW-y$tg-Ip_~+zlw{?wN+`wO z@9W~RK8(oze&6wb`M!y_E2u38_kWuYqv9ATfi`d@fMAh+RfH+-@~Up8cnq{Y?&lfN}W;gTT!n6vKG}=<)^_r7);d2|@hile$`F{wcEq!I%##scduf z&9DJ-ovo(WHx@+Tb&hyJ?1z%uAMW0j!&ci=ReqpO-5+zST5e8FoiZGIQMTi+*?uLT zj}ln~iTX=~9c4}XA4gGkZ9~+5i-MPn!grDQNIiNNM0y4WHz;RyHTgrp}FE{!qHRNX4Es;W!nvC^yf%H0)`T}pcU6_Q(ry_9B*vj{z&WxP>QQI@I3&M^a?TW%LF z3|-XGc{^VnxI!CC?Y4kju|%w`(Ax}7pAIz2>d5b!BA&i@@!~{%eSO3{l~vkwU|_)b zR^5B-^YhmE=F?=;EaPgNv|xb$!V9mHYHM}((^a9WXV0E} z_4V})Y47S9kn;Tk=dzZNNSJKp?wgduU5VX{3(Kwe@t7|AGcAJ$--StnIF)tOTCzDz^#@~=Roznt7XUmsx zGeMif9VQwZWs~WlG^LJ)wsOo;@(47+vZ0bwy+(KjVhfNdiHuOeCX`7)p-E1 zWEmwZRG%?7SI9hfvzzPr`(P8R@;)0lUjE$*nw9u^$y8oI|0mmZ{8*%%s)o%L* zUv`Kk&7-e0LFjfpJO04vtU7GV{}KwqBrP8tcP+rA7>hTvXhs;Ps|OQbXsG0CXXoV| zKYKJm)`SIOx-{^@r%eC`6EmK4&Z=w*@7xg<#R%ojojYS}!&H~qTAUUoT}7ERGj3kL zjselE3GQz*s?e4Zx=q<0?x6eL5#non+^?lF&N03=xD}H1%nE|X2CvUI^kuDXjxJL! z4DNuU6sL~j-tReOWYoH6(2*%j(|2y@b(Z)V&FmN~&lnoonV+A(aq{FzncczoH`+1} z46VyN2Ua!IO-{IbdHqdT`C`jP7lxa(x3{}JKfnLDU(o%zK^X@M(a%q1WMpK0)lea( z;jL$Xd3#59x8K0UqLitrsqC+lDmhGskIT!yJ?iP{!Jp96Q`^6Pe`*zR>U~9(tQs|a z^J~^seCS#qX|zVY_ejj3$Cq*&q`9?q;Bf7Y-6|X7NLT3PS6U!lZS8}p$$mo<6Z?9V zcmCV9ZS%}GUu%?97mulkCx4IxDV zOP|$bWMnqI3uyX^fx2gcK8;SaCY#?WNlZ+%yKI9D8rmkZqD~80n>`#H9DGRB`AFPU z;7GZ9jIe^Sfx+K*iG5-moSYUwL@mASAd`R7yLXj4fhFc=&o0EM5*(eJzKCo+zp+f2 z>kr*^Jn$8h!Pw-a6rj2^EI3T5>5Vi;BqSv0J+nG+6~#1n*3OQ`o!S0Lv1~2)3FdBG zoc+YkH!gBH{w=KR+d)7xfWHbzEeaq84I%0wagjlUERbsUUNE)`%b!1gZut;-|B{;< z(%H$$N&Q>OB?{5Ur*~v@wE6`*5;DB;74HuswtMg1y$eH&?M7+q>+6T(wFI#?cqB6^|X8K9>V$SPvW(5*(nR8TOFoBNL#BduBV) zmY%NjU}&r=97ds+;{7uC3#cInckeJB*l9HsC!`B9s>j*iaWmlW28p?A*E-(Rsn%bTXZAul~$KHNWImZg}p75?{bgHYb4~diE(I*|}KP|sx2r0iH zOh(^!OEk~5trLvY&Mqj>IHk5*GkSGx4R6}|9>BfeCf zJ?^E+A7oimjaTG9Q=p;1jg1YX53=Oz*RPxXxKB+@9TUHEl$xd%_<1TT-OfZg&Wk^+ z|BM~a*@I6y-TIezLt+P?m>n&yjP9NFO)FDog+zAC3AGC6I2dQ`CNuN>#lgX`3-Dtv z?hAWigH-H14rL_Vzh7<$M6i?AV@|!DBEGNQ*a;cvoj7rX<7mRonJ2dzMqeM{)rV90W@W4&qEAijy>a_?-9}V{#PQ=n-fttg=~-D>QWgj=+MuCTzWEA8 zC(?X;Vxnt-I-=jW`4a3$rwuskW_?RyWBJzW`Yr72HtD^+y&A)*hkE4XU{3|Xc@JF}b#S`-WAI-qO7(mCu2T#@#V00SWHm6LV6HvHuy=NKDa=vf;sMp_ z_x!di@8IO*Y&gOzDXbtqMnpe2n;pM6eOu;Dw+$AH6);EW@(3%Vl)VFdV(>xdV9Y1OKrG@}hFD)%)%EMgGFogFw#IZqQ zhuE(&lbNBrd@Nd;6uzFC(9rF4E{LU2n-Uuv8Y0yKA3``|8qe*RV4DN@lL(er>|X5+Q(QD(h1N zib1R}4yJqWR8;pY`6*N6h8QVWid5oRRtd*IY`z|d#Jsp5GD&3F2LbST9$mYhwPm~nD z>8||O)7=FErqsVc)vZoXPlswOmu7+<h(ww7iIF)=icXRuE zk=A=*VosZ_<__qUu7CgD%<(uv`(OqJF&DV34H8P6&Lu7DPYE9c`}B>;R3@kGWNl5D z9xt8iiqqiB;Or{duoVn~rLbwVTK-ZOY>o=i-g53=KkM#3<3`*2Di8siJB?07Ci-ok z1DZg~6n`mEQSCaFZT`GuZD{=W=oFQ5>MO%{ z&jZ~+U|*@j=(ZK!IH{k{m}Ye5Y1C8|li#Wvo0@ib(Dq(%`f1kwYP*7l50P_Xb{?FAM$;C z{`sZukXzo5%K$Q4nwi0{QAMEJe(9iz>KXfg$)Nv|v&oNk^XawgDC657>b04fnN^)u zj!Sws{-UqHe|`9=Z6$S%${hMLFAOZM-r|?MY4gmbJvUyK)JUaL_peoSy7`Epi7w|n zJv}9}42mO-4zLS>u#16a-P_dERFQt%o2-2$BeQ#Y5};YvBK4p?0>*v{Gz`Z;VIKm; ze+Fh-CEIw$)pg3`XQ{JDR@k+v@Y8dPXe_<*vl04XFAPSJKc zvXQl4&HDBM6v_zGJ~#vl`uC-8B^v-F#;UZcYJbyDn7IPBh3&Mx5CWll2A1u-t8G!> zLydE}{?mdoHUoPL+*8xg&|t;Ai5&10IrDXSIl8vC7DKxWI~MQyu?%mYV^=V!ioS|Df#&xn(e*4uD4=hEKIp^00mDulHd3D_a9P9H5TvGgxKxC%xdX{ zPUWF#LGU?c;pob%K+@2D7Kl0h?eM2P!aVv?bU4KK4P4wC@>2RT$HNR=X&d$mI@~ks z+dsktIeECaJkcxcn^h#SF@?PXCo?@_VAiyqWbhaSE!H&oQukBau^+dz>cGA|D^fit zzK0x0k>J#PDh6U?7b9 zu>yL+)PN%-itdRMkI%HR#2&hRA~P%Pq#fdXzV9AJ3wAR)dXqE9stiBqEA3l@0U$7A z7Vd){*v07D6!5ev!5pjV>gv>}0jc=S%}vjo*I*;LY*qq|8D@2mF|Ebt^KXM^u-wj5 zzNLBQxpfq`FgQStmltN*j>X3XjY67m5crQ7{6h*2#q`k{j^vp2Lv3gcYov=V8 zE8;h~FSD282h|s*TOzk*Fe-rin5mPWyy}cF7BIQ&U)Dd6qt3E4pEhP<4?L zs$&dEuVuGs7P`2*i`5)9*4J;C>uD1?3M=Uix~0H_ZBtw6HvyqY3_xm1OGrrcrry1~ z%S3Hub!mD_WqC1@1DryP!#z?0xweNVG}we)z+f=3jDv&0h5NQvR^La9(qjfA-w39A z6ta1JV0*<$uU?^*sQAu28Dq>xR-t_dkB&6*zWN$}@7~r;=LcLoJOTBgvv;&z!Pb>= zL$*jQaQTCxyu8nR3pCUQOnUiRaCb>b34t`B6uP;7NIXmZEMk(Hy2h5I6xT7mWFlQ( z3p{Z?T3EUGmfry=6m#y}Qeau1}@sx=D{#S6364 zmwip0ot?ig&-TSEQnw9($xbl&&YdFVBS($|PQ8CLXjSDuFHC#zc=YR3$2jPA0zKEe z#gD@@dI&Rw@bK`oNI8-{{8u&xvt9m_VJp&qj=w#Lkq8BAQV<}DrW{v0n}DzBh062u1yp0l%i z8Ja^ku|kLh%`qXxxS`DAl9H^dq|JEpMQOnlr;-ZWj|LFm_2~U7%kLUCTEJm}YFMxY zG}og@H=b_2a_-zYdYd{Y#F7)v#{2XEwav(n{6_Ua41#@gdBPF2mmfcVi~xsX2_4^i zT@`El@HPASvPQOrJ$r0@B#Lwf0jAmVyu6wG2Mlc;<%A{!78g5cW2B*$2eRoJi!HKN zc53Vpb99sAYI44HSyB}#s+;^q5Zl_?>IVj6Y;_16Vz~y|zVyklv8t)rQ71D*luQ?p znjRkylh0(^7B-lZm#6yd@@RdkENLc+w@>WwVZCl}n3Fs;%JgbWSS^4^H+*-k$ja_7 zw3?dQ5(q*%bwU|>rNKBz2^=O1&>plBTKwaj^WH}C;y=C&UUM!95ryEB-W8Sr{=~`( z?~EBfU%O{#CI>c9gZUsxGZVm$ZVt-9aAa+Par^});T**H~mmP$Hpeo<_k-F2l=K1Hxb0=4r+T7N5@ zUo7*xj^uU*ECcsz$O>{9(|W7+#FA0AV0JV)A|~ek!A0&^r`yui!ootAgj0L+&t^{` z5}MhcpU>yu<+X^2jErm?>bf&@=pmwbc-WyGB(5+|WXG^q&y4R{M#t1Mv@lO@$J9(w t->e7!t?_pe5fR?uum5gPDJfXjSeeAOXrjyb8UJxRsb{SFRQtl!{{SNEljZ;b literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6a34b71 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +py-cord +audioop-lts +PyNaCl +pymongo +yandex-music +pillow +python-dotenv +wavelink \ No newline at end of file