From 0d1ddb399bad832fda5a8941f09744b9958509d6 Mon Sep 17 00:00:00 2001 From: Choco <94597336+ChocoMeow@users.noreply.github.com> Date: Thu, 2 Feb 2023 21:06:51 +0800 Subject: [PATCH] Ready for public use --- .env Example | 11 + .gitignore | 154 +----- README.md | 83 ++++ addons/__init__.py | 1 + addons/lyrics.py | 161 ++++++ cogs/admin.py | 274 ++++++++++ cogs/basic.py | 856 ++++++++++++++++++++++++++++++++ cogs/effect.py | 264 ++++++++++ cogs/nodes.py | 48 ++ cogs/playlist.py | 430 ++++++++++++++++ cogs/task.py | 81 +++ function.py | 218 ++++++++ langs/CH.json | 184 +++++++ langs/DE.json | 185 +++++++ langs/EN.json | 184 +++++++ langs/JP.json | 185 +++++++ langs/KO.json | 185 +++++++ local_langs/zh-TW.json | 217 ++++++++ main.py | 103 ++++ requirements.txt | 8 + settings Example.json | 24 + update.py | 55 ++ view/__init__.py | 16 + view/chapter.py | 83 ++++ view/controller.py | 242 +++++++++ view/debug.py | 41 ++ view/help.py | 120 +++++ view/inbox.py | 96 ++++ view/link.py | 29 ++ view/list.py | 108 ++++ view/lyrics.py | 108 ++++ view/playlist.py | 181 +++++++ view/search.py | 64 +++ voicelink/__init__.py | 36 ++ voicelink/enums.py | 69 +++ voicelink/events.py | 168 +++++++ voicelink/exceptions.py | 104 ++++ voicelink/filters.py | 446 +++++++++++++++++ voicelink/objects.py | 151 ++++++ voicelink/player.py | 595 ++++++++++++++++++++++ voicelink/pool.py | 604 ++++++++++++++++++++++ voicelink/queue.py | 199 ++++++++ voicelink/spotify/__init__.py | 29 ++ voicelink/spotify/album.py | 20 + voicelink/spotify/artist.py | 20 + voicelink/spotify/client.py | 169 +++++++ voicelink/spotify/exceptions.py | 8 + voicelink/spotify/playlist.py | 24 + voicelink/spotify/track.py | 25 + voicelink/utils.py | 171 +++++++ 50 files changed, 7686 insertions(+), 151 deletions(-) create mode 100644 .env Example create mode 100644 README.md create mode 100644 addons/__init__.py create mode 100644 addons/lyrics.py create mode 100644 cogs/admin.py create mode 100644 cogs/basic.py create mode 100644 cogs/effect.py create mode 100644 cogs/nodes.py create mode 100644 cogs/playlist.py create mode 100644 cogs/task.py create mode 100644 function.py create mode 100644 langs/CH.json create mode 100644 langs/DE.json create mode 100644 langs/EN.json create mode 100644 langs/JP.json create mode 100644 langs/KO.json create mode 100644 local_langs/zh-TW.json create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 settings Example.json create mode 100644 update.py create mode 100644 view/__init__.py create mode 100644 view/chapter.py create mode 100644 view/controller.py create mode 100644 view/debug.py create mode 100644 view/help.py create mode 100644 view/inbox.py create mode 100644 view/link.py create mode 100644 view/list.py create mode 100644 view/lyrics.py create mode 100644 view/playlist.py create mode 100644 view/search.py create mode 100644 voicelink/__init__.py create mode 100644 voicelink/enums.py create mode 100644 voicelink/events.py create mode 100644 voicelink/exceptions.py create mode 100644 voicelink/filters.py create mode 100644 voicelink/objects.py create mode 100644 voicelink/player.py create mode 100644 voicelink/pool.py create mode 100644 voicelink/queue.py create mode 100644 voicelink/spotify/__init__.py create mode 100644 voicelink/spotify/album.py create mode 100644 voicelink/spotify/artist.py create mode 100644 voicelink/spotify/client.py create mode 100644 voicelink/spotify/exceptions.py create mode 100644 voicelink/spotify/playlist.py create mode 100644 voicelink/spotify/track.py create mode 100644 voicelink/utils.py diff --git a/.env Example b/.env Example new file mode 100644 index 0000000..5bd0292 --- /dev/null +++ b/.env Example @@ -0,0 +1,11 @@ +TOKEN = YOUR_BOT_TOKEN +CLIENT_ID = YOUR_BOT_CLIENT_ID +BUG_REPORT_CHANNEL_ID = YOUR_DISCORD_CHANNEL_ID + +SPOTIFY_CLIENT_ID = YOUR_SPOTIFY_CLIENT_ID +SPOTIFY_CLIENT_SECRET = YOUR_SPOTIFY_CLIENT_SECRET + +YOUTUBE_API_KEY = YOUR_YOUTUBE_API_KEY + +MONGODB_URL = YOUR_MONGODB_URL +MONGODB_NAME = YOUR_MONGODB_DB_NAME diff --git a/.gitignore b/.gitignore index d9005f2..8c2cfa7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,152 +1,4 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# 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 - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments +Vocard.rar .env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintainted in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +*.pyc +settings.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4da960f --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +![Support Server](https://discordapp.com/api/guilds/811542332678996008/widget.png?style=shield) + +# Vocard (Discord Music Bot) +> Vocard is a simple custom Disocrd Music Bot built with Python & [discord.py](https://discordpy.readthedocs.io/en/stable/) + +## Requirements +* [Python 3.8+](https://www.python.org/downloads/) +* [Modules in requirements](https://github.com/ChocoMeow/Vocard/blob/main/requirements.txt) +* [Lavalink Server](https://github.com/freyacodes/Lavalink) + +## Quick Start +```sh +git clone https://github.com/ChocoMeow/Vocard.git #Clone the repository +cd Vocard #Go to the directory +python -m pip install -r requirements.txt #Install required packages +``` +After installing all packages, you must configure the bot before to start! [How To Configure](https://github.com/ChocoMeow/Vocard#configuration)
+Start your bot with `python main.py` + +## Configuration +1. **Rename `.env Example` to `.env` and fill all the values** +```sh +TOKEN = XXXXXXXXXXXXXXXXXXXXXXXX.XXXXXX.XXXXXXXXXXXXXXXXXXXXXXXXXXX +CLIENT_ID = 123456789012345678 +BUG_REPORT_CHANNEL_ID = 123456789012345678 + +SPOTIFY_CLIENT_ID = 0XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +SPOTIFY_CLIENT_SECRET = 0XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + +YOUTUBE_API_KEY = AXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + +MONGODB_URL = mongodb+srv://user:password@clusterURL +MONGODB_NAME = Vocard +``` +| Values | Description | +| --- | --- | +| TOKEN | Your Discord bot token [(Discord Portal)](https://discord.com/developers/applications) | +| CLIENT_ID | Your Discord bot client id [(Discord Portal)](https://discord.com/developers/applications) | +| BUG_REPORT_CHANNEL_ID | All the error messages will send to this text channel ***(optional)*** | +| SPOTIFY_CLIENT_ID | Your Spoity client id [(Spotify Portal)](https://developer.spotify.com/dashboard/applications) ***(optional)*** | +| SPOTIFY_CLIENT_SECRET | Your Spoity client sercret id [(Spotify Portal)](https://developer.spotify.com/dashboard/applications) ***(optional)*** | +| YOUTUBE_API_KEY | Your youtube api key [(Google API)](https://cloud.google.com/apis) ***(optional)*** | +| MONGODB_URL | Your Mongo datebase url [(Mongodb)](https://www.mongodb.com/) | +| MONGODB_NAME | The datebase name that you created on [Mongodb](https://www.mongodb.com/) | + +2. **Rename `settings Example.json` to `settings.json` and customize your settings** +***(Note: Do not change any keys from `settings.json`)*** +```json +{ + "nodes": { + "DEFAULT": { + "host": "127.0.0.1", + "port": 2333, + "password": "password", + "secure": false, + "identifier": "DEFAULT" + } + }, + "bot_access_user": [], + "color_code":"0xb3b3b3", + "emoji_source_raw": { + "youtube": "<:youtube:826661982760992778>", + "youtube music": "<:youtube:826661982760992778>", + "spotify": "<:spotify:826661996615172146>", + "soundcloud": "<:soundcloud:852729280027033632>", + "twitch": "<:twitch:852729278285086741>", + "bandcamp": "<:bandcamp:864694003811221526>", + "vimeo": "<:vimeo:864694001919721473>", + "apple": "<:applemusic:994844332374884413>", + "reddit": "<:reddit:996007566863773717>", + "tiktok": "<:tiktok:996007689798811698>" + } +} +``` +* For `nodes` you have to provide host, port, password and identifier of the [Lavalink Server](https://github.com/freyacodes/Lavalink) +* For `bot_access_user` you can pass the [discord user id](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-). Example: `[123456789012345678]` +* For `color_code` you must pass a [Hexadecimal color code](https://htmlcolorcodes.com/) and add `0x` before the color code. Example: `"0xb3b3b3"` +* For `emoji_source_raw` you can change the source emoji of the track with discord emoji like `<:EMOJI_NAME:EMOJI_ID>` + +## How to update? +1. Run `python update --check` to check if your bot is up to date +2. Run `python update --start` to start update your bot
+***Note: Make sure there are no personal files in the directory! Otherwise it will be deleted.*** diff --git a/addons/__init__.py b/addons/__init__.py new file mode 100644 index 0000000..088e3ad --- /dev/null +++ b/addons/__init__.py @@ -0,0 +1 @@ +from .lyrics import getLyrics \ No newline at end of file diff --git a/addons/lyrics.py b/addons/lyrics.py new file mode 100644 index 0000000..206443c --- /dev/null +++ b/addons/lyrics.py @@ -0,0 +1,161 @@ +import aiohttp, random, bs4, re +from urllib.parse import quote +from math import floor + +userAgents = '''Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36 +Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.1 Safari/537.36 +Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.11 Safari/535.19 +Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.66 Safari/535.11 +Mozilla/5.0 (X11; U; Linux x86_64; en-US) AppleWebKit/532.2 (KHTML, like Gecko) Chrome/4.0.221.3 Safari/532.2 +Mozilla/5.0 (X11; U; Linux i686; en-US) AppleWebKit/532.2 (KHTML, like Gecko) Chrome/4.0.221.0 Safari/532.2 +Mozilla/5.0 (Windows; U; Windows NT 6.0; en-US) AppleWebKit/532.1 (KHTML, like Gecko) Chrome/4.0.220.1 Safari/532.1 +Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) AppleWebKit/532.1 (KHTML, like Gecko) Chrome/4.0.219.6 Safari/532.1 +Mozilla/5.0 (Windows; U; Windows NT 5.2; en-US) AppleWebKit/532.1 (KHTML, like Gecko) Chrome/4.0.219.5 Safari/532.1 +Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) AppleWebKit/532.1 (KHTML, like Gecko) Chrome/4.0.219.5 Safari/532.1 +Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) AppleWebKit/532.1 (KHTML, like Gecko) Chrome/4.0.219.4 Safari/532.1 +Mozilla/5.0 (X11; U; Linux x86_64; en-US) AppleWebKit/532.1 (KHTML, like Gecko) Chrome/4.0.219.3 Safari/532.1 +Mozilla/5.0 (Windows; U; Windows NT 5.2; en-US) AppleWebKit/532.1 (KHTML, like Gecko) Chrome/4.0.219.3 Safari/532.1 +Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) AppleWebKit/532.1 (KHTML, like Gecko) Chrome/4.0.219.3 Safari/532.1 +Mozilla/5.0 (X11; U; Linux i686; en-US) AppleWebKit/532.0 (KHTML, like Gecko) Chrome/3.0.197.0 Safari/532.0 +Mozilla/5.0 (X11; U; Linux i686 (x86_64); en-US) AppleWebKit/532.0 (KHTML, like Gecko) Chrome/3.0.197.0 Safari/532.0 +Mozilla/5.0 (Windows; U; Windows NT 6.0; en-US) AppleWebKit/530.5 (KHTML, like Gecko) Chrome/2.0.172.23 Safari/530.5 +Mozilla/5.0 (Windows; U; Windows NT 6.0; en-US) AppleWebKit/530.5 (KHTML, like Gecko) Chrome/2.0.172.2 Safari/530.5 +Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) AppleWebKit/530.5 (KHTML, like Gecko) Chrome/2.0.172.2 Safari/530.5 +Mozilla/5.0 (Windows; U; Windows NT 6.1; en-US) AppleWebKit/530.4 (KHTML, like Gecko) Chrome/2.0.172.0 Safari/530.4 +Mozilla/5.0 (Windows; U; Windows NT 5.2; eu) AppleWebKit/530.4 (KHTML, like Gecko) Chrome/2.0.172.0 Safari/530.4 +Mozilla/5.0 (Windows; U; Windows NT 5.2; en-US) AppleWebKit/530.4 (KHTML, like Gecko) Chrome/2.0.172.0 Safari/530.4 +Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) AppleWebKit/530.5 (KHTML, like Gecko) Chrome/2.0.172.0 Safari/530.5 +Mozilla/5.0 (Windows; U; Windows NT 6.0; en-US) AppleWebKit/530.4 (KHTML, like Gecko) Chrome/2.0.171.0 Safari/530.4 +Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) AppleWebKit/530.1 (KHTML, like Gecko) Chrome/2.0.170.0 Safari/530.1 +Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) AppleWebKit/530.1 (KHTML, like Gecko) Chrome/2.0.169.0 Safari/530.1 +Mozilla/5.0 (Windows; U; Windows NT 6.0; en-US) AppleWebKit/530.1 (KHTML, like Gecko) Chrome/2.0.168.0 Safari/530.1 +Mozilla/5.0 (Windows; U; Windows NT 6.0; en-US) AppleWebKit/530.1 (KHTML, like Gecko) Chrome/2.0.164.0 Safari/530.1 +Mozilla/5.0 (Windows; U; Windows NT 6.0; en-US) AppleWebKit/530.0 (KHTML, like Gecko) Chrome/2.0.162.0 Safari/530.0 +Mozilla/5.0 (Windows; U; Windows NT 6.0; en-US) AppleWebKit/530.0 (KHTML, like Gecko) Chrome/2.0.160.0 Safari/530.0 +Mozilla/5.0 (Windows; U; Windows NT 6.0; en-US) AppleWebKit/528.10 (KHTML, like Gecko) Chrome/2.0.157.2 Safari/528.10 +Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) AppleWebKit/528.10 (KHTML, like Gecko) Chrome/2.0.157.2 Safari/528.10 +Mozilla/5.0 (Windows; U; Windows NT 6.0; en-US) AppleWebKit/528.11 (KHTML, like Gecko) Chrome/2.0.157.0 Safari/528.11 +Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) AppleWebKit/528.9 (KHTML, like Gecko) Chrome/2.0.157.0 Safari/528.9 +Mozilla/5.0 (Linux; U; en-US) AppleWebKit/525.13 (KHTML, like Gecko) Chrome/0.2.149.27 Safari/525.13 +Mozilla/5.0 (Macintosh; U; Mac OS X 10_6_1; en-US) AppleWebKit/530.5 (KHTML, like Gecko) Chrome/ Safari/530.5 +Mozilla/5.0 (Macintosh; U; Mac OS X 10_5_7; en-US) AppleWebKit/530.5 (KHTML, like Gecko) Chrome/ Safari/530.5 +Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_6; en-US) AppleWebKit/530.9 (KHTML, like Gecko) Chrome/ Safari/530.9 +Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_6; en-US) AppleWebKit/530.6 (KHTML, like Gecko) Chrome/ Safari/530.6 +Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_6; en-US) AppleWebKit/530.5 (KHTML, like Gecko) Chrome/ Safari/530.5''' + +async def get(url): + try: + async with aiohttp.ClientSession() as session: + resp = await session.get(url=url, headers={'User-Agent': random.choice(userAgents)}) + if resp.status != 200: + return None + return await resp.text() + except: + return "" + +async def getLyrics(title: str): + link = await googleGet(title=title) + if not link: + return 0 + + page = await get(link) + + metadata = [elm.text for elm in htmlFindAll(page)('b')] + + if not metadata: + return None + + # v3.0.4: Update title and artist attributes with exact names + title = ''.join(i for i in metadata[1][1:-1] if i not in r'<>:"/\|?*') + + divs = [i.text for i in htmlFindAll(page)('div', {'class': None})] + + lyrics = max(divs, key=len).strip() + + if not lyrics: + return print("Lyrics not found") + + rr = re.split(r"(\[[\w\S_ ]+\:])", lyrics) + for item in rr: + if item == "": + rr.remove(item) + + count = len(rr) + if count > 1: + if (count % 2) != 0: + del rr[count-1] + return {rr[i].replace("[", "").replace(":]", ""): clearText(rr[i + 1]) for i in range(0, len(rr), 2)} + return {"default": clearText(rr[0])} + +async def googleGet(acc = 0.6, artist='', title=''): + data = artist + ' ' * (title != '' and artist != '') + title + encoded_data = quote(data.replace(' ', '+')) + + + google_page = await get('{}{}+site%3Aazlyrics.com'.format( + 'https://duckduckgo.com/html/?q=', encoded_data)) + + try: + results = re.findall(r'(azlyrics\.com\/lyrics\/[a-z0-9]+\/(\w+).html)', google_page) + except: + return None + + if len(results): + jaro_artist = 1.0 + jaro_title = 1.0 + + if artist: + jaro_artist = jaro_distance(artist.replace(' ', ''), results[0][0]) + if title: + jaro_title = jaro_distance(title.replace(' ', ''), results[0][1]) + + if jaro_artist >= acc and jaro_title >= acc: + return 'https://www.' + results[0][0] + else: + return None + return None + +def jaro_distance(s1, s2): + if (s1 == s2): + return 1.0 + + len1, len2 = len(s1), len(s2) + max_dist = floor(max(len1, len2) / 2) - 1 + match = 0 + hash_s1, hash_s2 = [0] * len(s1), [0] * len(s2) + + for i in range(len1): + for j in range(max(0, i - max_dist), + min(len2, i + max_dist + 1)): + if (s1[i] == s2[j] and hash_s2[j] == 0): + hash_s1[i], hash_s2[j] = 1, 1 + match += 1 + break + + if (match == 0): + return 0.0 + + t = 0 + point = 0 + + for i in range(len1): + if (hash_s1[i]): + while (hash_s2[point] == 0): + point += 1 + + if (s1[i] != s2[point]): + point += 1 + t += 1 + t = t//2 + + return (match/ len1 + match / len2 + (match - t + 1) / match)/ 3.0 + +def htmlFindAll(page): + soup = bs4.BeautifulSoup(page, "html.parser") + return soup.findAll + +def clearText(text: str): + if text.startswith("\n\n"): + text = text.replace("\n\n", "", 1) + + return text \ No newline at end of file diff --git a/cogs/admin.py b/cogs/admin.py new file mode 100644 index 0000000..c849922 --- /dev/null +++ b/cogs/admin.py @@ -0,0 +1,274 @@ +import discord +import voicelink +import io +import contextlib +import textwrap +import traceback + +from discord import app_commands +from discord.ext import commands +from function import ( + langs, + lang_guilds, + update_settings, + get_settings, + get_lang, + embed_color, + time as ctime +) +from view import DebugModal + +class Admin(commands.GroupCog, name="settings"): + def __init__(self, bot) -> None: + self.bot = bot + self.description = "This category is only available to admin permissions on the server." + + def get_settings(self, interaction: discord.Interaction) -> dict: + player: voicelink.Player = interaction.guild.voice_client + if not player: + settings = get_settings(interaction.guild_id) + else: + settings = player.settings + + return player, settings + + @app_commands.command( + name = "language", + description = "You can choose your preferred language, the bot message will change to the language you set." + ) + @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.checks.cooldown(2, 60.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def language(self, interaction: discord.Interaction, language: str): + language = language.upper() + if language not in langs: + return await interaction.response.send_message(get_lang(interaction.guild_id, "languageNotFound")) + + player, settings = self.get_settings(interaction) + if player: + player.lang = language + lang_guilds[interaction.guild_id] = language + update_settings(interaction.guild_id, {'lang': language}) + + await interaction.response.send_message(get_lang(interaction.guild_id, 'changedLanguage').format(language)) + + @language.autocomplete('language') + async def autocomplete_callback(self, interaction: discord.Interaction, current: str) -> list: + if current: + return [ app_commands.Choice(name=lang, value=lang) for lang in langs.keys() if current.upper() in lang ] + return [ app_commands.Choice(name=lang, value=lang) for lang in langs.keys() ] + + @app_commands.command( + name = "dj", + description = "Set a DJ role or remove DJ role." + ) + @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.guild_only() + async def dj(self, interaction: discord.Interaction, role: discord.Role = None): + player: voicelink.Player = interaction.guild.voice_client + + if not role: + if player: + player.settings.pop('dj', None) + update_settings(interaction.guild_id, {'dj':''}, mode="Unset") + else: + if player: + player.settings['dj'] = role.id + update_settings(interaction.guild_id, {'dj': role.id }) + + await interaction.response.send_message(get_lang(interaction.guild_id, 'setDJ').format(f"<@&{role.id}>" if role else "None"), allowed_mentions=discord.AllowedMentions.none()) + + @app_commands.command( + name = "queue", + description = "Change to another type of queue mode." + ) + @app_commands.choices(mode= [ + app_commands.Choice(name="FairQueue", value="FairQueue"), + app_commands.Choice(name="Queue", value="Queue") + ]) + @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.guild_only() + async def queue(self, interaction: discord.Interaction, mode: str): + player, settings = self.get_settings(interaction) + settings["queueType"] = mode + update_settings(interaction.guild_id, {"queueType": mode}) + await interaction.response.send_message(get_lang(interaction.guild_id, "setqueue").format(mode)) + + @app_commands.command( + name = "247", + description = "Toggles 24/7 mode, which disables automatic inactivity-based disconnects." + ) + @app_commands.guild_only() + @app_commands.checks.has_permissions(manage_guild=True) + async def playforever(self, interaction: discord.Interaction): + player, settings = self.get_settings(interaction) + toggle = settings.get('24/7', False) + settings['24/7'] = not toggle + update_settings(interaction.guild_id, {'24/7':not toggle}) + toggle = get_lang(interaction.guild_id, "enabled" if not toggle else "disabled") + await interaction.response.send_message(get_lang(interaction.guild_id, '247').format(toggle)) + + @app_commands.command( + name = "bypassvote", + description = "Toggles voting system.") + @app_commands.checks.has_permissions(manage_guild=True) + async def bypassvote(self, interaction: discord.Interaction): + player, settings = self.get_settings(interaction) + toggle = settings.get('votedisable', True) + settings['votedisable'] = not toggle + update_settings(interaction.guild_id, {'votedisable': not toggle}) + toggle = get_lang(interaction.guild_id, "enabled" if not toggle else "disabled") + await interaction.response.send_message(get_lang(interaction.guild_id, 'bypassVote').format(toggle)) + + @app_commands.command( + name = "view", + description = "Show all the bot settings in your server." + ) + @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.guild_only() + async def view(self, interaction: discord.Interaction): + player, settings = self.get_settings(interaction) + embed=discord.Embed(color=embed_color) + embed.set_author(name=get_lang(interaction.guild_id, 'settingsMenu').format(interaction.guild.name), icon_url=self.bot.user.display_avatar.url) + if interaction.guild.icon: + embed.set_thumbnail(url=interaction.guild.icon.url) + + embed.add_field(name=get_lang(interaction.guild_id, 'settingsTitle'), value=get_lang(interaction.guild_id, 'settingsValue').format( + settings.get('lang', 'EN'), + settings.get('controller', True), + f"<@&{settings['dj']}>" if 'dj' in settings else '`None`', + settings.get('votedisable', False), + settings.get('24/7', False), + settings.get('volume', 100), + ctime(settings.get('playTime', 0) * 60 * 1000), + inline=True) + ) + embed.add_field(name=get_lang(interaction.guild_id, 'settingsTitle2'), value=get_lang(interaction.guild_id, 'settingsValue2').format( + settings.get("queueType", "Queue"), + "200", + settings.get("duplicateTrack", True) + ) + ) + + perms = interaction.guild.me.guild_permissions + embed.add_field(name=get_lang(interaction.guild_id, 'settingsPermTitle'), value=get_lang(interaction.guild_id, 'settingsPermValue').format( + '' if perms.administrator else '', + '' if perms.manage_guild else '', + '' if perms.manage_channels else '', + '' if perms.manage_messages else ''), inline=False + ) + await interaction.response.send_message(embed=embed) + + @app_commands.command( + name = "volume", + description = "Set the player's volume." + ) + @app_commands.describe( + value = "Input a integer." + ) + @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.guild_only() + async def volume(self, interaction: discord.Interaction, value: app_commands.Range[int, 1, 150]): + player: voicelink.Player = interaction.guild.voice_client + + if player: + player.settings['volume'] = value + await player.set_volume(value) + + update_settings(interaction.guild_id, {'volume':value}) + await interaction.response.send_message(get_lang(interaction.guild_id, 'setVolume').format(value)) + + @app_commands.command( + name = "togglecontroller", + description = "Toggles the music controller." + ) + @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.guild_only() + async def togglecontroller(self, interaction: discord.Interaction): + player, settings = self.get_settings(interaction) + toggle = settings.get('controller', True) + settings['controller'] = not toggle + if player and settings['controller'] is False: + if player.controller: + try: + await player.controller.delete() + except: + discord.ui.View.from_message(player.controller).stop() + + update_settings(interaction.guild_id, {'controller': not toggle}) + toggle = get_lang(interaction.guild_id, "enabled" if not toggle else "disabled") + await interaction.response.send_message(get_lang(interaction.guild_id, 'togglecontroller').format(toggle)) + + @app_commands.command( + name = "duplicatetrack", + description = "Toggle Vocard to prevent duplicate songs from queuing." + ) + @app_commands.choices(toggle = [ + app_commands.Choice(name="Enable", value="enabled"), + app_commands.Choice(name="Disable", value="disabled") + ]) + @app_commands.checks.has_permissions(manage_guild=True) + @app_commands.guild_only() + async def duplicatetrack(self, interaction: discord.Interaction, toggle: str): + player, settings = self.get_settings(interaction) + if player: + player.queue._duplicateTrack = False if toggle == 'enabled' else True + + update_settings(interaction.guild_id, {'duplicateTrack': False if toggle == 'enabled' else True}) + return await interaction.response.send_message(get_lang(interaction.guild_id, "toggleDuplicateTrack").format(get_lang(interaction.guild_id, toggle))) + + @app_commands.command( + name = "debug", + ) + @app_commands.guild_only() + async def debug(self, interaction: discord.Interaction): + if interaction.user.id not in [358819659581227011, 705783356767338648]: + return await interaction.response.send_message("You are not able to use this command!") + + def clear_code(content): + if content.startswith("```") and content.endswith("```"): + return "\n".join(content.split("\n")[1:])[:-3] + else: + return content + + modal = DebugModal(title="Debug Panel") + await interaction.response.send_modal(modal) + await modal.wait() + + if modal.values is None: + return + + e = None + + local_variables = { + "discord": discord, + "commands": commands, + "voicelink": voicelink, + "bot": self.bot, + "interaction": interaction, + "channel": interaction.channel, + "author": interaction.user, + "guild": interaction.guild, + "message": interaction.message, + "input": None + } + + code = clear_code(modal.values) + str_obj = io.StringIO() #Retrieves a stream of data + try: + with contextlib.redirect_stdout(str_obj): + exec(f"async def func():\n{textwrap.indent(code, ' ')}", local_variables) + obj = await local_variables["func"]() + result = f"{str_obj.getvalue()}\n-- {obj}\n" + except Exception as e: + errormsg = ''.join(traceback.format_exception(e, e, e.__traceback__)) + return await interaction.followup.send(f"```py\n{errormsg}```") + + string = result.split("\n") + text = "" + for index, i in enumerate(string, start=1): + text += f"{'%03d' % index} | {i}\n" + return await interaction.followup.send(f"```{text}```") + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Admin(bot)) \ No newline at end of file diff --git a/cogs/basic.py b/cogs/basic.py new file mode 100644 index 0000000..1ab6839 --- /dev/null +++ b/cogs/basic.py @@ -0,0 +1,856 @@ +import discord +import voicelink +import re + +from discord import app_commands +from discord.ext import commands +from function import ( + time as ctime, + formatTime, + emoji_source, + youtube_api_key, + requests_api, + get_lang, + embed_color +) + +from addons import getLyrics +from view import SearchView, ListView, LinkView, LyricsView, ChapterView, HelpView +from validators import url +from random import shuffle + +async def connect_channel(interaction: discord.Interaction, channel: discord.VoiceChannel = None) -> voicelink.Player: + try: + channel = channel or interaction.user.voice.channel + except: + raise voicelink.VoicelinkException(get_lang(interaction.guild_id, 'noChannel')) + + check = channel.permissions_for(interaction.guild.me) + if check.connect == False or check.speak == False: + raise voicelink.VoicelinkException(get_lang(interaction.guild_id, 'noPermission')) + + player: voicelink.Player = await channel.connect(cls=voicelink.Player(interaction.client, channel, interaction)) + return player + +async def nowplay(interaction: discord.Interaction, player: voicelink.Player): + track = player.current + if not track: + return await interaction.response.send_message(player.get_msg('noTrackPlaying'), ephemeral=True) + + upnext = "\n".join(f"`{index}.` `[{track.formatLength}]` [{track.title[:30]}]({track.uri})" for index, track in enumerate(player.queue.tracks()[:2], start=2)) + embed=discord.Embed(description=player.get_msg('nowplayingDesc').format(track.title), color=embed_color) + embed.set_author(name=track.requester if track.requester else interaction.client, icon_url=track.requester.display_avatar.url if track.requester else interaction.client.user.display_avatar.url) + + if upnext: + embed.add_field(name=player.get_msg('nowplayingField'), value=upnext) + pbar = "".join(":radio_button:" if i == round(player.position // round(track.length // 15)) else "▬" for i in range(15)) + icon = ":red_circle:" if track.is_stream else (":pause_button:" if player.is_paused else ":arrow_forward:") + + embed.add_field(name="\u2800", value=f"{icon} {pbar} **[{ctime(player.position)}/{track.formatLength}]**", inline=False) + + return await interaction.response.send_message(embed=embed, view=LinkView(player.get_msg('nowplayingLink').format(track.source), track.emoji, track.uri)) + +async def help_autocomplete(interaction: discord.Interaction, current: str) -> list: + return [ app_commands.Choice(name=c.capitalize(), value=c) for c in interaction.client.cogs if c not in ["Nodes", "Task"] and current in c ] + +class Basic(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.description = "This category is available to anyone on this server. Voting is required in certain commands." + self.ctx_menu = app_commands.ContextMenu( + name="play", + callback=self._play, + ) + self.bot.tree.add_command(self.ctx_menu) + + async def cog_unload(self) -> None: + self.bot.tree.remove_command(self.ctx_menu.name, type=self.ctx_menu.type) + + @app_commands.command( + name = "connect", + description = "Connect to a voice channel." + ) + @app_commands.describe( + channel="Provide a channel to connect." + ) + @app_commands.checks.cooldown(2, 30.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def connect(self, interaction: discord.Interaction, channel: discord.VoiceChannel = None) -> None: + try: + player = await connect_channel(interaction, channel) + except discord.errors.ClientException: + return await interaction.response.send_message(get_lang(interaction.guild_id, "alreadyConnected")) + + await interaction.response.send_message(player.get_msg('connect').format(player.channel)) + + @app_commands.command( + name = "play", + description = "Loads your input and added it to the queue." + ) + @app_commands.describe( + query="Input a query or a searchable link.", + ) + @app_commands.checks.cooldown(2, 15.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def play(self, interaction: discord.Interaction, query: str) -> None: + player: voicelink.Player = interaction.guild.voice_client + if not player: + player = await connect_channel(interaction) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + tracks = await player.get_tracks(query, requester=interaction.user) + if not tracks: + return await interaction.response.send_message(player.get_msg('noTrackFound')) + + try: + if isinstance(tracks, voicelink.Playlist): + for track in tracks.tracks: + await player.queue.put(track) + await interaction.response.send_message(player.get_msg('playlistLoad').format(tracks.name, len(tracks.tracks))) + else: + position = await player.queue.put(tracks[0]) + await interaction.response.send_message((f"`{player.get_msg('live')}`" if tracks[0].is_stream else "") + ( player.get_msg('trackLoad_pos').format(tracks[0].title, tracks[0].author, tracks[0].formatLength, position) if position >= 1 and player.is_playing else player.get_msg('trackLoad').format(tracks[0].title, tracks[0].author, tracks[0].formatLength))) + except voicelink.QueueFull as e: + await interaction.response.send_message(e) + finally: + if not player.is_playing: + await player.do_next() + + @app_commands.checks.cooldown(2, 15.0, key=lambda i: (i.guild_id)) + async def _play(self, interaction: discord.Interaction, message: discord.Message): + query = "" + if message.content: + url = re.findall("http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", message.content) + if url: + query = url[0] + elif message.attachments: + query = message.attachments[0].url + + if not query: + return await interaction.response.send_message(get_lang(interaction.guild_id, key="noPlaySource"), ephemeral=True) + + player: voicelink.Player = interaction.guild.voice_client + if not player: + player = await connect_channel(interaction) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + tracks = await player.get_tracks(query, requester=interaction.user) + if not tracks: + return await interaction.response.send_message(player.get_msg('noTrackFound')) + + try: + if isinstance(tracks, voicelink.Playlist): + for track in tracks.tracks: + await player.queue.put(track) + await interaction.response.send_message(player.get_msg('playlistLoad').format(tracks.name, len(tracks.tracks))) + else: + await player.queue.put(tracks[0]) + await interaction.response.send_message((f"`{player.get_msg('live')}`" if tracks[0].is_stream else "") + ( player.get_msg('trackLoad_pos').format(tracks[0].title, tracks[0].author, tracks[0].formatLength, player.queue.count) if player.queue.count >= 1 and player.is_playing else player.get_msg('trackLoad').format(tracks[0].title, tracks[0].author, tracks[0].formatLength))) + except voicelink.QueueFull as e: + await interaction.response.send_message(e) + finally: + if not player.is_playing: + await player.do_next() + + @app_commands.command( + name = "search", + description = "Loads your input and added it to the queue." + ) + @app_commands.describe( + query="Input the name of the song.", + platform="Select the platform you want to search." + ) + @app_commands.checks.cooldown(2, 15.0, key=lambda i: (i.guild_id)) + @app_commands.choices(platform = [ + app_commands.Choice(name="Youtube", value="Youtube"), + app_commands.Choice(name="Youtube Music", value="Youtube Music"), + app_commands.Choice(name="Spotify", value="Spotify"), + app_commands.Choice(name="SoundCloud", value="SoundCloud"), + app_commands.Choice(name="Apple Music", value="Apple") + ]) + @app_commands.guild_only() + async def search(self, interaction: discord.Interaction, query: str, platform: str = "Youtube"): + player: voicelink.Player = interaction.guild.voice_client + if not player: + player = await connect_channel(interaction) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if url(query): + return await interaction.response.send_message(player.get_msg('noLinkSupport'), ephemeral=True) + if platform != 'Spotify': + query_platform = ("ytsearch" if platform == "Youtube" else "ytmsearch" if platform == "Youtube Music" else "scsearch" if platform == "SoundCloud" else "amsearch") + f":{query}" + tracks = await player.get_tracks(query=query_platform, requester=interaction.user) + else: + tracks = await player.spotifySearch(query=query, requester=interaction.user) + if not tracks: + return await interaction.response.send_message(player.get_msg('noTrackFound')) + query_track = "\n".join(f"`{index}.` `[{track.formatLength}]` **{track.title[:35]}**" for index, track in enumerate(tracks[0:10], start=1)) + embed=discord.Embed(title=player.get_msg('searchTitle').format(query), description=player.get_msg('searchDesc').format(emoji_source(platform.lower()), platform, len(tracks[0:10]), query_track), color=embed_color) + view = SearchView(tracks=tracks[0:10], lang=player.lang) + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + view.response = await interaction.original_response() + await view.wait() + if view.values is not None: + msg = "" + for value in view.values: + track = tracks[int(value.split(". ")[0]) - 1] + await player.queue.put(track) + msg += ((f"`{player.get_msg('live')}`" if track.is_stream else "") + ( player.get_msg('trackLoad_pos').format(track.title, track.author, track.formatLength, player.queue.count) if player.queue.count >= 1 else player.get_msg('trackLoad').format(track.title, track.author, track.formatLength)) ) + await interaction.followup.send(msg) + + if not player.is_playing: + await player.do_next() + + @app_commands.command( + name = "pause", + description = "Pause the music." + ) + @app_commands.checks.cooldown(2, 15.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def pause(self, interaction: discord.Interaction): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if player.is_paused: + return await interaction.response.send_message(player.get_msg('pauseError')) + + if not await player.is_privileged(interaction.user): + if interaction.user in player.pause_votes: + return await interaction.response.send_message(player.get_msg('voted'), ephemeral=True) + else: + player.pause_votes.add(interaction.user) + if len(player.pause_votes) >= (required := player.required()): + pass + else: + return await interaction.response.send_message(player.get_msg('pauseVote').format(interaction.user, len(player.pause_votes), required)) + + await player.set_pause(True) + player.pause_votes.clear() + await interaction.response.send_message(player.get_msg('paused').format(interaction.user)) + + @app_commands.command( + name = "resume", + description = "Resume the music." + ) + @app_commands.checks.cooldown(2, 15.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def resume(self, interaction: discord.Interaction): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if not player.is_paused: + return await interaction.response.send_message(player.get_msg('resumeError')) + + if not await player.is_privileged(interaction.user): + if interaction.user in player.resume_votes: + return await interaction.response.send_message(player.get_msg('voted'), ephemeral=True) + else: + player.resume_votes.add(interaction.user) + if len(player.resume_votes) >= (required := player.required()): + pass + else: + return await interaction.response.send_message(player.get_msg('resumeVote').format(interaction.user, len(player.resume_votes), required)) + + await player.set_pause(False) + player.resume_votes.clear() + await interaction.response.send_message(player.get_msg('resumed').format(interaction.user)) + + @app_commands.command( + name = "skip", + description = "Skips to the next song or skips to the specified song." + ) + @app_commands.describe( + index="Enter a index that you want to skip to." + ) + @app_commands.checks.cooldown(3, 20.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def skip(self, interaction: discord.Interaction, index: int = 0): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if not player.is_playing: + return await interaction.response.send_message(player.get_msg('skipError'), ephemeral=True) + + if not await player.is_privileged(interaction.user): + if interaction.user == player.current.requester: + pass + elif interaction.user in player.skip_votes: + return await interaction.response.send_message(player.get_msg('voted'), ephemeral=True) + else: + player.skip_votes.add(interaction.user) + if len(player.skip_votes) >= (required := player.required()): + pass + else: + return await interaction.response.send_message(player.get_msg('skipVote').format(interaction.user, len(player.skip_votes), required)) + + if not player.node._available: + return await interaction.response.send_message(player.get_msg('nodeReconnect')) + + if index: + await player.queue.skipto(index) + + await interaction.response.send_message(player.get_msg('skipped').format(interaction.user)) + + if player.queue.repeat == "Track": + player.queue.set_repeat("Off") + await player.stop() + + @app_commands.command( + name = "back", + description = "Skips back to the previous song or skips to the specified previous song." + ) + @app_commands.describe( + index="Enter a index that you want to skip back to." + ) + @app_commands.checks.cooldown(3, 20.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def back(self, interaction: discord.Interaction, index: int = 1): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if not await player.is_privileged(interaction.user): + if interaction.user in player.previous_votes: + return await interaction.response.send_message(player.get_msg('voted'), ephemeral=True) + else: + player.previous_votes.add(interaction.user) + if len(player.previous_votes) >= (required := player.required()): + pass + else: + return await interaction.response.send_message(player.get_msg('backVote').format(interaction.user, len(player.previous_votes), required)) + + if not player.node._available: + return await interaction.response.send_message(player.get_msg('nodeReconnect')) + + if not player.is_playing: + player.queue.backto(index) + await player.do_next() + else: + player.queue.backto(index + 1) + await player.stop() + + await interaction.response.send_message(player.get_msg('backed').format(interaction.user)) + + if player.queue.repeat == "Track": + player.queue.set_repeat("Off") + + @app_commands.command( + name = "seek", + description = "Change the player position." + ) + @app_commands.describe( + position="Input position. Exmaple: 1:20." + ) + @app_commands.checks.cooldown(3, 20.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def seek(self, interaction: discord.Interaction, position: str): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if not player.current: + return await interaction.response.send_message(player.get_msg('noTrackPlaying'), ephemeral=True) + if not await player.is_privileged(interaction.user): + return await interaction.response.send_message(player.get_msg('missingPerms_pos'), ephemeral=True) + if player.position == 0: + return await interaction.response.send_message(player.get_msg('noTrackPlaying'), ephemeral=True) + + num = formatTime(position) + if num is None: + return await interaction.response.send_message(player.get_msg('timeFormatError'), ephemeral=True) + + await player.seek(num) + await interaction.response.send_message(player.get_msg('seek').format(position)) + + @app_commands.command( + name = "queue", + description = "Display the players queue songs in your queue." + ) + @app_commands.checks.cooldown(2, 40.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def queue(self, interaction: discord.Interaction): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if player.queue.is_empty: + return await nowplay(interaction, player) + view = ListView(player=player, author=interaction.user) + await interaction.response.send_message(embed=view.build_embed(), view=view) + view.response = await interaction.original_response() + + @app_commands.command( + name = "history", + description = "Display the players queue songs in your history queue." + ) + @app_commands.checks.cooldown(2, 40.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def history(self, interaction: discord.Interaction): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if not player.queue.history(): + return await nowplay(interaction, player) + + view = ListView(player=player, author=interaction.user, isQueue=False) + await interaction.response.send_message(embed=view.build_embed(), view=view) + view.response = await interaction.original_response() + + @app_commands.command( + name = "leave", + description = "Disconnects the bot from your voice channel and chears the queue." + ) + @app_commands.guild_only() + async def leave(self, interaction: discord.Interaction): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if not await player.is_privileged(interaction.user): + if interaction.user in player.stop_votes: + return await interaction.response.send_message(player.get_msg('voted'), ephemeral=True) + else: + player.stop_votes.add(interaction.user) + if len(player.stop_votes) >= (required := player.required(leave=True)): + pass + else: + return await interaction.response.send_message(player.get_msg('leaveVote').format(interaction.user, len(player.stop_votes), required)) + + await interaction.response.send_message(player.get_msg('left').format(interaction.user)) + await player.teardown() + + @app_commands.command( + name = "nowplaying", + description = "Shows details of the current track." + ) + @app_commands.checks.cooldown(2, 10.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def nowplaying(self, interaction: discord.Interaction): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + await nowplay(interaction, player) + + @app_commands.command( + name = "loop", + description = "Changes Loop mode." + ) + @app_commands.describe( + mode = "Choose a looping mode." + ) + @app_commands.choices( mode = [ + app_commands.Choice(name='Off', value='Off'), + app_commands.Choice(name='Track', value='Track'), + app_commands.Choice(name='Queue', value='Queue') + ]) + @app_commands.guild_only() + async def loop(self, interaction: discord.Interaction, mode: str): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if not await player.is_privileged(interaction.user): + return await interaction.response.send_message(player.get_msg('missingPerms_mode'), ephemeral=True) + + player.queue.set_repeat(mode) + await interaction.response.send_message(player.get_msg('repeat').format(mode)) + + @app_commands.command( + name = "clear", + description = "Remove all the tracks in your queue or history queue." + ) + @app_commands.describe( + queue = "Choose a queue that you want to clear." + ) + @app_commands.choices( queue = [ + app_commands.Choice(name='Queue', value='Queue'), + app_commands.Choice(name='History', value='History') + ]) + @app_commands.guild_only() + async def clear(self, interaction: discord.Interaction, queue: str): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if not await player.is_privileged(interaction.user): + return await interaction.response.send_message(player.get_msg('missingPerms_queue'), ephemeral=True) + + if queue == 'Queue': + player.queue.clear() + elif queue == 'History': + player.queue.history_clear(player.is_playing) + + await interaction.response.send_message(player.get_msg('cleared').format(queue)) + + @app_commands.command( + name = "remove", + description = "Removes specified track or a range of tracks from the queue." + ) + @app_commands.describe( + position = "Input a position from the queue to be removed.", + position2 = "Set the range of the queue to be removed.", + member = "Remove tracks requested by a specific member." + ) + @app_commands.guild_only() + async def remove(self, interaction: discord.Interaction, position: int, position2: int = None, member: discord.Member = None): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if not await player.is_privileged(interaction.user): + return await interaction.response.send_message(player.get_msg('missingPerms_queue'), ephemeral=True) + + removedTrack = player.queue.remove(position, position2, member) + await interaction.response.send_message(player.get_msg('removed').format(removedTrack)) + + @app_commands.command( + name = "forward", + description = "Forwards by a certain amount of time in the current track. The default is 10 seconds." + ) + @app_commands.describe( + position = "Input a amount that you to forward to. Exmaple: 1:20" + ) + @app_commands.checks.cooldown(2, 20.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def forward(self, interaction: discord.Interaction, position: str = "10"): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if not player.current: + return await interaction.response.send_message(player.get_msg('noTrackPlaying'), ephemeral=True) + if not await player.is_privileged(interaction.user): + return await interaction.response.send_message(player.get_msg('missingPerms_pos'), ephemeral=True) + + num = formatTime(position) + if num is None: + return await interaction.response.send_message(player.get_msg('timeFormatError'), ephemeral=True) + + await player.seek(player.position + num) + await interaction.response.send_message(player.get_msg('forward').format(ctime(player.position + num))) + + @app_commands.command( + name = "rewind", + description = "Rewind by a certain amount of time in the current track. The default is 10 seconds." + ) + @app_commands.describe( + position = "Input a amount that you to rewind to. Exmaple: 1:20" + ) + @app_commands.checks.cooldown(2, 20.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def rewind(self, interaction: discord.Interaction, position: str = "10"): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if not player.current: + return await interaction.response.send_message(player.get_msg('noTrackPlaying'), ephemeral=True) + + if not await player.is_privileged(interaction.user): + return await interaction.response.send_message(player.get_msg('missingPerms_pos'), ephemeral=True) + + num = formatTime(position) + if num is None: + return await interaction.response.send_message(player.get_msg('timeFormatError'), ephemeral=True) + + await player.seek(player.position - num) + await interaction.response.send_message(player.get_msg('rewind').format(ctime(player.position - num))) + + @app_commands.command( + name = "replay", + description = "Reset the progress of the current song." + ) + @app_commands.checks.cooldown(2, 20.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def replay(self, interaction: discord.Interaction): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if not player.current: + return await interaction.response.send_message(player.get_msg('noTrackPlaying'), ephemeral=True) + + if not await player.is_privileged(interaction.user): + return await interaction.response.send_message(player.get_msg('missingPerms_pos'), ephemeral=True) + + await player.seek(0) + await interaction.response.send_message(player.get_msg('replay')) + + @app_commands.command( + name = "shuffle", + description = "Randomizes the tracks in the queue." + ) + @app_commands.checks.cooldown(1, 15.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def shuffle(self, interaction: discord.Interaction): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if not await player.is_privileged(interaction.user): + if interaction.user in player.shuffle_votes: + return await interaction.response.send_message(player.get_msg('voted'), ephemeral=True) + else: + player.shuffle_votes.add(interaction.user) + if len(player.shuffle_votes) >= (required := player.required()): + pass + else: + return await interaction.response.send_message(player.get_msg('shuffleVote').format(interaction.user, len(player.skip_votes), required)) + replacement = player.queue.tracks() + if len(replacement) < 3: + return await interaction.response.send_message(player.get_msg('shuffleError')) + shuffle(replacement) + player.queue.replace("Queue", replacement) + player.shuffle_votes.clear() + await interaction.response.send_message(player.get_msg('shuffled')) + + @app_commands.command( + name = "move", + description = "Moves the specified song to the specified position." + ) + @app_commands.describe( + track = "The track to move. Example: 2", + position = "The new position to move the track to. Exmaple: 1" + ) + @app_commands.checks.cooldown(2, 15.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def move(self, interaction: discord.Interaction, track: int, position: int ): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if not await player.is_privileged(interaction.user): + return await interaction.response.send_message(player.get_msg('missingPerms_pos'), ephemeral=True) + + player.queue.swap(track, position) + await interaction.response.send_message(player.get_msg('moved').format(track, position)) + + @app_commands.command( + name = "lyrics", + description = "Displays lyrics for the playing track." + ) + @app_commands.describe( + name = "Searches for your query and displays the reutned lyrics.", + ) + @app_commands.checks.cooldown(2, 60.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def lyrics(self, interaction: discord.Interaction, name: str = None): + player: voicelink.Player = interaction.guild.voice_client + + if not name: + if not player or not player.is_playing: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noTrackPlaying'), ephemeral=True) + name = player.current.title + " " + player.current.author + await interaction.response.defer() + song = await getLyrics(name) + if not song: + return await interaction.followup.send(get_lang(interaction.guild_id, 'lyricsNotFound'), ephemeral=True) + + view = LyricsView(name=name, source={_: re.findall(r'.*\n(?:.*\n){,22}', v) for _, v in song.items()}, author=interaction.user) + await interaction.followup.send(embed=view.build_embed(), view=view) + view.response = await interaction.original_response() + + @app_commands.command( + name = "swapdj", + description = "Transfer dj to another." + ) + @app_commands.describe( + member = "Choose a member to transfer the dj role." + ) + @app_commands.guild_only() + async def swapdj(self, interaction: discord.Interaction, member: discord.Member): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if player.dj.id != interaction.user.id or player.settings.get('dj', False): + return await interaction.response.send_message(player.get_msg('notdj').format(f"<@&{player.settings['dj']}>" if player.settings.get('dj') else player.dj.mention), ephemeral=True) + + if player.dj.id == member.id or member.bot: + return await interaction.response.send_message(player.get_msg('djToMe'), ephemeral=True) + + if member not in player.channel.members: + return await interaction.response.send_message(player.get_msg('djnotinchannel').format(member), ephemeral=True) + + player.dj = member + await interaction.response.send_message(player.get_msg('djswap').format(member)) + + + @app_commands.command( + name = "chapters", + description = "Lists all chapters of the currently playing song (if any)." + ) + @app_commands.checks.cooldown(2, 30.0, key=lambda i: (i.guild_id)) + @app_commands.guild_only() + async def chapters(self, interaction: discord.Interaction): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if not (track := player.current): + return await interaction.response.send_message(player.get_msg('noTrackPlaying'), ephemeral=True) + if not await player.is_privileged(interaction.user): + return await interaction.response.send_message(player.get_msg('missingPerms_pos'), ephemeral=True) + + if track.source != 'youtube': + return await interaction.response.send_message(player.get_msg('chatpersNotSupport'), ephemeral=True) + + request_uri = "https://youtube.googleapis.com/youtube/v3/videos?part=snippet&id={videoId}&key={key}".format(videoId=track.identifier, key=youtube_api_key) + + data = await requests_api(request_uri) + if not data: + return await interaction.response.send_message(player.get_msg('noChaptersFound'), ephemeral=True) + + try: + desc = data['items'][0]['snippet']['description'] + except KeyError: + return await interaction.response.send_message(player.get_msg('noChaptersFound'), ephemeral=True) + + chapters = re.findall(r"(?P\d+:\d+|\d+:\d+:\d+) (?P.+)", desc) + if not chapters: + return await interaction.response.send_message(player.get_msg('noChaptersFound'), ephemeral=True) + + view = ChapterView(player, chapters, author=interaction.user) + await interaction.response.send_message(view=view) + view.response = await interaction.original_response() + + @app_commands.command( + name = "autoplay", + description = "Toggles autoplay mode, it will automatically queue the best songs to play." + ) + @app_commands.guild_only() + async def autoplay(self, interaction: discord.Interaction): + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'noPlayer'), ephemeral=True) + + if not await player.is_privileged(interaction.user): + return await interaction.response.send_message(player.get_msg('missingPerms_autoplay'), ephemeral=True) + + check = not player.settings.get("autoplay", False) + player.settings['autoplay'] = check + await interaction.response.send_message(player.get_msg('autoplay').format(player.get_msg('enabled') if check else player.get_msg('disabled'))) + + if not player.is_playing: + await player.do_next() + + @app_commands.command( + name = "help", + description = "Lists all the commands in Vocard." + ) + @app_commands.autocomplete(category=help_autocomplete) + @app_commands.guild_only() + async def help(self, interaction: discord.Interaction, category: str = "News") -> None: + if category not in self.bot.cogs: + category = "News" + view = HelpView(self.bot, interaction.user) + embed = view.build_embed(category) + await interaction.response.send_message(embed=embed, view=view) + view.response = await interaction.original_response() + + @app_commands.command( + name = "ping", + description = "Test if the bot is alive, and see the delay between your commands and my response." + ) + @app_commands.guild_only() + async def ping(self, interaction: discord.Interaction): + player: voicelink.Player = interaction.guild.voice_client + embed = discord.Embed(color=embed_color) + embed.add_field(name=get_lang(interaction.guild_id, 'pingTitle1'), value=get_lang(interaction.guild_id, 'pingfield1').format("0", "0", self.bot.latency, '😭' if self.bot.latency > 5 else ('😨' if self.bot.latency > 1 else '👌'), "St Louis, MO, United States")) + if player: + embed.add_field(name=get_lang(interaction.guild_id, 'pingTitle2'), value=get_lang(interaction.guild_id, 'pingfield2').format(player.node._identifier, player.ping, player.node.player_count, player.channel.rtc_region), inline=False) + + await interaction.response.send_message(embed=embed) + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Basic(bot)) \ No newline at end of file diff --git a/cogs/effect.py b/cogs/effect.py new file mode 100644 index 0000000..e4dd7a3 --- /dev/null +++ b/cogs/effect.py @@ -0,0 +1,264 @@ +import discord +import voicelink + +from function import ( + get_lang, +) +from discord import app_commands +from discord.ext import commands + +async def check_access(interaction: discord.Interaction): + player: voicelink.Player = interaction.guild.voice_client + if not player: + raise voicelink.VoicelinkException(get_lang(interaction.guild_id, 'noPlayer')) + + return player + +class Effect(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.description = "This category is only available to DJ on this server. (You can setdj on your server by /settings setdj )" + + async def effect_autocomplete(self, interaction: discord.Interaction, current: str) -> list: + player: voicelink.Player = interaction.guild.voice_client + if not player: + return [] + if current: + return [ app_commands.Choice(name=effect.tag, value=effect.tag) for effect in player.filters.get_filters() if current in effect.tag] + return [ app_commands.Choice(name=effect.tag, value=effect.tag) for effect in player.filters.get_filters() ] + + @app_commands.command( + name = "speed", + description= "Sets the player's playback speed." + ) + @app_commands.describe( + value = "The value to set the speed to. Default is `1.0`" + ) + @app_commands.guild_only() + async def speed(self, interaction: discord.Interaction, value: app_commands.Range[float, 0, 2]): + player = await check_access(interaction) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if player.filters.has_filter(filter_tag="speed"): + player.filters.remove_filter(filter_tag="speed") + await player.add_filter(voicelink.Timescale(tag="speed", speed= value)) + await interaction.response.send_message(f"You set the speed to **{value}**.") + + @app_commands.command( + name = "karaoke", + description= "Uses equalization to eliminate part of a band, usually targeting vocals." + ) + @app_commands.describe( + level = "The level of the karaoke. Default is `1.0`", + monolevel = "The monolevel of the karaoke. Default is `1.0`", + filterband = "The filter band of the karaoke. Default is `220.0`", + filterwidth = "The filter band of the karaoke. Default is `100.0`" + ) + @app_commands.guild_only() + async def karaoke(self, interaction: discord.Interaction, level: app_commands.Range[float, 0, 2] = 1.0, monolevel: app_commands.Range[float, 0, 2] = 1.0, filterband: app_commands.Range[float, 100, 300] = 220.0, filterwidth: app_commands.Range[float, 50, 150] = 100.0) -> None: + player = await check_access(interaction) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if player.filters.has_filter(filter_tag="karaoke"): + player.filters.remove_filter(filter_tag="karaoke") + await player.add_filter(voicelink.Karaoke(tag="karaoke", level=level, mono_level=monolevel, filter_band=filterband, filter_width=filterwidth)) + await interaction.response.send_message(player.get_msg('karaoke').format(level, monolevel, filterband, filterwidth)) + + @app_commands.command( + name = "tremolo", + description= "Uses amplification to create a shuddering effect, where the volume quickly oscillates." + ) + @app_commands.describe( + frequency = "The frequency of the tremolo. Default is `2.0`", + depth = "The depth of the tremolo. Default is `0.5`" + ) + @app_commands.guild_only() + async def tremolo(self, interaction: discord.Interaction, frequency: app_commands.Range[float, 0, 10] = 2.0, depth: app_commands.Range[float, 0, 1] = 0.5) -> None: + player = await check_access(interaction) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if player.filters.has_filter(filter_tag="tremolo"): + player.filters.remove_filter(filter_tag="tremolo") + await player.add_filter(voicelink.Tremolo(tag="tremolo", frequency=frequency, depth=depth)) + await interaction.response.send_message(player.get_msg('tremolo&vibrato').format(frequency, depth)) + + @app_commands.command( + name = "vibrato", + description= "Similar to tremolo. While tremolo oscillates the volume, vibrato oscillates the pitch." + ) + @app_commands.describe( + frequency = "The frequency of the vibrato. Default is `2.0`", + depth = "The Depth of the vibrato. Default is `0.5`" + ) + @app_commands.guild_only() + async def vibrato(self, interaction: discord.Interaction, frequency: app_commands.Range[float, 0, 14] = 2.0, depth: app_commands.Range[float, 0, 1] = 0.5) -> None: + player = await check_access(interaction) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if player.filters.has_filter(filter_tag="vibrato"): + player.filters.remove_filter(filter_tag="vibrato") + await player.add_filter(voicelink.Vibrato(tag="vibrato", frequency=frequency, depth=depth)) + await interaction.response.send_message(player.get_msg('tremolo&vibrato').format(frequency, depth)) + + @app_commands.command( + name = "rotation", + description= "Rotates the sound around the stereo channels/user headphones aka Audio Panning." + ) + @app_commands.describe( + hertz = "The hertz of the rotation. Default is `0.2`" + ) + @app_commands.guild_only() + async def rotation(self, interaction: discord.Interaction, hertz: app_commands.Range[float, 0, 2] = 0.2) -> None: + player = await check_access(interaction) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if player.filters.has_filter(filter_tag="rotation"): + player.filters.remove_filter(filter_tag="rotation") + await player.add_filter(voicelink.Rotation(tag="rotation", rotation_hertz=hertz)) + await interaction.response.send_message(player.get_msg('rotation').format(hertz)) + + @app_commands.command( + name = "distortion", + description= "Distortion effect. It can generate some pretty unique audio effects." + ) + @app_commands.guild_only() + async def distortion(self, interaction: discord.Interaction) -> None: + player = await check_access(interaction) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if player.filters.has_filter(filter_tag="distortion"): + player.filters.remove_filter(filter_tag="distortion") + await player.add_filter(voicelink.Distortion(tag="distortion", sin_offset= 0.0, sin_scale= 1.0, cos_offset= 0.0, cos_scale=1.0, tan_offset= 0.0, tan_scale=1.0, offset=0.0, scale=1.0)) + await interaction.response.send_message(player.get_msg('distortion')) + + @app_commands.command( + name = "lowpass", + description= "Filter which supresses higher frequencies and allows lower frequencies to pass." + ) + @app_commands.describe( + smoothing = "The level of the lowPass. Default is `20.0`" + ) + @app_commands.guild_only() + async def lowpass(self, interaction: discord.Interaction, smoothing: app_commands.Range[float, 10, 30] = 20.0) -> None: + player = await check_access(interaction) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if player.filters.has_filter(filter_tag="lowpass"): + player.filters.remove_filter(filter_tag="lowpass") + await player.add_filter(voicelink.LowPass(tag="lowpass", smoothing= smoothing)) + await interaction.response.send_message(player.get_msg('lowpass').format(smoothing)) + + @app_commands.command( + name = "channelmix", + description= "Filter which manually adjusts the panning of the audio." + ) + @app_commands.describe( + left_to_left = "Sounds from left to left. Default is `1.0`", + right_to_right = "Sounds from right to right. Default is `1.0`", + left_to_right = "Sounds from left to right. Default is `0.0`", + right_to_left = "Sounds from right to left. Default is `0.0`" + ) + @app_commands.guild_only() + async def channelmix(self, interaction: discord.Interaction, left_to_left: app_commands.Range[float, 0, 1] = 1.0, right_to_right: app_commands.Range[float, 0, 1] = 1.0, left_to_right: app_commands.Range[float, 0, 1] = 0.0, right_to_left: app_commands.Range[float, 0, 1] = 0.0) -> None: + player = await check_access(interaction) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + if player.filters.has_filter(filter_tag="channelmix"): + player.filters.remove_filter(filter_tag="channelmix") + await player.add_filter(voicelink.ChannelMix(tag="channelmix", left_to_left=left_to_left, right_to_right=right_to_right, left_to_right=left_to_right, right_to_left=right_to_left)) + await interaction.response.send_message(player.get_msg('channelmix').format(left_to_left, right_to_right, left_to_right, right_to_left)) + + @app_commands.command( + name = "nightcore", + description= "Add nightcore filter into your player." + ) + @app_commands.guild_only() + async def nightcore(self, interaction: discord.Interaction) -> None: + player = await check_access(interaction) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + await player.add_filter(voicelink.Timescale.nightcore()) + await interaction.response.send_message(player.get_msg('nightcore')) + + @app_commands.command( + name = "8d", + description= "Add 8D filter into your player." + ) + @app_commands.guild_only() + async def eightD(self, interaction: discord.Interaction) -> None: + player = await check_access(interaction) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + await player.add_filter(voicelink.Rotation.nightD()) + await interaction.response.send_message(player.get_msg('8d')) + + @app_commands.command( + name = "vaporwave", + description= "Add vaporwave filter into your player." + ) + @app_commands.guild_only() + async def vaporwave(self, interaction: discord.Interaction) -> None: + player = await check_access(interaction) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + + await player.add_filter(voicelink.Timescale.vaporwave()) + await interaction.response.send_message(player.get_msg('vaporwave')) + + @app_commands.command( + name = "cleareffect", + description= "Clear all or specific sound effects." + ) + @app_commands.describe( + effect = "Remove a specific sound effects." + ) + @app_commands.guild_only() + @app_commands.autocomplete(effect=effect_autocomplete) + async def cleareffect(self, interaction: discord.Interaction, effect: str = None) -> None: + player: voicelink.Player = interaction.guild.voice_client + if not player: + return await interaction.response.send_message(get_lang(interaction.guild_id, "noPlayer"), ephemeral=True) + + if interaction.user not in player.channel.members: + if not interaction.user.guild_permissions.manage_guild: + return await interaction.response.send_message(player.get_msg('notInChannel').format(interaction.user.mention, player.channel.mention), ephemeral=True) + if effect: + await player.remove_filter(effect) + else: + await player.reset_filter() + await interaction.response.send_message(player.get_msg('cleareffect')) + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Effect(bot)) \ No newline at end of file diff --git a/cogs/nodes.py b/cogs/nodes.py new file mode 100644 index 0000000..eec3234 --- /dev/null +++ b/cogs/nodes.py @@ -0,0 +1,48 @@ +import voicelink +import asyncio +import function + +from os import getenv +from discord.ext import commands + +class Nodes(commands.Cog): + """Music Cog.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.voicelink = voicelink.NodePool() + + bot.loop.create_task(self.start_nodes()) + + async def start_nodes(self) -> None: + """Connect and intiate nodes.""" + await self.bot.wait_until_ready() + for n in function.nodes.values(): + try: + await self.voicelink.create_node(bot=self.bot, + spotify_client_id=getenv('SPOTIFY_CLIENT_ID'), + spotify_client_secret=getenv('SPOTIFY_CLIENT_SECRET'), + **n) + except: + print(f'Node {n["identifier"]} is not able to connect!') + + @commands.Cog.listener() + async def on_voicelink_track_end(self, player: voicelink.Player, track, _): + await player.do_next() + + @commands.Cog.listener() + async def on_voicelink_track_stuck(self, player: voicelink.Player, track, _): + await asyncio.sleep(5) + await player.do_next() + + @commands.Cog.listener() + async def on_voicelink_track_exception(self, player: voicelink.Player, track, _): + try: + await player.context.send(f"{_} Please wait for 5 seconds.", delete_after=5) + except: + pass + await asyncio.sleep(5) + await player.do_next() + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Nodes(bot)) diff --git a/cogs/playlist.py b/cogs/playlist.py new file mode 100644 index 0000000..77b5454 --- /dev/null +++ b/cogs/playlist.py @@ -0,0 +1,430 @@ +import discord +import voicelink +import re + +from discord import app_commands +from discord.ext import commands +from tldextract import extract +from function import ( + time as ctime, + get_playlist, + create_account, + checkroles, + update_playlist, + update_inbox, + get_lang, + playlist_name, + embed_color +) + +from datetime import datetime +from view import PlaylistView, InboxView + +def assign_playlistId(existed:list) -> str: + for i in range(200, 210): + if str(i) not in existed: + return str(i) + +async def check_playlist_perms(userid:int, authorid:int, dId:str) -> dict: + playlist = await get_playlist(authorid, 'playlist', dId) + if not playlist or userid not in playlist['perms']['read']: + return {} + return playlist + +async def check_playlist(interaction: discord.Interaction, name:str = None, full:bool = False, share:bool = True) -> dict: + user = await get_playlist(interaction.user.id, 'playlist') + if not user: + return None + await interaction.response.defer() + if full: + return user + if not name: + return {'playlist': user['200'], 'position': 1, 'id': "200"} + + for index, data in enumerate(user, start=1): + playlist = user[data] + if playlist['name'].lower() == name: + if playlist['type'] == 'share' and share: + playlist = await check_playlist_perms(interaction.user.id, playlist['user'], playlist['referId']) + if not playlist or interaction.user.id not in playlist['perms']['read']: + return {'playlist': None, 'position': index, 'id': data} + return {'playlist': playlist, 'position': index, 'id': data} + return {'playlist': None, 'position': None, 'id': None} + +async def search_playlist(url: str, requester: discord.Member, timeNeed=True): + try: + tracks = await voicelink.NodePool.get_node().get_tracks(url, requester=requester) + tracks = {"name": tracks.name, "tracks": tracks.tracks} + if timeNeed: + time = 0 + for track in tracks['tracks']: + time += track.length + except: + return None + return tracks | ({'time': ctime(time)} if timeNeed else {}) + +async def connect_channel(interaction: discord.Interaction, channel: discord.VoiceChannel = None) -> voicelink.Player: + try: + channel = channel or interaction.user.voice.channel + except: + raise voicelink.VoicelinkException(get_lang(interaction.guild_id, 'noChannel')) + + check = channel.permissions_for(interaction.guild.me) + if check.connect == False or check.speak == False: + raise voicelink.VoicelinkException(get_lang(interaction.guild_id, 'noPermission')) + + player: voicelink.Player = await channel.connect(cls=voicelink.Player(interaction.client, channel, interaction)) + return player + +class Playlist(commands.GroupCog, name="playlist"): + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.description = "This is the Vocard playlist system. You can save your favorites and use Vocard to play on any server." + + async def playlist_autocomplete(self, interaction: discord.Interaction, current: str) -> list: + playlists = playlist_name.get(str(interaction.user.id), None) + if not playlists: + playlists_raw = await get_playlist(interaction.user.id, 'playlist') + playlists = playlist_name[str(interaction.user.id)] = [value['name'] for value in playlists_raw.values()] if playlists_raw else [] + if current: + return [ app_commands.Choice(name=p, value=p) for p in playlists if current in p ] + return [ app_commands.Choice(name=p, value=p) for p in playlists ] + + @app_commands.command( + name = "play", + description = "Play all songs from your favorite playlist." + ) + @app_commands.describe( + name = "Input the name of your custom playlist", + value = "Play the specific track from your custom playlist." + ) + @app_commands.autocomplete(name=playlist_autocomplete) + @app_commands.guild_only() + async def play(self, interaction: discord.Interaction, name: str = None, value: int = None) -> None: + result = await check_playlist(interaction, name.lower() if name else None) + if not result: + return await create_account(interaction) + if not result['playlist']: + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistNotFound').format(name), ephemeral=True) + rank, max_p, max_t = await checkroles(interaction.user.id) + if result['position'] > max_p: + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistNotAccess'), ephemeral=True) + + player: voicelink.Player = interaction.guild.voice_client + if not player: + player = await connect_channel(interaction) + + if result['playlist']['type'] == 'link': + tracks = await search_playlist(result['playlist']['uri'], interaction.user, timeNeed=False) + else: + if not result['playlist']['tracks']: + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistNoTrack').format(result['playlist']['name']), ephemeral=True) + + playtrack = [] + for track in result['playlist']['tracks'][:max_t]: + track['info']['length'] *= 1000 + playtrack.append(voicelink.Track(track_id=track['id'], info=track['info'], requester=interaction.user, spotify= True if extract(track['info']['uri']).domain == 'spotify' else False)) + tracks = {"name": result['playlist']['name'], "tracks": playtrack} + + if not tracks: + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistNoTrack').format(result['playlist']['name']), ephemeral=True) + + if value and 0 < value <= (len(tracks['tracks'])): + tracks['tracks'] = [tracks['tracks'][value - 1]] + for track in tracks['tracks']: + await player.queue.put(track) + + await interaction.followup.send(get_lang(interaction.guild_id, 'playlistPlay').format(result['playlist']['name'], len(tracks['tracks'][:max_t]))) + + if not player.is_playing: + await player.do_next() + + @app_commands.command( + name = "view", + description = "List all your playlist and all songs in your favourite playlist." + ) + @app_commands.guild_only() + async def view(self, interaction: discord.Interaction) -> None: + user = await check_playlist(interaction, full=True) + if not user: + return await create_account(interaction) + rank, max_p, max_t = await checkroles(interaction.user.id) + + results = [] + for index, data in enumerate(user, start=1): + playlist = user[data] + time = 0 + try: + if playlist['type'] == 'link': + tracks = await search_playlist(playlist['uri'], requester=interaction.user) + results.append({'emoji':('🔒' if max_p < index else '🔗'), 'id': data, 'time': tracks['time'], 'name':playlist['name'], 'tracks': tracks['tracks'], 'perms': playlist['perms'], 'type': playlist['type']}) + else: + if share := playlist['type'] == 'share': + playlist = await check_playlist_perms(interaction.user.id, playlist['user'], playlist['referId']) + if not playlist: + await update_playlist(interaction.user.id, {f"playlist.{data}":1}, mode=False) + continue + if playlist['type'] == 'link': + tracks = await search_playlist(playlist['uri'], requester=interaction.user) + results.append({'emoji':('🔒' if max_p < index else '🤝'), 'id': data, 'time': tracks['time'], 'name':user[data]['name'], 'tracks': tracks['tracks'], 'perms': playlist['perms'], 'owner': user[data]['user'], 'type': 'share'}) + continue + for track in playlist['tracks']: + time += track['info']['length'] * 1000 + results.append({'emoji':('🔒' if max_p < index else ('🤝' if share else '❤️')), 'id': data, 'time': ctime(time), 'name':user[data]['name'], 'tracks': playlist['tracks'], 'perms': playlist['perms'], 'owner': user[data].get('user', None), 'type': user[data]['type']}) + except: + results.append({'emoji': '⛔', 'id': data, 'time': '00:00', 'name': 'Error', 'tracks': [], 'type': 'error'}) + + embed=discord.Embed(title=get_lang(interaction.guild_id, 'playlistViewTitle').format(interaction.user.name), + description='```%0s %4s %10s %10s %10s\n' % tuple(get_lang(interaction.guild_id, 'playlistViewHeaders')) + '\n'.join('%0s %3s. %10s %10s %10s'% (info['emoji'], info['id'], f"[{info['time']}]", info['name'], len(info['tracks'])) for info in results) + '```', + color=embed_color) + embed.add_field(name=get_lang(interaction.guild_id, 'playlistMaxP'), value=f"➥ {len(user)}/{max_p}", inline=True) + embed.add_field(name=get_lang(interaction.guild_id, 'playlistMaxT'), value=f"➥ {max_t}", inline=True) + embed.set_footer(text=get_lang(interaction.guild_id, 'playlistFooter')) + + view = PlaylistView(embed, results, interaction.user) + await interaction.followup.send(embed=embed, view=view, ephemeral=True) + view.response = await interaction.original_response() + + @app_commands.command( + name = "create", + description = "Create your custom playlist." + ) + @app_commands.describe( + name = "Give a name to your playlist.", + link = "Provide a playlist link if you are creating link playlist." + ) + @app_commands.guild_only() + async def create(self, interaction: discord.Interaction, name: str, link: str = None): + if len(name) > 10: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'playlistOverText'), ephemeral=True) + isLinkType = True if link else False + rank, max_p, max_t = await checkroles(interaction.user.id) + if isLinkType and rank != "Gold": + return await interaction.response.send_message(get_lang(interaction.guild_id, 'playlistCreateError'), ephemeral=True) + user = await check_playlist(interaction, full=True) + if not user: + return await create_account(interaction) + + if len(user) >= max_p: + return await interaction.followup.send(get_lang(interaction.guild_id, 'overPlaylistCreation').format(max_p), ephemeral=True) + for data in user: + if user[data]['name'].lower() == name.lower(): + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistExists'), ephemeral=True) + if isLinkType: + tracks = await voicelink.NodePool.get_node().get_tracks(link, requester=interaction.user) + if not isinstance(tracks, voicelink.Playlist): + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistNotInvaildUrl'), ephemeral=True) + + playlist_name.pop(str(interaction.user.id), None) + data = {'uri': link, 'perms': {'read': []}, 'name': name, 'type': 'link'} if isLinkType else {'tracks': [], 'perms': {'read': [], 'write': [], 'remove': []}, 'name': name, 'type': 'playlist'} + await update_playlist(interaction.user.id, { f"playlist.{assign_playlistId([data for data in user])}": data }) + await interaction.followup.send(get_lang(interaction.guild_id, 'playlistCreated')) + + @app_commands.command( + name = "delete", + description = "Delete your custom playlist." + ) + @app_commands.describe( + name = "The name of the playlist." + ) + @app_commands.guild_only() + @app_commands.autocomplete(name=playlist_autocomplete) + async def delete(self, interaction: discord.Interaction, name: str): + result = await check_playlist(interaction, name.lower(), share=False) + if not result: + return await create_account(interaction) + if not result['playlist']: + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistNotFound').format(name), ephemeral=True) + if result['id'] == "200": + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistDeleteError'), ephemeral=True) + + if result['playlist']['type'] == 'share': + await update_playlist(result['playlist']['user'], {f"playlist.{result['playlist']['referId']}.perms.read":interaction.user.id}, pull=True, mode=False) + + playlist_name.pop(str(interaction.user.id), None) + await update_playlist(interaction.user.id, {f"playlist.{result['id']}":1}, mode=False) + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistRemove').format(result['playlist']['name'])) + + @app_commands.command( + name = "share", + description = "Share your custom playlist with your friends." + ) + @app_commands.describe( + member = "The user id of your friend.", + name = "The name of the playlist that you want to share." + ) + @app_commands.guild_only() + @app_commands.autocomplete(name=playlist_autocomplete) + async def share(self, interaction: discord.Interaction, member: discord.Member, name: str): + if member.id == interaction.user.id: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'playlistSendErrorPlayer'), ephemeral=True) + if member.bot: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'playlistSendErrorBot'), ephemeral=True) + result = await check_playlist(interaction, name.lower(), share=False) + if not result: + return await create_account(interaction) + if not result['playlist']: + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistNotFound').format(name), ephemeral=True) + + if result['playlist']['type'] == 'share': + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistBelongs').format(result['playlist']['user']), ephemeral=True) + if member.id in result['playlist']['perms']['read']: + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistShare').format(member), ephemeral=True) + + receiver = await get_playlist(member.id) + if not receiver: + return await interaction.followup.send(get_lang(interaction.guild_id, 'noPlaylistAcc').format(member)) + for mail in receiver['inbox']: + if mail['sender'] == interaction.user.id and mail['referId'] == result['id']: + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistSent'), ephemeral=True) + if len(receiver['inbox']) >= 10: + return await interaction.followup.send(get_lang(interaction.guild_id, 'inboxFull').format(member), ephemeral=True) + + await update_inbox(member.id, {'sender': interaction.user.id, 'referId': result['id'], 'time': datetime.now(),'title': f'Playlist invitation from {interaction.user}', 'description': f"You are invited to use this playlist.\nPlaylist Name: {result['playlist']['name']}\nPlaylist type: {result['playlist']['type']}", 'type': 'invite'}) + return await interaction.followup.send(get_lang(interaction.guild_id, 'invitationSent').format(member)) + + @app_commands.command( + name = "rename", + description = "Rename your custom playlist." + ) + @app_commands.describe( + name = "The name of your playlist.", + newname = "The new name of your playlist." + ) + @app_commands.guild_only() + @app_commands.autocomplete(name=playlist_autocomplete) + async def rename(self, interaction: discord.Interaction, name: str, newname: str) -> None: + if len(newname) > 10: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'playlistOverText'), ephemeral=True) + if name.lower() == newname.lower(): + return await interaction.response.send_message(get_lang(interaction.guild_id, 'playlistSameName'), ephemeral=True) + user = await check_playlist(interaction, full=True) + if not user: + return await create_account(interaction) + found, id = False, 0 + for data in user: + if user[data]['name'].lower() == name.lower(): + found, id = True, data + if user[data]['name'].lower() == newname.lower(): + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistExists'), ephemeral=True) + + if not found: + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistNotFound').format(name), ephemeral=True) + + playlist_name.pop(str(interaction.user.id), None) + await update_playlist(interaction.user.id, {f'playlist.{id}.name': newname}) + await interaction.followup.send(get_lang(interaction.guild_id, 'playlistRenamed').format(name, newname)) + + @app_commands.command( + name = "inbox", + description = "Show your playlist invitation." + ) + @app_commands.guild_only() + async def inbox(self, interaction: discord.Interaction) -> None: + user = await get_playlist(interaction.user.id) + if user is None: + return await create_account(interaction) + if not user['inbox']: + return await interaction.response.send_message(get_lang(interaction.guild_id, 'inboxNoMsg'), ephemeral=True) + + inbox = user['inbox'].copy() + view = InboxView(interaction.user.name, user['inbox']) + await interaction.response.send_message(embed=view.build_embed(), view=view, ephemeral=True) + view.response = await interaction.original_response() + await view.wait() + + if inbox == user['inbox']: + return + updateData, dId = {}, {dId for dId in user["playlist"]} + for data in view.newplaylist[:(5 - len(user['playlist']))]: + addId = assign_playlistId(dId) + await update_playlist(data['sender'], {f"playlist.{data['referId']}.perms.read": interaction.user.id}, push=True) + updateData[f'playlist.{addId}'] = {'user':data['sender'], 'referId': data['referId'], 'name': f"Share{data['time'].strftime('%M%S')}", 'type': 'share'} + dId.add(addId) + + playlist_name.pop(str(interaction.user.id), None) + await update_playlist(interaction.user.id, updateData | {'inbox':view.inbox}) + + @app_commands.command( + name = "add", + description = "Add tracks in to your custom playlist." + ) + @app_commands.describe( + name = "The name of the playlist.", + query = "Input a query or a searchable link." + ) + @app_commands.guild_only() + @app_commands.autocomplete(name=playlist_autocomplete) + async def add(self, interaction: discord.Interaction, name: str, query: str) -> None: + result = await check_playlist(interaction, name.lower(), share=False) + if not result: + return await create_account(interaction) + if not result['playlist']: + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistNotFound').format(name), ephemeral=True) + if result['playlist']['type'] in ['share', 'link']: + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistNotAllow'), ephemeral=True) + rank, max_p, max_t = await checkroles(interaction.user.id) + + if len(result['playlist']['tracks']) >= max_t: + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistLimitTrack').format(max_t), ephemeral=True) + + results = await voicelink.NodePool.get_node().get_tracks(query, requester = interaction.user) + if not results: + return await interaction.followup.send(get_lang(interaction.guild_id, 'noTrackFound')) + if isinstance(results, voicelink.Playlist): + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistPlaylistLink'), ephemeral=True) + if results[0].is_stream: + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistStream'), ephemeral=True) + + await update_playlist(interaction.user.id, {f'playlist.{result["id"]}.tracks':{'id': results[0].track_id, 'info': {'identifier': results[0].identifier, + 'author': results[0].author, + 'length': results[0].length / 1000, + 'title': results[0].title, + 'uri': results[0].uri}}}, push=True) + await interaction.followup.send(get_lang(interaction.guild_id, 'playlistAdded').format(results[0].title, interaction.user, result['playlist']['name'])) + + @app_commands.command( + name = "remove", + description = "Remove song from your favorite playlist." + ) + @app_commands.describe( + name = "The name of the playlist.", + position = "Input a position from the playlist to be removed." + ) + @app_commands.guild_only() + @app_commands.autocomplete(name=playlist_autocomplete) + async def remove(self, interaction: discord.Interaction, name: str, position: int): + result = await check_playlist(interaction, name.lower(), share=False) + if not result: + return await create_account(interaction) + if not result['playlist']: + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistNotFound').format(name), ephemeral=True) + if result['playlist']['type'] in ['link', 'share']: + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistNotAllow'), ephemeral=True) + if not 0 < position <= len(result['playlist']['tracks']): + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistPositionNotFound').format(position, name)) + + await update_playlist(interaction.user.id, {f'playlist.{result["id"]}.tracks': result['playlist']['tracks'][position - 1]}, pull=True, mode=False) + await interaction.followup.send(get_lang(interaction.guild_id, 'playlistRemoved').format(result['playlist']['tracks'][position - 1]['info']['title'], interaction.user, name)) + + @app_commands.command( + name = "clear", + description = "Remove all songs from your favorite playlist." + ) + @app_commands.guild_only() + @app_commands.autocomplete(name=playlist_autocomplete) + async def clear(self, interaction: discord.Interaction, name: str) -> None: + result = await check_playlist(interaction, name.lower(), share=False) + if not result: + return await create_account(interaction) + if not result['playlist']: + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistNotFound').format(name), ephemeral=True) + + if result['playlist']['type'] in ['link', 'share']: + return await interaction.followup.send(get_lang(interaction.guild_id, 'playlistNotAllow'), ephemeral=True) + + await update_playlist(interaction.user.id, {f'playlist.{result["id"]}.tracks': []}) + await interaction.followup.send(get_lang(interaction.guild_id, 'playlistClear').format(name)) + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Playlist(bot)) \ No newline at end of file diff --git a/cogs/task.py b/cogs/task.py new file mode 100644 index 0000000..5261831 --- /dev/null +++ b/cogs/task.py @@ -0,0 +1,81 @@ +import function +import voicelink +import discord + +from discord.ext import commands, tasks +from datetime import datetime +from io import BytesIO + +class Task(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self.player_check.start() + self.cache_cleaner.start() + + def cog_unload(self): + self.player_check.cancel() + self.cache_cleaner.cancel() + + @tasks.loop(minutes=5.0) + async def player_check(self): + if not self.bot.voice_clients: + return + + player: voicelink.Player + for player in self.bot.voice_clients: + try: + if not player.channel or not player.context or not player.guild: + await player.teardown() + continue + except: + await player.teardown() + continue + + members = player.channel.members + if (not player.is_playing and player.queue.is_empty) or not any(False if member.bot or member.voice.self_deaf else True for member in members): + if not player.settings.get('24/7', False): + await player.teardown() + continue + else: + if not player.is_paused: + await player.set_pause(True) + else: + if not player.guild.me: + await player.teardown() + continue + elif not player.guild.me.voice: + await player.connect(timeout=0.0, reconnect=True) + + try: + if player.dj not in members: + for m in members: + if not m.bot: + player.dj = m + break + except: + pass + + @tasks.loop(hours=12.0) + async def cache_cleaner(self): + function.lang_guilds.clear() + function.playlist_name.clear() + + if function.error_log: + report_channel = self.bot.get_channel(function.report_channel_id) + if report_channel: + errorText = "" + for guildId, error in function.error_log.items(): + errorText += f"Guild ID: {guildId}\n" + "-" * 30 + "\n" + for index, (key, value) in enumerate(error.items() , start=1): + errorText += f"Error No: {index}, Time: {datetime.fromtimestamp(key)}\n" + value + "-" * 30 + "\n\n" + + buffer = BytesIO(errorText.encode('utf-8')) + tempFile = discord.File(buffer, filename='report.txt') + try: + await report_channel.send(content=f"Report Before: ", file=tempFile) + except Exception as e: + print(f"Report could not be sent (Reason: {e})") + function.error_log.clear() + +async def setup(bot: commands.Bot): + await bot.add_cog(Task(bot)) diff --git a/function.py b/function.py new file mode 100644 index 0000000..f002694 --- /dev/null +++ b/function.py @@ -0,0 +1,218 @@ +import discord +import json +import aiohttp +import os + +from dotenv import load_dotenv +from random import choice +from time import strptime +from pymongo import MongoClient + +if not os.path.exists("./.env"): + print("Error .env file not set!") + exit() + +if not os.path.exists("./settings.json"): + print("Error: Settings file not set!") + exit() + +#-------------- API Clients -------------- +load_dotenv() #Load .env settings +MONGODB_NAME = os.getenv('MONGODB_NAME') +youtube_api_key = os.getenv('YOUTUBE_API_KEY') +mongodb = MongoClient(os.getenv('MONGODB_URL')) +collection = mongodb[MONGODB_NAME]['Settings'] +Playlist = mongodb[MONGODB_NAME]['Playlist'] + +#--------------- Cache Var --------------- +invite_link = "https://discord.gg/wRCgB7vBQv" #Template of invite link +embed_color = None +report_channel_id = int(os.getenv("BUG_REPORT_CHANNEL_ID")) +emoji_source_raw = {} #Stores all source emoji for track +error_log = {} #Stores error that not a Voicelink Exception +bot_access_user = [] #Stores bot access user id +langs = {} #Stores all the languages in ./langs +lang_guilds = {} #Cache guild language +local_langs = {} #Stores all the localization languages in ./local_langs +playlist_name = {} #Cache the user's playlist name + +#----------------- Nodes ----------------- +nodes = {} + +#-------------- Vocard Functions -------------- +def get_settings(guildid:int): + settings = collection.find_one({"_id":guildid}) + if not settings: + collection.insert_one({"_id":guildid}) + return {} + return settings + +def update_settings(guildid:int, data, mode="Set"): + if mode == "Set": + collection.update_one({"_id":guildid}, {"$set":data}) + elif mode == "Delete": + collection.update_one({"_id":guildid}, {"$unset":data}) + elif mode == "Add": + collection.update_one({"_id":guildid}, {"$push":data}) + elif mode == "Remove": + collection.update_one({"_id":guildid}, {"$pull":data}) + return + +def langs_setup(): + for language in os.listdir('./langs'): + if language.endswith('.json'): + with open(f'./langs/{language}', encoding="utf8") as json_file: + lang = json.load(json_file) + + langs[language[:-5]] = lang + + for language in os.listdir('./local_langs'): + if language.endswith('.json'): + with open(f'./local_langs/{language}', encoding="utf8") as json_file: + lang = json.load(json_file) + + local_langs[language[:-5]] = lang + return + +def settings_setup(): + with open('./settings.json', encoding="utf8") as json_file: + rawSettings = json.load(json_file) + + global nodes, embed_color, bot_access_user, emoji_source_raw + nodes = rawSettings.get("nodes", {}) + embed_color = int(rawSettings.get("embed_color", "0xb3b3b3"), 16) + bot_access_user = rawSettings.get("bot_access_user", []) + emoji_source_raw = rawSettings.get("emoji_source_raw", {}) + +def get_lang(guildid:int, key:str): + lang = lang_guilds.get(guildid) + if not lang: + settings = get_settings(guildid) + lang = lang_guilds[guildid] = settings.get('lang', 'EN') + + return langs.get(lang, langs["EN"])[key] + +async def requests_api(url: str): + async with aiohttp.ClientSession() as session: + resp = await session.get(url) + if resp.status != 200: + return False + + return await resp.json(encoding="utf-8") + +async def create_account(interaction): + if not interaction.user: + return + from view import CreateView + view = CreateView() + embed=discord.Embed(title="Do you want to create an account on Vocard?", color=embed_color) + embed.description = f"> Plan: `Default` | `1` Playlist | `20` tracks in each playlist\n> You are able to upgrade the plan by Type `-shop` on [Vocard support discord]({invite_link})." + embed.add_field(name="Terms of Service:", value="‌ ➥ We assure you that all your data on Vocard will not be disclosed to any third party\n" + "‌ ➥ We will not perform any data analysis on your data\n" + "‌ ➥ You have the right to immediately stop the services we offer to you\n" + "‌ ➥ Please do not abuse our services, such as affecting other users\n" + "‌ ➥ Join our support server to get free extra custom playlist and extra 20 tracks per playlist\n", inline=False) + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + view.response = await interaction.original_response() + await view.wait() + if view.value: + try: + Playlist.insert_one({'_id':interaction.user.id, 'playlist': {'200':{'tracks':[],'perms':{ 'read': [], 'write':[], 'remove': []},'name':'Favourite', 'type':'playlist' }},'inbox':[] }) + except: + pass + +async def get_playlist(userid:int, dType:str=None, dId:str=None): + user = Playlist.find_one({"_id":userid}, {"_id": 0}) + if not user: + return None + if dType: + if dId and dType == "playlist": + return user[dType][dId] if dId in user[dType] else None + return user[dType] + return user + +async def update_playlist(userid:int, data:dict=None, push=False, pull=False, mode=True): + if mode is True: + if push: + return Playlist.update_one({"_id":userid}, {"$push": data}) + Playlist.update_one({"_id":userid}, {"$set": data}) + else: + if pull: + return Playlist.update_one({"_id":userid}, {"$pull": data}) + Playlist.update_one({"_id":userid}, {"$unset": data}) + return + +async def update_inbox(userid:int, data:dict): + return Playlist.update_one({"_id":userid}, {"$push":{'inbox':data}}) + +async def checkroles(userid:int): + rank, max_p, max_t = 'Normal', 5, 500 + + return rank, max_p, max_t + +async def similar_track(player): + trackids = [ track.identifier for track in player.queue.history(incTrack=True) if track.source == 'youtube' ] + randomTrack = choice(player.queue.history(incTrack=True)[-10:]) + tracks = [] + + if randomTrack.spotify: + tracks = await player.spotifyRelatedTrack(seed_artists=randomTrack.artistId[0], seed_tracks=randomTrack.track_id) + else: + if randomTrack.source != 'youtube': + return False + + request_url = "https://youtube.googleapis.com/youtube/v3/search?part={part}&relatedToVideoId={videoId}&type={type}&videoCategoryId={videoCategoryId}&key={key}".format( + part="snippet", + videoId=randomTrack.identifier, + type="video", + videoCategoryId="10", + key=youtube_api_key + ) + + try: + data = await requests_api(request_url) + if not data: + return False + + for item in data['items']: + if 'snippet' not in item: + continue + if item['id']['videoId'] not in trackids: + tracks = await player.get_tracks(f"https://www.youtube.com/watch?v={item['id']['videoId']}", requester=player._bot.user) + break + except: + return False + + if tracks: + for track in tracks: + await player.queue.put(track) + return True + + return False + +def time(millis:int): + seconds=(millis/1000)%60 + minutes=(millis/(1000*60))%60 + hours=(millis/(1000*60*60))%24 + if hours > 1: + return "%02d:%02d:%02d" % (hours, minutes, seconds) + else: + return "%02d:%02d" % (minutes, seconds) + +def formatTime(number:str): + try: + try: + num = strptime(number, '%M:%S') + except ValueError: + try: + num = strptime(number, '%S') + except ValueError: + num = strptime(number, '%H:%M:%S') + except: + return None + + return (int(num.tm_hour) * 3600 + int(num.tm_min) * 60 + int(num.tm_sec)) * 1000 + +def emoji_source(emoji:str): + return emoji_source_raw.get(emoji.lower(), "🔗"); + diff --git a/langs/CH.json b/langs/CH.json new file mode 100644 index 0000000..c03009a --- /dev/null +++ b/langs/CH.json @@ -0,0 +1,184 @@ +{ + "unknownException": "⚠️ 運行命令時出錯! 請稍後重試或加入我們的不和諧服務器以獲得進一步支持。", + "enabled": "啟用", + "disabled": "禁用", + + "nodeReconnect": "請在節點重新連接後再試一次。", + "noChannel": "沒有可連接的語音通道。 請提供一個頻道或加入一個頻道。", + "alreadyConnected": "已連接到語音通道。", + "noPermission": "對不起! 我無權加入您的語音頻道或在您的語音頻道中發言。", + "noPlaySource": "找不到任何可播放的資源!", + "noPlayer": "在此服務器上未找到任何播放器。", + "notVote": "此命令需要您的投票!輸入 `/vote` 獲取更多信息。", + "languageNotFound": "未找到語言包,請選擇一個存在的語言包。", + "changedLanguage": "已成功更改為`{0}`語言包。", + "setDJ": "將 dj 設置為 {0}。", + "setqueue": "將隊列模式設置為 `{0}`。", + "247": "現在您有了 `{0}` 24/7 模式。", + "bypassVote": "現在您 `{0}` 了投票系統。", + "setVolume": "將音量設置為 `{0}`%", + "togglecontroller": "現在您已經 `{0}` 了音樂控制器。", + "toggleDuplicateTrack": "現在您已經 `{0}` 以防止隊列中的重複曲目。", + "settingsMenu": "服務器設置 | {0}", + "settingsTitle": "❤️ 基本信息:", + "settingsValue": "語言:`{0}`\n啟用音樂控制器:`{1}`\nDJ角色:{2}\n投票繞過:`{3}`\n24/7:`{4}`\n默認音量:`{5}%`\n播放時間: `{6}`\n捐贈服務器:`{7}`", + "settingsTitle2": "🔗 隊列信息:", + "settingsValue2": "隊列模式: `{0}`\n最大曲目: `{1}`\n允許重複軌道: `{2}`", + "settingsPermTitle": "✨ 權限:", + "settingsPermValue": "{0} 管理員\n{1} 管理公會\n{2} 管理頻道\n{3} 管理消息", + "pingTitle1": "機器人信息:", + "pingTitle2": "播放器信息:", + "pingfield1": "```分片 ID:{0}/{1}\n分片延遲:{2:.3f}s {3}\n地區:{4}```", + "pingfield2": "```節點:{0} - {1:.3f}s\n播放機:{2}\n語音區域:{3}```", + "karaoke": "您將level設置為 **{0}**,monoLevel設置為 **{1}**,filterBand設置為 **{2}**,filterWidth設置為 **{3}**", + "tremolo&vibrato": "您將頻率設置為 **{0}** 並將深度設置為 **{1}**", + "rotation": "您將旋轉頻率設置為 **{0}**", + "distortion": "您已啟用失真。", + "lowpass": "您將 lowPass 設置為 **{0}**", + "channelmix": "您已更新 channelMix。 從左到左:**{0}**,從右到右:**{1}**,從左到右:**{2}**,從右到左:** {3}**", + "nightcore": "您添加了夜景音效。", + "8d": "您添加了 8D 音效。", + "vaporwave": "您添加了蒸汽波音效。", + "cleareffect": "音效已經清除!", + "FilterTagAlreadyInUse": "這個音效已經在使用了! 請使用 /cleareffect 將其刪除。", + + "playlistViewTitle": "📜{0}的所有播放列表", + "playlistViewHeaders": [" ", "ID:", "時間:", "姓名:", "曲目:"], + "playlistMaxP": "最大播放列表:", + "playlistMaxT": "最大曲目:", + "playlistFooter": "輸入 /playlist play [playlist] 將播放列表添加到隊列中。", + "playlistNotFound": "未找到播放列表 [`{0}`]。 鍵入 /playlist view 以查看您的所有播放列表。", + "playlistNotAccess": "對不起!您無權訪問此播放列表!", + "playlistNoTrack": "對不起!播放列表 [`{0}`] 中沒有曲目。", + "playlistNotAllow": "鏈接和共享的播放列表不允許使用此命令。", + "playlistPlay": "將帶有 `{1}` 歌曲的播放列表 [`{0}`] 添加到隊列中。", + "playlistOverText": "對不起!播放列表的名稱不能超過 10 個字符。", + "playlistSameName": "對不起!此名稱不能與您的新名稱相同。", + "playlistDeleteError": "您不能刪除默認播放列表。", + "playlistRemove": "您已刪除播放列表 [`{0}`]。", + "playlistSendErrorPlayer": "對不起!您無法向自己發送邀請。", + "playlistSendErrorBot": "對不起!您無法向機器人發送邀請。", + "playlistBelongs": "對不起!此播放列表屬於 <@{0}>。", + "playlistShare": "對不起!此播放列表已與 {0} 共享。", + "playlistSent": "對不起!您之前已經發送過邀請。", + "noPlaylistAcc": "{0} 沒有創建播放列表帳戶。", + "playlistCreateError": "對不起!您無法創建此類播放列表!", + "overPlaylistCreation": "您只能創建`{0}`個播放列表!", + "playlistExists": "播放列表 [`{0}`] 已存在。", + "playlistNotInvaildUrl": "請輸入有效鏈接或公開的 Spotify 或 youtube 播放列錶鍊接。", + "playlistCreated": "您已創建`{0}`播放列表。 鍵入 /playlist view 以獲取更多信息。", + "playlistRenamed": "您已將`{0}`重命名為`{1}`。", + "playlistLimitTrack": "你已經達到極限了! 您只能將`{0}`歌曲添加到播放列表。", + "playlistPlaylistLink": "您不能使用播放列表鍊接。", + "playlistStream": "您不能將流媒體視頻添加到您的播放列表中。", + "playlistPositionNotFound": "無法從您的播放列表 [`{1}`] 中找到位置 `{0}`!", + "playlistRemoved": "👋 從 {1} 的播放列表 [`{2}`] 中刪除了 **{0}**。", + "playlistClear": "您已成功清除播放列表 [`{0}`]。", + "playlistView": "播放列表查看器", + "playlistViewDesc": "```姓名 | ID:{0} | {1}\n總曲目:{2}\n創立者:{3}\n類型:{4}\n```", + "playlistViewPermsValue": "📖 閱覽:✓ ✍🏽 添加:{0} 🗑️ 刪除:{1}", + "playlistViewPermsValue2": "📖 閱覽:{0}", + "playlistViewTrack": "曲目", + "playlistViewPage": "頁面:{0}/{1} | 總時長:{2}", + "inboxFull": "對不起!{0} 的收件箱已滿。", + "inboxNoMsg": "您的收件箱中沒有郵件。", + "invitationSent": "邀請已發送至 {0}。", + + "notInChannel": "{0},您必須在 {1} 中才能使用語音命令。 如果您有聲音,請重新加入!", + "noTrackPlaying": "目前沒有歌曲正在播放", + "noTrackFound": "沒有找到該查詢的歌曲! 請提供有效的網址。", + "noLinkSupport": "搜索命令不支持鏈接!", + "voted": "你投票了!", + "missingPerms_pos": "只有 DJ 或管理員可以更改曲目位置。", + "missingPerms_mode": "只有 DJ 或管理員可以切換循環模式。", + "missingPerms_queue": "只有 DJ 或管理員可以從隊列中刪除曲目。", + "missingPerms_autoplay": "只有 DJ 或管理員可以啟用或禁用自動播放模式!", + "missingPerms_function": "只有 DJ 或管理員可以使用此功能。", + "timeFormatError": "時間格式不正確。 示例: 2:42 或 12:39:31", + "lyricsNotFound": "找不到歌詞。 鍵入 /lyrics <歌名> <作者> 以查找歌詞。", + "missingTrackInfo": "缺少一些曲目信息。", + "noVoiceChannel": "未找到语音频道!", + + "playlistAddError": "您不能將直播視頻添加到您的播放列表中!", + "playlistAddError2": "曲目添加到播放列表時出現問題!", + "playlistlimited": "你已經達到極限了! 您只能將 {0} 首歌曲添加到您的播放列表。", + "playlistrepeated": "您的播放列表中已經有相同的曲目!", + "playlistAdded": "❤️ 將 **{0}** 添加到 {1} 的播放列表 [`{2}`]!", + + "playerAuthor": "音樂控制器 | {0}", + "playerDesc": "**現在播放: ```[{0}]```\n鏈接: [點擊我]({1}) | 請求者: {2} | DJ: {3}**", + "playerFooter": "隊列長度: {0} | 時長: {1} | 音量: {2}% {3}", + "playerFooter2": "| 重複: {0}", + "playerDropdown": "選擇要跳到的歌曲...", + + "buttonBack": "後退", + "buttonPause": "暫停", + "buttonResume": "恢復", + "buttonSkip": "跳過", + "buttonLeave": "離開", + + "nowplayingDesc": "**現在播放:**\n```{0}```", + "nowplayingField": "接下來:", + "nowplayingLink": "在 {0} 上收聽", + + "connect": "已連接到 {0}", + + "live": "直播", + "playlistLoad": " 🎶 將帶有`{1}`歌曲的播放列表 **{0}** 添加到隊列中。", + "trackLoad": "添加了 **{1}** 的 **{0}** (`{2}`) 以開始播放。\n", + "trackLoad_pos": "將 **{1}** 的 **{0}** (`{2}`) 添加到隊列中的位置 **{3}**\n", + + "searchTitle": "搜索查詢: {0}", + "searchDesc": "➥ 平台: {0} **{1}**\n➥ 結果: **{2}**\n\n{3}", + "searchWait": "選擇要添加到隊列中的歌曲。", + "searchTimeout": "搜索超時。請稍後再試。", + "searchSuccess": "歌曲添加到隊列中。", + + "queueTitle": "即將到來的隊列:", + "historyTitle": "歷史隊列:", + "viewTitle": "音樂隊列", + "viewDesc": "**現在播放: [點擊我]({0}) ⮯**\n{1}", + "viewFooter": "頁: {0}/{1} | 總時長: {2}", + + "pauseError": "播放器已暫停。", + "pauseVote": "{0} 已投票暫停這首歌。 [{1}/{2}]", + "paused": "`{0}` 已暫停播放器。", + "resumeError": "播放器沒有暫停。", + "resumeVote": "{0} 已投票恢復這首歌。 [{1}/{2}]", + "resumed": "`{0}` 已恢復播放器。", + "shuffleError": "在隨機播放之前將更多歌曲添加到隊列中。", + "shuffleVote": "{0} 已投票改組隊列。 [{1}/{2}]", + "shuffled": "隊列被打亂了。", + "skipError": "沒有歌曲可以跳過。", + "skipVote": "{0} 已投票跳過這首歌。 [{1}/{2}]", + "skipped": "`{0}` 跳過了這首歌。", + + "backVote": "{0} 已投票跳到上一首歌曲。 [{1}/{2}]", + "backed": "`{0}` 已跳至上一首歌曲。", + + "leaveVote": "{0} 已投票阻止播放器。 [{1}/{2}]", + "left": "`{0}` 已停止播放器。", + + "seek": "將播放器設置為 **{0}**", + "repeat": "重複模式已設置為 `{0}`", + "cleared": "清除了所有曲目 `{0}`", + "removed": "從隊列中刪除了 `{0}` 曲目。", + "forward": "將播放器轉發到 **{0}**", + "rewind": "將播放器倒回至 **{0}**", + "replay": "重播當前歌曲。", + "moved": "{0} 和 {1} 被交換", + "autoplay": "現在是自動播放模式 **{0}**", + + "notdj": "你不是 DJ。 當前 DJ 是 {0}。", + "djToMe": "您不能將 DJ 轉移給您自己或機器人。", + "djnotinchannel": "`{0}` 不在語音頻道中。", + "djswap": "您已將 DJ 的角色轉移到 `{0}`。", + + "chaptersDropdown": "選擇要跳到的章節...", + "noChaptersFound": "沒有找到章節!", + "chatpersNotSupport": "此命令僅支持 Youtube 視頻!", + + "voicelinkQueueFull":"抱歉,您已達到隊列中 `{0}` 首曲目的上限!", + "voicelinkOutofList": "請提供有效的曲目索引!", + "voicelinkDuplicateTrack": "抱歉,此曲目已在隊列中。" +} \ No newline at end of file diff --git a/langs/DE.json b/langs/DE.json new file mode 100644 index 0000000..3be41b2 --- /dev/null +++ b/langs/DE.json @@ -0,0 +1,185 @@ +{ + "unknownException": "⚠️ Etwas falsch beim Ausführen des Befehls! Bitte versuchen Sie es später erneut oder treten Sie unserem Discord-Server bei, um weitere Unterstützung zu erhalten.", + "enabled": "aktiviert", + "disabled": "deaktiviert", + + "nodeReconnect": "Bitte versuche es erneut! Nachdem sich der Knoten wieder verbunden hat.", + "noChannel": "Kein Sprachkanal zum Verbinden. Bitte stellen Sie entweder einen zur Verfügung oder schließen Sie sich einem an.", + "alreadyConnected": "Bereits mit einem Sprachkanal verbunden.", + "noPermission": "Es tut uns leid! Ich bin nicht berechtigt, Ihrem Sprachkanal beizutreten oder darin zu sprechen.", + "noPlaySource": "Kann keine abspielbaren Quellen finden!", + "noPlayer": "Auf diesem Server wurde kein Spieler gefunden.", + "notDonate": "Dieser Befehl erfordert, dass der Server ein Spendenserver ist!", + "notVote": "Dieser Befehl erfordert Ihre Stimme! Geben Sie `/vote` ein, um weitere Informationen zu erhalten.", + "languageNotFound": "Kein Sprachpaket gefunden, bitte wählen Sie ein vorhandenes Sprachpaket aus.", + "changedLanguage": "Erfolgreich auf das Sprachpaket `{0}` geändert.", + "setDJ": "Stellen Sie den DJ auf {0}.", + "setqueue": "Stellen Sie den Warteschlangenmodus auf `{0}` ein.", + "247": "Jetzt haben Sie den 24/7-Modus von `{0}`.", + "bypassVote": "Jetzt haben Sie das Abstimmungssystem `{0}`.", + "setVolume": "Stellen Sie die Lautstärke auf `{0}` % ein", + "togglecontroller": "Jetzt haben Sie `{0}` den Musikcontroller.", + "toggleDuplicateTrack": "Jetzt haben Sie `{0}`, um doppelte Tracks in der Warteschlange zu verhindern.", + "settingsMenu": "Servereinstellungen | {0}", + "settingsTitle": "❤️ Grundlegende Informationen:", + "settingsValue": "Sprache: `{0}`\nMusik-Controller aktivieren: `{1}`\nDJ-Rolle: {2}\nAbstimmungsumgehung: `{3}`\nRund um die Uhr: `{4}`\nStandardvolumen: `{5} %`\nSpielzeit: `{6}`\nDonate-Server: `{7}`", + "settingsTitle2": "🔗 Warteschlangeninformationen:", + "settingsValue2": "Warteschlangenmodus: `{0}`\nMax Lied: `{1}`\nDoppelte Spur zulassen: `{2}`", + "settingsPermTitle": "✨ Berechtigungen:", + "settingsPermValue": "{0} Administrator\n{1} Gilde verwalten\n{2} Kanal verwalten\n{3} Manage_Messages", + "pingTitle1": "Bot Info:", + "pingTitle2": "Spielerinfo:", + "pingfield1": "```Shard-ID: {0}/{1}\nShard-Latenz: {2:.3f} s {3}\nRegion: {4}```", + "pingfield2": "```Knoten: {0} - {1:.3f}s\nSpieler: {2}\nSprachregion: {3}```", + "karaoke": "Sie setzen den Pegel auf **{0}**, monoLevel auf **{1}**, filterBand auf **{2}** und filterWidth auf **{3}**", + "tremolo&vibrato": "Sie stellen die Frequenz auf **{0}** und die Tiefe auf **{1}** ein", + "rotation": "Sie setzen die RotationHz auf **{0}**", + "distortion": "Sie haben die Verzerrung aktiviert.", + "lowpass": "Sie setzen den LowPass auf **{0}**", + "channelmix": "Sie haben den channelMix aktualisiert. Von links nach links: **{0}**, von rechts nach rechts: **{1}**, von links nach rechts: **{2}**, von rechts nach links: ** {3}**", + "nightcore": "Sie haben Nightcore-Soundeffekte hinzugefügt.", + "8d": "Sie haben 8D-Soundeffekte hinzugefügt.", + "vaporwave": "Sie haben Dampfwellen-Soundeffekte hinzugefügt.", + "cleareffect": "Die Soundeffekte wurden gelöscht!", + "FilterTagAlreadyInUse": "Diese Soundeffekte sind bereits im Einsatz! Bitte verwenden Sie /cleareffect , um es zu entfernen.", + + "playlistViewTitle": "📜 Alle Playlists von {0}", + "playlistViewHeaders": [" ", "ID:", "Zeit:", "Name:", "Spuren:"], + "playlistMaxP": "Maximale Wiedergabeliste:", + "playlistMaxT": "Max. Spuren:", + "playlistFooter": "Geben Sie /playlist play [playlist] ein, um die Playlist in die Warteschlange einzufügen.", + "playlistNotFound": "Wiedergabeliste [`{0}`] nicht gefunden. Geben Sie /playlist view ein, um Ihre gesamte Wiedergabeliste anzuzeigen.", + "playlistNotAccess": "Es tut uns leid! Du bist nicht berechtigt, auf diese Playlist zuzugreifen!", + "playlistNoTrack": "Es tut uns leid! Es gibt keine Titel in der Wiedergabeliste [`{0}`].", + "playlistNotAllow": "Dieser Befehl ist für verknüpfte und freigegebene Wiedergabelisten nicht zulässig.", + "playlistPlay": "Playlist [`{0}`] mit `{1}` Songs zur Warteschlange hinzugefügt.", + "playlistOverText": "Es tut uns leid! Der Name der Playlist darf 10 Zeichen nicht überschreiten.", + "playlistSameName": "Es tut uns leid! Dieser Name darf nicht mit Ihrem neuen Namen identisch sein.", + "playlistDeleteError": "Sie dürfen die Standard-Wiedergabeliste nicht löschen.", + "playlistRemove": "Sie haben die Wiedergabeliste [`{0}`] entfernt.", + "playlistSendErrorPlayer": "Es tut uns leid! Sie können keine Einladung an sich selbst senden.", + "playlistSendErrorBot": "Es tut uns leid! Sie können keine Einladung an einen Bot senden.", + "playlistBelongs": "Es tut uns leid! Diese Playlist gehört <@{0}>.", + "playlistShare": "Es tut uns leid! Diese Playlist wurde mit {0} geteilt.", + "playlistSent": "Es tut uns leid! Sie haben bereits eine Einladung gesendet.", + "noPlaylistAcc": "{0} hat kein Playlist-Konto erstellt.", + "playlistCreateError": "Es tut uns leid! Sie können diese Art von Wiedergabeliste nicht erstellen!", + "overPlaylistCreation": "Sie können nicht mehr als `{0}` Wiedergabelisten erstellen!", + "playlistExists": "Playlist [`{0}`] existiert bereits.", + "playlistNotInvaildUrl": "Bitte geben Sie einen gültigen Link oder öffentlichen Spotify- oder YouTube-Playlist-Link ein.", + "playlistCreated": "Sie haben die Wiedergabeliste „{0}“ erstellt. Geben Sie /playlist view ein, um weitere Informationen zu erhalten.", + "playlistRenamed": "Sie haben `{0}` in `{1}` umbenannt.", + "playlistLimitTrack": "Sie haben die Grenze erreicht! Du kannst deiner Playlist nur `{0}` Songs hinzufügen.", + "playlistPlaylistLink": "Sie dürfen keinen Playlist-Link verwenden.", + "playlistStream": "Du darfst deiner Playlist keine Streaming-Videos hinzufügen.", + "playlistPositionNotFound": "Position `{0}` kann nicht in Ihrer Playlist [`{1}`] gefunden werden!", + "playlistRemoved": "👋 **{0}** aus {1}s Playlist [`{2}`] entfernt.", + "playlistClear": "Sie haben Ihre Wiedergabeliste [`{0}`] erfolgreich gelöscht.", + "playlistView": "Playlist-Viewer", + "playlistViewDesc": "```Name | ID: {0} | {1}\nTitel insgesamt: {2}\nBesitzer: {3}\nTyp: {4}\n```", + "playlistViewPermsValue": "📖 Lesen: ✓ ✍🏽 Schreiben: {0} 🗑️ Entfernen: {1}", + "playlistViewPermsValue2": "📖 Lesen: {0}", + "playlistViewTrack": "Spuren", + "playlistViewPage": "Seite: {0}/{1} | Gesamtdauer: {2}", + "inboxFull": "Es tut uns leid! Der Posteingang von {0} ist voll.", + "inboxNoMsg": "Es sind keine Nachrichten in Ihrem Posteingang.", + "invitationSent": "Einladung an {0} gesendet.", + + "notInChannel": "{0}, Sie müssen sich in {1} befinden, um Sprachbefehle verwenden zu können. Bitte verbinden Sie sich erneut mit dem Sprachkanal, wenn Sie sich bereits dort befinden!", + "noTrackPlaying": "Es werden gerade keine Songs abgespielt", + "noTrackFound": "Mit dieser Suchanfrage wurden keine Songs gefunden! Bitte geben Sie eine gültige URL an.", + "noLinkSupport": "Suchbefehl unterstützt keine Links!", + "voted": "Sie haben abgestimmt!", + "missingPerms_pos": "Nur der DJ oder die Administratoren dürfen die Position ändern.", + "missingPerms_mode": "Nur der DJ oder die Administratoren können den Loop-Modus wechseln.", + "missingPerms_queue": "Nur der DJ oder die Administratoren können Tracks aus der Warteschlange entfernen.", + "missingPerms_autoplay": "Nur der DJ oder die Administratoren können den Autoplay-Modus aktivieren oder deaktivieren!", + "missingPerms_function": "Nur DJ oder die Administratoren können diese Funktion verwenden.", + "timeFormatError": "Falsches Zeitformat. Beispiele: 2:42 oder 12:39:31", + "lyricsNotFound": "Liedtext nicht gefunden. Geben Sie /lyrics ein, um den Liedtext zu finden.", + "missingTrackInfo": "Einige Trackinfos fehlen.", + "noVoiceChannel": "Sprachkanal nicht gefunden!", + + "playlistAddError": "Du darfst deiner Playlist keine Streaming-Videos hinzufügen!", + "playlistAddError2": "Beim Hinzufügen von Titeln zur Wiedergabeliste ist ein Problem aufgetreten!", + "playlistlimited": "Sie haben die Grenze erreicht! Du kannst deiner Playlist nur {0} Songs hinzufügen.", + "playlistrepeated": "Es gibt bereits denselben Titel in Ihrer Playlist!", + "playlistAdded": "❤️ **{0}** zur Playlist [`{2}`] von {1} hinzugefügt!", + + "playerAuthor": "Musik-Controller | {0}", + "playerDesc": "**Läuft gerade: ```[{0}]```\nLink: [Klick mich]({1}) | Anforderer: {2} | DJ: {3}**", + "playerFooter": "Warteschlangelänge: {0} | Dauer: {1} | Lautstärke: {2}% {3}", + "playerFooter2": "| Wiederholen: {0}", + "playerDropdown": "Wählen Sie einen Song aus, zudem Sie springen möchten...", + + "buttonBack": "Zurück", + "buttonPause": "Pause", + "buttonResume": "Fortsetzen", + "buttonSkip": "Überspringen", + "buttonLeave": "Verlassen", + + "nowplayingDesc": "**Läuft gerade:**\n```{0}```", + "nowplayingField": "Als nächstes:", + "nowplayingLink": "Hören Sie auf {0}", + + "connect": "Verbunden mit {0}", + + "live": "LIVE", + "playlistLoad": " 🎶 Playlist **{0}** mit `{1}` Songs zur Warteschlange hinzugefügt.", + "trackLoad": "**{0}** von **{1}** (`{2}`) hinzugefügt, um mit dem Spielen zu beginnen..\n", + "trackLoad_pos": "**{0}** von **{1}** (`{2}`) zur Warteschlange an der position **{3}** hinzugefügt\n", + + "searchTitle": "Suchanfrage: {0}", + "searchDesc": "➥ Plattform: {0} **{1}**\n➥ Ergebnisse: **{2}**\n\n{3}", + "searchWait": "Wählen Sie den Song aus, den Sie in der Warteschlange hinzufügen möchten.", + "searchTimeout": "Zeitüberschreitung bei der Suche. Bitte versuchen Sie es später erneut.", + "searchSuccess": "Lied zur Warteschlange hinzugefügt.", + + "queueTitle": "Bevorstehende Warteschlange:", + "historyTitle": "Verlaufswarteschlange:", + "viewTitle": "Musikwarteschlange", + "viewDesc": "**Läuft gerade: [Klick mich]({0}) ⮯**\n{1}", + "viewFooter": "Buchseite: {0}/{1} | Gesamtdauer: {2}", + + "pauseError": "Der Player ist bereits pausiert.", + "pauseVote": "{0} hat dafür gestimmt, den Song zu pausieren. [{1}/{2}]", + "paused": "`{0}` hat den Player angehalten.", + "resumeError": "Der Player wird nicht angehalten.", + "resumeVote": "{0} hat dafür gestimmt, den Song fortzusetzen. [{1}/{2}]", + "resumed": "`{0}` hat den Player fortgesetzt.", + "shuffleError": "Fügen Sie vor dem Mischen weitere Songs zur Warteschlange hinzu.", + "shuffleVote": "{0} hat dafür gestimmt, die Warteschlange zu mischen. [{1}/{2}]", + "shuffled": "Die Warteschlange wird gemischt.", + "skipError": "Es gibt keine Songs, zu denen man springen könnte.", + "skipVote": "{0} hat dafür gestimmt, den Song zu überspringen. [{1}/{2}]", + "skipped": "`{0}` hat das Lied übersprungen.", + + "backVote": "{0} hat dafür gestimmt, zu den vorherigen Song zu überspringen. [{1}/{2}]", + "backed": "`{0}` springt zum vorherigen Song.", + + "leaveVote": "{0} hat dafür gestimmt, den Spieler zu stoppen. [{1}/{2}]", + "left": "`{0}` hat den Player angehalten.", + + "seek": "Setze den Player auf **{0}**", + "repeat": "Der Wiederholungsmodus wurde auf `{0}` eingestellt", + "cleared": "Alle Tracks in `{0}` gelöscht", + "removed": "`{0}` Titel wurden aus der Warteschlange entfernt.", + "forward": "Leite den Spieler an **{0}** weiter", + "rewind": "Spulen Sie den Player auf **{0}** zurück", + "replay": "Wiederholen des aktuellen Songs.", + "moved": "{0} und {1} sind vertauscht", + "autoplay": "Der Autoplay-Modus ist jetzt **{0}**", + + "notdj": "Du bist kein DJ. Der aktuelle DJ ist {0}.", + "djToMe": "Sie können den DJ nicht auf sich selbst oder einen Bot übertragen.", + "djnotinchannel": "`{0}` ist nicht im Sprachkanal.", + "djswap": "Sie haben die Rolle des DJs an `{0}` übertragen.", + + "chaptersDropdown": "Wählen Sie ein Kapitel aus, zu dem Sie springen möchten ...", + "noChaptersFound": "Es wurden keine Kapitel gefunden!", + "chatpersNotSupport": "Dieser Befehl unterstützt nur YouTube-Videos!", + + "voicelinkQueueFull": "Entschuldigung, Sie haben das Maximum von `{0}` Titeln in der Warteschlange erreicht!", + "voicelinkOutofList": "Bitte geben Sie einen gültigen Titelindex an!", + "voicelinkDuplicateTrack": "Entschuldigung, dieser Titel befindet sich bereits in der Warteschlange." +} \ No newline at end of file diff --git a/langs/EN.json b/langs/EN.json new file mode 100644 index 0000000..3ccef51 --- /dev/null +++ b/langs/EN.json @@ -0,0 +1,184 @@ +{ + "unknownException": "⚠️ Something wrong while running the command! Please try again later or join our discord server for further support.", + "enabled": "enabled", + "disabled": "disabled", + + "nodeReconnect": "Please try again! After the node reconnects.", + "noChannel": "No voice channel to connect. Please either provide one or join one.", + "alreadyConnected": "Already connected to a voice channel.", + "noPermission": "Sorry! i don't have permissions to join or speak in your voice channel.", + "noPlaySource": "Can't found any playable sources!", + "noPlayer": "No player has found on this server.", + "notVote": "This command requires your vote! Type `/vote` for more info.", + "languageNotFound": "No language pack found! please select an existing language pack.", + "changedLanguage": "Successfully changed to `{0}` language pack.", + "setDJ": "Set the dj to {0}.", + "setqueue": "Set queue mode to `{0}`.", + "247": "Now you have `{0}` 24/7 mode.", + "bypassVote": "Now you have `{0}` voting system.", + "setVolume": "Set the volume to `{0}`%", + "togglecontroller": "Now you have `{0}` the music controller.", + "toggleDuplicateTrack": "Now you have `{0}` to prevent duplicate track in the queue.", + "settingsMenu": "Server Settings | {0}", + "settingsTitle": "❤️ Basic Info:", + "settingsValue": "Language: `{0}`\nEnable Music Controller: `{1}`\nDJ Role: {2}\nVote Bypass: `{3}`\n24/7: `{4}`\nDefault Volume: `{5}%`\nPlay Time: `{6}`\nDonate Server: `{7}`", + "settingsTitle2": "🔗 Queue info:", + "settingsValue2": "Queue Mode: `{0}`\nMax Song: `{1}`\nAllow Duplicate Track: `{2}`", + "settingsPermTitle": "✨ Permissions:", + "settingsPermValue": "{0} Administrator\n{1} Manage_Guild\n{2} Manage_Channel\n{3} Manage_Messages", + "pingTitle1": "Bot Info:", + "pingTitle2": "Player Info:", + "pingfield1": "```Shard ID: {0}/{1}\nShard Latency: {2:.3f}s {3}\nRegion: {4}```", + "pingfield2": "```Node: {0} - {1:.3f}s\nPlayers: {2}\nVoice Region: {3}```", + "karaoke": "You have updated the level to **{0}**, monoLevel to **{1}**, filterBand to **{2}** and filterWidth to **{3}**", + "tremolo&vibrato": "You updated the frequency to **{0}** and depth to **{1}**", + "rotation": "You have updated the rotationHz to **{0}**", + "distortion": "You have enabled distortion.", + "lowpass": "You have updated the lowPass to **{0}**", + "channelmix": "You have updated the channelMix. Left-to-left: **{0}**, Right-to-right: **{1}**, Left-to-right: **{2}**, Right-to-left: **{3}**", + "nightcore": "You have added nightcore sound effects.", + "8d": "You have added 8D sound effects.", + "vaporwave": "You have added vaporwave sound effects.", + "cleareffect": "The sound effects have been cleared!", + "FilterTagAlreadyInUse": "This sound effects has already in use! Please use /cleareffect to remove it.", + + "playlistViewTitle": "📜 All {0}'s Playlists", + "playlistViewHeaders": [" ", "ID:", "Time:", "Name:", "Tracks:"], + "playlistMaxP": "Max Playlist:", + "playlistMaxT": "Max Tracks:", + "playlistFooter": "Type /playlist play [playlist] to add the playlist the into queue.", + "playlistNotFound": "Playlist [`{0}`] not found. Type /playlist view to view all your playlist.", + "playlistNotAccess": "Sorry! You are not allowed to access this playlist!", + "playlistNoTrack": "Sorry! There are no tracks in the playlist [`{0}`].", + "playlistNotAllow": "This command is not allowed on linked and shared playlists.", + "playlistPlay": "Added the playlist [`{0}`] with `{1}` songs to the queue.", + "playlistOverText": "Sorry! The name of the playlist cannot exceed 10 characters.", + "playlistSameName": "Sorry! This name cannot be the same as your new name.", + "playlistDeleteError": "You are not allowed to delete default playlist.", + "playlistRemove": "You have removed playlist [`{0}`].", + "playlistSendErrorPlayer": "Sorry! You are not able to send invitation to yourself.", + "playlistSendErrorBot": "Sorry! You are not able to send invitation to a bot.", + "playlistBelongs": "Sorry! This playlist belongs to <@{0}>.", + "playlistShare": "Sorry! This playlist has been shared with {0}.", + "playlistSent": "Sorry! You have sent an invitation before.", + "noPlaylistAcc": "{0} didn't create a playlist account.", + "playlistCreateError": "Sorry! You are not able to create this type of playlist!", + "overPlaylistCreation": "You cannot create more than `{0}` playlists!", + "playlistExists": "Playlist [`{0}`] already exists.", + "playlistNotInvaildUrl": "Please enter a valid link or public spotify or youtube playlist link.", + "playlistCreated": "You have created `{0}` playlist. Type /playlist view for more info.", + "playlistRenamed": "You have renamed `{0}` to `{1}`.", + "playlistLimitTrack": "You have reached the limit! You can only add `{0}` songs to your playlist.", + "playlistPlaylistLink": "You are not allowed to use playlist link.", + "playlistStream": "You are not allowed to add streaming videos to your playlist.", + "playlistPositionNotFound": "Can't find position `{0}` from your playlist [`{1}`]!", + "playlistRemoved": "👋 Removed **{0}** from {1}'s playlist [`{2}`].", + "playlistClear": "You have successfully clear your playlist [`{0}`].", + "playlistView": "Playlist Viewer", + "playlistViewDesc": "```Name | ID: {0} | {1}\nTotal Tracks: {2}\nOwner: {3}\nType: {4}\n```", + "playlistViewPermsValue": "📖 Read: ✓ ✍🏽 Write: {0} 🗑️ Remove: {1}", + "playlistViewPermsValue2": "📖 Read: {0}", + "playlistViewTrack": "Tracks", + "playlistViewPage": "Page: {0}/{1} | Total Duration: {2}", + "inboxFull": "Sorry! {0}'s inbox is full.", + "inboxNoMsg": "There are no messages in your inbox.", + "invitationSent": "Invitation sent to {0}.", + + "notInChannel": "{0}, you must be in {1} to use voice commands. Please rejoin if you are in voice!", + "noTrackPlaying": "There are no songs playing right now", + "noTrackFound": "No songs were found with that query! Please provide a valid url.", + "noLinkSupport": "Search command does not support links!", + "voted": "You have voted!", + "missingPerms_pos": "Only the DJ or admins may change the position.", + "missingPerms_mode": "Only the DJ or admins may switch loop mode.", + "missingPerms_queue": "Only the DJ or admins may remove track from the queue.", + "missingPerms_autoplay": "Only the DJ or admins can enable or disable autoplay mode!", + "missingPerms_function": "Only DJ or Admin can use this function.", + "timeFormatError": "Incorrect time format. Example: 2:42 or 12:39:31", + "lyricsNotFound": "Lyrics not found. Type /lyrics to find the lyrics.", + "missingTrackInfo": "Some track infos are missing.", + "noVoiceChannel": "Voice Channel Not Found!", + + "playlistAddError": "You are not allowed to add streaming videos to your playlist!", + "playlistAddError2": "There was a problem adding tracks to the playlist!", + "playlistlimited": "You have reached the limit! You can only add {0} songs to your playlist.", + "playlistrepeated": "There is already the same track in your playlist!", + "playlistAdded": "❤️ Added **{0}** into {1}'s playlist [`{2}`]!", + + "playerAuthor": "Music Controller | {0}", + "playerDesc": "**Now Playing: ```[{0}]```\nLink: [Click Me]({1}) | Requester: {2} | DJ: {3}**", + "playerFooter": "Queue Length: {0} | Duration: {1} | Volume: {2}% {3}", + "playerFooter2": "| Repeat: {0}", + "playerDropdown": "Select a song to skip to ...", + + "buttonBack": "Back", + "buttonPause": "Pause", + "buttonResume": "Resume", + "buttonSkip": "Skip", + "buttonLeave": "Leave", + + "nowplayingDesc": "**Now Playing:**\n```{0}```", + "nowplayingField": "Up Next:", + "nowplayingLink": "Listen on {0}", + + "connect": "Connected to {0}", + + "live": "LIVE", + "playlistLoad": " 🎶 Added the playlist **{0}** with `{1}` songs to the queue.", + "trackLoad": "Added **{0}** by **{1}** (`{2}`) to begin playing.\n", + "trackLoad_pos": "Added **{0}** by **{1}** (`{2}`) to the queue at position **{3}**\n", + + "searchTitle": "Search Query: {0}", + "searchDesc": "➥ Platform: {0} **{1}**\n➥ Results: **{2}**\n\n{3}", + "searchWait": "Select the song you want to add to the queue.", + "searchTimeout": "Search timed out. Please try again later.", + "searchSuccess": "Song added to the queue.", + + "queueTitle": "Upcoming Queue:", + "historyTitle": "History Queue:", + "viewTitle": "Music Queue", + "viewDesc": "**Now Playing: [Click Me]({0}) ⮯**\n{1}", + "viewFooter": "Page: {0}/{1} | Total Duration: {2}", + + "pauseError": "The player is already paused.", + "pauseVote": "{0} has voted to pause the song. [{1}/{2}]", + "paused": "`{0}` has paused the player.", + "resumeError": "The player is not paused.", + "resumeVote": "{0} has voted to resume the song. [{1}/{2}]", + "resumed": "`{0}` has resumed the player.", + "shuffleError": "Add more songs to the queue before shuffling.", + "shuffleVote": "{0} has voted to shuffle the queue. [{1}/{2}]", + "shuffled": "The queue is shuffled.", + "skipError": "There are no songs too skip to.", + "skipVote": "{0} has voted to skip the song. [{1}/{2}]", + "skipped": "`{0}` has skipped the song.", + + "backVote": "{0} has voted to skip to the previous song. [{1}/{2}]", + "backed": "`{0}` has skipping to previous song.", + + "leaveVote": "{0} has voted to stop the player. [{1}/{2}]", + "left": "`{0}` has stopped the player.", + + "seek": "Set the player to **{0}**", + "repeat": "The repeat mode has been set to `{0}`", + "cleared": "Cleared all tracks in the `{0}`", + "removed": "`{0}` tracks were removed from the queue.", + "forward": "Forward the player to **{0}**", + "rewind": "Rewind the player to **{0}**", + "replay": "Replaying the current song.", + "moved": "{0} and {1} are Swapped", + "autoplay": "Autoplay mode is now **{0}**", + + "notdj": "You are not DJ. The current DJ is {0}.", + "djToMe": "You cannot transfer the DJ to yourself or a bot.", + "djnotinchannel": "`{0}` is not in the voice channel.", + "djswap": "You have transferred the role of dj to `{0}`.", + + "chaptersDropdown": "Select a chapter to skip to ...", + "noChaptersFound": "No chapters has been found!", + "chatpersNotSupport": "This command only supports Youtube videos!", + + "voicelinkQueueFull": "Sorry, you have reached the maximum of `{0}` tracks in the queue!", + "voicelinkOutofList": "Please provide a valid track index!", + "voicelinkDuplicateTrack": "Sorry, this track is already in the queue." +} \ No newline at end of file diff --git a/langs/JP.json b/langs/JP.json new file mode 100644 index 0000000..76433cb --- /dev/null +++ b/langs/JP.json @@ -0,0 +1,185 @@ +{ + "unknownException": "⚠️ コマンドの実行中に問題が発生しました! 後でもう一度試すか、discord サーバーに参加してさらなるサポートを受けてください。", + "enabled": "有効", + "disabled": "無効", + + "nodeReconnect": "もう一度お試しください。 ノードが再接続した後。", + "noChannel": "接続する音声チャネルがありません。いずれかを提供するか、いずれかに参加してください。", + "alreadyConnected": "すでに音声チャネルに接続されています。", + "noPermission": "ごめん!あなたの音声チャネルに参加したり、話したりする権限がありません。", + "noPlaySource": "再生可能なソースが見つかりません!", + "noPlayer": "このサーバーにはプレイヤーが見つかりません。", + "notDonate": "このコマンドを実行するには、サーバーが寄付サーバーである必要があります。", + "notVote": "このコマンドにはあなたの投票が必要です! 詳細については、`/vote` と入力してください。", + "languageNotFound": "言語パックが見つかりません。既存の言語パックを選択してください", + "changedLanguage": "`{0}` 言語パックに正常に変更されました。", + "setDJ": "DJ を {0} に設定します。", + "setqueue": "キュー モードを `{0}` に設定します。", + "247": "これで `{0}` 24/7 モードになりました。", + "bypassVote": "これで `{0}` 投票システムができました。", + "setVolume": "音量を `{0}`% に設定", + "togglecontroller": "これで音楽コントローラー `{0}` ができました。", + "toggleDuplicateTrack": "これで、キュー内でトラックが重複しないように `{0}` になりました。", + "settingsMenu": "サーバー設定 | {0}", + "settingsTitle": "❤️ 基本情報:", + "settingsValue": "言語: `{0}`\nミュージック コントローラを有効にする: `{1}`\nDJ ロール: {2}\n投票バイパス: `{3}`\n年中無休: `{4}`\nデフォルトのボリューム: `{5}%`\nプレイ時間: `{6}`\n寄付サーバー: `{7}`", + "settingsTitle2": "🔗 キュー情報:", + "settingsValue2": "キューモード: `{0}`\nマックスソング: `{1}`\n重複トラックを許可: `{2}`", + "settingsPermTitle": "✨ パーミッション:", + "settingsPermValue": "{0} 管理者\n{1}Manage_Guild\n{2}Manage_Channel\n{3}Manage_Messages", + "pingTitle1": "情報について:", + "pingTitle2": "プレイヤー情報:", + "pingfield1": "```シャード ID: {0}/{1}\nシャードの待ち時間: {2:.3f} 秒 {3}\nリージョン: {4}```", + "pingfield2": "ノード: {0} - {1:.3f}\nプレーヤー: {2}\n声域: {3}```", + "karaoke": "レベルを **{0}** に、monoLevel を **{1}** に、filterBand を **{2}** に、filterWidth を **{3}** に設定します。", + "tremolo&vibrato": "周波数を **{0}** に設定し、深さを **{1}** に設定しました", + "rotation": "RotationHz を **{0}** に設定しました", + "distortion": "歪みを有効にしました。", + "lowpass": "ローパスを **{0}** に設定しました", + "channelmix": "channelMix を更新しました。左から左: **{0}**、右から右: **{1}**、左から右: **{2}**、右から左: ** {3}**", + "nightcore": "ナイトコア効果音を追加しました。", + "8d": "8D 効果音を追加しました。", + "vaporwave": "蒸気波効果音を追加しました。", + "cleareffect": "効果音クリアしました!", + "FilterTagAlreadyInUse": "この効果音はすでに使用されています。 /cleareffect を使用して削除してください。", + + "playlistViewTitle": "📜 すべての {0} のプレイリスト", + "playlistViewHeaders": [" ", "ID:", "時間:", "名前:", "トラック:"], + "playlistMaxP": "最大プレイリスト:", + "playlistMaxT": "最大トラック:", + "playlistFooter": "/playlist play [playlist] と入力して、プレイリストをキューに追加します。", + "playlistNotFound": "プレイリスト [`{0}`] が見つかりません。 /playlist view と入力して、すべてのプレイリストを表示します。", + "playlistNotAccess": "ごめん!このプレイリストへのアクセスは許可されていません!", + "playlistNoTrack": "ごめん!プレイリスト [`{0}`] にトラックがありません。", + "playlistNotAllow": "このコマンドは、リンクされた共有プレイリストでは許可されていません。", + "playlistPlay": "`{1}` の曲を含むプレイリスト [`{0}`] をキューに追加しました。", + "playlistOverText": "ごめん!プレイリストの名前は 10 文字を超えることはできません。", + "playlistSameName": "ごめん!この名前を新しい名前と同じにすることはできません。", + "playlistDeleteError": "デフォルトのプレイリストを削除することはできません。", + "playlistRemove": "プレイリスト [`{0}`] を削除しました。", + "playlistSendErrorPlayer": "ごめん!自分自身に招待状を送信できません。", + "playlistSendErrorBot": "ごめん!ボットに招待状を送信できません。", + "playlistBelongs": "ごめん!この再生リストは <@{0}> のものです。", + "playlistShare": "ごめん!この再生リストは {0} と共有されています。", + "playlistSent": "ごめん!以前に招待状を送信しました。", + "noPlaylistAcc": "{0} はプレイリスト アカウントを作成しませんでした。", + "playlistCreateError": "ごめん!このタイプのプレイリストは作成できません!", + "overPlaylistCreation": "`{0}` 以上の再生リストを作成することはできません!", + "playlistExists": "プレイリスト [`{0}`] は既に存在します。", + "playlistNotInvaildUrl": "有効なリンク、または公開の Spotify または YouTube プレイリストのリンクを入力してください。", + "playlistCreated": "`{0}` プレイリストを作成しました。詳細については、/playlist view と入力してください。", + "playlistRenamed": "`{0}` を `{1}` に名前変更しました。", + "playlistLimitTrack": "限界に達しました!プレイリストに追加できるのは `{0}` 曲のみです。", + "playlistPlaylistLink": "プレイリスト リンクの使用は許可されていません。", + "playlistStream": "ストリーミング ビデオをプレイリストに追加することはできません。", + "playlistPositionNotFound": "プレイリスト [`{1}`] から位置 `{0}` が見つかりません!", + "playlistRemoved": "👋 {1} のプレイリスト [`{2}`] から **{0}** を削除しました。", + "playlistClear": "プレイリスト [`{0}`] を正常にクリアしました。", + "playlistView": "プレイリスト ビューア", + "playlistViewDesc": "```名前 | ID: {0} | {1}\n合計トラック数: {2}\n所有者: {3}\nタイプ: {4}\n```", + "playlistViewPermsValue": "📖 読み取り: ✓ ✍🏽 書き込み: {0} 🗑️ 削除: {1}", + "playlistViewPermsValue2": "📖 読む: {0}", + "playlistViewTrack": "トラック", + "playlistViewPage": "ページ: {0}/{1} |合計期間: {2}", + "inboxFull": "ごめん! {0} の受信ボックスがいっぱいです。", + "inboxNoMsg": "受信トレイにメッセージがありません。", + "invitationSent": "{0} に招待状を送信しました。", + + "notInChannel": "{0}, 音声コマンドを使用するには、{1}にいる必要があります。 声が出たらまた参加してください!", + "noTrackPlaying": "現在再生中の曲はありません", + "noTrackFound": "そのクエリで曲が見つかりませんでした! 有効なURLを入力してください。", + "noLinkSupport": "検索コマンドはリンクをサポートしていません!", + "voted": "投票しました!", + "missingPerms_pos": "DJまたは管理者のみが位置を変更できます。", + "missingPerms_mode": "DJまたは管理者のみがループモードを切り替えることができます。", + "missingPerms_queue": "DJまたは管理者のみがキューからトラックを削除できます。", + "missingPerms_autoplay": "自動再生モードを有効または無効にできるのは、DJまたは管理者だけです。", + "missingPerms_function": "この機能を使用できるのはDJまたは管理者のみです。", + "timeFormatError": "時間形式が正しくありません。 例: 2:42または12:39:31", + "lyricsNotFound": "歌詞が見つかりません。/lyrics <曲名> <著者> と入力して、歌詞を検索します。", + "missingTrackInfo": "一部のトラック情報が欠落しています。", + "noVoiceChannel": "音声チャンネルが見つかりません!", + + "playlistAddError": "再生リストにストリーミング動画を追加することは許可されていません。", + "playlistAddError2": "プレイリストにトラックを追加する際に問題が発生しました。", + "playlistlimited": "限界に達しました! プレイリストに追加できるのは{0}曲のみです。", + "playlistrepeated": "プレイリストにはすでに同じトラックがあります!", + "playlistAdded": "❤️ {1}のプレイリスト[`{2}`]に** {0} **を追加しました!", + + "playerAuthor": "ミュージックコントローラー | {0}", + "playerDesc": "**再生中: ```[{0}]```\nリンク: [クリックしてください]({1}) | リクエスター: {2} | DJ: {3}**", + "playerFooter": "キューの長さ: {0} | 間隔: {1} | 音量: {2}% {3}", + "playerFooter2": "| 繰り返す: {0}", + "playerDropdown": "スキップする曲を選択してください...", + + "buttonBack": "戻る", + "buttonPause": "休止", + "buttonResume": "再開", + "buttonSkip": "スキップ", + "buttonLeave": "離れる", + + "nowplayingDesc": "**再生中:**\n```{0}```", + "nowplayingField": "次に:", + "nowplayingLink": "{0}で聞く", + + "connect": "{0}に接続", + + "live": "生放送", + "playlistLoad": " 🎶 `{1}`の曲を含むプレイリスト** {0} **をキューに追加しました。", + "trackLoad": "再生を開始するには、**{1}** によって **{0}** (`{2}`) が追加されました。\n", + "trackLoad_pos": "**{1}** によって **{0}** (`{2}`)を** {3} **の位置のキューに追加しました\n", + + "searchTitle": "検索クエリ: {0}", + "searchDesc": "➥ プラットホーム: {0} **{1}**\n➥ 結果: **{2}**\n\n{3}", + "searchWait": "キューに追加する曲を選択します。", + "searchTimeout": "検索がタイムアウトしました。後でもう一度やり直してください。", + "searchSuccess": "曲がキューに追加されます。", + + "queueTitle": "今後のキュー:", + "historyTitle": "履歴キュー:", + "viewTitle": "ミュージックキュー", + "viewDesc": "**再生中: [クリックしてください]({0}) ⮯**\n{1}", + "viewFooter": "ページ: {0}/{1} | 合計期間: {2}", + + "pauseError": "プレーヤーはすでに一時停止しています。", + "pauseVote": "{0} は曲を一時停止することに投票しました。[{1}/{2}]", + "paused": "`{0}` はプレーヤーを一時停止しました。", + "resumeError": "プレーヤーは一時停止されません。", + "resumeVote": "{0}は曲の再開に投票しました。[{1}/{2}]", + "resumed": "`{0}`はプレーヤーを再開しました。", + "shuffleError": "シャッフルする前に、キューに曲を追加します。", + "shuffleVote": "{0}はキューをシャッフルすることに投票しました。[{1}/{2}]", + "shuffled": "キューがシャッフルされます。", + "skipError": "スキップしすぎる曲はありません。", + "skipVote": "{0}は曲をスキップすることに投票しました。[{1}/{2}]", + "skipped": "`{0}`は曲をスキップしました。", + + "backVote": "{0} 前の曲にスキップすることに投票しました。[{1}/{2}]", + "backed": "`{0}`は前の曲にスキップしています。", + + "leaveVote": "{0}はプレーヤーを停止することに投票しました。[{1}/{2}]", + "left": "`{0}`はプレーヤーを停止しました。", + + "seek": "プレーヤーを **{0}** に設定します", + "repeat": "リピートモードが `{0}`に設定されています", + "cleared": "`{0}`のすべてのトラックをクリアしました", + "removed": "`{0}` トラックがキューから削除されました。", + "forward": "プレーヤーを **{0}** に転送します", + "rewind": "プレーヤーを **{0}** に巻き戻します", + "replay": "現在の曲を再生します。", + "moved": "{0}と{1}は入れ替えられます", + "autoplay": "自動再生モードが **{0}** になりました", + + "notdj": "あなたはDJではありません。 現在のDJは{0}です。", + "djToMe": "DJを自分やロボットに転送することはできません。", + "djnotinchannel": "`{0}`は音声チャネルにありません。", + "djswap": "DJの役割を `{0}`に移しました。", + + "chaptersDropdown": "スキップするチャプターを選択してください...", + "noChaptersFound": "チャプターが見つかりません!", + "chatpersNotSupport": "このコマンドはYouTubeビデオのみをサポートします!", + + "voicelinkQueueFull": "申し訳ありませんが、キューの `{0}` トラックの最大数に達しました!", + "voicelinkOutofList": "有効なトラック インデックスを指定してください。", + "voicelinkDuplicateTrack": "申し訳ありませんが、このトラックは既にキューに入っています。" +} \ No newline at end of file diff --git a/langs/KO.json b/langs/KO.json new file mode 100644 index 0000000..d8eccc5 --- /dev/null +++ b/langs/KO.json @@ -0,0 +1,185 @@ +{ + "unknownException": "⚠️ 명령을 실행하는 동안 문제가 발생했습니다! 나중에 다시 시도하거나 추가 지원을 위해 디스코드 서버에 가입하세요.", + "enabled": "활성화", + "disabled": "장애가 있는", + + "nodeReconnect": "다시 시도해 주세요! 노드가 다시 연결된 후.", + "noChannel": "연결할 음성 채널이 없습니다. 하나를 제공하거나 하나를 가입하십시오.", + "alreadyConnected": "이미 음성 채널에 연결되어 있습니다.", + "noPermission": "죄송합니다! 나는 당신의 음성 채널에 참여하거나 말할 권한이 없습니다.", + "noPlaySource": "재생 가능한 소스를 찾을 수 없습니다!", + "noPlayer": "이 서버에서 플레이어를 찾지 못했습니다.", + "notDonate": "이 명령은 서버가 기부 서버여야 합니다!", + "notVote": "이 명령에는 투표가 필요합니다! 자세한 정보를 보려면 `/vote`를 입력하세요.", + "languageNotFound": "언어 팩을 찾을 수 없습니다. 기존 언어 팩을 선택하십시오.", + "changedLanguage": "`{0}` 언어 팩으로 변경되었습니다.", + "setDJ": "DJ를 {0}으로 설정합니다.", + "setqueue": "대기열 모드를 `{0}`로 설정합니다.", + "247": "이제 `{0}` 24/7 모드가 있습니다.", + "bypassVote": "이제 `{0}` 투표 시스템이 생겼습니다.", + "setVolume": "볼륨을 `{0}`%로 설정", + "togglecontroller": "이제 `{0}` 음악 컨트롤러가 있습니다.", + "toggleDuplicateTrack": "이제 대기열에서 중복 트랙을 방지하기 위해 `{0}`되었습니다.", + "settingsMenu": "서버 설정 | {0}", + "settingsTitle": "❤️ 기본 정보:", + "settingsValue": "언어: `{0}`\n음악 컨트롤러 활성화: `{1}`\nDJ 역할: {2}\n투표 우회: `{3}`\n연중무휴: `{4}`\n기본 볼륨: `{5}%`\n플레이 시간: `{6}`\n서버 기부: `{7}`", + "settingsTitle2": "🔗 대기열 정보:", + "settingsValue2": "대기열 모드: `{0}`\n맥스송: `{1}`\n중복 트랙 허용: `{2}`", + "settingsPermTitle": "✨ 권한:", + "settingsPermValue": "{0} 관리자\n{1} 관리_길드\n{2} 관리_채널\n{3} 관리_메시지", + "pingTitle1": "정보:", + "pingTitle2": "플레이어 정보:", + "pingfield1": "```샤드 ID: {0}/{1}\n샤드 지연 시간: {2:.3f}s {3}\n지역: {4}```", + "pingfield2": "```노드: {0} - {1:.3f}s\n선수: {2}\n음성 지역: {3}```", + "karaoke": "수준을 **{0}**, monoLevel을 **{1}**, filterBand를 **{2}**, filterWidth를 **{3}**으로 설정합니다.", + "tremolo&vibrato": "빈도를 **{0}**로 설정하고 깊이를 **{1}**으로 설정합니다.", + "rotation": "회전Hz를 **{0}**으로 설정했습니다.", + "distortion": "왜곡을 활성화했습니다.", + "lowpass": "저역 통과를 **{0}**으로 설정했습니다.", + "channelmix": "channelMix를 업데이트했습니다. 왼쪽에서 왼쪽으로: **{0}**, 오른쪽에서 오른쪽으로: **{1}**, 왼쪽에서 오른쪽으로: **{2}**, 오른쪽에서 왼쪽으로: **{3}**", + "nightcore": "나이트코어 음향 효과를 추가했습니다.", + "8d": "8D 음향 효과를 추가했습니다.", + "vaporwave": "증기파 음향 효과를 추가했습니다.", + "cleareffect": "효과음이 삭제되었습니다!", + "FilterTagAlreadyInUse": "이 음향 효과는 이미 사용 중입니다! 제거하려면 /cleareffect <태그>를 사용하세요.", + + "playlistViewTitle": "📜 {0}님의 모든 재생목록", + "playlistViewHeaders": [" ", "ID:", "시간:", "이름:", "트랙:"], + "playlistMaxP": "최대 재생 목록:", + "playlistMaxT": "최대 트랙:", + "playlistFooter": "/playlist play [playlist]를 입력하여 재생 목록을 대기열에 추가합니다.", + "playlistNotFound": "재생 목록 [`{0}`]을(를) 찾을 수 없습니다. 모든 재생 목록을 보려면 /playlist view를 입력하십시오.", + "playlistNotAccess": "죄송합니다! 이 재생 목록에 액세스할 수 없습니다!", + "playlistNoTrack": "죄송합니다! 재생목록 [`{0}`]에 트랙이 없습니다.", + "playlistNotAllow": "이 명령은 연결 및 공유 재생 목록에서 허용되지 않습니다.", + "playlistPlay": "대기열에 `{1}` 노래가 있는 재생목록 [`{0}`]을 추가했습니다.", + "playlistOverText": "죄송합니다! 재생 목록 이름은 10자를 초과할 수 없습니다.", + "playlistSameName": "죄송합니다! 이 이름은 새 이름과 같을 수 없습니다.", + "playlistDeleteError": "기본 재생 목록을 삭제할 수 없습니다.", + "playlistRemove": "재생목록 [`{0}`]을(를) 제거했습니다.", + "playlistSendErrorPlayer": "죄송합니다! 자신에게 초대장을 보낼 수 없습니다.", + "playlistSendErrorBot": "죄송합니다! 봇에 초대장을 보낼 수 없습니다.", + "playlistBelongs": "죄송합니다! 이 재생목록은 <@{0}>에 속합니다.", + "playlistShare": "죄송합니다! 이 재생목록은 {0}과(와) 공유되었습니다.", + "playlistSent": "죄송합니다! 이전에 초대장을 보냈습니다.", + "noPlaylistAcc": "{0}님은 재생목록 계정을 만들지 않았습니다.", + "playlistCreateError": "죄송합니다! 이 유형의 재생 목록을 만들 수 없습니다!", + "overPlaylistCreation": "재생목록은 `{0}`개 이상 만들 수 없습니다!", + "playlistExists": "재생 목록 [`{0}`]이(가) 이미 존재합니다.", + "playlistNotInvaildUrl": "유효한 링크나 공개 Spotify 또는 YouTube 재생 목록 링크를 입력하세요.", + "playlistCreated": "`{0}` 재생목록을 만들었습니다. 자세한 내용은 /재생 목록 보기를 입력하세요.", + "playlistRenamed": "`{0}`의 이름을 `{1}`로 변경했습니다.", + "playlistLimitTrack": "한계에 도달했습니다! 재생목록에는 `{0}`곡만 추가할 수 있습니다.", + "playlistPlaylistLink": "재생 목록 링크를 사용할 수 없습니다.", + "playlistStream": "재생 목록에 스트리밍 비디오를 추가할 수 없습니다.", + "playlistPositionNotFound": "재생목록 [`{1}`]에서 `{0}` 위치를 찾을 수 없습니다!", + "playlistRemoved": "👋 {1}님의 재생목록 [`{2}`]에서 **{0}**를 제거했습니다.", + "playlistClear": "재생목록 [`{0}`]을(를) 성공적으로 지웠습니다.", + "playlistView": "재생 목록 뷰어", + "playlistViewDesc": "```이름 | 아이디: {0} | {1}\n총 트랙: {2}\n소유자: {3}\n유형: {4}\n```", + "playlistViewPermsValue": "📖 읽기: ✓ ✍🏽 쓰기: {0} 🗑️ 제거: {1}", + "playlistViewPermsValue2": "📖 읽기: {0}", + "playlistViewTrack": "트랙", + "playlistViewPage": "페이지: {0}/{1} | 총 기간: {2}", + "inboxFull": "죄송합니다! {0}님의 받은편지함이 가득 찼습니다.", + "inboxNoMsg": "받은 편지함에 메시지가 없습니다.", + "invitationSent": "{0}(으)로 초대장을 보냈습니다.", + + "notInChannel": "{0}, 음성 명령을 사용하려면 {1}에 있어야 합니다. 목소리가 좋으시면 다시 참여해주세요!", + "noTrackPlaying": "현재 재생 중인 노래가 없습니다.", + "noTrackFound": "해당 검색어로 검색된 노래가 없습니다! 유효한 URL을 입력하세요.", + "noLinkSupport": "검색 명령은 링크를 지원하지 않습니다!", + "voted": "투표하셨습니다!", + "missingPerms_pos": "DJ 또는 관리자만 위치를 변경할 수 있습니다.", + "missingPerms_mode": "DJ 또는 관리자만 루프 모드를 전환할 수 있습니다.", + "missingPerms_queue": "DJ 또는 관리자만 대기열에서 트랙을 제거할 수 있습니다.", + "missingPerms_autoplay": "DJ 또는 관리자만 자동 재생 모드를 활성화 또는 비활성화할 수 있습니다!", + "missingPerms_function": "DJ 또는 Admin만 이 기능을 사용할 수 있습니다.", + "timeFormatError": "잘못된 시간 형식입니다. 예: 2:42 또는 12:39:31", + "lyricsNotFound": "가사를 찾을 수 없습니다. /lyrics <노래 이름> <작가> 을 입력하여 가사를 찾습니다.", + "missingTrackInfo": "일부 트랙 정보가 누락되었습니다.", + "noVoiceChannel": "음성 채널을 찾을 수 없습니다!", + + "playlistAddError": "재생 목록에 스트리밍 비디오를 추가할 수 없습니다!", + "playlistAddError2": "재생 목록에 트랙을 추가하는 중에 문제가 발생했습니다!", + "playlistlimited": "한계에 도달했습니다! 재생목록에는 {0}곡만 추가할 수 있습니다.", + "playlistrepeated": "재생 목록에 이미 같은 트랙이 있습니다!", + "playlistAdded": "❤️ **{0}**을 {1}의 재생목록 [`{2}`]에 추가했습니다!", + + "playerAuthor": "음악 컨트롤러 | {0}", + "playerDesc": "**지금 재생 중: ```[{0}]```\n링크: [클릭 해주세요]({1}) | 요청자: {2} | DJ: {3}**", + "playerFooter": "대기열 길이: {0} | 지속: {1} | 무게: {2}% {3}", + "playerFooter2": "| 반복하다: {0}", + "playerDropdown": "건너뛸 노래 선택 ...", + + "buttonBack": "뒤", + "buttonPause": "일시 중지", + "buttonResume": "이력서", + "buttonSkip": "거르다", + "buttonLeave": "떠나다", + + "nowplayingDesc": "**지금 재생 중:**\n```{0}```", + "nowplayingField": "다음으로:", + "nowplayingLink": "{0}에서 듣기", + + "connect": "{0}에 연결됨", + + "live": "라이브 스트리밍", + "playlistLoad": " 🎶 대기열에 `{1}` 노래가 있는 재생목록 **{0}**을 추가했습니다.", + "trackLoad": "재생을 시작하기 위해 **{1}**님이 **{0}** (`{2}`) 을(를) 추가했습니다.\n", + "trackLoad_pos": "**{1}**님이 **{0}** (`{2}`)를 **{3}** 위치의 대기열에 추가했습니다.\n", + + "searchTitle": "검색어: {0}", + "searchDesc": "➥ 플랫폼: {0} **{1}**\n➥ 결과: **{2}**\n\n{3}", + "searchWait": "대기열에 추가하려는 노래를 선택합니다.", + "searchTimeout": "검색 시간이 초과되었습니다. 나중에 다시 시도 해주십시오.", + "searchSuccess": "노래가 대기열에 추가되었습니다.", + + "queueTitle": "예정 대기열:", + "historyTitle": "기록 대기열:", + "viewTitle": "음악 대기열", + "viewDesc": "**지금 재생 중: [클릭 해주세요]({0}) ⮯**\n{1}", + "viewFooter": "페이지: {0}/{1} | 총 기간: {2}", + + "pauseError": "플레이어가 이미 일시 중지되었습니다.", + "pauseVote": "{0}님이 노래를 일시중지하기 위해 투표했습니다. [{1}/{2}]", + "paused": "``{0}`이(가) 플레이어를 일시 중지했습니다.", + "resumeError": "플레이어가 일시 중지되지 않았습니다.", + "resumeVote": "{0}님이 노래 재개에 투표했습니다. [{1}/{2}]", + "resumed": "`{0}`이(가) 플레이어를 재개했습니다.", + "shuffleError": "섞기 전에 대기열에 더 많은 노래를 추가하십시오.", + "shuffleVote": "{0}이(가) 대기열을 섞기 위해 투표했습니다. [{1}/{2}]", + "shuffled": "대기열이 섞입니다.", + "skipError": "건너뛸 노래가 없습니다.", + "skipVote": "{0}님이 노래 건너뛰기에 투표했습니다. [{1}/{2}]", + "skipped": "`{0}`이(가) 노래를 건너뛰었습니다.", + + "backVote": "{0}이(가) 이전 노래로 건너뛰도록 투표했습니다. [{1}/{2}]", + "backed": "`{0}`이(가) 이전 노래로 건너뛰었습니다.", + + "leaveVote": "{0}이(가) 플레이어를 중지하는 데 투표했습니다. [{1}/{2}]", + "left": "`{0}`이(가) 플레이어를 중지했습니다.", + + "seek": "플레이어를 **{0}**으로 설정", + "repeat": "반복 모드가 `{0}`(으)로 설정되었습니다.", + "cleared": "`{0}`의 모든 트랙을 지웠습니다.", + "removed": "`{0}` 트랙이 대기열에서 제거되었습니다.", + "forward": "플레이어를 **{0}**로 전달", + "rewind": "플레이어를 **{0}**으로 되감기", + "replay": "현재 노래를 재생합니다.", + "moved": "{0} 및 {1}이(가) 바뀌었습니다.", + "autoplay": "자동재생 모드는 이제 **{0}**입니다.", + + "notdj": "당신은 DJ가 아닙니다. 현재 DJ는 {0}입니다.", + "djToMe": "DJ를 자신이나 봇에게 전송할 수 없습니다.", + "djnotinchannel": "`{0}`은(는) 음성 채널에 없습니다.", + "djswap": "dj의 역할을 `{0}`에게 이전했습니다.", + + "chaptersDropdown": "건너뛸 챕터 선택...", + "noChaptersFound": "챕터를 찾을 수 없습니다!", + "chatpersNotSupport": "이 명령은 youtube 동영상만 지원합니다!", + + "voicelinkQueueFull": "죄송합니다. 대기열의 최대 `{0}` 트랙에 도달했습니다!", + "voicelinkOutofList": "유효한 트랙 색인을 제공하십시오!", + "voicelinkDuplicateTrack": "죄송합니다. 이 트랙은 이미 대기열에 있습니다." +} \ No newline at end of file diff --git a/local_langs/zh-TW.json b/local_langs/zh-TW.json new file mode 100644 index 0000000..4e24035 --- /dev/null +++ b/local_langs/zh-TW.json @@ -0,0 +1,217 @@ +{ + "language": "語言", + "dj": "dj", + "247": "247", + "bypassvote": "繞過投票", + "serversettings": "服務器設置", + "volume": "音量", + "togglecontroller": "切換控制器", + "ping": "發信", + "debug": "調試", + "connect": "連接", + "play": "播放", + "search": "搜索", + "playtop": "播放頂端", + "pause": "暫停", + "resume": "繼續", + "skip": "跳過", + "back": "退回", + "seek": "尋找", + "queue": "隊列", + "history": "歷史隊列", + "leave": "離開", + "nowplaying": "正在播放", + "loop": "循環", + "clear": "清除", + "remove": "移除", + "forward": "向前", + "rewind": "倒帶", + "replay": "重播", + "shuffle": "洗牌", + "move": "移動", + "lyrics": "歌詞", + "swapdj": "交換dj", + "chapters": "章節", + "autoplay": "自動播放", + "help": "幫助", + "donate": "捐贈", + "vote": "投票", + "speed": "速度", + "karaoke": "卡拉ok", + "tremolo": "震音", + "vibrato": "顫音", + "rotation": "迴轉", + "distortion": "失真", + "lowpass": "低通", + "channelmix": "通道混合", + "nightcore": "夜心", + "effectclear": "效果清晰", + "view": "查看", + "create": "創建", + "delete": "刪除", + "share": "分享", + "rename": "改名", + "inbox": "收件箱", + "add": "添加", + "You can choose your preferred language, the bot message will change to the language you set.": "您可以選擇首選語言,機器人消息將更改為您設置的語言。", + "…": "…", + "Set a DJ role or remove DJ role.": "設置 DJ 角色或刪除 DJ 角色。", + "role": "角色", + "Toggles 24/7 mode, which disables automatic inactivity-based disconnects.": "切換 24/7 模式,該模式禁用基於不活動的自動斷開連接。", + "Toggles voting system.": "切換投票系統。", + "Show all the bot settings in your server.": "顯示服務器中的所有機器人設置。", + "Set the player's volume.": "設置播放器的音量。", + "value": "價值", + "Input a integer.": "輸入一個整數。", + "Toggles the music controller.": "切換音樂控制器。", + "Test if the bot is alive, and see the delay between your commands and my response.": "測試機器人是否還活著,看看你的命令和我的響應之間的延遲。", + "Connect to a voice channel.": "連接到語音通道。", + "channel": "渠道", + "Provide a channel to connect.": "提供連接渠道。", + "Loads your input and added it to the queue.": "加載您的輸入並將其添加到隊列中。", + "query": "詢問", + "Input a query or a searchable link.": "輸入查詢或可搜索鏈接。", + "Input the name of the song.": "輸入歌曲名稱。", + "platform": "平台", + "Select the platform you want to search.": "選擇您要搜索的平台。", + "Youtube": "YouTube", + "Youtube Music": "YouTube 音樂", + "SoundCloud": "聲雲", + "Spotify": "Spotify", + "Apple Music": "蘋果音樂", + "Adds a song with the given url or query on the top of the queue.": "在隊列頂部添加具有給定網址或查詢的歌曲。", + "Input the name of the song. (Without URL)": "輸入歌曲名稱。(無網址)", + "Pause the music.": "暫停音樂。", + "Resume the music.": "恢復音樂。", + "Skips to the next song or skips to the specified song.": "跳到下一首歌曲或跳到指定的歌曲。", + "index": "指數", + "Enter a index that you want to skip to.": "輸入要跳到的索引。", + "Skips back to the previous song or skips to the specified previous song.": "跳回上一首歌曲或跳到指定的上一首歌曲。", + "Enter a index that you want to skip back to.": "輸入要跳回的索引。", + "Change the player position.": "改變玩家位置。", + "position": "位置", + "Input position. Exmaple: 1:20.": "輸入位置。示例:1:20。", + "Display the players queue songs in your queue.": "在您的隊列中顯示玩家隊列歌曲。", + "Display the players queue songs in your history queue.": "在您的歷史隊列中顯示玩家隊列歌曲。", + "Disconnects the bot from your voice channel and chears the queue.": "斷開機器人與您的語音通道的連接並監聽隊列。", + "Shows details of the current track.": "顯示當前曲目的詳細信息。", + "Changes Loop mode.": "更改循環模式。", + "mode": "模式", + "Choose a looping mode.": "選擇循環模式。", + "Off": "關掉", + "Track": "曲目", + "Queue": "隊列", + "Remove all the tracks in your queue or history queue.": "刪除隊列或歷史隊列中的所有曲目。", + "Choose a queue that you want to clear.": "選擇要清除的隊列。", + "History": "歷史", + "Removes specified track or a range of tracks from the queue.": "從隊列中刪除指定的曲目或一系列曲目。", + "Input a position from the queue to be removed.": "從隊列中輸入要刪除的位置。", + "position2": "位置2", + "Set the range of the queue to be removed.": "設置要刪除的隊列範圍。", + "Forwards by a certain amount of time in the current track. The default is 10 seconds.": "在當前曲目中前進一定時間。默認值為 10 秒。", + "Input a amount that you to forward to. Exmaple: 1:20": "輸入您要轉發的時間。示例:1:20", + "Rewind by a certain amount of time in the current track. The default is 10 seconds.": "在當前曲目中倒退一定時間。默認值為 10 秒。", + "Input a amount that you to rewind to. Exmaple: 1:20": "輸入您要倒回的時間。示例:1:20", + "Reset the progress of the current song.": "重置當前歌曲的進度。", + "Randomizes the tracks in the queue.": "隨機化隊列中的曲目。", + "Moves the specified song to the specified position.": "將指定歌曲移動到指定位置。", + "track": "曲目", + "The track to move. Example: 2": "要移動的曲目。示例:2", + "The new position to move the track to. Exmaple: 1": "要將曲目移動到的新位置。示例:1", + "Displays lyrics for the playing track.": "顯示播放曲目的歌詞。", + "name": "歌名", + "Searches for your query and displays the reutned lyrics.": "搜索您的查詢並顯示 reutned 歌詞。", + "author": "作者", + "Seaches for lyrics by author.": "按作者搜索歌詞。", + "Transfer dj to another.": "將 dj 轉移到另一個。", + "member": "成員", + "Choose a member to transfer the dj role.": "選擇一個成員來轉移 dj 角色。", + "Lists all chapters of the currently playing song (if any).": "列出當前播放歌曲的所有章節(如果有)。", + "Toggles autoplay mode, it will automatically queue the best songs to play.": "切換自動播放模式,它會自動排隊播放最好的歌曲。", + "Lists all the commands in Vocard.": "列出 Vocard 中的所有命令。", + "category": "類別", + "Basic": "基本", + "Tutorial": "教程", + "DJ": "DJ", + "Admin": "管理員", + "Show all the donate plan on Vocard.": "在 Vocard 上顯示所有捐贈計劃。", + "You can buy plan by voting Vocard on Top.gg.": "您可以通過在 Top.gg 上投票 Vocard 來購買計劃。", + "Sets the player's playback speed.": "設置播放器的播放速度。", + "The value to set the speed to. Default is `1.0`": "將速度設置為的值。默認為`1.0`", + "Uses equalization to eliminate part of a band, usually targeting vocals.": "使用均衡來消除樂隊的一部分,通常針對人聲。", + "level": "等級", + "The level of the karaoke. Default is `1.0`": "卡拉OK的水平。默認為`1.0`", + "monolevel": "單級", + "The monolevel of the karaoke. Default is `1.0`": "卡拉OK的單聲道。默認為`1.0`", + "filterband": "濾帶", + "The filter band of the karaoke. Default is `220.0`": "卡拉 OK 的濾波器頻段。默認為`220.0`", + "filterwidth": "過濾器寬度", + "The filter band of the karaoke. Default is `100.0`": "卡拉 OK 的濾波器頻段。默認為`100.0`", + "Uses amplification to create a shuddering effect, where the volume quickly oscillates.": "使用放大來產生顫抖的效果,音量會快速振盪。", + "frequency": "頻率", + "The frequency of the tremolo. Default is `2.0`": "顫音的頻率。默認為`2.0`", + "depth": "深度", + "The depth of the tremolo. Default is `0.5`": "顫音的深度。默認為`0.5`", + "Similar to tremolo. While tremolo oscillates the volume, vibrato oscillates the pitch.": "類似於顫音。顫音使音量振盪,而顫音則使音高振盪。", + "The frequency of the vibrato. Default is `2.0`": "顫音的頻率。默認為`2.0`", + "The Depth of the vibrato. Default is `0.5`": "顫音的深度。默認為`0.5`", + "Rotates the sound around the stereo channels/user headphones aka Audio Panning.": "圍繞立體聲通道/用戶耳機旋轉聲音,即音頻平移。", + "hertz": "赫茲", + "The hertz of the rotation. Default is `0.2`": "旋轉的赫茲。默認為`0.2`", + "Distortion effect. It can generate some pretty unique audio effects.": "失真效果。它可以產生一些非常獨特的音頻效果。", + "Filter which supresses higher frequencies and allows lower frequencies to pass.": "抑制較高頻率並允許較低頻率通過的濾波器。", + "smoothing": "平滑", + "The level of the lowPass. Default is `20.0`": "低通的水平。默認為`20.0`", + "Filter which manually adjusts the panning of the audio.": "手動調整音頻平移的過濾器。", + "left_to_left": "左到左", + "Sounds from left to left. Default is `1.0`": "聲音從左到左。默認為`1.0`", + "right_to_right": "右到右", + "Sounds from right to right. Default is `1.0`": "聲音從右到右。默認為`1.0`", + "left_to_right": "左到右", + "Sounds from left to right. Default is `0.0`": "聲音從左到右。默認為`0.0`", + "right_to_left": "右到左", + "Sounds from right to left. Default is `0.0`": "聲音從右到左。默認為`0.0`", + "Add nightcore filter into your player.": "將夜核心過濾器添加到您的播放器中。", + "Clear all the sound effect.": "清除所有音效。", + "playlist": "播放列表", + "Play all songs from your favorite playlist.": "播放您喜愛的播放列表中的所有歌曲。", + "Input the name of your custom playlist": "輸入您的自定義播放列表的名稱", + "Play the specific track from your custom playlist.": "播放自定義播放列表中的特定曲目。", + "List all your playlist and all songs in your favourite playlist.": "列出您喜歡的播放列表中的所有播放列表和所有歌曲。", + "Create your custom playlist.": "創建您的自定義播放列表。", + "Give a name to your playlist.": "為您的播放列表命名。", + "link": "網址", + "Provide a playlist link if you are creating link playlist.": "如果您正在創建鏈接播放列表,請提供播放列錶鍊接。", + "Delete your custom playlist.": "刪除您的自定義播放列表。", + "The name of the playlist.": "播放列表的名稱。", + "Share your custom playlist with your friends.": "與您的朋友分享您的自定義播放列表。", + "The user id of your friend.": "您朋友的用戶 ID。", + "The name of the playlist that you want to share.": "您要共享的播放列表的名稱。", + "Rename your custom playlist.": "重命名您的自定義播放列表。", + "The name of your playlist.": "您的播放列表的名稱。", + "newname": "新名字", + "The new name of your playlist.": "播放列表的新名稱。", + "Show your playlist invitation.": "顯示您的播放列表邀請。", + "Add tracks in to your custom playlist.": "將曲目添加到您的自定義播放列表中。", + "Remove song from your favorite playlist.": "從您最喜歡的播放列表中刪除歌曲。", + "Input a position from the playlist to be removed.": "從播放列表中輸入要刪除的位置。", + "Remove all songs from your favorite playlist.": "從您喜歡的播放列表中刪除所有歌曲。", + "8d": "8d", + "Add 8D filter into your player.": "在播放器中添加 8D 濾鏡。", + "cleareffect": "清除音效", + "Clear all or specific sound effects.": "清除所有或特定音效。", + "effect": "音效", + "Remove a specific sound effects.": "移除特定音效。", + "vaporwave": "蒸汽波", + "Add vaporwave filter into your player.": "將蒸汽波過濾器添加到您的播放器中。", + "Change to another type of queue mode.": "更改為另一種隊列模式。", + "FairQueue": "公平隊列", + "Playlist": "播放列表", + "duplicatetrack": "重複軌道", + "toggle": "切換", + "Enable": "啟用", + "Disable": "停用", + "Toggle Vocard to prevent duplicate songs from queuing.": "切換 Vocard 以防止重複歌曲排隊。", + "Remove tracks requested by a specific member.": "刪除特定成員請求的曲目。", + "settings": "設置" +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..994ddd0 --- /dev/null +++ b/main.py @@ -0,0 +1,103 @@ +import discord +import sys, os, traceback, aiohttp +import update + +from function import ( + langs_setup, + settings_setup, + local_langs, + get_lang, + invite_link, + error_log +) +from discord.ext import commands +from dotenv import load_dotenv +from googletrans import Translator +from datetime import datetime +from voicelink import VoicelinkException + +translator = Translator() + +load_dotenv() + +class Translator(discord.app_commands.Translator): + async def load(self): + print("Loaded Translator") + + async def unload(self): + print("Unload Translator") + + async def translate(self, string: discord.app_commands.locale_str, locale: discord.Locale, context: discord.app_commands.TranslationContext): + if str(locale) in local_langs: + text = local_langs[str(locale)].get(string.message, None) + if not text: + result = translator.translate(string.message, str(locale).lower()) + print(result.origin, ' -> ', result.text) + return result.text + return text + return None + +class Vocard(commands.Bot): + + async def on_message(self, message): + pass + + async def setup_hook(self): + langs_setup() + settings_setup() + for module in os.listdir('./cogs'): + if module.endswith('.py'): + try: + await self.load_extension(f"cogs.{module[:-3]}") + print(f"Loaded {module[:-3]}") + except Exception as e: + print(traceback.format_exc()) + + # await bot.tree.set_translator(Translator()) + # await bot.tree.sync() + + async def on_ready(self): + print("------------------") + print(f"Logging As {self.user}") + print(f"Bot ID: {self.user.id}") + print("------------------") + print(f"Discord Version: {discord.__version__}") + print(f"Python Version: {sys.version}") + print("------------------") + +intents = discord.Intents.default() +intents.members = True +member_cache = discord.MemberCacheFlags( + voice=True, + joined=False +) + +bot = Vocard(command_prefix="?", + help_command=None, + chunk_guilds_at_startup = False, + member_cache_flags=member_cache, + activity=discord.Activity(type=discord.ActivityType.listening,name="/help"), + intents=intents) + +@bot.tree.error +async def app_command_error(interaction: discord.Interaction, error): + error = getattr(error, 'original', error) + if isinstance(error, (discord.errors.NotFound, aiohttp.client_exceptions.ClientOSError)): + return + elif isinstance(error, (discord.app_commands.CommandOnCooldown, discord.app_commands.errors.MissingPermissions)): + pass + elif not issubclass(error.__class__, VoicelinkException): + error = get_lang(interaction.guild_id, "unknownException") + invite_link + if (guildId := interaction.guild_id) not in error_log: + error_log[guildId] = {} + error_log[guildId][round(datetime.timestamp(datetime.now()))] = str(traceback.format_exc()) + try: + if interaction.response.is_done(): + return await interaction.followup.send(error, ephemeral=True) + + return await interaction.response.send_message(error, ephemeral=True) + except: + pass + +update.checkVersion(withMsg = True) +bot.run(os.getenv("TOKEN"), log_handler=None) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..04ddf2b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +discord.py==2.1.0 +pymongo==4.1.1 +dnspython==2.2.1 +tldextract==3.2.1 +validators==0.18.2 +humanize==4.0.0 +python-dotenv==0.20.0 +beautifulsoup4==4.11.1 diff --git a/settings Example.json b/settings Example.json new file mode 100644 index 0000000..110bd8e --- /dev/null +++ b/settings Example.json @@ -0,0 +1,24 @@ +{ + "nodes": { + "DEFAULT": {"host": "127.0.0.1", + "port": 2333, + "password": "password", + "secure": false, + "identifier": "DEFAULT" + } + }, + "bot_access_user": [], + "color_code":"", + "emoji_source_raw": { + "youtube": "<:youtube:826661982760992778>", + "youtube music": "<:youtube:826661982760992778>", + "spotify": "<:spotify:826661996615172146>", + "soundcloud": "<:soundcloud:852729280027033632>", + "twitch": "<:twitch:852729278285086741>", + "bandcamp": "<:bandcamp:864694003811221526>", + "vimeo": "<:vimeo:864694001919721473>", + "apple": "<:applemusic:994844332374884413>", + "reddit": "<:reddit:996007566863773717>", + "tiktok": "<:tiktok:996007689798811698>" + } +} \ No newline at end of file diff --git a/update.py b/update.py new file mode 100644 index 0000000..9465da4 --- /dev/null +++ b/update.py @@ -0,0 +1,55 @@ +import requests, zipfile, os, sys, shutil + +root_dir = "./" +filename = root_dir + "Vocard.zip" +__version__ = "v2.5.0" + +def checkVersion(withMsg = False): + resp = requests.get("https://api.github.com/repos/ChocoMeow/Vocard/releases/latest") + version = resp.json().get("name", __version__) + if withMsg: + if version == __version__: + print(f"Your bot is up-to-date! - {version}") + else: + print(f"Your bot is not up-to-date! This latest version is {version} and you are currently running version {__version__}\n. Run `python update --start` to update your bot!") + return version + +def downloadFile(version:str = None): + if not version: + version = checkVersion() + response = requests.get("https://github.com/ChocoMeow/Vocard/archive/" + version + ".zip") + open(filename, 'wb').write(response.content) + unZip(filename, version) + +def unZip(path: str, version: str): + with zipfile.ZipFile(path,"r") as f: + f.extractall() + + version = version.replace("v", "") + source_dir = root_dir + "Vocard-" + version + if os.path.exists(source_dir): + for filename in os.listdir(root_dir): + if filename in ["settings.json", ".env", "Vocard-" + version]: + continue + if os.path.isdir(os.path.join(root_dir, filename)): + shutil.rmtree(filename) + else: + os.remove(filename) + for filename in os.listdir(source_dir): + shutil.move(os.path.join(source_dir, filename), os.path.join(root_dir, filename)) + os.rmdir(source_dir) + +def start(): + try: + downloadFile() + if os.path.exists(filename): + os.remove(filename) + print("Update Successfully! Run `python main.py` to start your bot") + except Exception as e: + print(f"Error: {e}") + +if "--start" in sys.argv: + start() + +if "--check" in sys.argv: + checkVersion(withMsg = True) \ No newline at end of file diff --git a/view/__init__.py b/view/__init__.py new file mode 100644 index 0000000..6a1c0b8 --- /dev/null +++ b/view/__init__.py @@ -0,0 +1,16 @@ +from discord.ext import commands + +class ButtonOnCooldown(commands.CommandError): + def __init__(self, retry_after: float) -> None: + self.retry_after = retry_after + +from .controller import InteractiveController +from .search import SearchView +from .help import HelpView +from .list import ListView +from .lyrics import LyricsView +from .chapter import ChapterView +from .playlist import PlaylistView, CreateView +from .inbox import InboxView +from .link import LinkView +from .debug import DebugModal diff --git a/view/chapter.py b/view/chapter.py new file mode 100644 index 0000000..93b2caa --- /dev/null +++ b/view/chapter.py @@ -0,0 +1,83 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +import discord + +from function import formatTime + +class Dropdown(discord.ui.Select): + def __init__(self, player, chapters): + + self.player = player + self.chapters = chapters + self.current = player.current.uri + + options = [ + discord.SelectOption(label=f"{index}. {title[:30]}", + description=time) + for index, (time, title) in enumerate(self.chapters, start=1) + ] + + super().__init__( + placeholder=self.player.get_msg('chaptersDropdown'), + min_values=1, max_values=1, + options=options, + ) + + async def callback(self, interaction: discord.Interaction): + position = formatTime(self.chapters[int(self.values[0].split(". ")[0]) - 1][0]) + if position is None: + return + if not self.player.current or self.current != self.player.current.uri: + return await self.view.stop_view() + + await self.player.seek(position) + +class ChapterView(discord.ui.View): + def __init__(self, player, chapters, author): + super().__init__(timeout=180) + + self.player = player + self.chapters = chapters + self.author = author + self.response = None + self.add_item(self.Dropdown(player, chapters)) + + async def on_error(self, error: Exception, item, interaction) -> None: + return + + async def stop_view(self) -> None: + for child in self.children: + child.disabled = True + try: + await self.response.edit(view=self) + except: + pass + + async def on_timeout(self) -> None: + await self.stop_view() + + async def interaction_check(self, interaction): + if interaction.user == self.author: + return True + return False \ No newline at end of file diff --git a/view/controller.py b/view/controller.py new file mode 100644 index 0000000..ce524b5 --- /dev/null +++ b/view/controller.py @@ -0,0 +1,242 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +import discord + +from discord.ext import commands +from . import ButtonOnCooldown +from function import ( + get_playlist, + update_playlist, + create_account, + checkroles +) + +def key(interaction: discord.Interaction): + return interaction.user + +class previous_command(discord.ui.Button): + def __init__(self, player): + self.player = player + super().__init__(emoji="⏮️", label=player.get_msg('buttonBack'), style=discord.ButtonStyle.secondary, disabled=False if self.player.queue.history() or not self.player.current else True) + + async def callback(self, interaction: discord.Interaction): + if not await self.player.is_privileged(interaction.user): + if interaction.user in self.player.previous_votes: + return await interaction.response.send_message(self.player.get_msg("voted"), ephemeral=True) + else: + self.player.previous_votes.add(interaction.user) + if len(self.player.previous_votes) >= (required := self.player.required()): + pass + else: + return await interaction.response.send_message(self.player.get_msg("backVote").format(interaction.user, len(self.player.previous_votes), required)) + + if not self.player.node._available: + return await interaction.response.send_message(self.player.get_msg("nodeReconnect")) + + if not self.player.is_playing: + self.player.queue.backto(1) + await self.player.do_next() + else: + self.player.queue.backto(2) + await self.player.stop() + + await interaction.response.send_message(self.player.get_msg("backed").format(interaction.user)) + + if self.player.queue.repeat == "Track": + self.player.queue.set_repeat("Off") + +class resume_command(discord.ui.Button): + def __init__(self, player): + self.player = player + super().__init__(emoji="⏸️", label=player.get_msg('buttonPause'), style=discord.ButtonStyle.secondary, disabled=False if self.player.current else True) + + async def callback(self, interaction: discord.Interaction): + if self.player.is_paused: + if not await self.player.is_privileged(interaction.user): + if interaction.user in self.player.resume_votes: + return await interaction.response.send_message(self.player.get_msg("voted"), ephemeral=True) + else: + self.player.resume_votes.add(interaction.user) + if len(self.player.resume_votes) >= (required := self.player.required()): + pass + else: + return await interaction.response.send_message(self.player.get_msg("resumeVote").format(interaction.user, len(self.player.resume_votes), required)) + + self.player.resume_votes.clear() + self.emoji = "⏸️" + self.label = self.player.get_msg("buttonPause") + await self.player.set_pause(False) + + else: + if not await self.player.is_privileged(interaction.user): + if interaction.user in self.player.pause_votes: + return await interaction.response.send_message(self.player.get_msg("voted"), ephemeral=True) + else: + self.player.pause_votes.add(interaction.user) + if len(self.player.pause_votes) >= (required := self.player.required()): + pass + else: + return await interaction.response.send_message(self.player.get_msg("pauseVote").format(interaction.user, len(self.player.pause_votes), required)) + + self.player.pause_votes.clear() + self.emoji = "▶️" + self.label = self.player.get_msg("buttonResume") + await self.player.set_pause(True) + await interaction.response.edit_message(view=self.view) + +class skip_command(discord.ui.Button): + def __init__(self, player): + self.player = player + super().__init__(emoji="⏭️", label=player.get_msg('buttonSkip'), style=discord.ButtonStyle.secondary) + + async def callback(self, interaction: discord.Interaction): + if not self.player.is_playing: + return + if not await self.player.is_privileged(interaction.user): + if interaction.user == self.player.current.requester: + pass + elif interaction.user in self.player.skip_votes: + return await interaction.response.send_message(self.player.get_msg("voted"), ephemeral=True) + else: + self.player.skip_votes.add(interaction.user) + if len(self.player.skip_votes) >= (required := self.player.required()): + pass + else: + return await interaction.response.send_message(self.player.get_msg("skipVote").format(interaction.user, len(self.player.skip_votes), required)) + + if not self.player.node._available: + return await interaction.response.send_message(self.player.get_msg("nodeReconnect")) + + await interaction.response.send_message(self.player.get_msg("skipped").format(interaction.user)) + + if self.player.queue.repeat == "Track": + self.player.queue.set_repeat("Off") + await self.player.stop() + +class stop_command(discord.ui.Button): + def __init__(self, player): + self.player = player + super().__init__(emoji="⏹️", label=player.get_msg('buttonLeave'), style=discord.ButtonStyle.danger) + async def callback(self, interaction: discord.Interaction): + if not await self.player.is_privileged(interaction.user): + if interaction.user in self.player.stop_votes: + return await interaction.response.send_message(self.player.get_msg("voted"), ephemeral=True) + else: + self.player.stop_votes.add(interaction.user) + if len(self.player.stop_votes) >= (required := self.player.required(leave=True)): + pass + else: + return await interaction.response.send_message(self.player.get_msg("leaveVote").format(interaction.user, len(self.player.stop_votes), required)) + + await interaction.response.send_message(self.player.get_msg("left").format(interaction.user)) + await self.player.teardown() + +class add_playlist(discord.ui.Button): + def __init__(self, player): + self.player = player + super().__init__(emoji="❤️", style=discord.ButtonStyle.secondary, disabled=False if self.player.current else True) + + async def callback(self, interaction: discord.Interaction): + track = self.player.current + if not track: + return await interaction.response.send_message(self.player.get_msg("noTrackPlaying")) + if track.is_stream: + return await interaction.response.send_message(self.player.get_msg("playlistAddError")) + user = await get_playlist(interaction.user.id, 'playlist') + if not user: + return await create_account(interaction) + rank, max_p, max_t = await checkroles(interaction.user.id) + if len(user['200']['tracks']) >= max_t: + return await interaction.response.send_message(self.player.get_msg("playlistlimited").format(max_t), ephemeral=True) + addtrack = {'id': track.track_id, + 'info':{'identifier': track.identifier, + 'author': track.author, + 'length': track.length / 1000, + 'title': track.title, + 'uri': track.uri}} + if addtrack in user['200']['tracks']: + return await interaction.response.send_message(self.player.get_msg("playlistrepeated"), ephemeral=True) + respond = await update_playlist(interaction.user.id, {'playlist.200.tracks': addtrack}, push=True) + if respond: + await interaction.response.send_message(self.player.get_msg("playlistAdded").format(track.title, interaction.user.mention, user['200']['name']), ephemeral=True) + else: + await interaction.response.send_message(self.player.get_msg("playlistAddError2"), ephemeral=True) + +class Dropdown(discord.ui.Select): + def __init__(self, player): + + self.player = player + + options = [] + for index, track in enumerate(self.player.queue.tracks(), start=1): + if index > 10: + break + options.append(discord.SelectOption(label=f"{index}. {track.title[:40]}", description=f"{track.author[:30]} · " + ("Live" if track.is_stream else track.formatLength), emoji=track.emoji)) + + super().__init__( + placeholder=player.get_msg("playerDropdown"), + min_values=1, max_values=1, + options=options, + ) + + async def callback(self, interaction: discord.Interaction): + if not await self.player.is_privileged(interaction.user): + return await interaction.response.send_message(self.player.get_msg("missingPerms_function"), ephemeral=True) + + await self.player.queue.skipto(int(self.values[0].split(". ")[0])) + await self.player.stop() + await interaction.response.send_message(self.player.get_msg("skipped").format(interaction.user)) + +class InteractiveController(discord.ui.View): + def __init__(self, player): + super().__init__(timeout=None) + + self.player = player + self.add_item(previous_command(player)) + self.add_item(resume_command(player)) + self.add_item(skip_command(player)) + self.add_item(stop_command(player)) + self.add_item(add_playlist(player)) + if not self.player.queue.is_empty: + self.add_item(Dropdown(player)) + self.cooldown = commands.CooldownMapping.from_cooldown(2.0, 10.0, key) + + async def interaction_check(self, interaction): + if self.player.channel and interaction.user in self.player.channel.members: + retry_after = self.cooldown.update_rate_limit(interaction) + if retry_after: + raise ButtonOnCooldown(retry_after) + return True + else: + await interaction.response.send_message(self.player.get_msg("notInChannel").format(interaction.user.mention, self.player.channel.mention), ephemeral=True) + return False + + async def on_error(self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item): + if isinstance(error, ButtonOnCooldown): + try: + sec = int(error.retry_after) + await interaction.response.send_message(f"You're on cooldown for {sec} second{'' if sec == 1 else 's'}!", ephemeral=True) + except: + return + return \ No newline at end of file diff --git a/view/debug.py b/view/debug.py new file mode 100644 index 0000000..a1348c3 --- /dev/null +++ b/view/debug.py @@ -0,0 +1,41 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +import discord + +class DebugModal(discord.ui.Modal): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.values = None + + self.add_item( + discord.ui.TextInput( + label="Code Runner", + placeholder="Input Your Code", + style=discord.TextStyle.long, + ) + ) + + async def on_submit(self, interaction: discord.Interaction): + self.values = self.children[0].value + self.stop() \ No newline at end of file diff --git a/view/help.py b/view/help.py new file mode 100644 index 0000000..c2aa221 --- /dev/null +++ b/view/help.py @@ -0,0 +1,120 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +import discord +from discord.ext import commands + +from os import getenv +from function import ( + embed_color, + invite_link +) + +class HelpDropdown(discord.ui.Select): + def __init__(self, category:list): + options = [ + discord.SelectOption(emoji="🆕", label="News", description="View new updates of Vocard."), + discord.SelectOption(emoji="🕹️", label="Tutorial", description="How to use Vocard."), + ] + cog_emojis = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣"] + for category, emoji in zip(category, cog_emojis): + options.append(discord.SelectOption(emoji=emoji, + label=f"{category} Commands", + description=f"This is {category} Category.")) + + super().__init__( + placeholder="Select Category!", + min_values=1, max_values=1, + options=options, custom_id="select" + ) + + async def callback(self, interaction: discord.Interaction): + embed = self.view.build_embed(self.values[0].split(" ")[0]) + await interaction.response.edit_message(embed=embed) + +class HelpView(discord.ui.View): + def __init__(self, bot: commands.Bot, author: discord.Member): + super().__init__(timeout=60) + + self.author = author + self.bot = bot + self.response = None + self.categorys = [ category.capitalize() for category in bot.cogs if category not in ["Task", "Nodes"] ] + + self.add_item(discord.ui.Button(label='Support', emoji=':support:915152950471581696', url=invite_link)) + self.add_item(discord.ui.Button(label='Invite', emoji=':invite:915152589056790589', url='https://discord.com/oauth2/authorize?client_id={}&permissions=2184260928&scope=bot%20applications.commands'.format(getenv('CLIENT_ID')))) + self.add_item(discord.ui.Button(label='Github', url='https://github.com/ChocoMeow/Vocard')) + self.add_item(discord.ui.Button(label='Donate', emoji=':patreon:913397909024800878', url='https://www.patreon.com/Vocard')) + self.add_item(HelpDropdown(self.categorys)) + + async def on_error(self, error, item, interaction): + return + + async def on_timeout(self): + for child in self.children: + if child.custom_id == "select": + child.disabled = True + try: + await self.response.edit(view=self) + except: + pass + + async def interaction_check(self, interaction): + if interaction.user == self.author: + return True + return False + + def build_embed(self, category: str): + if category == "News": + embed = discord.Embed(title="Vocard Help Menu", url="https://discord.com/channels/811542332678996008/811909963718459392/1069971173116481636", color=embed_color) + + embed.add_field(name=f"Available Categories: [{2 + len(self.categorys)}]", + value="```py\n👉 News\n2. Tutorial\n{}```".format("".join(f"{i}. {c}\n" for i, c in enumerate(self.categorys, start=3))), + inline=True) + + update = "> Update Contents\n" \ + "➥ More text support for other languages\n" \ + "➥ Add author name in playing track msg\n" \ + "➥ Fixed some commands\n\n" \ + "[Click Me For More Info](https://discord.com/channels/811542332678996008/811909963718459392/1063728318777671741)" \ + + embed.add_field(name="📰 Latest News ()", value=update, inline=True) + embed.add_field(name="Get Started", value="```Join a voice channel and /play {Song/URL} a song. (Names, Youtube Video Links or Playlist links or Spotify links are supported on Vocard)```", inline=False) + + return embed + + embed = discord.Embed(title=f"Category: {category}", color=embed_color) + embed.add_field(name=f"Categories: [{2 + len(self.categorys)}]", value="```py\n" + "\n".join(f"👉 {c}" if c == category else f"{i}. {c}" for i, c in enumerate(['News', 'Tutorial'] + self.categorys, start=1)) + "```", inline=True) + + if category == 'Tutorial': + embed.description = "How can use Vocard? Some simple commands you should know now after watching this video." + embed.set_image(url="https://cdn.discordapp.com/attachments/674788144931012638/917656288899514388/final_61aef3aa7836890135c6010c_669380.gif") + else: + cog = [c for _, c in self.bot.cogs.items() if _.capitalize() == category][0] + + commands = [command for command in cog.walk_app_commands()] + embed.description = cog.description + embed.add_field(name=f"{category} Commands: [{len(commands)}]", + value="```{}```".format("".join(f"/{command.qualified_name}\n" for command in commands))) + + return embed \ No newline at end of file diff --git a/view/inbox.py b/view/inbox.py new file mode 100644 index 0000000..9697027 --- /dev/null +++ b/view/inbox.py @@ -0,0 +1,96 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +import discord + +from function import embed_color + +class Select_message(discord.ui.Select): + def __init__(self, inbox): + options = [discord.SelectOption(label=f"{index}. {mail['title'][:50]}", description=mail['type'], emoji='✉️' if mail['type'] == 'invite' else '📢') for index, mail in enumerate(inbox, start=1) ] + + super().__init__( + placeholder="Select a message to view ..", + options=options, custom_id='select' + ) + + async def callback(self, interaction: discord.Interaction): + self.view.current = self.view.inbox[int(self.values[0].split(". ")[0]) - 1] + await self.view.button_change(interaction) + +class InboxView(discord.ui.View): + def __init__(self, author, inbox): + super().__init__(timeout=60) + self.author = author + self.inbox = inbox + self.response = None + self.newplaylist = [] + + self.current = None + self.add_item(Select_message(inbox)) + + def build_embed(self): + embed=discord.Embed(title=f"📭 All {self.author}'s Inbox", + description=f'Max Messages: {len(self.inbox)}/10' + '```%0s %2s %20s\n' % (" ", "ID:", "Title:") + '\n'.join('%0s %2s. %35s'% ('✉️' if mail['type'] == 'invite' else '📢', index, mail['title'][:35] + "...") for index, mail in enumerate(self.inbox, start=1)) + '```', + color=embed_color) + if self.current: + embed.add_field(name="Message Info:", value=f"```{self.current['description']}\nSender ID: {self.current['sender']}\nPlaylist ID: {self.current['referId']}\nInvite Time: {self.current['time'].strftime('%d-%m %H:%M:%S')}```") + return embed + + async def button_change(self, interaction: discord.Interaction): + for child in self.children: + if child.custom_id in ['accept', 'dismiss']: + child.disabled = True if self.current is None else False + elif child.custom_id == "select": + child.options = [discord.SelectOption(label=f"{index}. {mail['title'][:50]}", description=mail['type'], emoji='✉️' if mail['type'] == 'invite' else '📢') for index, mail in enumerate(self.inbox, start=1) ] + + if not self.inbox: + await interaction.response.edit_message(embed=self.build_embed(), view=None) + return self.stop() + await interaction.response.edit_message(embed=self.build_embed(), view=self) + + async def on_timeout(self): + for child in self.children: + child.disabled = True + try: + await self.response.edit(view=self) + except: + pass + + @discord.ui.button(label='Accept', style=discord.ButtonStyle.green, custom_id="accept", disabled=True) + async def accept_button(self, interaction: discord.Interaction, button: discord.ui.Button): + self.newplaylist.append(self.current) + self.inbox.remove(self.current) + self.current = None + await self.button_change(interaction) + + @discord.ui.button(label='Dismiss', style=discord.ButtonStyle.red, custom_id="dismiss", disabled=True) + async def dismiss_button(self, interaction: discord.Interaction, button: discord.ui.Button): + self.inbox.remove(self.current) + self.current = None + await self.button_change(interaction) + + @discord.ui.button(label='Click Me To Save The Changes', style=discord.ButtonStyle.blurple) + async def save_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await self.response.edit(view=None) + self.stop() \ No newline at end of file diff --git a/view/link.py b/view/link.py new file mode 100644 index 0000000..9f6531d --- /dev/null +++ b/view/link.py @@ -0,0 +1,29 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +import discord + +class LinkView(discord.ui.View): + def __init__(self, label=None, emoji=None, url=None): + super().__init__(timeout=60) + self.add_item(discord.ui.Button(label=label, emoji=emoji, url=url)) \ No newline at end of file diff --git a/view/list.py b/view/list.py new file mode 100644 index 0000000..4210f7a --- /dev/null +++ b/view/list.py @@ -0,0 +1,108 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +import discord + +from math import ceil +from function import ( + embed_color, + time as ctime +) + +class ListView(discord.ui.View): + def __init__(self, player, author, isQueue = True): + super().__init__(timeout=60) + self.player = player + + self.name = player.get_msg('queueTitle') if isQueue else player.get_msg('historyTitle') + self.tracks = player.queue.tracks() if isQueue else player.queue.history() + if not isQueue: + self.tracks.reverse() + self.author = author + + self.page = ceil(len(self.tracks) / 7) + self.current_page = 1 + try: + self.time = ctime(sum([track.length for track in self.tracks])) + except: + self.time = "∞" + + async def on_timeout(self): + for child in self.children: + child.disabled = True + try: + await self.response.edit(view=self) + except: + pass + + async def on_error(self, error, item, interaction): + return + + async def interaction_check(self, interaction): + if interaction.user == self.author: + return True + return False + + def build_embed(self): + offset = self.current_page * 7 + tracks = self.tracks[(offset-7):offset] + + embed = discord.Embed(title=self.player.get_msg('viewTitle'), color=embed_color) + embed.description=self.player.get_msg('viewDesc').format(self.player.current.uri, f"```{self.player.current.title}```") if self.player.current else self.player.get_msg('nowplayingDesc').format("None") + queueText = "" + for index, track in enumerate(tracks, start=offset - 6): + queueText += f"{track.emoji} `{index}.` `[" + (self.player.get_msg("live") if track.is_stream else ctime(track.length)) + f'`] **{track.title[:30]}** ' + (track.requester.mention if track.requester else "<@605618911471468554>") + "\n" + + embed.add_field(name=self.name, value=queueText) + embed.set_footer(text=self.player.get_msg('viewFooter').format(self.current_page, self.page, self.time)) + + return embed + + @discord.ui.button(label='<<', style=discord.ButtonStyle.grey) + async def fast_back_button(self, interaction: discord.Interaction, button: discord.ui.Button): + if self.current_page != 1: + self.current_page = 1 + await interaction.response.edit_message(embed=self.build_embed()) + + @discord.ui.button(label='Back', style=discord.ButtonStyle.blurple) + async def back_button(self, interaction: discord.Interaction, button: discord.ui.Button): + if self.current_page > 1: + self.current_page -= 1 + await interaction.response.edit_message(embed=self.build_embed()) + + @discord.ui.button(label='Next', style=discord.ButtonStyle.blurple) + async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button): + if self.current_page < self.page: + self.current_page += 1 + await interaction.response.edit_message(embed=self.build_embed()) + + @discord.ui.button(label='>>', style=discord.ButtonStyle.grey) + async def fast_next_button(self, interaction: discord.Interaction, button: discord.ui.Button): + if self.current_page != self.page: + self.current_page = self.page + await interaction.response.edit_message(embed=self.build_embed()) + + @discord.ui.button(emoji='🗑️', style=discord.ButtonStyle.red) + async def stop_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await self.response.delete() + self.stop() \ No newline at end of file diff --git a/view/lyrics.py b/view/lyrics.py new file mode 100644 index 0000000..2ba9338 --- /dev/null +++ b/view/lyrics.py @@ -0,0 +1,108 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +import discord + +from function import embed_color + +class LyricsDropdown(discord.ui.Select): + def __init__(self, langs: list): + options = [discord.SelectOption(label=lang) for lang in langs] + + super().__init__( + placeholder="Select A Lyrics Translation", + min_values=1, max_values=1, + options=options, custom_id="selectLyricsLangs" + ) + + async def callback(self, interaction: discord.Interaction): + self.view.lang = self.values[0] + self.view.current_page = 1 + self.view.pages = len(self.view.source.get(self.values[0])) + await interaction.response.edit_message(embed=self.view.build_embed()) + +class LyricsView(discord.ui.View): + def __init__(self, name: str, source: dict, author: discord.Member): + super().__init__(timeout=60) + + self.name = name + self.source = source + self.lang = list(source.keys())[0] + self.author = author + + self.response = None + self.pages = len(self.source.get(self.lang)) + self.current_page = 1 + self.add_item(LyricsDropdown(list(source.keys()))) + + async def interaction_check(self, interaction): + if interaction.user == self.author: + return True + return False + + async def on_timeout(self): + for child in self.children: + child.disabled = True + try: + await self.response.edit(view=self) + except: + pass + + async def on_error(self, error, item, interaction): + return + + def build_embed(self): + chunk = self.source.get(self.lang)[self.current_page - 1] + embed=discord.Embed(description=chunk, color=embed_color) + embed.set_author(name=f"Searching Query: {self.name}", icon_url=self.author.display_avatar.url) + embed.set_footer(text=f"Page: {self.current_page}/{self.pages}") + return embed + + @discord.ui.button(label='<<', style=discord.ButtonStyle.grey) + async def fast_back_button(self, interaction: discord.Interaction, button: discord.ui.Button): + if self.current_page != 1: + self.current_page = 1 + await interaction.response.edit_message(embed=self.build_embed()) + + @discord.ui.button(label='Back', style=discord.ButtonStyle.blurple) + async def back_button(self, interaction: discord.Interaction, button: discord.ui.Button): + if self.current_page > 1: + self.current_page -= 1 + await interaction.response.edit_message(embed=self.build_embed()) + + @discord.ui.button(label='Next', style=discord.ButtonStyle.blurple) + async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button): + if self.current_page < self.pages: + self.current_page += 1 + await interaction.response.edit_message(embed=self.build_embed()) + + @discord.ui.button(label='>>', style=discord.ButtonStyle.grey) + async def fast_next_button(self, interaction: discord.Interaction, button: discord.ui.Button): + if self.current_page != self.pages: + self.current_page = self.pages + await interaction.response.edit_message(embed=self.build_embed()) + + @discord.ui.button(emoji='🗑️', style=discord.ButtonStyle.red) + async def stop_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await self.response.delete() + self.stop() \ No newline at end of file diff --git a/view/playlist.py b/view/playlist.py new file mode 100644 index 0000000..71b1a9f --- /dev/null +++ b/view/playlist.py @@ -0,0 +1,181 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +import discord + +from math import ceil +from tldextract import extract + +from function import ( + embed_color, + invite_link, + emoji_source, + time as ctime, + get_lang +) + +class Select_playlist(discord.ui.Select): + def __init__(self, results): + options = [discord.SelectOption(emoji='🌎', label='All Playlist')] + for index, playlist in enumerate(results, start=1): + if playlist['type'] != 'error': + options.append(discord.SelectOption(emoji=playlist['emoji'], label=f'{index}. {playlist["name"]}', description=f"{playlist['time']} · {playlist['type']}")) + + super().__init__( + placeholder="Select a playlist to view ..", + options=options + ) + + async def callback(self, interaction: discord.Interaction): + if self.values[0] == 'All Playlist': + self.view.current = None + return await interaction.response.edit_message(embed=self.view.viewEmbed) + self.view.current = self.view.results[int(self.values[0].split(". ")[0]) - 1] + self.view.page = ceil(len(self.view.current['tracks']) / 7) + self.view.current_page = 1 + await interaction.response.edit_message(embed=self.view.build_embed()) + +class agree(discord.ui.Button): + def __init__(self): + super().__init__(label="Agree", style=discord.ButtonStyle.green) + + async def callback(self, interaction: discord.Interaction): + self.label = "Created" + self.disabled = True + self.style=discord.ButtonStyle.primary + embed = discord.Embed(description="Your account has been successfully created", color=0x55e27f) + await interaction.response.edit_message(embed=embed, view=self.view) + self.view.value = True + self.view.stop() + +class PlaylistView(discord.ui.View): + def __init__(self, viewEmbed, results, author): + super().__init__(timeout=60) + self.viewEmbed = viewEmbed + self.results = results + self.author = author + self.guildID = author.guild.id + self.response = None + + self.current = None + self.page = 0 + self.current_page = 1 + + self.add_item(Select_playlist(results)) + + async def interaction_check(self, interaction): + if interaction.user == self.author: + return True + return False + + async def on_error(self, error, item, interaction): + return + + def build_embed(self): + offset = self.current_page * 7 + tracks = self.current['tracks'][(offset-7):offset] + + embed = discord.Embed(title=get_lang(self.guildID, 'playlistView'), color=embed_color) + + embed.description= get_lang(self.guildID, 'playlistViewDesc').format(self.current['name'], self.current['id'], len(self.current['tracks']), self.current.get('owner', "None"), self.current['type']) + + perms = self.current['perms'] + permsStr = get_lang(self.guildID, 'settingsPermTitle') + if self.current['type'] == 'share': + embed.add_field(name=permsStr, value=get_lang(self.guildID, 'playlistViewPermsValue').format('✓' if 'write' in perms and self.author.id in perms['write'] else '✘', '✓' if 'remove' in perms and self.author.id in perms['remove'] else '✘')) + else: + embed.add_field(name=permsStr, value=get_lang(self.guildID, 'playlistViewPermsValue2').format(', '.join(f'<@{user}>' for user in perms['read']))) + + trackStr = get_lang(self.guildID, 'playlistViewTrack') + if tracks: + try: + embed.add_field(name=trackStr, value='\n'.join(f"{emoji_source(extract(track['info']['uri']).domain)} `{index}.` `[{ctime(track['info']['length'] * 1000)}]` **{track['info']['title'][:30]}** " for index, track in enumerate(tracks, start=offset - 6)), inline=False) + except: + embed.add_field(name=trackStr, value='\n'.join(f"{emoji_source(extract(track.info['uri']).domain)} `{index}.` `[{ctime(track.length)}]` **{track.title[:30]}** " for index, track in enumerate(tracks, start=offset - 6)), inline=False) + else: + embed.add_field(name=trackStr, value=get_lang(self.guildID, 'playlistNoTrack').format(self.current['name']), inline=False) + + embed.set_footer(text=get_lang(self.guildID, 'playlistViewPage').format(self.current_page, self.page, self.current['time'])) + + return embed + + async def on_timeout(self): + for child in self.children: + child.disabled = True + try: + await self.response.edit(view=self) + except: + pass + + @discord.ui.button(label='<<', style=discord.ButtonStyle.grey) + async def fast_back_button(self, interaction: discord.Interaction, button: discord.ui.Button): + if not self.current: + return + if self.current_page != 1: + self.current_page = 1 + await interaction.response.edit_message(embed=self.build_embed()) + + @discord.ui.button(label='Back', style=discord.ButtonStyle.blurple) + async def back_button(self, interaction: discord.Interaction, button: discord.ui.Button): + if not self.current: + return + if self.current_page > 1: + self.current_page -= 1 + await interaction.response.edit_message(embed=self.build_embed()) + + @discord.ui.button(label='Next', style=discord.ButtonStyle.blurple) + async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button): + if not self.current: + return + if self.current_page < self.page: + self.current_page += 1 + await interaction.response.edit_message(embed=self.build_embed()) + + @discord.ui.button(label='>>', style=discord.ButtonStyle.grey) + async def fast_next_button(self, interaction: discord.Interaction, button: discord.ui.Button): + if not self.current: + return + if self.current_page != self.page: + self.current_page = self.page + await interaction.response.edit_message(embed=self.build_embed()) + + @discord.ui.button(emoji='🗑️', style=discord.ButtonStyle.red) + async def stop_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await self.response.delete() + self.stop() + +class CreateView(discord.ui.View): + def __init__(self): + super().__init__(timeout=20) + self.value = None + self.response = None + self.add_item(agree()) + self.add_item(discord.ui.Button(label='Support', emoji=':support:915152950471581696', url=invite_link)) + + async def on_timeout(self): + for child in self.children: + child.disabled = True + try: + await self.response.edit(view=self) + except: + pass \ No newline at end of file diff --git a/view/search.py b/view/search.py new file mode 100644 index 0000000..a8c9141 --- /dev/null +++ b/view/search.py @@ -0,0 +1,64 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +import discord + +from function import langs + +class SearchDropdown(discord.ui.Select): + def __init__(self, tracks, lang): + self.tracks = tracks + self.lang = lang + options = [] + for index, track in enumerate(self.tracks, start=1): + options.append(discord.SelectOption(label=f"{index}. {track.title[:50]}", description=f"{track.author[:50]} · {track.formatLength}")) + + super().__init__(placeholder=langs[lang]['searchWait'], + min_values=1, max_values=len(tracks), + options=options + ) + + async def callback(self, interaction: discord.Interaction): + self.disabled = True + self.placeholder = langs[self.lang]['searchSuccess'] + await interaction.response.edit_message(view=self.view) + self.view.values = self.values + self.view.stop() + +class SearchView(discord.ui.View): + def __init__(self, tracks, lang): + super().__init__(timeout=60) + self.response = None + self.values = None + self.add_item(SearchDropdown(tracks, lang)) + + async def on_error(self, error, item, interaction): + return + + async def on_timeout(self): + for child in self.children: + child.disabled = True + try: + await self.response.edit(view=self) + except: + pass \ No newline at end of file diff --git a/voicelink/__init__.py b/voicelink/__init__.py new file mode 100644 index 0000000..4cfe1d6 --- /dev/null +++ b/voicelink/__init__.py @@ -0,0 +1,36 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +__version__ = "0.7" +__author__ = 'Vocard Development, Choco' +__license__ = "MIT" +__copyright__ = "Copyright 2022 (c) Vocard Development, Choco" + +from .enums import SearchType +from .events import * +from .exceptions import * +from .filters import * +from .objects import * +from .player import Player +from .pool import * +from .queue import * diff --git a/voicelink/enums.py b/voicelink/enums.py new file mode 100644 index 0000000..8d16f4c --- /dev/null +++ b/voicelink/enums.py @@ -0,0 +1,69 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +from enum import Enum, auto + + +class SearchType(Enum): + """The enum for the different search types for Voicelink. + This feature is exclusively for the Spotify search feature of Voicelink. + If you are not using this feature, this class is not necessary. + + SearchType.ytsearch searches using regular Youtube, + which is best for all scenarios. + + SearchType.ytmsearch searches using YouTube Music, + which is best for getting audio-only results. + + SearchType.scsearch searches using SoundCloud, + which is an alternative to YouTube or YouTube Music. + """ + ytsearch = "ytsearch" + ytmsearch = "ytmsearch" + scsearch = "scsearch" + amsearch = "amsearch" + + def __str__(self) -> str: + return self.value + +class NodeAlgorithm(Enum): + """The enum for the different node algorithms in Voicelink. + + The enums in this class are to only differentiate different + methods, since the actual method is handled in the + get_best_node() method. + + NodeAlgorithm.by_ping returns a node based on it's latency, + preferring a node with the lowest response time + + NodeAlgorithm.by_region returns a node based on its voice region, + which the region is specified by the user in the method as an arg. + This method will only work if you set a voice region when you create a node. + """ + + # We don't have to define anything special for these, since these just serve as flags + by_ping = auto() + by_region = auto() + + def __str__(self) -> str: + return self.value \ No newline at end of file diff --git a/voicelink/events.py b/voicelink/events.py new file mode 100644 index 0000000..cb9b83c --- /dev/null +++ b/voicelink/events.py @@ -0,0 +1,168 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +from .pool import NodePool +from discord import Client + + +class VoicelinkEvent: + """The base class for all events dispatched by a node. + Every event must be formatted within your bot's code as a listener. + i.e: If you want to listen for when a track starts, the event would be: + ```py + @bot.listen + async def on_voicelink_track_start(self, event): + ``` + """ + name = "event" + handler_args = () + + def dispatch(self, bot: Client): + bot.dispatch(f"voicelink_{self.name}", *self.handler_args) + + +class TrackStartEvent(VoicelinkEvent): + """Fired when a track has successfully started. + Returns the player associated with the event and the voicelink.Track object. + """ + name = "track_start" + + def __init__(self, data: dict, player): + self.player = player + self.track = self.player._current + + # on_voicelink_track_start(player, track) + self.handler_args = self.player, self.track + + def __repr__(self) -> str: + return f"" + + +class TrackEndEvent(VoicelinkEvent): + """Fired when a track has successfully ended. + Returns the player associated with the event along with the voicelink.Track object and reason. + """ + name = "track_end" + + def __init__(self, data: dict, player): + self.player = player + self.track = self.player._ending_track + self.reason: str = data["reason"] + + # on_voicelink_track_end(player, track, reason) + self.handler_args = self.player, self.track, self.reason + + def __repr__(self) -> str: + return ( + f"" + ) + + +class TrackStuckEvent(VoicelinkEvent): + """Fired when a track is stuck and cannot be played. Returns the player + associated with the event along with the voicelink.Track object + to be further parsed by the end user. + """ + name = "track_stuck" + + def __init__(self, data: dict, player): + self.player = player + self.track = self.player._ending_track + self.threshold: float = data["thresholdMs"] + + # on_voicelink_track_stuck(player, track, threshold) + self.handler_args = self.player, self.track, self.threshold + + def __repr__(self) -> str: + return f"" + + +class TrackExceptionEvent(VoicelinkEvent): + """Fired when a track error has occured. + Returns the player associated with the event along with the error code and exception. + """ + name = "track_exception" + + def __init__(self, data: dict, player): + self.player = player + self.track = self.player._ending_track + if data.get('error'): + # User is running Lavalink <= 3.3 + self.exception: str = data["error"] + else: + # User is running Lavalink >=3.4 + self.exception: str = data["exception"] + + # on_voicelink_track_exception(player, track, error) + self.handler_args = self.player, self.track, self.exception + + def __repr__(self) -> str: + return f"" + + +class WebSocketClosedPayload: + def __init__(self, data: dict): + self.guild = NodePool.get_node().bot.get_guild(int(data["guildId"])) + self.code: int = data["code"] + self.reason: str = data["code"] + self.by_remote: bool = data["byRemote"] + + def __repr__(self) -> str: + return f"" + + +class WebSocketClosedEvent(VoicelinkEvent): + """Fired when a websocket connection to a node has been closed. + Returns the reason and the error code. + """ + name = "websocket_closed" + + def __init__(self, data: dict, _): + self.payload = WebSocketClosedPayload(data) + + # on_voicelink_websocket_closed(payload) + self.handler_args = self.payload, + + def __repr__(self) -> str: + return f"" + + +class WebSocketOpenEvent(VoicelinkEvent): + """Fired when a websocket connection to a node has been initiated. + Returns the target and the session SSRC. + """ + name = "websocket_open" + + def __init__(self, data: dict, _): + self.target: str = data["target"] + self.ssrc: int = data["ssrc"] + + # on_voicelink_websocket_open(target, ssrc) + self.handler_args = self.target, self.ssrc + + def __repr__(self) -> str: + return f"" + diff --git a/voicelink/exceptions.py b/voicelink/exceptions.py new file mode 100644 index 0000000..b38a0ac --- /dev/null +++ b/voicelink/exceptions.py @@ -0,0 +1,104 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +class VoicelinkException(Exception): + """Base of all Voicelink exceptions.""" + + +class NodeException(Exception): + """Base exception for nodes.""" + + +class NodeCreationError(NodeException): + """There was a problem while creating the node.""" + + +class NodeConnectionFailure(NodeException): + """There was a problem while connecting to the node.""" + + +class NodeConnectionClosed(NodeException): + """The node's connection is closed.""" + pass + + +class NodeNotAvailable(VoicelinkException): + """The node is currently unavailable.""" + pass + + +class NoNodesAvailable(VoicelinkException): + """There are no nodes currently available.""" + pass + + +class TrackInvalidPosition(VoicelinkException): + """An invalid position was chosen for a track.""" + pass + + +class TrackLoadError(VoicelinkException): + """There was an error while loading a track.""" + pass + + +class FilterInvalidArgument(VoicelinkException): + """An invalid argument was passed to a filter.""" + pass + +class FilterTagAlreadyInUse(VoicelinkException): + """A filter with a tag is already in use by another filter""" + pass + +class FilterTagInvalid(VoicelinkException): + """An invalid tag was passed or Voicelink was unable to find a filter tag""" + pass + +class SpotifyAlbumLoadFailed(VoicelinkException): + """The voicelink Spotify client was unable to load an album.""" + pass + + +class SpotifyTrackLoadFailed(VoicelinkException): + """The voicelink Spotify client was unable to load a track.""" + pass + + +class SpotifyPlaylistLoadFailed(VoicelinkException): + """The voicelink Spotify client was unable to load a playlist.""" + pass + + +class InvalidSpotifyClientAuthorization(VoicelinkException): + """No Spotify client authorization was provided for track searching.""" + pass + + +class QueueFull(VoicelinkException): + pass + +class OutofList(VoicelinkException): + pass + +class DuplicateTrack(VoicelinkException): + pass \ No newline at end of file diff --git a/voicelink/filters.py b/voicelink/filters.py new file mode 100644 index 0000000..75829d0 --- /dev/null +++ b/voicelink/filters.py @@ -0,0 +1,446 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +import collections +from .exceptions import FilterInvalidArgument, FilterTagAlreadyInUse, FilterTagInvalid + +from typing import ( + List +) + +class Filter: + """ + The base class for all filters. + You can use these filters if you have the latest Lavalink version + installed. If you do not have the latest Lavalink version, + these filters will not work. + """ + def __init__(self): + self.payload = None + self.tag: str = None + +class Filters: + def __init__(self) -> None: + self._filters: List[Filter] = [] + + def add_filter(self, *, filter: Filter) -> None: + if self.has_filter(filter_tag=filter.tag): + raise FilterTagAlreadyInUse("A filter with that tag is already in use") + self._filters.append(filter) + + def remove_filter(self, *, filter_tag: str) -> None: + if not self.has_filter(filter_tag=filter_tag): + raise FilterTagInvalid("A filter with that tag was not found.") + + for index, filter in enumerate(self._filters): + if filter.tag == filter_tag: + del self._filters[index] + + def has_filter(self, *, filter_tag: str) -> bool: + return any(f for f in self._filters if f.tag == filter_tag) + + def reset_filters(self) -> None: + self._filters = [] + + def get_all_payloads(self) -> dict: + payload = {} + for filter in self._filters: + payload.update(filter.payload) + return payload + + def get_filters(self) -> list: + return self._filters + +class Equalizer(Filter): + """ + Filter which represents a 15 band equalizer. + You can adjust the dynamic of the sound using this filter. + i.e: Applying a bass boost filter to emphasize the bass in a song. + The format for the levels is: List[Tuple[int, float]] + """ + + def __init__(self, *, tag: str, levels: list): + super().__init__() + + self.eq = self._factory(levels) + self.raw = levels + + self.payload = {"equalizer": self.eq} + self.tag = tag + + def _factory(self, levels: list): + _dict = collections.defaultdict(int) + + _dict.update(levels) + _dict = [{"band": i, "gain": _dict[i]} for i in range(15)] + + return _dict + + def __repr__(self) -> str: + return f"" + + @classmethod + def flat(cls): + """Equalizer preset which represents a flat EQ board, + with all levels set to their default values. + """ + + levels = [ + (0, 0.0), (1, 0.0), (2, 0.0), (3, 0.0), (4, 0.0), + (5, 0.0), (6, 0.0), (7, 0.0), (8, 0.0), (9, 0.0), + (10, 0.0), (11, 0.0), (12, 0.0), (13, 0.0), (14, 0.0) + ] + return cls(tag="flat", levels=levels) + + @classmethod + def boost(cls): + """Equalizer preset which boosts the sound of a track, + making it sound fun and energetic by increasing the bass + and the highs. + """ + + levels = [ + (0, -0.075), (1, 0.125), (2, 0.125), (3, 0.1), (4, 0.1), + (5, .05), (6, 0.075), (7, 0.0), (8, 0.0), (9, 0.0), + (10, 0.0), (11, 0.0), (12, 0.125), (13, 0.15), (14, 0.05) + ] + return cls(tag="boost", levels=levels) + + @classmethod + def metal(cls): + """Equalizer preset which increases the mids of a track, + preferably one of the metal genre, to make it sound + more full and concert-like. + """ + + levels = [ + (0, 0.0), (1, 0.1), (2, 0.1), (3, 0.15), (4, 0.13), + (5, 0.1), (6, 0.0), (7, 0.125), (8, 0.175), (9, 0.175), + (10, 0.125), (11, 0.125), (12, 0.1), (13, 0.075), (14, 0.0) + ] + + return cls(tag="metal", levels=levels) + + @classmethod + def piano(cls): + """Equalizer preset which increases the mids and highs + of a track, preferably a piano based one, to make it + stand out. + """ + + levels = [ + (0, -0.25), (1, -0.25), (2, -0.125), (3, 0.0), + (4, 0.25), (5, 0.25), (6, 0.0), (7, -0.25), (8, -0.25), + (9, 0.0), (10, 0.0), (11, 0.5), (12, 0.25), (13, -0.025) + ] + return cls(tag="piano", levels=levels) + + +class Timescale(Filter): + """Filter which changes the speed and pitch of a track. + You can make some very nice effects with this filter, + i.e: a vaporwave-esque filter which slows the track down + a certain amount to produce said effect. + """ + + def __init__( + self, + *, + tag: str, + speed: float = 1.0, + pitch: float = 1.0, + rate: float = 1.0 + ): + super().__init__() + + if speed < 0: + raise FilterInvalidArgument("Timescale speed must be more than 0.") + if pitch < 0: + raise FilterInvalidArgument("Timescale pitch must be more than 0.") + if rate < 0: + raise FilterInvalidArgument("Timescale rate must be more than 0.") + + self.speed = speed + self.pitch = pitch + self.rate = rate + self.tag = tag + + self.payload = {"timescale": {"speed": self.speed, + "pitch": self.pitch, + "rate": self.rate}} + + @classmethod + def vaporwave(cls): + """Timescale preset which slows down the currently playing track, + giving it the effect of a half-speed record/casette playing. + + This preset will assign the tag 'vaporwave'. + """ + + return cls(tag="vaporwave", speed=0.8, pitch=0.8) + + @classmethod + def nightcore(cls): + """Timescale preset which speeds up the currently playing track, + which matches up to nightcore, a genre of sped-up music + + This preset will assign the tag 'nightcore'. + """ + + return cls(tag="nightcore", speed=1.25, pitch=1.3) + + def __repr__(self): + return f"" + + +class Karaoke(Filter): + """Filter which filters the vocal track from any song and leaves the instrumental. + Best for karaoke as the filter implies. + """ + + def __init__( + self, + *, + tag: str, + level: float = 1.0, + mono_level: float = 1.0, + filter_band: float = 220.0, + filter_width: float = 100.0 + ): + super().__init__() + + self.level = level + self.mono_level = mono_level + self.filter_band = filter_band + self.filter_width = filter_width + self.tag = tag + + self.payload = {"karaoke": {"level": self.level, + "monoLevel": self.mono_level, + "filterBand": self.filter_band, + "filterWidth": self.filter_width}} + + def __repr__(self): + return ( + f"" + ) + + +class Tremolo(Filter): + """Filter which produces a wavering tone in the music, + causing it to sound like the music is changing in volume rapidly. + """ + + def __init__( + self, + *, + tag: str, + frequency: float = 2.0, + depth: float = 0.5 + ): + super().__init__() + + if frequency < 0: + raise FilterInvalidArgument( + "Tremolo frequency must be more than 0.") + if depth < 0 or depth > 1: + raise FilterInvalidArgument( + "Tremolo depth must be between 0 and 1.") + + self.frequency = frequency + self.depth = depth + self.tag = tag + + self.payload = {"tremolo": {"frequency": self.frequency, + "depth": self.depth}} + + def __repr__(self): + return f"" + + +class Vibrato(Filter): + """Filter which produces a wavering tone in the music, similar to the Tremolo filter, + but changes in pitch rather than volume. + """ + + def __init__( + self, + *, + tag: str, + frequency: float = 2.0, + depth: float = 0.5 + ): + + super().__init__() + if frequency < 0 or frequency > 14: + raise FilterInvalidArgument( + "Vibrato frequency must be between 0 and 14.") + if depth < 0 or depth > 1: + raise FilterInvalidArgument( + "Vibrato depth must be between 0 and 1.") + + self.frequency = frequency + self.depth = depth + self.tag = tag + + self.payload = {"vibrato": {"frequency": self.frequency, + "depth": self.depth}} + + def __repr__(self): + return f"" + + +class Rotation(Filter): + """Filter which produces a stereo-like panning effect, which sounds like + the audio is being rotated around the listener's head + """ + + def __init__(self, *, tag: str, rotation_hertz: float = 5): + super().__init__() + + self.rotation_hertz = rotation_hertz + self.tag = tag + self.payload = {"rotation": {"rotationHz": self.rotation_hertz}} + + @classmethod + def nightD(cls): + return cls(tag="8d", rotation_hertz=0.2) + + def __repr__(self) -> str: + return f"" + + +class ChannelMix(Filter): + """Filter which manually adjusts the panning of the audio, which can make + for some cool effects when done correctly. + """ + + def __init__( + self, + *, + tag: str, + left_to_left: float = 1, + right_to_right: float = 1, + left_to_right: float = 0, + right_to_left: float = 0 + ): + super().__init__() + + if 0 > left_to_left > 1: + raise ValueError( + "'left_to_left' value must be more than or equal to 0 or less than or equal to 1.") + if 0 > right_to_right > 1: + raise ValueError( + "'right_to_right' value must be more than or equal to 0 or less than or equal to 1.") + if 0 > left_to_right > 1: + raise ValueError( + "'left_to_right' value must be more than or equal to 0 or less than or equal to 1.") + if 0 > right_to_left > 1: + raise ValueError( + "'right_to_left' value must be more than or equal to 0 or less than or equal to 1.") + + self.left_to_left = left_to_left + self.left_to_right = left_to_right + self.right_to_left = right_to_left + self.right_to_right = right_to_right + self.tag = tag + + self.payload = {"channelMix": {"leftToLeft": self.left_to_left, + "leftToRight": self.left_to_right, + "rightToLeft": self.right_to_left, + "rightToRight": self.right_to_right} + } + + + def __repr__(self) -> str: + return ( + f"" + ) + +class Distortion(Filter): + """Filter which generates a distortion effect. Useful for certain filter implementations where + distortion is needed. + """ + + def __init__( + self, + *, + tag: str, + sin_offset: float = 0, + sin_scale: float = 1, + cos_offset: float = 0, + cos_scale: float = 1, + tan_offset: float = 0, + tan_scale: float = 1, + offset: float = 0, + scale: float = 1 + ): + super().__init__() + + self.sin_offset = sin_offset + self.sin_scale = sin_scale + self.cos_offset = cos_offset + self.cos_scale = cos_scale + self.tan_offset = tan_offset + self.tan_scale = tan_scale + self.offset = offset + self.scale = scale + self.tag = tag + + self.payload = {"distortion": { + "sinOffset": self.sin_offset, + "sinScale": self.sin_scale, + "cosOffset": self.cos_offset, + "cosScale": self.cos_scale, + "tanOffset": self.tan_offset, + "tanScale": self.tan_scale, + "offset": self.offset, + "scale": self.scale + }} + + def __repr__(self) -> str: + return ( + f" " + f"cos_offset={self.cos_offset} cos_scale={self.cos_scale} tan_offset={self.tan_offset} " + f"tan_scale={self.tan_scale} offset={self.offset} scale={self.scale}" + ) + + +class LowPass(Filter): + """Filter which supresses higher frequencies and allows lower frequencies to pass. + You can also do this with the Equalizer filter, but this is an easier way to do it. + """ + + def __init__(self, *, tag: str, smoothing: float = 20): + super().__init__() + + self.smoothing = smoothing + self.tag = tag + self.payload = {"lowPass": {"smoothing": self.smoothing}} + + def __repr__(self) -> str: + return f"" + + diff --git a/voicelink/objects.py b/voicelink/objects.py new file mode 100644 index 0000000..12aada4 --- /dev/null +++ b/voicelink/objects.py @@ -0,0 +1,151 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +import re +from typing import Optional + +from discord import Member +from tldextract import extract + +from .enums import SearchType +from function import ( + emoji_source, + time as ctime +) + +YOUTUBE_REGEX = re.compile(r'(https?://)?(www\.)?youtube\.(com|nl)/watch\?v=([-\w]+)') +class Track: + """The base track object. Returns critical track information needed for parsing by Lavalink. + You can also pass in commands.Context to get a discord.py Context object in your track. + """ + + def __init__( + self, + *, + track_id: str, + info: dict, + requester: Member, + spotify: bool = False, + search_type: SearchType = SearchType.ytsearch, + spotify_track = None, + ): + self.track_id = track_id + self.info = info + self.spotify = spotify + if self.spotify: + self.artistId: Optional[list] = info.get("artistId") + + self.original: Optional[Track] = None if spotify else self + self._search_type = SearchType.ytmsearch if spotify else search_type + self.spotify_track = spotify_track + + self.title = info.get("title", "Unknown") + self.author = info.get("author", "Unknown") + self.uri = info.get("uri", "https://discord.com/application-directory/605618911471468554") + self.identifier = info.get("identifier") + self.thumbnail = None + self.source = extract(self.uri).domain + self.emoji = emoji_source(self.source) + + if info.get("thumbnail"): + self.thumbnail = info.get("thumbnail") + elif YOUTUBE_REGEX.match(self.uri): + self.thumbnail = f"https://img.youtube.com/vi/{self.identifier}/hqdefault.jpg" + + if self.source == "soundcloud" and "/preview/" in self.identifier: + self.length = 30000 + else: + self.length = info.get("length") + + self.formatLength = ctime(self.length) + self.requester = requester + self.is_stream = info.get("isStream", False) + self.is_seekable = info.get("isSeekable", True) + self.position = info.get("position", 0) + + def __eq__(self, other): + if not isinstance(other, Track): + return False + + return other.track_id == self.track_id + + def __str__(self): + return self.title + + def __repr__(self): + return f" length={self.length}>" + + +class Playlist: + """The base playlist object. + Returns critical playlist information needed for parsing by Lavalink. + You can also pass in commands.Context to get a discord.py Context object in your tracks. + """ + + def __init__( + self, + *, + playlist_info: dict, + tracks: list, + requester: Member = None, + spotify: bool = False, + spotify_playlist = None + ): + self.playlist_info = playlist_info + self.tracks_raw = tracks + self.spotify = spotify + self.name = playlist_info.get("name") + self.spotify_playlist = spotify_playlist + + self._thumbnail = None + self._uri = None + + if self.spotify: + self.tracks = tracks + self._thumbnail = self.spotify_playlist.image + self._uri = self.spotify_playlist.uri + else: + self.tracks = [ + Track(track_id=track["track"], info=track["info"], requester=requester) + for track in self.tracks_raw + ] + self._thumbnail = None + self._uri = None + + self.track_count = len(self.tracks) + + def __str__(self): + return self.name + + def __repr__(self): + return f"" + + @property + def uri(self) -> Optional[str]: + """Spotify album/playlist URI, or None if not a Spotify object.""" + return self._uri + + @property + def thumbnail(self) -> Optional[str]: + """Spotify album/playlist thumbnail, or None if not a Spotify object.""" + return self._thumbnail diff --git a/voicelink/player.py b/voicelink/player.py new file mode 100644 index 0000000..b77caaa --- /dev/null +++ b/voicelink/player.py @@ -0,0 +1,595 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +import time +import function + +from math import ceil +from asyncio import sleep +from view import InteractiveController +from typing import ( + Any, + Dict, + Optional +) + +from discord import ( + Client, + Guild, + VoiceChannel, + VoiceProtocol, + StageChannel, + Member, + Embed, + ui, + Interaction +) + +from . import events +from .enums import SearchType +from .events import VoicelinkEvent, TrackEndEvent, TrackStartEvent +from .exceptions import VoicelinkException, FilterInvalidArgument, TrackInvalidPosition, TrackLoadError, FilterTagAlreadyInUse +from .filters import Filter, Filters +from .objects import Track +from .pool import Node, NodePool +from .queue import Queue, FairQueue + +class Player(VoiceProtocol): + """The base player class for Voicelink. + In order to initiate a player, you must pass it in as a cls when you connect to a channel. + i.e: ```py + await ctx.author.voice.channel.connect(cls=voicelink.Player) + ``` + """ + + def __call__(self, client: Client, channel: VoiceChannel): + self.client: Client = client + self.channel: VoiceChannel = channel + + return self + + def __init__( + self, + client: Optional[Client] = None, + channel: Optional[VoiceChannel] = None, + ctx: Interaction = None, + ): + self.client = client + self._bot = client + self.context = ctx + self.dj: Member = ctx.user + self.channel = channel + self._guild = channel.guild if channel else None + + self.settings: dict = function.get_settings(ctx.guild.id) + self.joinTime = round(time.time()) + self._volume = self.settings.get('volume', 100) + self.lang = self.settings.get('lang', 'EN') if self.settings.get('lang', 'EN') in function.langs else "EN" + self.queue = eval(self.settings.get("queueType", "Queue"))(5000, self.settings.get("duplicateTrack", True), self.get_msg) + + self._node = NodePool.get_node() + self._current: Track = None + self._filters: Filters = Filters() + self._paused = False + self._is_connected = False + self._ping = 0.0 + + self._position = 0 + self._last_position = 0 + self._last_update = 0 + self._ending_track: Optional[Track] = None + + self._voice_state = {} + + self.controller = None + self.updating = False + + self.pause_votes = set() + self.resume_votes = set() + self.skip_votes = set() + self.previous_votes = set() + self.shuffle_votes = set() + self.stop_votes = set() + + def __repr__(self): + return ( + f"" + ) + + @property + def position(self) -> float: + """Property which returns the player's position in a track in milliseconds""" + current = self._current.original + + if not self.is_playing or not self._current: + return 0 + + if self.is_paused: + return min(self._last_position, current.length) + + difference = (time.time() * 1000) - self._last_update + position = self._last_position + difference + + if position > current.length: + return 0 + + return min(position, current.length) + + @property + def is_playing(self) -> bool: + """Property which returns whether or not the player is actively playing a track.""" + return self._is_connected and self._current is not None + + @property + def is_connected(self) -> bool: + """Property which returns whether or not the player is connected""" + return self._is_connected + + @property + def is_paused(self) -> bool: + """Property which returns whether or not the player has a track which is paused or not.""" + return self._is_connected and self._paused + + @property + def current(self) -> Track: + """Property which returns the currently playing track""" + return self._current + + @property + def node(self) -> Node: + """Property which returns the node the player is connected to""" + return self._node + + @property + def guild(self) -> Guild: + """Property which returns the guild associated with the player""" + return self._guild + + @property + def volume(self) -> int: + """Property which returns the players current volume""" + return self._volume + + @property + def filters(self) -> Filter: + """Property which returns the helper class for interacting with filters""" + return self._filters + + @property + def bot(self) -> Client: + """Property which returns the bot associated with this player instance""" + return self._bot + + @property + def is_dead(self) -> bool: + """Returns a bool representing whether the player is dead or not. + A player is considered dead if it has been destroyed and removed from stored players. + """ + return self.guild.id not in self._node._players + + @property + def ping(self) -> float: + return round(self._ping / 1000, 2) + + def get_msg(self, mKey: str) -> str: + return function.langs.get(self.lang, function.langs["EN"])[mKey] + + def required(self, leave=False): + if self.settings.get('votedisable'): + return 0 + + required = ceil((len(self.channel.members) - 1) / 2.5) + if leave: + if len(self.channel.members) == 3: + required = 2 + + return required + + async def _update_state(self, data: dict): + state: dict = data.get("state") + self._last_update = time.time() * 1000 + self._is_connected = state.get("connected") + self._last_position = state.get("position") + self._ping = state.get("ping") + + async def _dispatch_voice_update(self, voice_data: Dict[str, Any]): + if {"sessionId", "event"} != self._voice_state.keys(): + return + + await self._node.send( + op="voiceUpdate", + guildId=str(self.guild.id), + **voice_data + ) + + async def on_voice_server_update(self, data: dict): + self._voice_state.update({"event": data}) + await self._dispatch_voice_update(self._voice_state) + + async def on_voice_state_update(self, data: dict): + self._voice_state.update({"sessionId": data.get("session_id")}) + + if not (channel_id := data.get("channel_id")): + await self.teardown() + self._voice_state.clear() + return + + self.channel = self.guild.get_channel(int(channel_id)) + + if not data.get("token"): + return + + await self._dispatch_voice_update({**self._voice_state, "event": data}) + + async def _dispatch_event(self, data: dict): + event_type = data.get("type") + event: VoicelinkEvent = getattr(events, event_type)(data, self) + + if isinstance(event, TrackEndEvent) and event.reason != "REPLACED": + self._current = None + + event.dispatch(self._bot) + + if isinstance(event, TrackStartEvent): + self._ending_track = self._current + + async def do_next(self): + if self.is_playing or not self.channel: + return + + if self._paused: + self._paused = False + + if not self.guild.me.voice: + await self.connect(timeout=0.0, reconnect=True) + + self.pause_votes.clear() + self.resume_votes.clear() + self.skip_votes.clear() + self.previous_votes.clear() + self.shuffle_votes.clear() + self.stop_votes.clear() + + track: Track = await self.queue.get() + if not track: + if self.settings.get("autoplay", False): + if await function.similar_track(self): + return await self.do_next() + if self.settings.get('controller', True): + await self.invoke_controller() + return + try: + await self.play(track, start=track.position) + except: + await sleep(5) + return await self.do_next() + + if self.settings.get('controller', True): + await self.invoke_controller() + + async def invoke_controller(self): + if self.updating or not self.channel: + return + + self.updating = True + + if not self.controller: + try: + self.controller = await self.context.channel.send(embed=await self.build_embed(), view=InteractiveController(self)) + except: + pass + + elif not await self.is_position_fresh(): + try: + await self.controller.delete() + except: + ui.View.from_message(self.controller).stop() + try: + self.controller = await self.context.channel.send(embed= await self.build_embed(), view=InteractiveController(self)) + except: + pass + else: + embed = await self.build_embed() + if embed: + try: + await self.controller.edit(embed=embed, view=InteractiveController(self)) + except: + pass + + self.updating = False + + async def build_embed(self): + track = self.current + + if not track: + embed=Embed(title=self.get_msg("noTrackPlaying"), description=f"[Vote](https://top.gg/bot/605618911471468554/vote/) | [Support]({function.invite_link}) | [Invite](https://discord.com/oauth2/authorize?client_id=605618911471468554&permissions=2184260928&scope=bot%20applications.commands) | [Questionnaire](https://forms.gle/UqeeEv4GEdCq9hi3A)", color=function.embed_color) + embed.set_image(url='https://i.imgur.com/dIFBwU7.png') + + else: + try: + embed = Embed(color=function.embed_color) + embed.set_author(name=self.get_msg("playerAuthor").format(self.channel.name), icon_url=self.client.user.avatar.url) + embed.description = self.get_msg("playerDesc").format(track.title, track.uri, (track.requester.mention if track.requester else "<@605618911471468554>"), (f"<@&{self.settings['dj']}>" if self.settings.get('dj') else f"{self.dj.mention}")) + embed.set_image(url=track.thumbnail if track.thumbnail else "https://cdn.discordapp.com/attachments/674788144931012638/823086668445384704/eq-dribbble.gif") + embed.set_footer(text=self.get_msg("playerFooter").format(self.queue.count, (self.get_msg("live") if track.is_stream else function.time(track.length)), self.volume, self.get_msg("playerFooter2").format(self.queue.repeat) if self.queue.repeat != "Off" else "")) + except: + embed = Embed(description=self.get_msg("missingTrackInfo"), color=function.embed_color) + return embed + + async def is_position_fresh(self): + try: + async for message in self.context.channel.history(limit=5): + if message.id == self.controller.id: + return True + except: + pass + + return False + + async def teardown(self): + timeNow = round(time.time()) + function.update_settings(self.guild.id, {"lastActice": timeNow, "playTime": round(self.settings.get("playTime", 0) + ((timeNow - self.joinTime) / 60), 2)}) + + try: + await self.controller.delete() + except: + pass + + try: + await self.destroy() + except: + pass + + async def is_privileged(self, user: Member): + if 'dj' in self.settings and self.settings['dj']: + return user.guild_permissions.manage_guild or (self.settings['dj'] in [role.id for role in user.roles]) + return self.dj.id == user.id or user.guild_permissions.manage_guild + + async def get_tracks( + self, + query: str, + *, + requester: Member, + search_type: SearchType = SearchType.ytsearch + ): + """Fetches tracks from the node's REST api to parse into Lavalink. + + If you passed in Spotify API credentials when you created the node, + you can also pass in a Spotify URL of a playlist, album or track and it will be parsed + accordingly. + + You can also pass in a discord.py Context object to get a + Context object on any track you search. + """ + return await self._node.get_tracks(query, requester=requester, search_type=search_type) + + async def spotifySearch(self, query: str, *, requester: Member) -> list: + + try: + tracks = await self._node._spotify_client.trackSearch(query=query) + except: + raise TrackLoadError("Not able to find the provided Spotify entity, is it private?") + + return [ Track( + track_id=track.id, + requester=requester, + search_type=SearchType.ytsearch, + spotify=True, + spotify_track=track, + info={ + "title": track.name, + "author": track.artists, + "length": track.length, + "identifier": track.id, + "artistId": track.artistId, + "uri": track.uri, + "isStream": False, + "isSeekable": True, + "position": 0, + "thumbnail": track.image + } + ) + for track in tracks ] + + async def spotifyRelatedTrack(self, seed_artists: str, seed_tracks: str): + + tracks = await self._node._spotify_client.similar_track(seed_artists=seed_artists, seed_tracks=seed_tracks) + + return [ Track( + track_id=track.id, + search_type=SearchType.ytsearch, + spotify=True, + spotify_track=track, + info={ + "title": track.name, + "author": track.artists, + "length": track.length, + "identifier": track.id, + "artistId": track.artistId, + "uri": track.uri, + "isStream": False, + "isSeekable": True, + "position": 0, + "thumbnail": track.image + }, + requester=self.client.user + ) + for track in tracks ] + + async def connect(self, *, timeout: float, reconnect: bool, self_deaf: bool = True, self_mute: bool = False): + await self.guild.change_voice_state(channel=self.channel, self_deaf=True, self_mute=self_mute) + self._node._players[self.guild.id] = self + self._is_connected = True + + async def stop(self): + """Stops the currently playing track.""" + self._current = None + await self._node.send(op="stop", guildId=str(self.guild.id)) + + async def disconnect(self, *, force: bool = False): + """Disconnects the player from voice.""" + try: + await self.guild.change_voice_state(channel=None) + finally: + self.cleanup() + self._is_connected = False + self.channel = None + + async def destroy(self): + """Disconnects and destroys the player, and runs internal cleanup.""" + + try: + await self.disconnect() + except: + # 'NoneType' has no attribute '_get_voice_client_key' raised by self.cleanup() -> + # assume we're already disconnected and cleaned up + assert self.channel is None and not self.is_connected + + self._node._players.pop(self.guild.id) + await self._node.send(op="destroy", guildId=str(self.guild.id)) + + async def play( + self, + track: Track, + *, + start: int = 0, + end: int = 0, + ignore_if_playing: bool = False + ) -> Track: + """Plays a track. If a Spotify track is passed in, it will be handled accordingly.""" + if not self._node: + return track + + if track.spotify: + if not track.original: + search: Track = (await self._node.get_tracks( + f"ytmsearch:{track.author} - {track.title}", + requester=track.requester + )) + + if not search: + raise TrackLoadError("Can't not found a playable source!") + + track.original = search[0] + + data = { + "op": "play", + "guildId": str(self.guild.id), + "track": track.original.track_id, + "startTime": str(start), + "noReplace": ignore_if_playing + } + else: + data = { + "op": "play", + "guildId": str(self.guild.id), + "track": track.track_id, + "startTime": str(start), + "noReplace": ignore_if_playing + } + + if end > 0: + data["endTime"] = str(end) + + await self._node.send(**data) + self._current = track + + if self.volume != 100: + await self.set_volume(self.volume) + + return self._current + + async def seek(self, position: float) -> float: + """Seeks to a position in the currently playing track milliseconds""" + if position < 0 or position > self._current.original.length: + raise TrackInvalidPosition( + "Seek position must be between 0 and the track length" + ) + + await self._node.send(op="seek", guildId=str(self.guild.id), position=position) + return self._position + + async def set_pause(self, pause: bool) -> bool: + """Sets the pause state of the currently playing track.""" + await self._node.send(op="pause", guildId=str(self.guild.id), pause=pause) + self._paused = pause + return self._paused + + async def set_volume(self, volume: int) -> int: + """Sets the volume of the player as an integer. Lavalink accepts values from 0 to 500.""" + await self._node.send(op="volume", guildId=str(self.guild.id), volume=volume) + self._volume = volume + return self._volume + + async def add_filter(self, filter: Filter, fast_apply=False) -> Filters: + try: + self._filters.add_filter(filter=filter) + except FilterTagAlreadyInUse: + raise FilterTagAlreadyInUse(self.get_msg("FilterTagAlreadyInUse")) + payload = self._filters.get_all_payloads() + await self._node.send(op="filters", guildId=str(self.guild.id), **payload) + if fast_apply: + await self.seek(self.position) + return self._filters + + async def remove_filter(self, filter_tag: str, fast_apply=False) -> Filters: + self._filters.remove_filter(filter_tag=filter_tag) + payload = self._filters.get_all_payloads() + await self._node.send(op="filters", guildId=str(self.guild.id), **payload) + if fast_apply: + await self.seek(self.position) + + return self._filters + + async def reset_filter(self, *, fast_apply=False) -> None: + if not self._filters: + raise FilterInvalidArgument("You must have filters applied first in order to use this method.") + self._filters.reset_filters() + await self._node.send(op="filters", guildId=str(self.guild.id)) + if fast_apply: + await self.seek(self.position) + + async def change_node(self, identifier: str = None) -> None: + """Change node. + """ + try: + node = NodePool.get_node(identifier=identifier) + except: + return await self.teardown() + + self._node._players.pop(self.guild.id) + self._node = node + self._node._players[self.guild.id] = self + + await self._dispatch_voice_update(self._voice_state) + + if self.current: + await self.play(self.current, start=self.position) + self._last_update = time.time() * 1000 + + if self.is_paused: + await self.set_pause(True) + + if self.volume != 100: + await self.set_volume(self.volume) \ No newline at end of file diff --git a/voicelink/pool.py b/voicelink/pool.py new file mode 100644 index 0000000..d4a2b91 --- /dev/null +++ b/voicelink/pool.py @@ -0,0 +1,604 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import re +from typing import Dict, Optional, TYPE_CHECKING +from urllib.parse import quote + +import aiohttp +from discord import Client, Member + + +from . import ( + __version__, + spotify, +) + +from .enums import SearchType, NodeAlgorithm +from .exceptions import ( + InvalidSpotifyClientAuthorization, + NodeConnectionFailure, + NodeCreationError, + NodeException, + NodeNotAvailable, + NoNodesAvailable, + TrackLoadError +) +from .objects import Playlist, Track +from .utils import ExponentialBackoff, NodeStats, Ping + +if TYPE_CHECKING: + from .player import Player + +SPOTIFY_URL_REGEX = re.compile( + r"https?://open.spotify.com/(?Palbum|playlist|track|artist)/(?P[a-zA-Z0-9]+)" +) + +DISCORD_MP3_URL_REGEX = re.compile( + r"https?://cdn.discordapp.com/attachments/(?P[0-9]+)/" + r"(?P[0-9]+)/(?P[a-zA-Z0-9_.]+)+" +) + +URL_REGEX = re.compile( + r"https?://(?:www\.)?.+" +) + + +def exception_catch_callback(task): + if task.exception(): + return + +class Node: + """The base class for a node. + This node object represents a Lavalink node. + To enable Spotify searching, pass in a proper Spotify Client ID and Spotify Client Secret + """ + + def __init__( + self, + *, + pool, + bot: Client, + host: str, + port: int, + password: str, + identifier: str, + secure: bool = False, + heartbeat: int = 30, + session: Optional[aiohttp.ClientSession] = None, + spotify_client_id: Optional[str] = None, + spotify_client_secret: Optional[str] = None, + resume_key: Optional[str] = None + + ): + self._bot = bot + self._host = host + self._port = port + self._pool = pool + self._password = password + self._identifier = identifier + self._heartbeat = heartbeat + self._secure = secure + + self._websocket_uri = f"{'wss' if self._secure else 'ws'}://{self._host}:{self._port}" + self._rest_uri = f"{'https' if self._secure else 'http'}://{self._host}:{self._port}" + + self._session = session or aiohttp.ClientSession() + self._websocket: aiohttp.ClientWebSocketResponse = None + self._task: asyncio.Task = None + + self.resume_key = resume_key or str(os.urandom(8).hex()) + + self._connection_id = None + self._metadata = None + self._available = None + + self._headers = { + "Authorization": self._password, + "User-Id": str(os.getenv('CLIENT_ID')), + "Client-Name": f"Voicelink/{__version__}", + 'Resume-Key': self.resume_key + } + + self._players: Dict[int, Player] = {} + + self._spotify_client_id = spotify_client_id + self._spotify_client_secret = spotify_client_secret + + if self._spotify_client_id and self._spotify_client_secret: + self._spotify_client = spotify.Client( + self._spotify_client_id, self._spotify_client_secret + ) + + self._bot.add_listener(self._update_handler, "on_socket_response") + + def __repr__(self): + return ( + f"" + ) + + @property + def is_connected(self) -> bool: + """"Property which returns whether this node is connected or not""" + return self._websocket is not None and not self._websocket.closed + + + @property + def stats(self) -> NodeStats: + """Property which returns the node stats.""" + return self._stats + + @property + def players(self) -> Dict[int, Player]: + """Property which returns a dict containing the guild ID and the player object.""" + return self._players + + @property + def bot(self) -> Client: + """Property which returns the discord.py client linked to this node""" + return self._bot + + @property + def player_count(self) -> int: + """Property which returns how many players are connected to this node""" + return len(self.players) + + @property + def pool(self): + """Property which returns the pool this node is apart of""" + return self._pool + + @property + def latency(self): + """Property which returns the latency of the node""" + return Ping(self._host, port=self._port).get_ping() + + async def _update_handler(self, data: dict): + #await self._bot.wait_until_ready() + + if not data: + return + + if data["t"] == "VOICE_SERVER_UPDATE": + guild_id = int(data["d"]["guild_id"]) + try: + player = self._players[guild_id] + await player.on_voice_server_update(data["d"]) + except KeyError: + return + + elif data["t"] == "VOICE_STATE_UPDATE": + if int(data["d"]["user_id"]) != self._bot.user.id: + return + + guild_id = int(data["d"]["guild_id"]) + try: + player = self._players[guild_id] + await player.on_voice_state_update(data["d"]) + except KeyError: + return + + async def _listen(self): + backoff = ExponentialBackoff(base=7) + + while True: + try: + msg = await self._websocket.receive() + except: + break + if msg.type == aiohttp.WSMsgType.CLOSED: + self._available = False + + retry = backoff.delay() + print(f"Trying to reconnect {self._identifier} with {round(retry)}s") + await asyncio.sleep(retry) + if not self.is_connected: + try: + await self.connect() + except: + pass + else: + self._bot.loop.create_task(self._handle_payload(msg.json())) + + async def _handle_payload(self, data: dict): + op = data.get("op", None) + if not op: + return + + if op == "ready": + return + + if op == "stats": + self._stats = NodeStats(data) + return + + if not (player := self._players.get(int(data["guildId"]))): + return + + if op == "event": + await player._dispatch_event(data) + elif op == "playerUpdate": + await player._update_state(data) + + async def send(self, **data): + if not self._available: + raise NodeNotAvailable( + f"The node '{self._identifier}' is unavailable." + ) + + payload = json.dumps(data) + if isinstance(payload, bytes): + payload = payload.decode('utf-8') + + await self._websocket.send_str(payload) + + def get_player(self, guild_id: int): + """Takes a guild ID as a parameter. Returns a voicelink Player object.""" + return self._players.get(guild_id, None) + + async def connect(self): + """Initiates a connection with a Lavalink node and adds it to the node pool.""" + + try: + self._websocket = await self._session.ws_connect( + self._websocket_uri, headers=self._headers, heartbeat=self._heartbeat + ) + self._task = self._bot.loop.create_task(self._listen()) + self._available = True + + except aiohttp.ClientConnectorError: + raise NodeConnectionFailure( + f"The connection to node '{self._identifier}' failed." + ) + except aiohttp.WSServerHandshakeError: + raise NodeConnectionFailure( + f"The password for node '{self._identifier}' is invalid." + ) + except aiohttp.InvalidURL: + raise NodeConnectionFailure( + f"The URI for node '{self._identifier}' is invalid." + ) + + if self.is_connected: + resume = { + "op": "configureResuming", + "key": f"{self.resume_key}", + "timeout": 120 + } + + await self.send(**resume) + print(f"{self._identifier} is connected!") + + if self.players: + await self.reconnect() + + async def disconnect(self): + """Disconnects a connected Lavalink node and removes it from the node pool. + This also destroys any players connected to the node. + """ + for player in self.players.copy().values(): + await player.teardown() + + if self._spotify_client_id and self._spotify_client_secret: + await self._spotify_client.close() + + await self._websocket.close() + del self._pool._nodes[self._identifier] + self._available = False + self._task.cancel() + + async def reconnect(self): + for player in self.players.copy().values(): + try: + if player._voice_state: + await player._dispatch_voice_update(player._voice_state) + + if player.current: + await player.play(track=player.current, start=min(player._last_position, player.current.length)) + + if player.is_paused: + await player.set_pause(True) + except: + await player.teardown() + await asyncio.sleep(2) + + async def build_track( + self, + identifier: str, + requester: Member = None + ) -> Track: + """ + Builds a track using a valid track identifier + + You can also pass in a discord.py Context object to get a + Context object on the track it builds. + """ + + async with self._session.get( + f"{self._rest_uri}/decodetrack?", + headers={"Authorization": self._password}, + params={"track": identifier} + ) as resp: + if not resp.status == 200: + raise TrackLoadError( + f"Failed to build track. Check if the identifier is correct and try again." + ) + + data: dict = await resp.json() + return Track(track_id=identifier, info=data, requester=requester) + + async def get_tracks( + self, + query: str, + *, + requester: Member, + search_type: SearchType = SearchType.ytsearch + ): + """Fetches tracks from the node's REST api to parse into Lavalink. + + If you passed in Spotify API credentials, you can also pass in a + Spotify URL of a playlist, album or track and it will be parsed accordingly. + + You can also pass in a discord.py Context object to get a + Context object on any track you search. + """ + + if not URL_REGEX.match(query) and not re.match(r"(?:ytm?|sc)search:.", query): + query = f"{search_type}:{query}" + + if SPOTIFY_URL_REGEX.match(query): + if not self._spotify_client_id and not self._spotify_client_secret: + raise InvalidSpotifyClientAuthorization( + "You did not provide proper Spotify client authorization credentials. " + "If you would like to use the Spotify searching feature, " + "please obtain Spotify API credentials here: https://developer.spotify.com/" + ) + + try: + spotify_results = await self._spotify_client.search(query=query) + except: + raise TrackLoadError("Not able to find the provided Spotify entity, is it private?") + + if isinstance(spotify_results, spotify.Track): + return [ + Track( + track_id=spotify_results.id, + requester=requester, + search_type=search_type, + spotify=True, + spotify_track=spotify_results, + info={ + "title": spotify_results.name, + "author": spotify_results.artists, + "length": spotify_results.length, + "identifier": spotify_results.id, + "uri": spotify_results.uri, + "artistId": spotify_results.artistId, + "isStream": False, + "isSeekable": True, + "position": 0, + "thumbnail": spotify_results.image + } + ) + ] + + tracks = [ + Track( + track_id=track.id, + requester=requester, + search_type=search_type, + spotify=True, + spotify_track=track, + info={ + "title": track.name, + "author": track.artists, + "length": track.length, + "identifier": track.id, + "artistId": track.artistId, + "uri": track.uri, + "isStream": False, + "isSeekable": True, + "position": 0, + "thumbnail": track.image + } + ) for track in spotify_results.tracks if track.uri + ] + + return Playlist( + playlist_info={"name": spotify_results.name, "selectedTrack": 0}, + tracks=tracks, + requester=requester, + spotify=True, + spotify_playlist=spotify_results + ) + + elif discord_url := DISCORD_MP3_URL_REGEX.match(query): + async with self._session.get( + url=f"{self._rest_uri}/loadtracks?identifier={quote(query)}", + headers={"Authorization": self._password} + ) as response: + data: dict = await response.json() + + try: + track: dict = data["tracks"][0] + info: dict = track.get("info") + except: + raise TrackLoadError("Not able to find the provided track.") + + return [ + Track( + track_id=track["track"], + info={ + "title": discord_url.group("file"), + "author": "Unknown", + "length": info.get("length"), + "uri": info.get("uri"), + "position": info.get("position"), + "identifier": info.get("identifier") + }, + requester=requester + ) + ] + + else: + async with self._session.get( + url=f"{self._rest_uri}/loadtracks?identifier={quote(query)}", + headers={"Authorization": self._password} + ) as response: + data = await response.json() + + load_type = data.get("loadType") + + if not load_type: + raise TrackLoadError("There was an error while trying to load this track.") + + elif load_type == "LOAD_FAILED": + exception = data["exception"] + raise TrackLoadError(f"{exception['message']} [{exception['severity']}]") + + elif load_type == "NO_MATCHES": + return None + + elif load_type == "PLAYLIST_LOADED": + return Playlist( + playlist_info=data["playlistInfo"], + tracks=data["tracks"], + requester=requester + ) + + elif load_type == "SEARCH_RESULT" or load_type == "TRACK_LOADED": + return [ + Track( + track_id=track["track"], + info=track["info"], + requester=requester + ) + for track in data["tracks"] + ] + + +class NodePool: + """The base class for the node pool. + This holds all the nodes that are to be used by the bot. + """ + + _nodes = {} + + def __repr__(self): + return f"" + + @property + def nodes(self) -> Dict[str, Node]: + """Property which returns a dict with the node identifier and the Node object.""" + return self._nodes + + @property + def node_count(self): + return len(self._nodes.values()) + + @classmethod + def get_best_node(cls, *, algorithm: NodeAlgorithm) -> Node: + """Fetches the best node based on an NodeAlgorithm. + This option is preferred if you want to choose the best node + from a multi-node setup using either the node's latency + or the node's voice region. + Use NodeAlgorithm.by_ping if you want to get the best node + based on the node's latency. + Use NodeAlgorithm.by_region if you want to get the best node + based on the node's voice region. This method will only work + if you set a voice region when you create a node. + Use NodeAlgorithm.by_players if you want to get the best node + based on how players it has. This method will return a node with + the least amount of players + """ + available_nodes = [node for node in cls._nodes.values() if node._available] + + if not available_nodes: + raise NoNodesAvailable("There are no nodes available.") + + if algorithm == NodeAlgorithm.by_ping: + tested_nodes = {node: node.latency for node in available_nodes} + return min(tested_nodes, key=tested_nodes.get) + + elif algorithm == NodeAlgorithm.by_players: + tested_nodes = {node: len(node.players.keys()) for node in available_nodes} + return min(tested_nodes, key=tested_nodes.get) + + @classmethod + def get_node(cls, *, identifier: str = None) -> Node: + """Fetches a node from the node pool using it's identifier. + If no identifier is provided, it will choose a node at random. + """ + + available_nodes = { node + for _, node in cls._nodes.items() if node.is_connected + } + + if identifier: + available_nodes = { node for node in available_nodes if node._identifier == identifier } + + if not available_nodes: + raise NoNodesAvailable("There are no nodes available.") + + nodes = {node: len(node.players.keys()) for node in available_nodes} + return min(nodes, key=nodes.get) + + @classmethod + async def create_node( + cls, + *, + bot: Client, + host: str, + port: str, + password: str, + identifier: str, + secure: bool = False, + heartbeat: int = 30, + spotify_client_id: Optional[str] = None, + spotify_client_secret: Optional[str] = None, + session: Optional[aiohttp.ClientSession] = None, + resume_key: Optional[str] = None, + + ) -> Node: + """Creates a Node object to be then added into the node pool. + For Spotify searching capabilites, pass in valid Spotify API credentials. + """ + if identifier in cls._nodes.keys(): + raise NodeCreationError(f"A node with identifier '{identifier}' already exists.") + + node = Node( + pool=cls, bot=bot, host=host, port=port, password=password, + identifier=identifier, secure=secure, heartbeat=heartbeat, spotify_client_id=spotify_client_id, + session=session, spotify_client_secret=spotify_client_secret, + resume_key=resume_key + ) + + await node.connect() + cls._nodes[node._identifier] = node + return node \ No newline at end of file diff --git a/voicelink/queue.py b/voicelink/queue.py new file mode 100644 index 0000000..222265d --- /dev/null +++ b/voicelink/queue.py @@ -0,0 +1,199 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +from .exceptions import QueueFull, OutofList, DuplicateTrack +from .objects import Track +from discord import Member + +class Queue: + def __init__(self, size:int, duplicateTrack: bool, get_msg): + + self._queue = [] + self._position = 0 + self._size = size + self._repeat = 0 + self._repeat_position = 0 + self._duplicateTrack = duplicateTrack + self.get_msg = get_msg + + async def get(self): + track = None + try: + track = self._queue[self._position - 1 if self._repeat == 1 else self._position] + if self._repeat != 1: + self._position += 1 + except: + if self._repeat == 2: + try: + track = self._queue[self._repeat_position] + self._position = self._repeat_position + 1 + except IndexError: + self._repeat = 0 + + return track + + async def put(self, item: Track) -> int: + if len(self._queue) >= self._size: + raise QueueFull(self.get_msg("voicelinkQueueFull").format(self._size)) + + if not self._duplicateTrack: + if item.uri in [track.uri for track in self._queue]: + raise DuplicateTrack(self.get_msg("voicelinkDuplicateTrack")) + + self._queue.append(item) + return self.count + + async def put_at_front(self, item: Track): + if len(self._queue) >= self._size: + raise QueueFull(self.get_msg("voicelinkQueueFull").format(self._size)) + + if not self._duplicateTrack: + if item.uri in [track.uri for track in self._queue]: + raise DuplicateTrack(self.get_msg("voicelinkDuplicateTrack")) + + return self._queue.insert(self._position, item) + + async def put_at_index(self, index: int, item: Track): + if len(self._queue) >= self._size: + raise QueueFull(self.get_msg("voicelinkQueueFull").format(self._size)) + + if not self._duplicateTrack: + if item.uri in [track.uri for track in self._queue]: + raise DuplicateTrack(self.get_msg("voicelinkDuplicateTrack")) + + return self._queue.insert(self._position - 1 + index, item) + + async def skipto(self, index: int): + if not 0 < index <= self.count: + raise OutofList(self.get_msg("voicelinkOutofList")) + else: + self._position += index - 1 + + def backto(self, index: int): + if not self._position - index >= 0: + raise OutofList(self.get_msg("voicelinkOutofList")) + else: + self._position -= index + + def set_repeat(self, mode:str): + if mode == 'Track': + self._repeat = 1 + elif mode == 'Queue': + self._repeat = 2 + self._repeat_position = self._position - 1 + else: + self._repeat = 0 + + def history_clear(self, is_playing: bool): + self._queue[:self._position - 1 if is_playing else self._position] = [] + self._position = 1 if is_playing else 0 + + def clear(self): + del self._queue[self._position:] + + def replace(self, queuetype:str, replacement:list): + if queuetype == "Queue": + self.clear() + self._queue += replacement + elif queuetype == "History": + self._queue[:self._position] = replacement + + def swap(self, num1:int, num2:int): + try: + pos = self._position - 1 + self._queue[pos + num1], self._queue[pos + num2] = self._queue[pos + num2], self._queue[pos + num1] + except IndexError: + raise OutofList(self.get_msg("voicelinkOutofList")) + + def remove(self, index:int, index2:int, member: Member = None): + pos = self._position - 1 + if not index2: + index2 = index + count = 0 + try: + for track in self._queue[pos + index: pos + index2 + 1]: + if member: + if track.requester == member: + self._queue.remove(track) + count += 1 + continue + else: + self._queue.remove(track) + count += 1 + return count + except: + raise OutofList(self.get_msg("voicelinkOutofList")) + + def history(self, incTrack: bool = False) -> list: + if incTrack: + return self._queue[:self._position] + return self._queue[:self._position - 1] + + def tracks(self, incTrack: bool = False): + if incTrack: + return self._queue[self._position - 1:] + return self._queue[self._position:] + + @property + def count(self): + return len(self._queue[self._position:]) + + @property + def repeat(self): + return "Off" if self._repeat == 0 else ("Track" if self._repeat == 1 else "Queue") + + @property + def is_empty(self): + try: + self._queue[self._position] + except: + return True + return False + +class FairQueue(Queue): + def __init__(self, size:int, duplicateTrack: bool, get_msg): + super().__init__(size, duplicateTrack, get_msg) + self._set = set() + + async def put(self, item: Track) -> int: + if len(self._queue) >= self._size: + raise QueueFull(self.get_msg("voicelinkQueueFull").format(self._size)) + + if not self._duplicateTrack: + if item.uri in [track.uri for track in self._queue]: + raise DuplicateTrack(self.get_msg("voicelinkDuplicateTrack")) + + tracks = self.tracks(incTrack=True) + lastIndex = len(tracks) + for track in reversed(tracks): + if track.requester == item.requester: + break + lastIndex -= 1 + self._set.clear() + for track in tracks[lastIndex:]: + if track.requester in self._set: + break; + lastIndex += 1 + self._set.add(track.requester) + await self.put_at_index(lastIndex, item) + return lastIndex \ No newline at end of file diff --git a/voicelink/spotify/__init__.py b/voicelink/spotify/__init__.py new file mode 100644 index 0000000..48611a5 --- /dev/null +++ b/voicelink/spotify/__init__.py @@ -0,0 +1,29 @@ +"""MIT License + +Copyright (c) 2022 Vocard Development + +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. +""" + +from .exceptions import InvalidSpotifyURL, SpotifyRequestException +from .track import Track +from .playlist import Playlist +from .album import Album +from .artist import Artist +from .client import Client diff --git a/voicelink/spotify/album.py b/voicelink/spotify/album.py new file mode 100644 index 0000000..bde4a95 --- /dev/null +++ b/voicelink/spotify/album.py @@ -0,0 +1,20 @@ +from .track import Track + + +class Album: + """The base class for a Spotify album""" + + def __init__(self, data: dict) -> None: + self.name = data.get('name', 'Unknown') + self.artists = ", ".join(artist["name"] for artist in data.get('artists')) + self.image = data["images"][0]["url"] + self.tracks = [Track(track, image=self.image) for track in data["tracks"]["items"]] + self.total_tracks = data["total_tracks"] + self.id = data.get('id') + self.uri = data["external_urls"]["spotify"] + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/voicelink/spotify/artist.py b/voicelink/spotify/artist.py new file mode 100644 index 0000000..1a3dffc --- /dev/null +++ b/voicelink/spotify/artist.py @@ -0,0 +1,20 @@ +from .track import Track + +class Artist: + """The base class for a Spotify playlist""" + + def __init__(self, data: dict) -> None: + self.tracks = [ Track(track) for track in data['tracks'] ] + if self.tracks: + self.image = self.tracks[0].image + self.total_tracks = len(self.tracks) + self.owner = self.tracks[0].artists + self.id = self.tracks[0].artistId + self.uri = data['tracks'][0]['album']['artists'][0]['external_urls']['spotify'] + self.name = f"Top tracks - {self.owner}" + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/voicelink/spotify/client.py b/voicelink/spotify/client.py new file mode 100644 index 0000000..9acf771 --- /dev/null +++ b/voicelink/spotify/client.py @@ -0,0 +1,169 @@ +"""MIT License + +Copyright (c) 2022 Vocard Development + +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. +""" + +import re +import time +from base64 import b64encode + +import aiohttp + +from .album import Album +from .exceptions import InvalidSpotifyURL, SpotifyRequestException +from .playlist import Playlist +from .track import Track +from .artist import Artist + +GRANT_URL = "https://accounts.spotify.com/api/token" +REQUEST_URL = "https://api.spotify.com/v1/{type}s/{id}" +SEARCH_URL = "https://api.spotify.com/v1/search?q={query}&type={type}&limit={limit}" +SUGGESTION_URL = "https://api.spotify.com/v1/recommendations?limit={limit}&seed_tracks={seed_tracks}" +SPOTIFY_URL_REGEX = re.compile( + r"https?://open.spotify.com/(?Palbum|playlist|track|artist)/(?P[a-zA-Z0-9]+)" +) + + +class Client: + """The base client for the Spotify module of Voicelink. + This class will do all the heavy lifting of getting all the metadata + for any Spotify URL you throw at it. + """ + + def __init__(self, client_id: str, client_secret: str) -> None: + self._client_id = client_id + self._client_secret = client_secret + + self.session = aiohttp.ClientSession() + + self._bearer_token: str = None + self._expiry = 0 + self._auth_token = b64encode(f"{self._client_id}:{self._client_secret}".encode()) + self._grant_headers = {"Authorization": f"Basic {self._auth_token.decode()}"} + self._bearer_headers = None + + async def _fetch_bearer_token(self) -> None: + _data = {"grant_type": "client_credentials"} + + async with self.session.post(GRANT_URL, data=_data, headers=self._grant_headers) as resp: + if resp.status != 200: + raise SpotifyRequestException( + f"Error fetching bearer token: {resp.status} {resp.reason}" + ) + + data: dict = await resp.json() + + self._bearer_token = data["access_token"] + self._expiry = time.time() + (int(data["expires_in"]) - 10) + self._bearer_headers = {"Authorization": f"Bearer {self._bearer_token}"} + + async def trackSearch(self, query: str, track: str = "track", limit: int = 10) -> list: + if not self._bearer_token or time.time() >= self._expiry: + await self._fetch_bearer_token() + + request_url = SEARCH_URL.format(query=query, type=track, limit=limit) + + async with self.session.get(request_url, headers=self._bearer_headers) as resp: + if resp.status != 200: + raise SpotifyRequestException( + f"Error while fetching results: {resp.status} {resp.reason}" + ) + + data: dict = await resp.json() + + return [ Track(track) for track in data['tracks']['items'] ] + + async def similar_track(self, seed_artists: str, seed_tracks: str, limit: int = 5) -> list: + if not self._bearer_token or time.time() >= self._expiry: + await self._fetch_bearer_token() + + request_url = SUGGESTION_URL.format(limit=limit, seed_tracks=seed_tracks) + + async with self.session.get(request_url, headers=self._bearer_headers) as resp: + if resp.status != 200: + raise SpotifyRequestException( + f"Error while fetching results: {resp.status} {resp.reason}" + ) + + data: dict = await resp.json() + + return [ Track(track) for track in data['tracks'] ] + + async def search(self, *, query: str): + if not self._bearer_token or time.time() >= self._expiry: + await self._fetch_bearer_token() + + result = SPOTIFY_URL_REGEX.match(query) + spotify_type = result.group("type") + spotify_id = result.group("id") + + if not result: + raise InvalidSpotifyURL("The Spotify link provided is not valid.") + + request_url = REQUEST_URL.format(type=spotify_type, id=spotify_id) + if isArtist := (spotify_type == "artist"): + request_url += "/top-tracks?market=US" + + async with self.session.get(request_url, headers=self._bearer_headers) as resp: + if resp.status != 200: + raise SpotifyRequestException( + f"Error while fetching results: {resp.status} {resp.reason}" + ) + + data: dict = await resp.json() + + if spotify_type == "track": + return Track(data) + elif spotify_type == "album": + return Album(data) + elif isArtist: + return Artist(data) + else: + + tracks = [ + Track(track["track"]) + for track in data["tracks"]["items"] if track["track"] is not None + ] + + if not tracks: + raise SpotifyRequestException("This playlist is empty and therefore cannot be queued.") + + next_page_url = data["tracks"]["next"] + + while next_page_url is not None: + async with self.session.get(next_page_url, headers=self._bearer_headers) as resp: + if resp.status != 200: + raise SpotifyRequestException( + f"Error while fetching results: {resp.status} {resp.reason}" + ) + + next_data: dict = await resp.json() + + tracks += [ + Track(track["track"]) + for track in next_data["items"] if track["track"] is not None + ] + next_page_url = next_data["next"] + + return Playlist(data, tracks) + + async def close(self): + await self.session.close() diff --git a/voicelink/spotify/exceptions.py b/voicelink/spotify/exceptions.py new file mode 100644 index 0000000..e421fbf --- /dev/null +++ b/voicelink/spotify/exceptions.py @@ -0,0 +1,8 @@ +class SpotifyRequestException(Exception): + """An error occurred when making a request to the Spotify API""" + pass + + +class InvalidSpotifyURL(Exception): + """An invalid Spotify URL was passed""" + pass diff --git a/voicelink/spotify/playlist.py b/voicelink/spotify/playlist.py new file mode 100644 index 0000000..d82236b --- /dev/null +++ b/voicelink/spotify/playlist.py @@ -0,0 +1,24 @@ +from .track import Track +from typing import List + + +class Playlist: + """The base class for a Spotify playlist""" + + def __init__(self, data: dict, tracks: List[Track]) -> None: + self.name = data.get('name', 'Unknown') + self.tracks = tracks + self.owner = data["owner"]["display_name"] + self.total_tracks = data["tracks"]["total"] + self.id = data.get('id') + if data.get("images") and len(data["images"]): + self.image = data["images"][0]["url"] + else: + self.image = None + self.uri = data["external_urls"]["spotify"] + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/voicelink/spotify/track.py b/voicelink/spotify/track.py new file mode 100644 index 0000000..325f920 --- /dev/null +++ b/voicelink/spotify/track.py @@ -0,0 +1,25 @@ +class Track: + """The base class for a Spotify Track""" + + def __init__(self, data: dict, image = None) -> None: + self.name = data.get('name', 'Unknown') + self.artists = ", ".join(artist["name"] for artist in data.get('artists')) + self.artistId = [ artist['id'] for artist in data.get('artists') ] + self.length = data.get('duration_ms') + self.id = data.get('id') + + if data.get("album") and data["album"].get("images"): + self.image = data["album"]["images"][0]["url"] + else: + self.image = image + + if data["is_local"]: + self.uri = None + else: + self.uri = data["external_urls"]["spotify"] + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/voicelink/utils.py b/voicelink/utils.py new file mode 100644 index 0000000..0a692a0 --- /dev/null +++ b/voicelink/utils.py @@ -0,0 +1,171 @@ +"""MIT License + +Copyright (c) 2023 Vocard Development + +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. +""" + +import random +import time +import socket +from timeit import default_timer as timer +from itertools import zip_longest + +__all__ = [ + "ExponentialBackoff", + "NodeStats" +] + +class ExponentialBackoff: + """ + The MIT License (MIT) + Copyright (c) 2015-present Rapptz + 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. + """ + + def __init__(self, base: int = 1, *, integral: bool = False) -> None: + + self._base = base + + self._exp = 0 + self._max = 10 + self._reset_time = base * 2 ** 11 + self._last_invocation = time.monotonic() + + rand = random.Random() + rand.seed() + + self._randfunc = rand.randrange if integral else rand.uniform + + def delay(self) -> float: + + invocation = time.monotonic() + interval = invocation - self._last_invocation + self._last_invocation = invocation + + if interval > self._reset_time: + self._exp = 0 + + self._exp = min(self._exp + 1, self._max) + return self._randfunc(0, self._base * 2 ** self._exp) + + +class NodeStats: + """The base class for the node stats object. + Gives critical information on the node, which is updated every minute. + """ + + def __init__(self, data: dict) -> None: + + memory: dict = data.get("memory") + self.used = memory.get("used") + self.free = memory.get("free") + self.reservable = memory.get("reservable") + self.allocated = memory.get("allocated") + + cpu: dict = data.get("cpu") + self.cpu_cores = cpu.get("cores") + self.cpu_system_load = cpu.get("systemLoad") + self.cpu_process_load = cpu.get("lavalinkLoad") + + self.players_active = data.get("playingPlayers") + self.players_total = data.get("players") + self.uptime = data.get("uptime") + + def __repr__(self) -> str: + return f"" + + +class Ping: + # Thanks to https://github.com/zhengxiaowai/tcping for the nice ping impl + def __init__(self, host, port, timeout=5): + self.timer = self.Timer() + + self._successed = 0 + self._failed = 0 + self._conn_time = None + self._host = host + self._port = port + self._timeout = timeout + + class Socket(object): + def __init__(self, family, type_, timeout): + s = socket.socket(family, type_) + s.settimeout(timeout) + self._s = s + + def connect(self, host, port): + self._s.connect((host, int(port))) + + def shutdown(self): + self._s.shutdown(socket.SHUT_RD) + + def close(self): + self._s.close() + + + class Timer(object): + def __init__(self): + self._start = 0 + self._stop = 0 + + def start(self): + self._start = timer() + + def stop(self): + self._stop = timer() + + def cost(self, funcs, args): + self.start() + for func, arg in zip_longest(funcs, args): + if arg: + func(*arg) + else: + func() + + self.stop() + return self._stop - self._start + + def _create_socket(self, family, type_): + return self.Socket(family, type_, self._timeout) + + def get_ping(self): + s = self._create_socket(socket.AF_INET, socket.SOCK_STREAM) + + cost_time = self.timer.cost( + (s.connect, s.shutdown), + ((self._host, self._port), None)) + s_runtime = 1000 * (cost_time) + + return s_runtime +