├── .DS_Store ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.yml └── workflows │ └── docker-image.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── addons ├── __init__.py ├── lyrics.py ├── placeholders.py └── settings.py ├── cogs ├── basic.py ├── effect.py ├── listeners.py ├── playlist.py ├── settings.py └── task.py ├── docker-compose.yml ├── function.py ├── ipc ├── __init__.py ├── client.py └── methods.py ├── langs ├── CH.json ├── DE.json ├── EN.json ├── ES.json ├── JA.json ├── KO.json ├── PL.json ├── RU.json └── UA.json ├── lavalink ├── Dockerfile-lavalink └── application.yml ├── local_langs └── zh-TW.json ├── main.py ├── requirements.txt ├── settings Example.json ├── update.py ├── views ├── __init__.py ├── controller.py ├── debug.py ├── embedBuilder.py ├── help.py ├── inbox.py ├── link.py ├── list.py ├── lyrics.py ├── playlist.py └── search.py └── voicelink ├── __init__.py ├── enums.py ├── events.py ├── exceptions.py ├── filters.py ├── objects.py ├── placeholders.py ├── player.py ├── pool.py ├── queue.py ├── ratelimit.py ├── transformer.py └── utils.py /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ChocoMeow/Vocard/9a593299583b8e95efe5b4dc444959d5ebe29e12/.DS_Store -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: chocoo 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report broken or incorrect behaviour 3 | labels: unconfirmed bug 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: > 8 | Thank you for submitting a bug report! For real-time support, please join our [Discord community](https://discord.gg/wRCgB7vBQv). 9 | This form is specifically for reporting bugs, and we appreciate your understanding! 10 | 11 | **Note:** This form is for bugs only! 12 | - type: input 13 | attributes: 14 | label: Summary 15 | description: A simple summary of your bug report 16 | validations: 17 | required: true 18 | - type: textarea 19 | attributes: 20 | label: Reproduction Steps 21 | description: What you did to make it happen. 22 | validations: 23 | required: true 24 | - type: textarea 25 | attributes: 26 | label: System Information 27 | description: > 28 | Run `python -m discord -v` and paste this information below. This command requires v1.1.0 or higher of the library. 29 | If this errors out, please provide basic information about your system, such as your operating system and Python version. 30 | validations: 31 | required: true 32 | - type: textarea 33 | attributes: 34 | label: Error Logs 35 | description: Paste the loggings from your console. Include only relevant errors or warnings. 36 | validations: 37 | required: true 38 | - type: checkboxes 39 | attributes: 40 | label: Checklist 41 | description: Let's ensure you've done your due diligence when reporting this issue! 42 | options: 43 | - label: I have searched the open issues for duplicates. 44 | required: true 45 | - label: I have included the entire traceback, if possible. 46 | required: true 47 | - label: I have removed my token from display, if visible. 48 | required: true 49 | - type: textarea 50 | attributes: 51 | label: Additional Context 52 | description: If there is anything else to say, please do so here. -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | paths-ignore: 7 | - '**.md' 8 | 9 | permissions: 10 | packages: write 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Build the Docker image 19 | run: docker build . --file Dockerfile --tag vocard:latest 20 | 21 | - name: Log in to GitHub Docker Registry 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin 25 | 26 | - name: Push the Docker image 27 | run: | 28 | docker tag vocard:latest ghcr.io/chocomeow/vocard:latest # Ensure lowercase 29 | docker push ghcr.io/chocomeow/vocard:latest # Ensure lowercase -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | **/.DS_Store 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # UV 99 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | #uv.lock 103 | 104 | # poetry 105 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 106 | # This is especially recommended for binary packages to ensure reproducibility, and is more 107 | # commonly ignored for libraries. 108 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 109 | #poetry.lock 110 | 111 | # pdm 112 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 113 | #pdm.lock 114 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 115 | # in version control. 116 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 117 | .pdm.toml 118 | .pdm-python 119 | .pdm-build/ 120 | 121 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 122 | __pypackages__/ 123 | 124 | # Celery stuff 125 | celerybeat-schedule 126 | celerybeat.pid 127 | 128 | # SageMath parsed files 129 | *.sage.py 130 | 131 | # Environments 132 | .env 133 | .venv 134 | env/ 135 | venv/ 136 | ENV/ 137 | env.bak/ 138 | venv.bak/ 139 | 140 | # Spyder project settings 141 | .spyderproject 142 | .spyproject 143 | 144 | # Rope project settings 145 | .ropeproject 146 | 147 | # mkdocs documentation 148 | /site 149 | 150 | # mypy 151 | .mypy_cache/ 152 | .dmypy.json 153 | dmypy.json 154 | 155 | # Pyre type checker 156 | .pyre/ 157 | 158 | # pytype static type analyzer 159 | .pytype/ 160 | 161 | # Cython debug symbols 162 | cython_debug/ 163 | 164 | # PyCharm 165 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 166 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 167 | # and can be added to the global gitignore or merged into this file. For a more nuclear 168 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 169 | #.idea/ 170 | 171 | # Ruff stuff: 172 | .ruff_cache/ 173 | 174 | # PyPI configuration file 175 | .pypirc 176 | 177 | # Custom file 178 | settings.json 179 | logs -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build 2 | FROM python:3.12-slim-bookworm as builder 3 | 4 | # Install build dependencies (gcc, Python headers, etc.) 5 | RUN apt-get update && apt-get install -y --no-install-recommends \ 6 | gcc \ 7 | python3-dev \ 8 | && apt-get clean \ 9 | && rm -rf /var/lib/apt/lists/* 10 | 11 | # Set the working directory 12 | WORKDIR /app 13 | 14 | # Copy only the requirements file to take advantage of Docker's caching 15 | COPY requirements.txt . 16 | 17 | # Install Python dependencies 18 | RUN pip install --no-cache-dir -r requirements.txt 19 | 20 | # Stage 2: Runtime 21 | FROM python:3.12-slim-bookworm 22 | 23 | # Set the working directory 24 | WORKDIR /app 25 | 26 | # Copy installed Python packages from the builder stage 27 | COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages 28 | 29 | # Copy the application code 30 | COPY . . 31 | 32 | # Run the application 33 | CMD ["python", "-u", "main.py"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Choco 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Discord 3 | 4 | 5 | # Vocard Bot 6 | Vocard is a highly customizable Discord music bot, designed to deliver a user-friendly experience. It offers support for a wide range of streaming platforms including Youtube, Soundcloud, Spotify, Twitch, and more. 7 | 8 | ## Features 9 | * Fast song loading 10 | * Works with slash and message commands 11 | * Lightweight design 12 | * Smooth playback 13 | * Clean and nice interface 14 | * Supports many music platforms (YouTube, SoundCloud, etc.) 15 | * Built-in playlist support 16 | * Fully customizable settings 17 | * Lyrics support 18 | * Various sound effects 19 | * Multiple languages available 20 | * Easy to update 21 | * Supports docker 22 | * [Premium dashboard](https://github.com/ChocoMeow/Vocard-Dashboard) 23 | 24 | ## Screenshot 25 | ![features](https://github.com/user-attachments/assets/2a1baf75-d1c8-41d1-a66f-7011e96d5feb) 26 | 27 | ## Requirements 28 | * [Python 3.11+](https://www.python.org/downloads/) 29 | * [Lavalink Server (Requires 4.0.0+)](https://github.com/freyacodes/Lavalink) 30 | 31 | ## Setup 32 | Please see the [Setup Page](https://docs.vocard.xyz) in the docs to run this bot yourself! 33 | 34 | ## Need Help? 35 | Join the [Vocard Support Discord](https://discord.gg/wRCgB7vBQv) for help or questions. 36 | 37 | -------------------------------------------------------------------------------- /addons/__init__.py: -------------------------------------------------------------------------------- 1 | from .lyrics import LYRICS_PLATFORMS 2 | from .placeholders import Placeholders 3 | from .settings import Settings -------------------------------------------------------------------------------- /addons/placeholders.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | from discord.ext import commands 25 | from re import findall 26 | from importlib import import_module 27 | 28 | class Placeholders: 29 | def __init__(self, bot: commands.Bot) -> None: 30 | self.bot = bot 31 | self.voicelink = import_module("voicelink") 32 | self.variables = { 33 | "guilds": self.guilds_count, 34 | "users": self.users_count, 35 | "players": self.players_count, 36 | "nodes": self.nodes_count 37 | } 38 | 39 | def guilds_count(self) -> int: 40 | return len(self.bot.guilds) 41 | 42 | def users_count(self) -> int: 43 | return len(self.bot.users) 44 | 45 | def players_count(self) -> int: 46 | count = 0 47 | for node in self.voicelink.NodePool._nodes.values(): 48 | count += len(node._players) 49 | 50 | return count 51 | 52 | def nodes_count(self): 53 | return len(self.voicelink.NodePool._nodes) 54 | 55 | def replace(self, msg: str) -> str: 56 | keys = findall(r'@@(.*?)@@', msg) 57 | 58 | for key in keys: 59 | value = self.variables.get(key.lower(), None) 60 | if value: 61 | msg = msg.replace(f"@@{key}@@", str(value())) 62 | 63 | return msg -------------------------------------------------------------------------------- /addons/settings.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | from typing import ( 25 | Dict, 26 | List, 27 | Any, 28 | Union 29 | ) 30 | 31 | class Settings: 32 | def __init__(self, settings: Dict) -> None: 33 | self.token: str = settings.get("token") 34 | self.client_id: int = int(settings.get("client_id", 0)) 35 | self.genius_token: str = settings.get("genius_token") 36 | self.mongodb_url: str = settings.get("mongodb_url") 37 | self.mongodb_name: str = settings.get("mongodb_name") 38 | 39 | self.invite_link: str = "https://discord.gg/wRCgB7vBQv" 40 | self.nodes: Dict[str, Dict[str, Union[str, int, bool]]] = settings.get("nodes", {}) 41 | self.max_queue: int = settings.get("default_max_queue", 1000) 42 | self.bot_prefix: str = settings.get("prefix", "") 43 | self.activity: List[Dict[str, str]] = settings.get("activity", [{"listen": "/help"}]) 44 | self.logging: Dict[Union[str, Dict[str, Union[str, bool]]]] = settings.get("logging", {}) 45 | self.embed_color: str = int(settings.get("embed_color", "0xb3b3b3"), 16) 46 | self.bot_access_user: List[int] = settings.get("bot_access_user", []) 47 | self.sources_settings: Dict[Dict[str, str]] = settings.get("sources_settings", {}) 48 | self.cooldowns_settings: Dict[str, List[int]] = settings.get("cooldowns", {}) 49 | self.aliases_settings: Dict[str, List[str]] = settings.get("aliases", {}) 50 | self.controller: Dict[str, Dict[str, Any]] = settings.get("default_controller", {}) 51 | self.voice_status_template: str = settings.get("default_voice_status_template", "") 52 | self.lyrics_platform: str = settings.get("lyrics_platform", "A_ZLyrics").lower() 53 | self.ipc_client: Dict[str, Union[str, bool, int]] = settings.get("ipc_client", {}) 54 | self.version: str = settings.get("version", "") -------------------------------------------------------------------------------- /cogs/listeners.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | import os 25 | import asyncio 26 | import discord 27 | import voicelink 28 | import function as func 29 | 30 | from discord.ext import commands 31 | 32 | class Listeners(commands.Cog): 33 | """Music Cog.""" 34 | 35 | def __init__(self, bot: commands.Bot): 36 | self.bot = bot 37 | self.voicelink = voicelink.NodePool() 38 | 39 | bot.loop.create_task(self.start_nodes()) 40 | bot.loop.create_task(self.restore_last_session_players()) 41 | 42 | async def start_nodes(self) -> None: 43 | """Connect and intiate nodes.""" 44 | for n in func.settings.nodes.values(): 45 | try: 46 | await self.voicelink.create_node( 47 | bot=self.bot, 48 | logger=func.logger, 49 | **n 50 | ) 51 | except Exception as e: 52 | func.logger.error(f'Node {n["identifier"]} is not able to connect! - Reason: {e}') 53 | 54 | async def restore_last_session_players(self) -> None: 55 | """Re-establish connections for players from the last session.""" 56 | await self.bot.wait_until_ready() 57 | players = func.open_json(func.LAST_SESSION_FILE_NAME) 58 | if not players: 59 | return 60 | 61 | for data in players: 62 | try: 63 | channel_id = data.get("channel_id") 64 | if not channel_id: 65 | continue 66 | 67 | channel = self.bot.get_channel(channel_id) 68 | if not channel: 69 | continue 70 | elif not any(False if member.bot or member.voice.self_deaf else True for member in channel.members): 71 | continue 72 | 73 | dj_member = channel.guild.get_member(data.get("dj")) 74 | if not dj_member: 75 | continue 76 | 77 | # Get the guild settings 78 | settings = await func.get_settings(channel.guild.id) 79 | 80 | # Connect to the channel and initialize the player. 81 | player: voicelink.Player = await channel.connect( 82 | cls=voicelink.Player(self.bot, channel, func.TempCtx(dj_member, channel), settings) 83 | ) 84 | 85 | # Restore the queue. 86 | queue_data = data.get("queue", {}) 87 | for track_data in queue_data.get("tracks", []): 88 | track_id = track_data.get("track_id") 89 | if not track_id: 90 | continue 91 | 92 | decoded_track = voicelink.decode(track_id) 93 | requester = channel.guild.get_member(track_data.get("requester_id")) 94 | track = voicelink.Track(track_id=track_id, info=decoded_track, requester=requester) 95 | player.queue._queue.append(track) 96 | 97 | # Restore queue settings. 98 | player.queue._position = queue_data.get("position", 0) - 1 99 | repeat_mode = queue_data.get("repeat_mode", "OFF") 100 | try: 101 | loop_mode = voicelink.LoopType[repeat_mode] 102 | except KeyError: 103 | loop_mode = voicelink.LoopType.OFF 104 | player.queue._repeat.set_mode(loop_mode) 105 | player.queue._repeat_position = queue_data.get("repeat_position") 106 | 107 | # Restore player settings 108 | player.dj = dj_member 109 | player.settings['autoplay'] = data.get('autoplay', False) 110 | 111 | # Resume playback or invoke the controller based on the player's state. 112 | if not player.is_playing: 113 | await player.do_next() 114 | 115 | if is_paused := data.get("is_paused"): 116 | await player.set_pause(is_paused, self.bot.user) 117 | 118 | if position := data.get("position"): 119 | await player.seek(int(position), self.bot.user) 120 | 121 | await asyncio.sleep(5) 122 | 123 | except Exception as e: 124 | func.logger.error(f"Error encountered while restoring a player for channel ID {channel_id}.", exc_info=e) 125 | 126 | # Delete the last session file if it exists. 127 | try: 128 | file_path = os.path.join(func.ROOT_DIR, func.LAST_SESSION_FILE_NAME) 129 | if os.path.exists(file_path): 130 | os.remove(file_path) 131 | 132 | except Exception as del_error: 133 | func.logger.error("Failed to remove session file: %s", file_path, exc_info=del_error) 134 | 135 | @commands.Cog.listener() 136 | async def on_voicelink_track_end(self, player: voicelink.Player, track, _): 137 | await player.do_next() 138 | 139 | @commands.Cog.listener() 140 | async def on_voicelink_track_stuck(self, player: voicelink.Player, track, _): 141 | await asyncio.sleep(10) 142 | await player.do_next() 143 | 144 | @commands.Cog.listener() 145 | async def on_voicelink_track_exception(self, player: voicelink.Player, track, error: dict): 146 | try: 147 | player._track_is_stuck = True 148 | await player.context.send(f"{error['message']} The next song will begin in the next 5 seconds.", delete_after=10) 149 | except: 150 | pass 151 | 152 | @commands.Cog.listener() 153 | async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState): 154 | if member.bot: 155 | return 156 | 157 | if before.channel == after.channel: 158 | return 159 | 160 | player: voicelink.Player = member.guild.voice_client 161 | if not player: 162 | return 163 | 164 | is_joined = True 165 | 166 | if not before.channel and after.channel: 167 | if after.channel.id != player.channel.id: 168 | return 169 | 170 | elif before.channel and not after.channel: 171 | is_joined = False 172 | 173 | elif before.channel and after.channel: 174 | if after.channel.id != player.channel.id: 175 | is_joined = False 176 | 177 | if is_joined and player.settings.get("24/7", False): 178 | if player.is_paused and len([m for m in player.channel.members if not m.bot]) == 1: 179 | await player.set_pause(False, member) 180 | 181 | if self.bot.ipc._is_connected: 182 | await self.bot.ipc.send({ 183 | "op": "updateGuild", 184 | "user": { 185 | "userId": str(member.id), 186 | "avatarUrl": member.display_avatar.url, 187 | "name": member.name, 188 | }, 189 | "channelName": member.voice.channel.name if is_joined else "", 190 | "guildId": str(member.guild.id), 191 | "isJoined": is_joined 192 | }) 193 | 194 | async def setup(bot: commands.Bot) -> None: 195 | await bot.add_cog(Listeners(bot)) 196 | -------------------------------------------------------------------------------- /cogs/task.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | import voicelink 25 | import discord 26 | import function as func 27 | 28 | from discord.ext import commands, tasks 29 | from addons import Placeholders 30 | 31 | class Task(commands.Cog): 32 | def __init__(self, bot: commands.Bot): 33 | self.bot = bot 34 | self.activity_update.start() 35 | self.player_check.start() 36 | self.cache_cleaner.start() 37 | 38 | self.current_act = 0 39 | self.placeholder = Placeholders(bot) 40 | 41 | def cog_unload(self): 42 | self.activity_update.cancel() 43 | self.player_check.cancel() 44 | self.cache_cleaner.cancel() 45 | 46 | @tasks.loop(minutes=10.0) 47 | async def activity_update(self): 48 | await self.bot.wait_until_ready() 49 | 50 | try: 51 | act_data = func.settings.activity[(self.current_act + 1) % len(func.settings.activity) - 1] 52 | act_original = self.bot.activity 53 | act_type = getattr(discord.ActivityType, act_data.get("type", "").lower(), discord.ActivityType.playing) 54 | act_name = self.placeholder.replace(act_data.get("name", "")) 55 | 56 | status_type = getattr(discord.Status, act_data.get("status", "").lower(), None) 57 | 58 | if act_original.type != act_type or act_original.name != act_name: 59 | self.bot.activity = discord.Activity(type=act_type, name=act_name) 60 | await self.bot.change_presence(activity=self.bot.activity, status=status_type) 61 | self.current_act = (self.current_act + 1) % len(func.settings.activity) 62 | 63 | func.logger.info(f"Changed the bot status to {act_name}") 64 | 65 | except Exception as e: 66 | func.logger.error("Error occurred while changing the bot status!", exc_info=e) 67 | 68 | @tasks.loop(minutes=5.0) 69 | async def player_check(self): 70 | for identifier, node in voicelink.NodePool._nodes.items(): 71 | for guild_id, player in node._players.copy().items(): 72 | try: 73 | if not player.channel or not player.context or not player.guild: 74 | await player.teardown() 75 | continue 76 | except: 77 | await player.teardown() 78 | continue 79 | 80 | try: 81 | members = player.channel.members 82 | 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): 83 | if not player.settings.get('24/7', False): 84 | await player.teardown() 85 | continue 86 | else: 87 | if not player.is_paused: 88 | await player.set_pause(True) 89 | else: 90 | if not player.guild.me: 91 | await player.teardown() 92 | continue 93 | elif not player.guild.me.voice: 94 | await player.connect(timeout=0.0, reconnect=True) 95 | 96 | if player.dj not in members: 97 | for m in members: 98 | if not m.bot: 99 | player.dj = m 100 | break 101 | 102 | except Exception as e: 103 | func.logger.error("Error occurred while checking the player!", exc_info=e) 104 | 105 | @tasks.loop(hours=12.0) 106 | async def cache_cleaner(self): 107 | func.SETTINGS_BUFFER.clear() 108 | func.USERS_BUFFER.clear() 109 | 110 | async def setup(bot: commands.Bot): 111 | await bot.add_cog(Task(bot)) -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------------------------------------ # 2 | 3 | # READ THIS BEFORE INSTALL! 4 | 5 | # This is a docker-compose file for running Vocard with Lavalink and MongoDB. 6 | # In order to run this, you need to have Docker and Docker Compose installed. 7 | # You can install Docker from https://docs.docker.com/get-docker/ 8 | # and Docker Compose from https://docs.docker.com/compose/install/ 9 | 10 | # Step 1: Start the installation by creating the future config directory for Vocard. 11 | # example - `root@docker:~# mkdir -p /opt/vocard/config` 12 | 13 | # Use `cd` to navigate to the config directory. 14 | # example - `root@docker:~# cd /opt/vocard/config` 15 | 16 | # Step 3: Choose installation method: Build the image from the Dockerfile or pull it from GitHub(recommended). 17 | # If you chose to pull from Docker Hub, comment the "build" lines and uncomment the "image" line. 18 | # If you chose to build the image from the Dockerfile, do the following: 19 | # uncomment this 20 | # build: 21 | # dockerfile: ./Dockerfile 22 | # and comment this 23 | # image: ghcr.io/chocomeow/vocard:latest 24 | # example - `root@docker:/opt/vocard/config# wget https://github.com/ChocoMeow/Vocard/archive/refs/heads/main.zip` 25 | 26 | # Step 4: Configure application.yml and settings.json in the config directory. 27 | # In order to avoid silly syntax errors it is recommended to use external code editor such as VS Code or Notepad++. 28 | # Then you can upload files to host using tools such as WinSCP or 29 | # using `nano` to create and edit the files directly using hosts terminal. 30 | # NOTE that some terminals DO NOT let you paste, so you can either use WinSCP or SSH app like Putty. 31 | 32 | # example - `root@docker:/opt/vocard/config# nano application.yml` 33 | # example - `root@docker:/opt/vocard/config# nano settings.json` 34 | # To exit nano, press `Ctrl + S`, then `Ctrl + X` to save changes. 35 | 36 | # Step 5: If the values are set correctly, you can start the installation by running the following command 37 | # example - `root@docker:/opt/vocard/config# docker-compose up -d` (could be `docker compose` on some systems) 38 | 39 | # ------------------------------------------ THANK YOU FOR READING! ------------------------------------------ # 40 | name: vocard 41 | services: 42 | lavalink: 43 | container_name: lavalink 44 | # image: ghcr.io/lavalink-devs/lavalink:latest 45 | # Use build to run Lavalink on the latest JRE 23 version (for better performance) 46 | build: 47 | context: ./lavalink 48 | dockerfile: ./Dockerfile-lavalink 49 | restart: unless-stopped 50 | environment: 51 | - _JAVA_OPTIONS=-Xmx1G 52 | - SERVER_PORT=2333 53 | # there is no point in changing the password here, since the container is available only in docker network 54 | - LAVALINK_SERVER_PASSWORD=youshallnotpass # Change password if needed (don't forget to change it in healthcheck below and settings.json) 55 | volumes: 56 | - ./lavalink/application.yml:/opt/Lavalink/application.yml 57 | networks: 58 | - vocard 59 | expose: 60 | - "2333" 61 | healthcheck: 62 | test: nc -z -v localhost 2333 63 | interval: 10s 64 | timeout: 5s 65 | retries: 5 66 | 67 | vocard-db: 68 | container_name: vocard-db 69 | image: mongo:8 70 | restart: unless-stopped 71 | volumes: 72 | - ./data/mongo/db:/data/db 73 | - ./data/mongo/conf:/data/configdb 74 | environment: 75 | - MONGO_INITDB_ROOT_USERNAME=admin # For your MongoDB URL use "mongodb://admin:admin@vocard-db:27017" 76 | - MONGO_INITDB_ROOT_PASSWORD=admin 77 | expose: 78 | - "27017" 79 | networks: 80 | - vocard 81 | command: ["mongod", "--oplogSize=1024", "--wiredTigerCacheSizeGB=1", "--auth", "--noscripting"] 82 | healthcheck: 83 | test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet 84 | interval: 10s 85 | timeout: 5s 86 | retries: 5 87 | start_period: 10s 88 | 89 | 90 | # vocard-dashboard: 91 | # container_name: vocard-dashboard 92 | # image: ghcr.io/chocomeow/vocard-dashboard:latest 93 | # restart: unless-stopped 94 | # # If you want to build the image from the Dockerfile, uncomment the "build" lines and comment the "image" line. 95 | # # build: 96 | # # context: ./dashboard 97 | # # dockerfile: ./Dockerfile 98 | # volumes: 99 | # - ./dashboard/settings.json:/app/settings.json 100 | # ports: 101 | # - 8000:8000 102 | # networks: 103 | # - vocard 104 | # - web 105 | 106 | vocard: 107 | container_name: vocard 108 | restart: unless-stopped 109 | # If you want to build the image from the Dockerfile, uncomment the "build" lines and comment the "image" line. 110 | image: ghcr.io/chocomeow/vocard:latest 111 | # build: 112 | # dockerfile: ./Dockerfile 113 | volumes: 114 | - ./settings.json:/app/settings.json 115 | networks: 116 | - vocard 117 | depends_on: 118 | lavalink: 119 | condition: service_healthy 120 | # vocard-dashboard: 121 | # condition: service_started 122 | vocard-db: 123 | condition: service_healthy 124 | 125 | networks: 126 | vocard: 127 | name: vocard 128 | -------------------------------------------------------------------------------- /ipc/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import IPCClient -------------------------------------------------------------------------------- /ipc/client.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import asyncio 3 | import logging 4 | import function as func 5 | 6 | from discord.ext import commands 7 | from typing import Optional 8 | 9 | from .methods import process_methods 10 | 11 | class IPCClient: 12 | def __init__( 13 | self, 14 | bot: commands.Bot, 15 | host: str, 16 | port: int, 17 | password: str, 18 | heartbeat: int = 30, 19 | secure: bool = False, 20 | *arg, 21 | **kwargs 22 | ) -> None: 23 | 24 | self._bot: commands.Bot = bot 25 | self._host: str = host 26 | self._port: int = port 27 | self._password: str = password 28 | self._heartbeat: int = heartbeat 29 | self._is_secure: bool = secure 30 | self._is_connected: bool = False 31 | self._is_connecting: bool = False 32 | self._logger: logging.Logger = logging.getLogger("ipc_client") 33 | 34 | self._websocket_url: str = f"{'wss' if self._is_secure else 'ws'}://{self._host}:{self._port}/ws_bot" 35 | self._session: Optional[aiohttp.ClientSession] = None 36 | self._websocket: Optional[aiohttp.ClientWebSocketResponse] = None 37 | self._task: Optional[asyncio.Task] = None 38 | 39 | self._heanders = { 40 | "Authorization": self._password, 41 | "User-Id": str(bot.user.id), 42 | "Client-Version": func.settings.version 43 | } 44 | 45 | async def _listen(self) -> None: 46 | while True: 47 | try: 48 | msg = await self._websocket.receive() 49 | self._logger.debug(f"Received Message: {msg}") 50 | except: 51 | break 52 | 53 | if msg.type in [aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED]: 54 | self._is_connected = False 55 | self._logger.info("Connection closed. Trying to reconnect in 10s.") 56 | await asyncio.sleep(10) 57 | 58 | if not self._is_connected: 59 | try: 60 | await self.connect() 61 | except Exception as e: 62 | self._logger.error("Reconnection failed.") 63 | else: 64 | self._bot.loop.create_task(process_methods(self, self._bot, msg.json())) 65 | 66 | async def send(self, data: dict): 67 | if self.is_connected: 68 | try: 69 | await self._websocket.send_json(data) 70 | self._logger.debug(f"Send Message: {data}") 71 | except ConnectionResetError as _: 72 | await self.disconnect() 73 | await self.connect() 74 | await self._websocket.send_json(data) 75 | self._logger.debug(f"Send Message: {data}") 76 | 77 | async def send(self, data: dict): 78 | # Check if the websocket is still open 79 | if self.is_connected: 80 | try: 81 | await self._websocket.send_json(data) 82 | self._logger.debug(f"Sent Message: {data}") 83 | except ConnectionResetError: 84 | self._logger.warning("Connection lost, attempting to reconnect.") 85 | await self._handle_reconnect(data) 86 | except Exception as e: 87 | self._logger.error(f"Failed to send message: {e}") 88 | else: 89 | self._logger.warning("WebSocket is not connected or already closed.") 90 | 91 | async def _handle_reconnect(self, data: dict): 92 | await self.disconnect() 93 | await self.connect() 94 | await asyncio.sleep(1) # Optional delay before retrying 95 | if self.is_connected: 96 | try: 97 | await self._websocket.send_json(data) 98 | self._logger.debug(f"Sent Message on reconnect: {data}") 99 | except Exception as e: 100 | self._logger.error(f"Failed to send message on reconnect: {e}") 101 | else: 102 | self._logger.error("Reconnection failed, not connected.") 103 | 104 | async def connect(self): 105 | try: 106 | if not self._session: 107 | self._session = aiohttp.ClientSession() 108 | 109 | if self._is_connecting or self._is_connected: 110 | return 111 | 112 | self._is_connecting = True 113 | self._websocket = await self._session.ws_connect( 114 | self._websocket_url, headers=self._heanders, heartbeat=self._heartbeat 115 | ) 116 | 117 | self._task = self._bot.loop.create_task(self._listen()) 118 | self._is_connected = True 119 | 120 | self._logger.info("Connected to dashboard!") 121 | 122 | except aiohttp.ClientConnectorError: 123 | raise Exception("Connection failed.") 124 | 125 | except aiohttp.WSServerHandshakeError as e: 126 | self._logger.error("Access forbidden: Missing bot ID, version mismatch, or invalid password.") 127 | 128 | except Exception as e: 129 | self._logger.error("Error occurred while connecting to dashboard.", exc_info=e) 130 | 131 | finally: 132 | self._is_connecting = False 133 | 134 | return self 135 | 136 | async def disconnect(self) -> None: 137 | self._is_connected = False 138 | self._task.cancel() 139 | self._logger.info("Disconnected to dashboard!") 140 | 141 | @property 142 | def is_connected(self) -> bool: 143 | return self._is_connected and self._websocket and not self._websocket.closed -------------------------------------------------------------------------------- /langs/CH.json: -------------------------------------------------------------------------------- 1 | { 2 | "unknownException": "⚠️ 執行命令時出現問題!請稍後再試,或加入我們的 Discord 伺服器獲得進一步支援。", 3 | "enabled": "已啟用", 4 | "disabled": "已停用", 5 | 6 | "nodeReconnect": "請稍後再試!在節點重新連接後再嘗試。", 7 | "noChannel": "沒有語音頻道可供連接。請提供一個語音頻道或加入一個語音頻道。", 8 | "alreadyConnected": "已經連接到語音頻道。", 9 | "noPermission": "抱歉!我沒有權限加入或在您的語音頻道中發言。", 10 | "noCreatePermission": "抱歉!我沒有權限建立歌曲請求頻道。", 11 | "noPlaySource": "找不到任何可播放的來源!", 12 | "noPlayer": "在此伺服器上找不到播放器。", 13 | "notVote": "此命令需要您的投票!輸入 `/vote` 以獲取更多資訊。", 14 | "missingIntents": "抱歉,此命令無法執行,因為機器人缺少所需的請求意圖:`({0})`.", 15 | "languageNotFound": "找不到語言包!請選擇一個現有的語言包。", 16 | "changedLanguage": "已成功切換到 `{0}` 語言包。", 17 | "setPrefix": "完成!我的前綴在您的伺服器中現在是 `{0}`。嘗試運行 `{1}ping` 來測試它。", 18 | "setDJ": "已將 DJ 設置為 {0}。", 19 | "setqueue": "已將隊列模式設置為 `{0}`。", 20 | "247": "現在您有 `{0}` 24/7 模式。", 21 | "bypassVote": "現在您有 `{0}` 投票系統。", 22 | "setVolume": "已將音量設置為 `{0}`%", 23 | "togglecontroller": "現在您已 `{0}` 音樂控制器。", 24 | "toggleDuplicateTrack": "現在您已 `{0}` 防止隊列中存在重複曲目。", 25 | "toggleControllerMsg": "現在您已從音樂控制器 `{0}` 消息。", 26 | "settingsMenu": "伺服器設置 | {0}", 27 | "settingsTitle": "❤️ 基本資訊:", 28 | "settingsValue": "```前綴:{0}\n語言:{1}\n音樂控制器:{2}\nDJ 角色:@{3}\n投票繞過:{4}\n24/7:{5}\n默認音量:{6}%\n播放時間:{7}```", 29 | "settingsTitle2": "🔗 隊列資訊:", 30 | "settingsValue2": "```隊列模式:{0}\n最大歌曲數:{1}\n允許重複曲目:{2}```", 31 | "settingsTitle3": "🎤 語音狀態資訊:", 32 | "settingsPermTitle": "✨ 權限:", 33 | "settingsPermValue": "```{0} 管理員\n{1} 管理伺服器\n{2} 管理頻道\n{3} 管理訊息```", 34 | "pingTitle1": "機器人資訊:", 35 | "pingTitle2": "播放器信息:", 36 | "pingfield1": "```分片 ID: {0}/{1}\n分片延遲: {2:.3f}s {3}\n區域: {4}```", 37 | "pingfield2": "```節點: {0} - {1:.3f}s\n播放器數量: {2}\n語音區域: {3}```", 38 | "addEffect": "套用音效`{0}`濾鏡。", 39 | "clearEffect": "聲音效果已清除!", 40 | "FilterTagAlreadyInUse": "此聲音效果已在使用中!請使用 /cleareffect 移除它。", 41 | 42 | "playlistViewTitle": "📜 所有 {0} 的播放清單", 43 | "playlistViewHeaders": "ID:,時間:,名稱:,曲目數:", 44 | "playlistFooter": "輸入 /playlist play [播放清單] 加入此播放清單至隊列中。", 45 | "playlistNotFound": "找不到播放清單 [`{0}`]。輸入 /playlist view 查看所有播放清單。", 46 | "playlistNotAccess": "抱歉!你無權訪問此播放清單!", 47 | "playlistNoTrack": "抱歉!播放清單 [`{0}`] 中沒有曲目。", 48 | "playlistNotAllow": "此命令不允許使用於已連結或共享的播放清單。", 49 | "playlistPlay": "已加入播放清單 [`{0}`] 的 `{1}` 首歌曲至隊列中。", 50 | "playlistOverText": "抱歉!播放清單名稱不能超過 10 個字符。", 51 | "playlistSameName": "抱歉!此名稱不能與你的新名稱相同。", 52 | "playlistDeleteError": "你無權刪除預設播放清單。", 53 | "playlistRemove": "你已移除播放清單 [`{0}`]。", 54 | "playlistSendErrorPlayer": "抱歉!你無法向自己發送邀請。", 55 | "playlistSendErrorBot": "抱歉!你無法向機器人發送邀請。", 56 | "playlistBelongs": "抱歉!此播放清單屬於 <@{0}>。", 57 | "playlistShare": "抱歉!此播放清單已與 {0} 共享。", 58 | "playlistSent": "抱歉!你已發送過邀請。", 59 | "noPlaylistAcc": "{0} 沒有建立播放清單帳戶。", 60 | "overPlaylistCreation": "你不能建立超過 `{0}` 個播放清單!", 61 | "playlistExists": "播放清單 [`{0}`] 已存在。", 62 | "playlistNotInvalidUrl": "請輸入有效的連結或公開的 Spotify 或 YouTube 播放清單連結。", 63 | "playlistCreated": "你已建立 `{0}` 播放清單。輸入 /playlist view 檢視更多資訊。", 64 | "playlistRenamed": "你已將 `{0}` 更名為 `{1}`。", 65 | "playlistLimitTrack": "你已達到限制!你只能將 `{0}` 首歌曲添加至你的播放清單中。", 66 | "playlistPlaylistLink": "你無法使用播放清單連結。", 67 | "playlistStream": "你無法將串流影片添加至你的播放清單中。", 68 | "playlistPositionNotFound": "找不到播放清單 [`{1}`] 中位置為 `{0}` 的曲目!", 69 | "playlistRemoved": "👋 已從 {1} 的播放清單 [`{2}`] 中刪除 **{0}**。", 70 | "playlistClear": "你已成功清除播放清單 [`{0}`]。", 71 | "playlistView": "播放清單檢視器", 72 | "playlistViewDesc": "```名稱 | ID: {0} | {1}\n總曲目數: {2}\n擁有者: {3}\n類型: {4}\n```", 73 | "playlistViewPermsValue": "📖 讀取: ✓ ✍🏽 編輯: {0} 🗑️ 刪除: {1}", 74 | "playlistViewPermsValue2": "📖 讀取: {0}", 75 | "playlistViewTrack": "音軌", 76 | "playlistViewPage": "頁面: {0}/{1} | 總長度: {2}", 77 | "inboxFull": "抱歉!{0} 的收件匣已滿。", 78 | "inboxNoMsg": "您的收件匣中沒有任何訊息。", 79 | "invitationSent": "已發送邀請給 {0}。", 80 | 81 | "notInChannel": "{0},您必須在 {1} 中使用語音指令。如果您已在語音頻道中,請重新加入!", 82 | "noTrackPlaying": "現在沒有歌曲正在播放", 83 | "noTrackFound": "找不到符合該查詢的歌曲!請提供有效的網址。", 84 | "noLinkSupport": "搜索命令不支援網址!", 85 | "voted": "您已投票!", 86 | "missingPerms_pos": "只有 DJ 或管理員才能更改位置。", 87 | "missingPerms_mode": "只有 DJ 或管理員才能切換循環模式。", 88 | "missingPerms_queue": "只有 DJ 或管理員才能從隊列中移除音軌。", 89 | "missingPerms_autoplay": "只有 DJ 或管理員才能啟用或停用自動播放模式!", 90 | "missingPerms_function": "只有 DJ 或管理員才能使用此功能。", 91 | "timeFormatError": "時間格式不正確。例如:2:42 或 12:39:31", 92 | "lyricsNotFound": "找不到歌詞。輸入 /lyrics <歌曲名稱> <作者> 查找歌詞。", 93 | "missingTrackInfo": "有些音軌資訊缺失。", 94 | "noVoiceChannel": "找不到語音頻道!", 95 | 96 | "playlistAddError": "您無權將串流視訊添加到播放清單中!", 97 | "playlistAddError2": "添加音軌到播放清單時發生問題!", 98 | "playlistlimited": "您已達到上限!您只能將 {0} 首歌曲添加到播放清單中。", 99 | "playlistrepeated": "您的播放清單中已經存在相同的音軌!", 100 | "playlistAdded": "❤️ 已將 **{0}** 添加到 {1} 的播放清單中 [`{2}`]!", 101 | 102 | "playerDropdown": "選擇要跳轉到的音軌...", 103 | "playerFilter": "選擇要套用的篩選器...", 104 | 105 | "buttonBack": "返回", 106 | "buttonPause": "暫停", 107 | "buttonResume": "繼續", 108 | "buttonSkip": "跳過", 109 | "buttonLeave": "離開", 110 | "buttonLoop": "循環", 111 | "buttonVolumeUp": "增加音量", 112 | "buttonVolumeDown": "降低音量", 113 | "buttonVolumeMute": "靜音", 114 | "buttonVolumeUnmute": "取消靜音", 115 | "buttonAutoPlay": "自動播放", 116 | "buttonShuffle": "隨機播放", 117 | "buttonForward": "前進", 118 | "buttonRewind": "後退", 119 | "buttonLyrics": "歌詞", 120 | 121 | "nowplayingDesc": "**現在播放:**\n```{0}```", 122 | "nowplayingField": "接下來播放:", 123 | "nowplayingLink": "在 {0} 上收聽", 124 | 125 | "connect": "已連接至 {0}", 126 | 127 | "live": "直播", 128 | "playlistLoad": " 🎶 已添加播放清單 **{0}**,共 `{1}` 首歌曲至隊列。", 129 | "trackLoad": "已添加 **[{0}](<{1}>)**,由 **{2}** (`{3}`) 開始播放。\n", 130 | "trackLoad_pos": "已將 **[{0}](<{1}>)**,由 **{2}** (`{3}`) 添加到隊列中位置 **{4}**\n", 131 | 132 | "searchTitle": "搜索查詢: {0}", 133 | "searchDesc": "➥ 平台: {0} **{1}**\n➥ 結果: **{2}**\n\n{3}", 134 | "searchWait": "選擇您想要添加到隊列中的歌曲。", 135 | "searchTimeout": "搜索超時。請稍後再試。", 136 | "searchSuccess": "已將歌曲添加到隊列中。", 137 | 138 | "queueTitle": "即將播放的隊列:", 139 | "historyTitle": "歷史隊列:", 140 | "viewTitle": "音樂隊列", 141 | "viewDesc": "**現正播放:[點擊我]({0}) ⮯**\n{1}", 142 | "viewFooter": "頁數:{0}/{1} | 總長度:{2}", 143 | 144 | "pauseError": "播放器已經暫停。", 145 | "pauseVote": "{0} 已投票暫停歌曲。[{1}/{2}]", 146 | "paused": "播放器已被 `{0}` 暫停。", 147 | "resumeError": "播放器未暫停。", 148 | "resumeVote": "{0} 已投票恢復歌曲。[{1}/{2}]", 149 | "resumed": "播放器已被 `{0}` 恢復。", 150 | "shuffleError": "在洗牌之前必須添加更多歌曲到隊列中。", 151 | "shuffleVote": "{0} 已投票洗牌隊列。[{1}/{2}]", 152 | "shuffled": "隊列已被洗牌。", 153 | "skipError": "沒有歌曲可以跳過。", 154 | "skipVote": "{0} 已投票跳過歌曲。[{1}/{2}]", 155 | "skipped": "播放器已被 `{0}` 跳過歌曲。", 156 | 157 | "backVote": "{0} 已投票跳到上一首歌曲。[{1}/{2}]", 158 | "backed": "播放器已被 `{0}` 跳到上一首歌曲。", 159 | 160 | "leaveVote": "{0} 已投票停止播放器。[{1}/{2}]", 161 | "left": "播放器已被 `{0}` 停止。", 162 | 163 | "seek": "將播放器設置到 **{0}**。", 164 | "repeat": "重複模式已設置為 `{0}`。", 165 | "cleared": "清除了 `{0}` 中的所有歌曲。", 166 | "removed": "已從隊列中刪除 `{0}` 首歌曲。", 167 | "forward": "將播放器快進到 **{0}**。", 168 | "rewind": "將播放器倒回到 **{0}**。", 169 | "replay": "重新播放當前歌曲。", 170 | "swapped": "已交換 `{0}` 和 `{1}`。", 171 | "moved": "已將 `{0}` 移動到 `{1}`。", 172 | "autoplay": "自動播放模式現在為 **{0}**。", 173 | 174 | "notdj": "您不是DJ,當前DJ為 {0}。", 175 | "djToMe": "您無法將DJ權限轉移給自己或機器人。", 176 | "djnotinchannel": "`{0}` 不在語音頻道中。", 177 | "djswap": "您已將DJ權限轉移給 `{0}`。", 178 | 179 | "chaptersDropdown": "選擇要跳轉到的章節...", 180 | "noChaptersFound": "找不到任何章節!", 181 | "chatpersNotSupport": "此命令僅支持 YouTube 影片!", 182 | 183 | "voicelinkQueueFull": "抱歉,您已達到隊列中 `{0}` 首歌曲的最大數量!", 184 | "voicelinkOutofList": "請提供有效的歌曲索引!", 185 | "voicelinkDuplicateTrack": "抱歉,此歌曲已在隊列中。", 186 | 187 | "deocdeError": "解碼文件時出現問題!", 188 | "invalidStartTime": "無效的開始時間! 時間必須在 `00:00` 和 `{0}` 之間。", 189 | "invalidEndTime": "無效的結束時間! 時間必須在 `00:00` 和 `{0}` 之間。", 190 | "invalidTimeOrder": "結束時間不能小於或等於開始時間。", 191 | 192 | "setStageAnnounceTemplate": "完成!從現在開始,像您現在的語音狀態將根據您的模板命名。您應該在幾秒鐘內看到它更新。", 193 | "createSongRequestChannel": "一個歌曲請求頻道 ({0}) 已建立!您可以在該頻道中透過歌曲名稱或 URL 開始要求任何歌曲,而無需使用機器人前綴。" 194 | } -------------------------------------------------------------------------------- /langs/JA.json: -------------------------------------------------------------------------------- 1 | { 2 | "unknownException": "⚠️ コマンドの実行中に何かが間違っています!後でもう一度試してください。または、より詳しいサポートを求めるために当社のDiscordサーバーに参加してください。", 3 | "enabled": "有効化", 4 | "disabled": "無効化", 5 | 6 | "nodeReconnect": "再試行してください!ノードが再接続した後。", 7 | "noChannel": "接続する音声チャンネルがありません。提供するか、参加してください。", 8 | "alreadyConnected": "すでに音声チャンネルに接続しています。", 9 | "noPermission": "申し訳ありません!私はあなたの音声チャンネルに参加または話すための許可がありません。", 10 | "noCreatePermission": "ごめんなさい!曲リクエストチャンネルを作成する権限がありません。", 11 | "noPlaySource": "再生可能なソースが見つかりません!", 12 | "noPlayer": "このサーバーにプレイヤーが見つかりません。", 13 | "notVote": "このコマンドにはあなたの投票が必要です!詳細については、/voteを入力してください。", 14 | "missingIntents": "申し訳ありませんが、このコマンドは実行できません。ボットに必要なリクエストインテントが不足しています:`({0})`.", 15 | "languageNotFound": "言語パックが見つかりません。既存の言語パックを選択してください。", 16 | "changedLanguage": "「{0}」言語パックに正常に変更しました。", 17 | "setPrefix": "完了!あなたのサーバーのプレフィックスは今や「{0}」です。 `{1}ping`を実行してテストしてみてください。", 18 | "setDJ": "{0}にDJを設定しました。", 19 | "setqueue": "キューモードを「{0}」に設定しました。", 20 | "247": "今、24/7モードは「{0}」です。", 21 | "bypassVote": "今、投票システムは「{0}」になりました。", 22 | "setVolume": "音量を「{0}%」に設定しました。", 23 | "togglecontroller": "今、音楽コントローラーは「{0}」です。", 24 | "toggleDuplicateTrack": "今、キュー内の重複トラックを防止するための設定は「{0}」です。", 25 | "toggleControllerMsg": "現在、音楽コントローラーから `{0}` 件のメッセージがあります。", 26 | "settingsMenu": "サーバー設定| {0}", 27 | "settingsTitle": "❤️ 基本情報:", 28 | "settingsValue": "```プレフィックス:{0}\n言語:{1}\n音楽コントローラーを有効にする:{2}\nDJロール:@{3}\n投票バイパス:{4}\n24/7:{5}\nデフォルトボリューム:{6}%\n再生時間:{7}```", 29 | "settingsTitle2": "🔗 キュー情報:", 30 | "settingsValue2": "```キューモード:{0}\n最大曲数:{1}\n重複トラックを許可する:{2}```", 31 | "settingsTitle3": "🎤 音声ステータス情報:", 32 | "settingsPermTitle": "✨ 権限:", 33 | "settingsPermValue": "```{0} 管理者\n{1} ギルド管理\n{2} チャンネル管理\n{3} メッセージ管理```", 34 | "pingTitle1": "ボット情報:", 35 | "pingTitle2": "プレーヤー情報:", 36 | "pingfield1": "```シャードID:{0}/{1}\nシャードレイテンシ:{2:.3f}s {3}\nリージョン:{4}```", 37 | "pingfield2": "```ノード:{0} - {1:.3f}s\nプレイヤー数:{2}\n音声リージョン:{3}```", 38 | "addEffect": "`{0}` フィルターを適用します。", 39 | "clearEffect": "効果音がクリアされました!", 40 | "FilterTagAlreadyInUse": "このサウンドエフェクトはすでに使用されています!削除するには/cleareffect を使用してください。", 41 | 42 | "playlistViewTitle": "📜 {0}のすべてのプレイリスト", 43 | "playlistViewHeaders": "ID:,時間:,名前:,トラック:", 44 | "playlistFooter": "プレイリストをキューに追加するには、/playlist play [playlist]を入力してください。", 45 | "playlistNotFound": "プレイリスト[{0}]が見つかりません。すべてのプレイリストを表示するには、/playlist viewを入力してください。", 46 | "playlistNotAccess": "申し訳ありません!このプレイリストにアクセスすることはできません!", 47 | "playlistNoTrack": "申し訳ありません!プレイリスト[{0}]にはトラックがありません。", 48 | "playlistNotAllow": "リンクまたは共有プレイリストでは、このコマンドは許可されていません。", 49 | "playlistPlay": "プレイリスト[{0}]をキューに{1}曲追加しました。", 50 | "playlistOverText": "申し訳ありません!プレイリストの名前は10文字を超えることはできません。", 51 | "playlistSameName": "申し訳ありません!この名前は新しい名前と同じであってはなりません。", 52 | "playlistDeleteError": "デフォルトのプレイリストを削除することはできません。", 53 | "playlistRemove": "プレイリスト[{0}]を削除しました。", 54 | "playlistSendErrorPlayer": "申し訳ありません!自分に招待状を送信することはできません。", 55 | "playlistSendErrorBot": "申し訳ありません!ボットに招待状を送信することはできません。", 56 | "playlistBelongs": "申し訳ありません!このプレイリストは<@{0}>さんのものです。", 57 | "playlistShare": "申し訳ありません!このプレイリストは{0}さんと共有されています。", 58 | "playlistSent": "申し訳ありません!以前に招待状を送信しました。", 59 | "noPlaylistAcc": "{0}さんはプレイリストアカウントを作成していません。", 60 | "overPlaylistCreation": " {0}個以上のプレイリストを作成することはできません!", 61 | "playlistExists": "プレイリスト[{0}]はすでに存在します。", 62 | "playlistNotInvalidUrl": "有効なリンクまたは公開SpotifyまたはYouTubeプレイリストリンクを入力してください。", 63 | "playlistCreated": "{0}プレイリストを作成しました。詳細については、/playlist viewを入力してください。", 64 | "playlistRenamed": "{0}を{1}に名前を変更しました。", 65 | "playlistLimitTrack": "この制限に達しました!プレイリストには{0}曲しか追加できません。", 66 | "playlistPlaylistLink": "プレイリストリンクを使用することはできません。", 67 | "playlistStream": "ストリーミングビデオをプレイリストに追加することはできません。", 68 | "playlistPositionNotFound": "プレイリスト[{1}]から位置{0}を見つけることができませんでした!", 69 | "playlistRemoved": "👋 {1}さんのプレイリスト[{2}]から**{0}**を削除しました。", 70 | "playlistClear": "プレイリスト[{0}]を正常にクリアしました。", 71 | "playlistView": "プレイリストビューアー", 72 | "playlistViewDesc": "```名前 | ID: {0} | {1}\nトータルトラック: {2}\nオーナー: {3}\nタイプ: {4}\n```", 73 | "playlistViewPermsValue": "📖 読み込み: ✓ ✍🏽 書き込み: {0} 🗑️ 削除: {1}", 74 | "playlistViewPermsValue2": "📖 読み込み: {0}", 75 | "playlistViewTrack": "トラック", 76 | "playlistViewPage": "ページ: {0}/{1} | トータルダレーション: {2}", 77 | "inboxFull": "申し訳ありません!{0}さんの受信トレイはいっぱいです。", 78 | "inboxNoMsg": "受信トレイにメッセージはありません。", 79 | "invitationSent": "{0}さんに招待状を送信しました。", 80 | 81 | "notInChannel": "{0}さん、音声コマンドを使用するには{1}にいる必要があります。音声に接続している場合は再接続してください!", 82 | "noTrackPlaying": "現在再生中の曲はありません", 83 | "noTrackFound": "そのクエリで曲が見つかりませんでした!有効なURLを入力してください。", 84 | "noLinkSupport": "検索コマンドはリンクをサポートしていません!", 85 | "voted": "投票しました!", 86 | "missingPerms_pos": "DJまたは管理者のみが位置を変更できます。", 87 | "missingPerms_mode": "DJまたは管理者のみがループモードを切り替えることができます。", 88 | "missingPerms_queue": "DJまたは管理者のみがキューからトラックを削除できます。", 89 | "missingPerms_autoplay": "DJまたは管理者のみがオートプレイモードを有効化または無効化できます!", 90 | "missingPerms_function": "DJまたは管理者のみがこの機能を使用できます。", 91 | "timeFormatError": "時間の形式が間違っています。例:2:42または12:39:31", 92 | "lyricsNotFound": "歌詞が見つかりませんでした。/lyrics <曲名> <アーティスト>と入力して歌詞を検索してください。", 93 | "missingTrackInfo": "一部のトラック情報が欠落しています。", 94 | "noVoiceChannel": "音声チャンネルが見つかりません!", 95 | 96 | "playlistAddError": "ストリーミングビデオをプレイリストに追加することはできません!", 97 | "playlistAddError2": "トラックをプレイリストに追加する際に問題が発生しました!", 98 | "playlistlimited": "制限に達しました!プレイリストには{0}曲しか追加できません。", 99 | "playlistrepeated": "すでにプレイリストに同じトラックがあります!", 100 | "playlistAdded": "❤️ **{0}**を{1}のプレイリスト[`{2}`]に追加しました!", 101 | 102 | "playerDropdown": "スキップする曲を選択してください...", 103 | "playerFilter": "適用するフィルターを選択してください...", 104 | 105 | "buttonBack": "戻る", 106 | "buttonPause": "一時停止", 107 | "buttonResume": "再開", 108 | "buttonSkip": "スキップ", 109 | "buttonLeave": "退出", 110 | "buttonLoop": "ループ", 111 | "buttonVolumeUp": "音量を上げる", 112 | "buttonVolumeDown": "音量を下げる", 113 | "buttonVolumeMute": "消音", 114 | "buttonVolumeUnmute": "消音を解除", 115 | "buttonAutoPlay": "自動再生", 116 | "buttonShuffle": "シャッフル", 117 | "buttonForward": "進む", 118 | "buttonRewind": "戻る", 119 | "buttonLyrics": "歌詞", 120 | 121 | "nowplayingDesc": "**現在再生中:**\n```{0}```", 122 | "nowplayingField": "次に再生する曲:", 123 | "nowplayingLink": "{0}で聴く", 124 | 125 | "connect": "{0}に接続しました", 126 | 127 | "live": "ライブ", 128 | "playlistLoad": " 🎶 プレイリスト**{0}**をキューに`{1}`曲追加しました。", 129 | "trackLoad": "**{2}**の**[{0}](<{1}>)** (`{3}`)を再生を開始するために追加しました。\n", 130 | "trackLoad_pos": "**{2}**の**[{0}](<{1}>)** (`{3}`)をキューの位置**{4}**に追加しました。\n", 131 | 132 | "searchTitle": "検索クエリ:{0}", 133 | "searchDesc": "➥ プラットフォーム:{0} **{1}**\n➥ 結果:**{2}**\n\n{3}", 134 | "searchWait": "キューに追加したい曲を選択してください。", 135 | "searchTimeout": "検索がタイムアウトしました。後でもう一度お試しください。", 136 | "searchSuccess": "曲がキューに追加されました。", 137 | 138 | "queueTitle": "キューに追加された曲:", 139 | "historyTitle": "再生履歴:", 140 | "viewTitle": "音楽キュー", 141 | "viewDesc": "**現在再生中:[ここをクリックして聴く]({0}) ⮯**\n{1}", 142 | "viewFooter": "ページ:{0}/{1} | 合計再生時間:{2}", 143 | 144 | "pauseError": "プレイヤーはすでに一時停止しています。", 145 | "pauseVote": "{0}が曲を一時停止することに賛成しました。[{1}/{2}]", 146 | "paused": "{0}がプレイヤーを一時停止しました。", 147 | "resumeError": "プレイヤーは一時停止していません。", 148 | "resumeVote": "{0}が曲を再開することに賛成しました。[{1}/{2}]", 149 | "resumed": "{0}がプレイヤーを再開しました。", 150 | "shuffleError": "シャッフルする前にキューに曲を追加してください。", 151 | "shuffleVote": "{0}がキューをシャッフルすることに賛成しました。[{1}/{2}]", 152 | "shuffled": "キューがシャッフルされました。", 153 | "skipError": "スキップする曲はありません。", 154 | "skipVote": "{0}が曲をスキップすることに賛成しました。[{1}/{2}]", 155 | "skipped": "{0}が曲をスキップしました。", 156 | 157 | "backVote": "{0}が前の曲にスキップすることに賛成しました。[{1}/{2}]", 158 | "backed": "`{0}`が前の曲にスキップしました。", 159 | 160 | "leaveVote": "{0}がプレイヤーを停止することに賛成しました。[{1}/{2}]", 161 | "left": "`{0}`がプレイヤーを停止しました。", 162 | 163 | "seek": "プレイヤーを **{0}** に設定しました。", 164 | "repeat": "リピートモードが `{0}` に設定されました。", 165 | "cleared": "`{0}`のすべてのトラックをクリアしました。", 166 | "removed": "`{0}`のトラックがキューから削除されました。", 167 | "forward": "プレイヤーを **{0}** に進めました。", 168 | "rewind": "プレイヤーを **{0}** に戻しました。", 169 | "replay": "現在の曲をリプレイします。", 170 | "swapped": "`{0}`と`{1}`が交換されました。", 171 | "moved": "`{0}`を`{1}`に移動しました。", 172 | "autoplay": "オートプレイモードが **{0}** に設定されました。", 173 | 174 | "notdj": "DJではありません。現在のDJは{0}です。", 175 | "djToMe": "自分自身またはボットにDJ権限を移行することはできません。", 176 | "djnotinchannel": "`{0}`はボイスチャンネルにいません。", 177 | "djswap": "DJの役割を`{0}`に移行しました。", 178 | 179 | "chaptersDropdown": "スキップする章を選択してください...", 180 | "noChaptersFound": "章が見つかりませんでした!", 181 | "chatpersNotSupport": "このコマンドはYouTubeの動画のみサポートしています!", 182 | 183 | "voicelinkQueueFull": "申し訳ありませんが、キュー内の曲数が最大値の`{0}`に達しました!", 184 | "voicelinkOutofList": "有効なトラックインデックスを指定してください!", 185 | "voicelinkDuplicateTrack": "申し訳ありませんが、このトラックは既にキューに存在します。", 186 | 187 | "deocdeError": "ファイルのデコード中に問題が発生しました!", 188 | "invalidStartTime": "無効な開始時間!時間は `00:00` と `{0}` の間に設定する必要があります。", 189 | "invalidEndTime": "無効な終了時間!時間は `00:00` と `{0}` の間に設定する必要があります。", 190 | "invalidTimeOrder": "終了時間は開始時間より大きくない必要があります。", 191 | 192 | "setStageAnnounceTemplate": "完了!これからは、今いるボイスステータスがあなたのテンプレートに従って名前が付けられます。数秒以内に更新されるのを見ることができるはずです。", 193 | "createSongRequestChannel": "曲のリクエストチャンネル ({0}) が作成されました!そのチャンネルで、曲名または URL を使用して任意の曲をリクエストできます。ボットのプレフィックスは必要ありません。" 194 | } -------------------------------------------------------------------------------- /langs/KO.json: -------------------------------------------------------------------------------- 1 | { 2 | "unknownException": "⚠️ 명령어 실행 중 오류가 발생했습니다! 나중에 다시 시도하거나 추가 지원을 위해 디스코드 서버에 참여하십시오.", 3 | "enabled": "활성화됨", 4 | "disabled": "비활성화됨", 5 | 6 | "nodeReconnect": "다시 시도하세요! 노드가 다시 연결될 때까지 기다려주세요.", 7 | "noChannel": "연결할 음성 채널이 없습니다. 하나를 제공하거나 참여하십시오.", 8 | "alreadyConnected": "이미 음성 채널에 연결되어 있습니다.", 9 | "noPermission": "죄송합니다! 음성 채널에 참여하거나 말할 권한이 없습니다.", 10 | "noCreatePermission": "죄송합니다! 노래 요청 채널을 생성할 권한이 없습니다.", 11 | "noPlaySource": "재생 가능한 소스를 찾을 수 없습니다!", 12 | "noPlayer": "이 서버에서 플레이어를 찾을 수 없습니다.", 13 | "notVote": "이 명령어를 실행하려면 투표해야합니다! 자세한 내용은 `/vote`를 입력하십시오.", 14 | "missingIntents": "죄송하지만 이 명령을 실행할 수 없습니다. 봇에 필요한 요청 의도가 없습니다:`({0})`.", 15 | "languageNotFound": "언어 팩을 찾을 수 없습니다! 기존 언어 팩을 선택하십시오.", 16 | "changedLanguage": "성공적으로 `{0}` 언어 팩으로 변경되었습니다.", 17 | "setPrefix": "완료되었습니다! 이 서버에서 내 접두사는 이제 `{0}`입니다. `{1}ping`을 실행하여 테스트해보세요.", 18 | "setDJ": "DJ를 {0}(으)로 설정했습니다.", 19 | "setqueue": "큐 모드를 `{0}`(으)로 설정했습니다.", 20 | "247": "이제 24/7 모드에서 `{0}`으로 변경되었습니다.", 21 | "bypassVote": "이제 `{0}` 투표 시스템을 사용할 수 있습니다.", 22 | "setVolume": "볼륨을 `{0}`%로 설정했습니다.", 23 | "togglecontroller": "이제 음악 컨트롤러가 `{0}`(으)로 설정되었습니다.", 24 | "toggleDuplicateTrack": "이제 대기열에서 중복된 트랙을 방지하기 위해 `{0}`(으)로 설정되었습니다.", 25 | "toggleControllerMsg": "이제 음악 컨트롤러로부터 `{0}` 메시지가 있습니다.", 26 | "settingsMenu": "서버 설정 | {0}", 27 | "settingsTitle": "❤️ 기본 정보:", 28 | "settingsValue": "```접두사: {0}\n언어: {1}\n음악 컨트롤러 사용: {2}\nDJ 역할: @{3}\n투표 우회: {4}\n24/7 모드: {5}\n기본 볼륨: {6}%\n재생 시간: {7}```", 29 | "settingsTitle2": "🔗 대기열 정보:", 30 | "settingsValue2": "```큐 모드: {0}\n최대 노래: {1}\n중복된 트랙 허용: {2}```", 31 | "settingsTitle3": "🎤 음성 상태 정보:", 32 | "settingsPermTitle": "✨ 권한:", 33 | "settingsPermValue": "```{0} 관리자\n{1} 서버 관리\n{2} 채널 관리\n{3} 메시지 관리```", 34 | "pingTitle1": "봇 정보:", 35 | "pingTitle2": "플레이어 정보:", 36 | "pingfield1": "```쉬드 ID: {0}/{1}\n쉬드 대기 시간: {2:.3f}s {3}\n지역: {4}```", 37 | "pingfield2": "```노드: {0} - {1:.3f}s\n플레이어: {2}\n음성 지역: {3}```", 38 | "addEffect": "`{0}` 필터를 적용하세요.", 39 | "clearEffect": "효과가 삭제되었습니다!", 40 | "FilterTagAlreadyInUse": "이 필터는 이미 사용 중입니다! 삭제하려면 /cleareffect 를 사용하십시오.", 41 | 42 | "playlistViewTitle": "📜 {0}의 모든 재생 목록", 43 | "playlistViewHeaders": "ID:,시간:,이름:,트랙:", 44 | "playlistFooter": "/playlist play [재생 목록]을 입력하여 재생 목록을 대기열에 추가하세요.", 45 | "playlistNotFound": "재생 목록 [{0}]을(를) 찾을 수 없습니다. 모든 재생 목록을 보려면 /playlist view를 입력하세요.", 46 | "playlistNotAccess": "죄송합니다! 이 재생 목록에 액세스할 수 없습니다!", 47 | "playlistNoTrack": "죄송합니다! 재생 목록 [{0}]에 트랙이 없습니다.", 48 | "playlistNotAllow": "이 명령어는 연결된 및 공유된 재생 목록에서 허용되지 않습니다.", 49 | "playlistPlay": "재생 목록 [{0}]을(를) 대기열에 {1}곡 추가했습니다.", 50 | "playlistOverText": "죄송합니다! 재생 목록의 이름은 10자를 초과할 수 없습니다.", 51 | "playlistSameName": "죄송합니다! 이 이름은 새 이름과 동일할 수 없습니다.", 52 | "playlistDeleteError": "기본 재생 목록은 삭제할 수 없습니다.", 53 | "playlistRemove": "재생 목록 [{0}]을(를) 삭제했습니다.", 54 | "playlistSendErrorPlayer": "죄송합니다! 자신에게 초대장을 보낼 수 없습니다.", 55 | "playlistSendErrorBot": "죄송합니다! 봇에게 초대장을 보낼 수 없습니다.", 56 | "playlistBelongs": "죄송합니다! 이 재생 목록은 <@{0}>님의 것입니다.", 57 | "playlistShare": "죄송합니다! 이 재생 목록은 {0}과(와) 공유되었습니다.", 58 | "playlistSent": "죄송합니다! 이미 초대장을 보냈습니다.", 59 | "noPlaylistAcc": "{0}님은 재생 목록 계정을 만들지 않았습니다.", 60 | "overPlaylistCreation": "더 이상 {0}개 이상의 재생 목록을 만들 수 없습니다!", 61 | "playlistExists": "재생 목록 [{0}]이(가) 이미 있습니다.", 62 | "playlistNotInvalidUrl": "유효한 링크 또는 공개 Spotify 또는 YouTube 재생 목록 링크를 입력하세요.", 63 | "playlistCreated": "{0} 재생 목록을 만들었습니다. 자세한 내용은 /playlist view를 입력하세요.", 64 | "playlistRenamed": "{0}을(를) {1}(으)로 이름을 바꿨습니다.", 65 | "playlistLimitTrack": "죄송합니다! 재생 목록에 추가할 수 있는 노래는 {0}곡까지입니다.", 66 | "playlistPlaylistLink": "재생 목록 링크를 사용할 수 없습니다.", 67 | "playlistStream": "스트리밍 비디오를 재생 목록에 추가할 수 없습니다.", 68 | "playlistPositionNotFound": "재생 목록 [{1}]에서 위치 {0}을(를) 찾을 수 없습니다!", 69 | "playlistRemoved": "👋 {1}님의 재생 목록 [{2}]에서 **{0}**을(를) 제거했습니다.", 70 | "playlistClear": "재생 목록 [{0}]을(를) 성공적으로 지웠습니다.", 71 | "playlistView": "재생 목록 뷰어", 72 | "playlistViewDesc": "```이름 | ID: {0} | {1}\n총 트랙: {2}\n소유자: {3}\n유형: {4}\n```", 73 | "playlistViewPermsValue": "📖 읽기: ✓ ✍🏽 쓰기: {0} 🗑️ 삭제: {1}", 74 | "playlistViewPermsValue2": "📖 읽기: {0}", 75 | "playlistViewTrack": "트랙", 76 | "playlistViewPage": "페이지: {0}/{1} | 총 길이: {2}", 77 | "inboxFull": "죄송합니다! {0}님의 받은 편지함이 가득 찼습니다.", 78 | "inboxNoMsg": "받은 편지함에 메시지가 없습니다.", 79 | "invitationSent": "{0}님에게 초대장을 보냈습니다.", 80 | 81 | "notInChannel": "{0}, 음성 명령을 사용하려면 {1}에 있어야합니다. 음성 채널에 있지 않으면 다시 참여하세요!", 82 | "noTrackPlaying": "현재 재생중인 곡이 없습니다.", 83 | "noTrackFound": "해당 쿼리로 곡을 찾을 수 없습니다. 유효한 URL을 제공해주세요.", 84 | "noLinkSupport": "검색 명령은 링크를 지원하지 않습니다!", 85 | "voted": "투표하셨습니다!", 86 | "missingPerms_pos": "DJ 또는 관리자만 위치를 변경할 수 있습니다.", 87 | "missingPerms_mode": "DJ 또는 관리자만 루프 모드를 전환할 수 있습니다.", 88 | "missingPerms_queue": "DJ 또는 관리자만 대기열에서 곡을 제거할 수 있습니다.", 89 | "missingPerms_autoplay": "DJ 또는 관리자만 자동재생 모드를 활성화하거나 비활성화할 수 있습니다!", 90 | "missingPerms_function": "DJ 또는 관리자만이 이 기능을 사용할 수 있습니다.", 91 | "timeFormatError": "잘못된 시간 형식입니다. 예: 2:42 또는 12:39:31", 92 | "lyricsNotFound": "가사를 찾을 수 없습니다. 가사를 찾으려면 /lyrics <노래 제목> <작곡가>를 입력하세요.", 93 | "missingTrackInfo": "일부 트랙 정보가 누락되었습니다.", 94 | "noVoiceChannel": "음성 채널을 찾을 수 없습니다!", 95 | 96 | "playlistAddError": "스트리밍 비디오를 재생목록에 추가할 수 없습니다!", 97 | "playlistAddError2": "재생목록에 곡을 추가하는 중 문제가 발생했습니다!", 98 | "playlistlimited": "한 재생목록에 최대 {0}곡까지 추가 가능합니다. 이제 한도에 도달했습니다!", 99 | "playlistrepeated": "재생목록에 이미 같은 곡이 있습니다!", 100 | "playlistAdded": "❤️ **{0}**을(를) {1}의 재생목록 [`{2}`] 에 추가했습니다!", 101 | 102 | "playerDropdown": "건너뛰기 할 노래를 선택하세요...", 103 | "playerFilter": "적용할 필터를 선택하세요...", 104 | 105 | "buttonBack": "이전", 106 | "buttonPause": "일시정지", 107 | "buttonResume": "재개", 108 | "buttonSkip": "스킵", 109 | "buttonLeave": "나가기", 110 | "buttonLoop": "반복", 111 | "buttonVolumeUp": "볼륨 높이기", 112 | "buttonVolumeDown": "볼륨 낮추기", 113 | "buttonVolumeMute": "음소거", 114 | "buttonVolumeUnmute": "음소거 해제", 115 | "buttonAutoPlay": "자동재생", 116 | "buttonShuffle": "셔플", 117 | "buttonForward": "앞으로", 118 | "buttonRewind": "뒤로", 119 | "buttonLyrics": "가사", 120 | 121 | "nowplayingDesc": "**현재 재생중인 곡:**\n```{0}```", 122 | "nowplayingField": "다음 곡:", 123 | "nowplayingLink": "{0}에서 듣기", 124 | 125 | "connect": "{0}에 연결되었습니다.", 126 | 127 | "live": "라이브", 128 | "playlistLoad": "재생목록 **{0}**을(를) 대기열에 `{1}`개의 곡과 함께 추가했습니다.", 129 | "trackLoad": "**{2}**의 **[{0}](<{1}>)** (`{3}`)를 재생목록에 추가하고 재생을 시작합니다.\n", 130 | "trackLoad_pos": "**{2}**의 **[{0}](<{1}>)** (`{3}`)를 대기열의 **{4}**번째로 추가합니다.\n", 131 | 132 | "searchTitle": "검색 쿼리: {0}", 133 | "searchDesc": "➥ 플랫폼: {0} **{1}**\n➥ 결과: **{2}**\n\n{3}", 134 | "searchWait": "대기열에 추가할 곡을 선택하세요.", 135 | "searchTimeout": "검색이 시간 초과되었습니다. 나중에 다시 시도해주세요.", 136 | "searchSuccess": "곡이 대기열에 추가되었습니다.", 137 | 138 | "queueTitle": "다음 대기열:", 139 | "historyTitle": "이전 곡 대기열:", 140 | "viewTitle": "음악 대기열", 141 | "viewDesc": "**현재 재생중: [여기를 클릭하여 듣기]({0}) ⮯**\n{1}", 142 | "viewFooter": "페이지: {0}/{1} | 총 재생 시간: {2}", 143 | 144 | "pauseError": "플레이어가 이미 일시정지되었습니다.", 145 | "pauseVote": "{0}님이 노래 일시정지 투표를 했습니다. [{1}/{2}]", 146 | "paused": "{0}님이 플레이어를 일시정지했습니다.", 147 | "resumeError": "플레이어가 일시정지되지 않았습니다.", 148 | "resumeVote": "{0}님이 노래 재생 투표를 했습니다. [{1}/{2}]", 149 | "resumed": "{0}님이 플레이어를 재생했습니다.", 150 | "shuffleError": "셔플하기 전에 더 많은 노래를 추가해주세요.", 151 | "shuffleVote": "{0}님이 플레이어 셔플 투표를 했습니다. [{1}/{2}]", 152 | "shuffled": "플레이어 큐가 셔플되었습니다.", 153 | "skipError": "건너뛸 노래가 없습니다.", 154 | "skipVote": "{0}님이 노래 건너뛰기 투표를 했습니다. [{1}/{2}]", 155 | "skipped": "{0}님이 노래를 건너뛰었습니다.", 156 | 157 | "backVote": "{0}님이 이전 노래로 건너뛰기 투표를 했습니다. [{1}/{2}]", 158 | "backed": "`{0}`님이 이전 노래로 건너뛰었습니다.", 159 | 160 | "leaveVote": "{0}님이 플레이어를 중지하기 투표를 했습니다. [{1}/{2}]", 161 | "left": "`{0}`님이 플레이어를 중지했습니다.", 162 | 163 | "seek": "플레이어가 **{0}**로 설정되었습니다.", 164 | "repeat": "반복 모드가 `{0}`(으)로 설정되었습니다.", 165 | "cleared": " `{0}`의 모든 트랙이 삭제되었습니다.", 166 | "removed": " `{0}`개의 트랙이 큐에서 제거되었습니다.", 167 | "forward": "플레이어가 **{0}**로 전진되었습니다.", 168 | "rewind": "플레이어가 **{0}**로 되감기되었습니다.", 169 | "replay": "현재 노래를 다시 재생합니다.", 170 | "swapped": "`{0}`와 `{1}`이(가) 스왑되었습니다.", 171 | "moved": "`{0}`을(를) `{1}`로 이동했습니다.", 172 | "autoplay": "자동 재생 모드가 **{0}**(으)로 변경되었습니다.", 173 | 174 | "notdj": "당신은 DJ가 아닙니다. 현재 DJ는 {0}입니다.", 175 | "djToMe": "자신이나 봇에게 DJ를 이전할 수 없습니다.", 176 | "djnotinchannel": "`{0}`님이 음성 채널에 없습니다.", 177 | "djswap": "당신은 DJ 권한을 `{0}`님에게 이전했습니다.", 178 | 179 | "chaptersDropdown": "건너뛸 챕터를 선택해주세요.", 180 | "noChaptersFound": "챕터를 찾을 수 없습니다!", 181 | "chatpersNotSupport": "이 명령어는 유튜브 비디오만 지원합니다!", 182 | 183 | "voicelinkQueueFull": "죄송합니다. 큐에 `{0}`개의 트랙을 모두 추가하셨습니다!", 184 | "voicelinkOutofList": "유효한 트랙 인덱스를 제공해주세요!", 185 | "voicelinkDuplicateTrack": "죄송합니다. 이 트랙은 이미 큐에 있습니다.", 186 | 187 | "deocdeError": "파일 디코딩 중 문제가 발생했습니다!", 188 | "invalidStartTime": "효력 없는 시작 시간! 시간은 `00:00` 과 `{0}` 사이에 설정해야 합니다.", 189 | "invalidEndTime": "효력 없는 종료 시간! 시간은 `00:00` 과 `{0}` 사이에 설정해야 합니다.", 190 | "invalidTimeOrder": "종료 시간은 시작 시간보다 클수 있어야 합니다.", 191 | 192 | "setStageAnnounceTemplate": "완료! 이제부터 지금 있는 음성 상태는 귀하의 템플릿에 따라 이름이 지정됩니다. 몇 초 후에 업데이트되는 것을 볼 수 있을 것입니다.", 193 | "createSongRequestChannel": "노래 요청 채널 ({0})이 생성되었습니다! 해당 채널에서 노래 제목이나 URL로 원하는 노래를 요청할 수 있으며, 봇 접두사를 사용할 필요가 없습니다." 194 | } -------------------------------------------------------------------------------- /lavalink/Dockerfile-lavalink: -------------------------------------------------------------------------------- 1 | FROM eclipse-temurin:24-jre-noble 2 | 3 | RUN groupadd -g 322 lavalink && \ 4 | useradd -u 322 -g lavalink -m -d /opt/Lavalink lavalink 5 | 6 | WORKDIR /opt/Lavalink 7 | 8 | ADD --chown=lavalink:lavalink https://github.com/lavalink-devs/Lavalink/releases/download/4.0.8/Lavalink.jar . 9 | 10 | RUN apt-get update && \ 11 | apt-get install -y --no-install-recommends netcat-openbsd && \ 12 | rm -rf /var/lib/apt/lists/* 13 | 14 | USER lavalink 15 | 16 | ENTRYPOINT ["java", "-Djdk.tls.client.protocols=TLSv1.1,TLSv1.2", "-jar", "Lavalink.jar"] 17 | -------------------------------------------------------------------------------- /lavalink/application.yml: -------------------------------------------------------------------------------- 1 | server: # REST and WS server 2 | port: 2333 3 | address: 0.0.0.0 4 | http2: 5 | enabled: true # Whether to enable HTTP/2 support 6 | plugins: 7 | youtube: 8 | enabled: true # Whether this source can be used. 9 | allowSearch: true # Whether "ytsearch:" and "ytmsearch:" can be used. 10 | allowDirectVideoIds: true # Whether just video IDs can match. If false, only complete URLs will be loaded. 11 | allowDirectPlaylistIds: true # Whether just playlist IDs can match. If false, only complete URLs will be loaded. 12 | # The clients to use for track loading. See below for a list of valid clients. 13 | # Clients are queried in the order they are given (so the first client is queried first and so on...) 14 | clients: 15 | - MUSIC 16 | - ANDROID_VR 17 | - ANDROID_MUSIC 18 | - WEB 19 | - WEBEMBEDDED 20 | - TVHTML5EMBEDDED 21 | # The below section of the config allows setting specific options for each client, such as the requests they will handle. 22 | # If an option, or client, is unspecified, then the default option value/client values will be used instead. 23 | # If a client is configured, but is not registered above, the options for that client will be ignored. 24 | # WARNING!: THE BELOW CONFIG IS FOR ILLUSTRATION PURPOSES. DO NOT COPY OR USE THIS WITHOUT 25 | # WARNING!: UNDERSTANDING WHAT IT DOES. MISCONFIGURATION WILL HINDER YOUTUBE-SOURCE'S ABILITY TO WORK PROPERLY. 26 | 27 | # Write the names of clients as they are specified under the heading "Available Clients". 28 | clientOptions: 29 | WEB: 30 | # Example: Disabling a client's playback capabilities. 31 | playback: false 32 | videoLoading: false # Disables loading of videos for this client. A client may still be used for playback even if this is set to 'false'. 33 | WEBEMBEDDED: 34 | # Example: Configuring a client to exclusively be used for video loading and playback. 35 | playlistLoading: false # Disables loading of playlists and mixes. 36 | searching: false # Disables the ability to search for videos. 37 | lavasrc: 38 | providers: # Custom providers for track loading. This is the default 39 | # - "dzisrc:%ISRC%" # Deezer ISRC provider 40 | # - "dzsearch:%QUERY%" # Deezer search provider 41 | - "ytsearch:\"%ISRC%\"" # Will be ignored if track does not have an ISRC. See https://en.wikipedia.org/wiki/International_Standard_Recording_Code 42 | - "ytsearch:%QUERY%" # Will be used if track has no ISRC or no track could be found for the ISRC 43 | # you can add multiple other fallback sources here 44 | sources: 45 | spotify: true # Enable Spotify source 46 | applemusic: false # Enable Apple Music source 47 | deezer: false # Enable Deezer source 48 | yandexmusic: false # Enable Yandex Music source 49 | flowerytts: false # Enable Flowery TTS source 50 | youtube: false # Enable YouTube search source (https://github.com/topi314/LavaSearch) 51 | vkmusic: false # Enable Vk Music source 52 | lyrics-sources: 53 | spotify: false # Enable Spotify lyrics source 54 | deezer: false # Enable Deezer lyrics source 55 | youtube: false # Enable YouTube lyrics source 56 | yandexmusic: false # Enable Yandex Music lyrics source 57 | vkmusic: false # Enable Vk Music lyrics source 58 | spotify: 59 | clientId: "" 60 | clientSecret: "" 61 | # spDc: "your sp dc cookie" # the sp dc cookie used for accessing the spotify lyrics api 62 | countryCode: "US" # the country code you want to use for filtering the artists top tracks. See https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 63 | playlistLoadLimit: 6 # The number of pages at 100 tracks each 64 | albumLoadLimit: 6 # The number of pages at 50 tracks each 65 | resolveArtistsInSearch: true # Whether to resolve artists in track search results (can be slow) 66 | localFiles: false # Enable local files support with Spotify playlists. Please note `uri` & `isrc` will be `null` & `identifier` will be `"local"` 67 | applemusic: 68 | countryCode: "US" # the country code you want to use for filtering the artists top tracks and language. See https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 69 | mediaAPIToken: "your apple music api token" # apple music api token 70 | # or specify an apple music key 71 | keyID: "your key id" 72 | teamID: "your team id" 73 | musicKitKey: | 74 | -----BEGIN PRIVATE KEY----- 75 | your key 76 | -----END PRIVATE KEY----- 77 | playlistLoadLimit: 6 # The number of pages at 300 tracks each 78 | albumLoadLimit: 6 # The number of pages at 300 tracks each 79 | deezer: 80 | masterDecryptionKey: "your master decryption key" # the master key used for decrypting the deezer tracks. (yes this is not here you need to get it from somewhere else) 81 | # arl: "your deezer arl" # the arl cookie used for accessing the deezer api this is optional but required for formats above MP3_128 82 | formats: [ "FLAC", "MP3_320", "MP3_256", "MP3_128", "MP3_64", "AAC_64" ] # the formats you want to use for the deezer tracks. "FLAC", "MP3_320", "MP3_256" & "AAC_64" are only available for premium users and require a valid arl 83 | yandexmusic: 84 | accessToken: "your access token" # the token used for accessing the yandex music api. See https://github.com/TopiSenpai/LavaSrc#yandex-music 85 | playlistLoadLimit: 1 # The number of pages at 100 tracks each 86 | albumLoadLimit: 1 # The number of pages at 50 tracks each 87 | artistLoadLimit: 1 # The number of pages at 10 tracks each 88 | flowerytts: 89 | voice: "default voice" # (case-sensitive) get default voice from here https://api.flowery.pw/v1/tts/voices 90 | translate: false # whether to translate the text to the native language of voice 91 | silence: 0 # the silence parameter is in milliseconds. Range is 0 to 10000. The default is 0. 92 | speed: 1.0 # the speed parameter is a float between 0.5 and 10. The default is 1.0. (0.5 is half speed, 2.0 is double speed, etc.) 93 | audioFormat: "mp3" # supported formats are: mp3, ogg_opus, ogg_vorbis, aac, wav, and flac. Default format is mp3 94 | youtube: 95 | countryCode: "US" # the country code you want to use for searching lyrics via ISRC. See https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 96 | vkmusic: 97 | userToken: "your user token" # This token is needed for authorization in the api. Guide: https://github.com/topi314/LavaSrc#vk-music 98 | playlistLoadLimit: 1 # The number of pages at 50 tracks each 99 | artistLoadLimit: 1 # The number of pages at 10 tracks each 100 | recommendationsLoadLimit: 10 # Number of tracks 101 | lavalink: 102 | plugins: 103 | - dependency: "dev.lavalink.youtube:youtube-plugin:1.12.0" 104 | snapshot: false 105 | - dependency: "com.github.topi314.lavasrc:lavasrc-plugin:4.6.0" 106 | snapshot: false 107 | # - dependency: "com.github.example:example-plugin:1.0.0" # required, the coordinates of your plugin 108 | # repository: "https://maven.example.com/releases" # optional, defaults to the Lavalink releases repository by default 109 | # snapshot: false # optional, defaults to false, used to tell Lavalink to use the snapshot repository instead of the release repository 110 | # pluginsDir: "./plugins" # optional, defaults to "./plugins" 111 | # defaultPluginRepository: "https://maven.lavalink.dev/releases" # optional, defaults to the Lavalink release repository 112 | # defaultPluginSnapshotRepository: "https://maven.lavalink.dev/snapshots" # optional, defaults to the Lavalink snapshot repository 113 | server: 114 | password: "youshallnotpass" 115 | sources: 116 | # The default Youtube source is now deprecated and won't receive further updates. Please use https://github.com/lavalink-devs/youtube-source#plugin instead. 117 | youtube: false 118 | bandcamp: true 119 | soundcloud: true 120 | twitch: true 121 | vimeo: true 122 | nico: true 123 | http: true # warning: keeping HTTP enabled without a proxy configured could expose your server's IP address. 124 | local: false 125 | filters: # All filters are enabled by default 126 | volume: true 127 | equalizer: true 128 | karaoke: true 129 | timescale: true 130 | tremolo: true 131 | vibrato: true 132 | distortion: true 133 | rotation: true 134 | channelMix: true 135 | lowPass: true 136 | nonAllocatingFrameBuffer: false # Setting to true reduces the number of allocations made by each player at the expense of frame rebuilding (e.g. non-instantaneous volume changes) 137 | bufferDurationMs: 400 # The duration of the NAS buffer. Higher values fare better against longer GC pauses. Duration <= 0 to disable JDA-NAS. Minimum of 40ms, lower values may introduce pauses. 138 | frameBufferDurationMs: 5000 # How many milliseconds of audio to keep buffered 139 | opusEncodingQuality: 10 # Opus encoder quality. Valid values range from 0 to 10, where 10 is best quality but is the most expensive on the CPU. 140 | resamplingQuality: LOW # Quality of resampling operations. Valid values are LOW, MEDIUM and HIGH, where HIGH uses the most CPU. 141 | trackStuckThresholdMs: 10000 # The threshold for how long a track can be stuck. A track is stuck if does not return any audio data. 142 | useSeekGhosting: true # Seek ghosting is the effect where whilst a seek is in progress, the audio buffer is read from until empty, or until seek is ready. 143 | youtubePlaylistLoadLimit: 6 # Number of pages at 100 each 144 | playerUpdateInterval: 5 # How frequently to send player updates to clients, in seconds 145 | youtubeSearchEnabled: true 146 | soundcloudSearchEnabled: true 147 | gc-warnings: true 148 | #ratelimit: 149 | #ipBlocks: ["1.0.0.0/8", "..."] # list of ip blocks 150 | #excludedIps: ["...", "..."] # ips which should be explicit excluded from usage by lavalink 151 | #strategy: "RotateOnBan" # RotateOnBan | LoadBalance | NanoSwitch | RotatingNanoSwitch 152 | #searchTriggersFail: true # Whether a search 429 should trigger marking the ip as failing 153 | #retryLimit: -1 # -1 = use default lavaplayer value | 0 = infinity | >0 = retry will happen this numbers times 154 | #youtubeConfig: # Required for avoiding all age restrictions by YouTube, some restricted videos still can be played without. 155 | #email: "" # Email of Google account 156 | #password: "" # Password of Google account 157 | #httpConfig: # Useful for blocking bad-actors from ip-grabbing your music node and attacking it, this way only the http proxy will be attacked 158 | #proxyHost: "localhost" # Hostname of the proxy, (ip or domain) 159 | #proxyPort: 3128 # Proxy port, 3128 is the default for squidProxy 160 | #proxyUser: "" # Optional user for basic authentication fields, leave blank if you don't use basic auth 161 | #proxyPassword: "" # Password for basic authentication 162 | 163 | metrics: 164 | prometheus: 165 | enabled: false 166 | endpoint: /metrics 167 | 168 | sentry: 169 | dsn: "" 170 | environment: "" 171 | # tags: 172 | # some_key: some_value 173 | # another_key: another_value 174 | 175 | logging: 176 | file: 177 | path: ./logs/ 178 | 179 | level: 180 | root: INFO 181 | lavalink: INFO 182 | 183 | request: 184 | enabled: true 185 | includeClientInfo: true 186 | includeHeaders: false 187 | includeQueryString: true 188 | includePayload: true 189 | maxPayloadLength: 10000 190 | 191 | 192 | logback: 193 | rollingpolicy: 194 | max-file-size: 1GB 195 | max-history: 30 -------------------------------------------------------------------------------- /local_langs/zh-TW.json: -------------------------------------------------------------------------------- 1 | { 2 | "connect": "連線", 3 | "Connect to a voice channel.": "連線到語音頻道。", 4 | "channel": "頻道", 5 | "Provide a channel to connect.": "提供要連線的頻道。", 6 | "play": "播放", 7 | "Loads your input and added it to the queue.": "載入您的輸入並將其加入排隊。", 8 | "query": "查詢", 9 | "Input a query or a searchable link.": "輸入查詢或可搜尋的連結。", 10 | "search": "搜尋", 11 | "Input the name of the song.": "輸入歌曲名稱。", 12 | "platform": "平台", 13 | "Select the platform you want to search.": "選擇您要搜尋的平台。", 14 | "Youtube": "YouTube", 15 | "Youtube Music": "YouTube 音樂", 16 | "Spotify": "Spotify", 17 | "SoundCloud": "SoundCloud", 18 | "Apple Music": "Apple 音樂", 19 | "playtop": "將歌曲加入頂部", 20 | "Adds a song with the given url or query on the top of the queue.": "將指定的 URL 或查詢加入排隊的頂部。", 21 | "forceplay": "強制播放", 22 | "Enforce playback using the given URL or query.": "使用指定的 URL 或查詢強制播放。", 23 | "pause": "暫停", 24 | "Pause the music.": "暫停音樂。", 25 | "resume": "繼續", 26 | "Resume the music.": "繼續音樂。", 27 | "skip": "跳過", 28 | "Skips to the next song or skips to the specified song.": "跳過到下一首歌曲或跳過到指定的歌曲。", 29 | "index": "索引", 30 | "Enter a index that you want to skip to.": "輸入您要跳過到的索引。", 31 | "back": "返回", 32 | "Skips back to the previous song or skips to the specified previous song.": "跳回上一首歌曲或跳回到指定的上一首歌曲。", 33 | "Enter a index that you want to skip back to.": "輸入您要跳回到的索引。", 34 | "seek": "搜尋", 35 | "Change the player position.": "更改播放器的位置。", 36 | "position": "位置", 37 | "Input position. Exmaple: 1:20.": "輸入位置。範例:1:20。", 38 | "queue": "排隊", 39 | "Display the players queue songs in your queue.": "顯示您的排隊歌曲。", 40 | "export": "匯出", 41 | "Exports the entire queue to a text file": "將整個排隊匯出到文字檔。", 42 | "import": "匯入", 43 | "Imports the text file and adds the track to the current queue.": "匯入文字檔並將歌曲加入目前的排隊。", 44 | "attachment": "附件", 45 | "history": "歷史", 46 | "Display the players queue songs in your history queue.": "顯示您的歷史排隊歌曲。", 47 | "leave": "離開", 48 | "Disconnects the bot from your voice channel and chears the queue.": "從您的語音頻道斷開機器人連線並清除排隊。", 49 | "nowplaying": "正在播放", 50 | "Shows details of the current track.": "顯示目前歌曲的詳細資訊。", 51 | "loop": "迴圈", 52 | "Changes Loop mode.": "更改迴圈模式。", 53 | "mode": "模式", 54 | "Choose a looping mode.": "選擇迴圈模式。", 55 | "Off": "關閉", 56 | "Track": "歌曲", 57 | "Queue": "排隊", 58 | "clear": "清除", 59 | "Remove all the tracks in your queue or history queue.": "清除您的排隊或歷史排隊中的所有歌曲。", 60 | "Choose a queue that you want to clear.": "選擇您要清除的排隊。", 61 | "History": "歷史", 62 | "remove": "移除", 63 | "Removes specified track or a range of tracks from the queue.": "刪除指定的歌曲或從排隊中刪除一系列歌曲。", 64 | "position1": "位置1", 65 | "Input a position from the queue to be removed.": "輸入要刪除的歌曲的位置。", 66 | "position2": "位置2", 67 | "Set the range of the queue to be removed.": "設定要刪除的排隊範圍。", 68 | "member": "成員", 69 | "Remove tracks requested by a specific member.": "刪除指定成員所要求的歌曲。", 70 | "forward": "前進", 71 | "Forwards by a certain amount of time in the current track. The default is 10 seconds.": "在目前歌曲中前進一定的時間。預設為 10 秒。", 72 | "Input an amount that you to forward to. Exmaple: 1:20": "輸入您要前進到的時間。範例:1:20", 73 | "rewind": "倒退", 74 | "Rewind by a certain amount of time in the current track. The default is 10 seconds.": "在目前歌曲中倒退一定的時間。預設為 10 秒。", 75 | "Input an amount that you to rewind to. Exmaple: 1:20": "輸入您要倒退到的時間。範例:1:20", 76 | "replay": "重新播放", 77 | "Reset the progress of the current song.": "重設目前歌曲的進度。", 78 | "shuffle": "隨機播放", 79 | "Randomizes the tracks in the queue.": "隨機排隊中的歌曲。", 80 | "swap": "交換", 81 | "Swaps the specified song to the specified song.": "交換指定的歌曲到另一個指定的歌曲。", 82 | "The track to swap. Example: 2": "要交換的歌曲。範例:2", 83 | "The track to swap with position1. Exmaple: 1": "與位置 1 交換的歌曲。範例:1", 84 | "move": "移動", 85 | "Moves the specified song to the specified position.": "將指定的歌曲移到指定的位置。", 86 | "target": "目標", 87 | "The track to move. Example: 2": "要移動的歌曲。範例:2", 88 | "to": "到", 89 | "The new position to move the track to. Exmaple: 1": "要移動到的新位置。範例:1", 90 | "lyrics": "歌詞", 91 | "Displays lyrics for the playing track.": "顯示播放中歌曲的歌詞。", 92 | "title": "標題", 93 | "Searches for your query and displays the reutned lyrics.": "搜尋您的查詢並顯示返回的歌詞。", 94 | "artist": "藝術家", 95 | "swapdj": "交換dj", 96 | "Transfer dj to another.": "將 DJ 轉移給另一個。", 97 | "Choose a member to transfer the dj role.": "選擇一個成員來轉移 DJ 角色。", 98 | "autoplay": "自動播放", 99 | "Toggles autoplay mode, it will automatically queue the best songs to play.": "切換自動播放模式,將自動將最好的歌曲加入排隊播放。", 100 | "help": "幫助", 101 | "Lists all the commands in Vocard.": "列出 Vocard 中的所有命令。", 102 | "category": "分類", 103 | "Test if the bot is alive, and see the delay between your commands and my response.": "測試機器人是否活躍,並查看您的命令和我的回應之間的延遲。", 104 | "playlist": "播放列表", 105 | "Play all songs from your favorite playlist.": "播放您最愛的播放列表中的所有歌曲。", 106 | "name": "名稱", 107 | "Input the name of your custom playlist": "輸入您自定義播放列表的名稱", 108 | "value": "值", 109 | "Play the specific track from your custom playlist.": "播放您自定義播放列表中的指定歌曲。", 110 | "view": "查看", 111 | "List all your playlist and all songs in your favourite playlist.": "列出您所有的播放列表和您最愛的播放列表中的所有歌曲。", 112 | "create": "創建", 113 | "Create your custom playlist.": "創建您自定義的播放列表。", 114 | "Give a name to your playlist.": "給您的播放列表命名。", 115 | "link": "鏈接", 116 | "Provide a playlist link if you are creating link playlist.": "如果您正在創建鏈接播放列表,請提供播放列表鏈接。", 117 | "delete": "刪除", 118 | "Delete your custom playlist.": "刪除您自定義的播放列表。", 119 | "The name of the playlist.": "播放列表的名稱。", 120 | "share": "分享", 121 | "Share your custom playlist with your friends.": "與您的朋友分享自定義的播放列表。", 122 | "The user id of your friend.": "您朋友的用戶 ID。", 123 | "The name of the playlist that you want to share.": "您想要分享的播放列表名稱。", 124 | "rename": "重新命名", 125 | "Rename your custom playlist.": "重新命名自定義的播放列表。", 126 | "The name of your playlist.": "您的播放列表名稱。", 127 | "newname": "新名稱", 128 | "The new name of your playlist.": "您的播放列表的新名稱。", 129 | "inbox": "收件箱", 130 | "Show your playlist invitation.": "顯示播放列表邀請。", 131 | "add": "添加", 132 | "Add tracks in to your custom playlist.": "將歌曲添加到自定義的播放列表。", 133 | "Remove song from your favorite playlist.": "從最愛的播放列表中刪除歌曲。", 134 | "Input a position from the playlist to be removed.": "輸入要刪除的歌曲的位置。", 135 | "Remove all songs from your favorite playlist.": "刪除最愛的播放列表中的所有歌曲。", 136 | "Exports the entire playlist to a text file": "將整個播放列表匯出到文字檔。", 137 | "settings": "設定", 138 | "prefix": "前綴", 139 | "Change the default prefix for message commands.": "更改消息命令的預設前綴。", 140 | "language": "語言", 141 | "You can choose your preferred language, the bot message will change to the language you set.": "您可以選擇喜好的語言,機器人訊息將會改為您所設定的語言。", 142 | "Set a DJ role or remove DJ role.": "設置或移除 DJ 角色。", 143 | "role": "角色", 144 | "Change to another type of queue mode.": "更改為另一种隊列模式。", 145 | "FairQueue": "公平隊列", 146 | "Toggles 24/7 mode, which disables automatic inactivity-based disconnects.": "切換 24/7 模式,禁用自動休眠斷線。", 147 | "bypassvote": "繞過投票", 148 | "Toggles voting system.": "切換投票系統。", 149 | "Show all the bot settings in your server.": "顯示機器人所有設定在您的服務器中。", 150 | "volume": "音量", 151 | "Set the player's volume.": "設置播放器的音量。", 152 | "Input a integer.": "輸入一個整數。", 153 | "togglecontroller": "切換控制器", 154 | "Toggles the music controller.": "切換音樂控制器。", 155 | "duplicatetrack": "重複歌曲", 156 | "Toggle Vocard to prevent duplicate songs from queuing.": "切換 Vocard 以防止重複歌曲加入隊列。", 157 | "customcontroller": "自定義控制器", 158 | "Customizes music controller embeds.": "自定義音樂控制器嵌入。", 159 | "controllermsg": "控制器訊息", 160 | "Toggles to send a message when clicking the button in the music controller.": "切換發送訊息當點擊音樂控制器中的按鈕。", 161 | "debug": "除錯", 162 | "speed": "速度", 163 | "Sets the player's playback speed": "設置播放器的播放速度。", 164 | "The value to set the speed to. Default is `1.0`": "設置速度的值。預設為 `1.0`。", 165 | "karaoke": "卡拉ok", 166 | "Uses equalization to eliminate part of a band, usually targeting vocals.": "使用均衡器消除頻帶的一部分,通常針對人聲。", 167 | "level": "級別", 168 | "The level of the karaoke. Default is `1.0`": "卡拉 OK 的級別。預設為 `1.0`。", 169 | "monolevel": "單聲道級別", 170 | "The monolevel of the karaoke. Default is `1.0`": "卡拉 OK 的單聲道級別。預設為 `1.0`。", 171 | "filterband": "濾波頻帶", 172 | "The filter band of the karaoke. Default is `220.0`": "卡拉 OK 的濾波頻帶。預設為 `220.0`。", 173 | "filterwidth": "濾波寬度", 174 | "The filter band of the karaoke. Default is `100.0`": "卡拉 OK 的濾波頻帶。預設為 `100.0`", 175 | "tremolo": "顫音", 176 | "Uses amplification to create a shuddering effect, where the volume quickly oscillates.": "使用放大來創建顫音效果,音量快速振盪。", 177 | "frequency": "頻率", 178 | "The frequency of the tremolo. Default is `2.0`": "顫音的頻率。預設為 `2.0`", 179 | "depth": "深度", 180 | "The depth of the tremolo. Default is `0.5`": "顫音的深度。預設為 `0.5`", 181 | "vibrato": "振動", 182 | "Similar to tremolo. While tremolo oscillates the volume, vibrato oscillates the pitch.": "與顫音相似。顫音振盪音量,而振動振盪音高。", 183 | "The frequency of the vibrato. Default is `2.0`": "振動的頻率。預設為 `2.0`", 184 | "The Depth of the vibrato. Default is `0.5`": "振動的深度。預設為 `0.5`", 185 | "rotation": "旋轉", 186 | "Rotates the sound around the stereo channels/user headphones aka Audio Panning.": "旋轉聲音在立體聲道/用戶耳機中,也就是音頻泛音。", 187 | "hertz": "赫茲", 188 | "The hertz of the rotation. Default is `0.2`": "旋轉的赫茲。預設為 `0.2`", 189 | "distortion": "失真", 190 | "Distortion effect. It can generate some pretty unique audio effects.": "失真效果。可以生成一些非常獨特的音頻效果。", 191 | "lowpass": "低通", 192 | "Filter which supresses higher frequencies and allows lower frequencies to pass.": "濾波器,抑制高頻率,允許低頻率通過。", 193 | "smoothing": "平滑", 194 | "The level of the lowPass. Default is `20.0`": "低通的平滑級別。預設為 `20.0`", 195 | "channelmix": "頻道混合", 196 | "Filter which manually adjusts the panning of the audio.": "濾波器,手動調整音頻的泛音。", 197 | "left_to_left": "左到左", 198 | "Sounds from left to left. Default is `1.0`": "左聲道到左聲道。預設為 `1.0`", 199 | "right_to_right": "右到右", 200 | "Sounds from right to right. Default is `1.0`": "右聲道到右聲道。預設為 `1.0`", 201 | "left_to_right": "左到右", 202 | "Sounds from left to right. Default is `0.0`": "左聲道到右聲道。預設為 `0.0`", 203 | "right_to_left": "右到左", 204 | "Sounds from right to left. Default is `0.0`": "右聲道到左聲道。預設為 `0.0`", 205 | "nightcore": "夜核", 206 | "Add nightcore filter into your player.": "將夜核濾波器添加到您的播放器中。", 207 | "Add 8D filter into your player.": "將 8D 濾波器添加到您的播放器中。", 208 | "vaporwave": "水蒸波", 209 | "Add vaporwave filter into your player.": "將水蒸波濾波器添加到您的播放器中。", 210 | "cleareffect": "清除效果", 211 | "Clear all or specific sound effects.": "清除所有或指定的音效。", 212 | "effect": "效果", 213 | "Remove a specific sound effects.": "刪除指定的音效。", 214 | "start": "開始", 215 | "end": "結束", 216 | "Specify a time you would like to start, e.g. 1:00": "指定您希望開始的時間,例如:1:00。", 217 | "Specify a time you would like to end, e.g. 4:00": "指定您希望結束的時間,例如:4:00。", 218 | "list": "列表", 219 | "Customize the channel topic template": "自訂頻道主題模板", 220 | "template": "模板", 221 | "setupchannel": "設置頻道", 222 | "Sets up a dedicated channel for song requests in your server.": "為您的伺服器設置一個專用的歌曲請求頻道。", 223 | "Provide a request channel. If not, a text channel will be generated.": "提供請求頻道。如果沒有,將生成一個文本頻道。", 224 | "ping": "ping", 225 | "…": "...", 226 | "8d": "8d", 227 | "dj": "dj", 228 | "247": "247", 229 | "stageannounce": "舞台公告", 230 | "Soundcloud": "Soundcloud" 231 | } -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | import discord 25 | import sys 26 | import os 27 | import aiohttp 28 | import update 29 | import logging 30 | import voicelink 31 | import function as func 32 | 33 | from discord.ext import commands 34 | from ipc import IPCClient 35 | from motor.motor_asyncio import AsyncIOMotorClient 36 | from logging.handlers import TimedRotatingFileHandler 37 | from addons import Settings 38 | 39 | class Translator(discord.app_commands.Translator): 40 | async def load(self): 41 | func.logger.info("Loaded Translator") 42 | 43 | async def unload(self): 44 | func.logger.info("Unload Translator") 45 | 46 | async def translate(self, string: discord.app_commands.locale_str, locale: discord.Locale, context: discord.app_commands.TranslationContext): 47 | locale_key = str(locale) 48 | 49 | if locale_key in func.LOCAL_LANGS: 50 | translated_text = func.LOCAL_LANGS[locale_key].get(string.message) 51 | 52 | if translated_text is None: 53 | missing_translations = func.MISSING_TRANSLATOR.setdefault(locale_key, []) 54 | if string.message not in missing_translations: 55 | missing_translations.append(string.message) 56 | 57 | return translated_text 58 | 59 | return None 60 | 61 | class Vocard(commands.Bot): 62 | def __init__(self, *args, **kwargs): 63 | super().__init__(*args, **kwargs) 64 | 65 | self.ipc: IPCClient 66 | 67 | async def on_message(self, message: discord.Message, /) -> None: 68 | # Ignore messages from bots or DMs 69 | if message.author.bot or not message.guild: 70 | return False 71 | 72 | # Check if the bot is directly mentioned 73 | if self.user.id in message.raw_mentions and not message.mention_everyone: 74 | prefix = await self.command_prefix(self, message) 75 | if not prefix: 76 | return await message.channel.send("I don't have a bot prefix set.") 77 | await message.channel.send(f"My prefix is `{prefix}`") 78 | 79 | # Fetch guild settings and check if the mesage is in the music request channel 80 | settings = await func.get_settings(message.guild.id) 81 | if settings and (request_channel := settings.get("music_request_channel")): 82 | if message.channel.id == request_channel.get("text_channel_id"): 83 | ctx = await self.get_context(message) 84 | try: 85 | cmd = self.get_command("play") 86 | if message.content: 87 | await cmd(ctx, query=message.content) 88 | 89 | elif message.attachments: 90 | for attachment in message.attachments: 91 | await cmd(ctx, query=attachment.url) 92 | 93 | except Exception as e: 94 | await func.send(ctx, str(e), ephemeral=True) 95 | 96 | finally: 97 | return await message.delete() 98 | 99 | await self.process_commands(message) 100 | 101 | async def connect_db(self) -> None: 102 | if not ((db_name := func.settings.mongodb_name) and (db_url := func.settings.mongodb_url)): 103 | raise Exception("MONGODB_NAME and MONGODB_URL can't not be empty in settings.json") 104 | 105 | try: 106 | func.MONGO_DB = AsyncIOMotorClient(host=db_url) 107 | await func.MONGO_DB.server_info() 108 | func.logger.info(f"Successfully connected to [{db_name}] MongoDB!") 109 | 110 | except Exception as e: 111 | func.logger.error("Not able to connect MongoDB! Reason:", exc_info=e) 112 | exit() 113 | 114 | func.SETTINGS_DB = func.MONGO_DB[db_name]["Settings"] 115 | func.USERS_DB = func.MONGO_DB[db_name]["Users"] 116 | 117 | async def setup_hook(self) -> None: 118 | func.langs_setup() 119 | 120 | # Connecting to MongoDB 121 | await self.connect_db() 122 | 123 | # Set translator 124 | await self.tree.set_translator(Translator()) 125 | 126 | # Loading all the module in `cogs` folder 127 | for module in os.listdir(func.ROOT_DIR + '/cogs'): 128 | if module.endswith('.py'): 129 | try: 130 | await self.load_extension(f"cogs.{module[:-3]}") 131 | func.logger.info(f"Loaded {module[:-3]}") 132 | except Exception as e: 133 | func.logger.error(f"Something went wrong while loading {module[:-3]} cog.", exc_info=e) 134 | 135 | self.ipc = IPCClient(self, **func.settings.ipc_client) 136 | if func.settings.ipc_client.get("enable", False): 137 | try: 138 | await self.ipc.connect() 139 | except Exception as e: 140 | func.logger.error(f"Cannot connected to dashboard! - Reason: {e}") 141 | 142 | if not func.settings.version or func.settings.version != update.__version__: 143 | await self.tree.sync() 144 | func.update_json("settings.json", new_data={"version": update.__version__}) 145 | for locale_key, values in func.MISSING_TRANSLATOR.items(): 146 | func.logger.warning(f'Missing translation for "{", ".join(values)}" in "{locale_key}"') 147 | 148 | async def on_ready(self): 149 | func.logger.info("------------------") 150 | func.logger.info(f"Logging As {self.user}") 151 | func.logger.info(f"Bot ID: {self.user.id}") 152 | func.logger.info("------------------") 153 | func.logger.info(f"Discord Version: {discord.__version__}") 154 | func.logger.info(f"Python Version: {sys.version}") 155 | func.logger.info("------------------") 156 | 157 | func.settings.client_id = self.user.id 158 | func.LOCAL_LANGS.clear() 159 | func.MISSING_TRANSLATOR.clear() 160 | 161 | async def on_command_error(self, ctx: commands.Context, exception, /) -> None: 162 | error = getattr(exception, 'original', exception) 163 | if ctx.interaction: 164 | error = getattr(error, 'original', error) 165 | 166 | if isinstance(error, (commands.CommandNotFound, aiohttp.client_exceptions.ClientOSError, discord.errors.NotFound)): 167 | return 168 | 169 | elif isinstance(error, (commands.CommandOnCooldown, commands.MissingPermissions, commands.RangeError, commands.BadArgument)): 170 | pass 171 | 172 | elif isinstance(error, (commands.MissingRequiredArgument, commands.MissingRequiredAttachment)): 173 | command = f"{ctx.prefix}" + (f"{ctx.command.parent.qualified_name} " if ctx.command.parent else "") + f"{ctx.command.name} {ctx.command.signature}" 174 | position = command.find(f"<{ctx.current_parameter.name}>") + 1 175 | description = f"**Correct Usage:**\n```{command}\n" + " " * position + "^" * len(ctx.current_parameter.name) + "```\n" 176 | if ctx.command.aliases: 177 | description += f"**Aliases:**\n`{', '.join([f'{ctx.prefix}{alias}' for alias in ctx.command.aliases])}`\n\n" 178 | description += f"**Description:**\n{ctx.command.help}\n\u200b" 179 | 180 | embed = discord.Embed(description=description, color=func.settings.embed_color) 181 | embed.set_footer(icon_url=ctx.me.display_avatar.url, text=f"More Help: {func.settings.invite_link}") 182 | return await ctx.reply(embed=embed) 183 | 184 | elif not issubclass(error.__class__, voicelink.VoicelinkException): 185 | error = await func.get_lang(ctx.guild.id, "unknownException") + func.settings.invite_link 186 | func.logger.error(f"An unexpected error occurred in the {ctx.command.name} command on the {ctx.guild.name}({ctx.guild.id}).", exc_info=exception) 187 | 188 | try: 189 | return await ctx.reply(error, ephemeral=True) 190 | except: 191 | pass 192 | 193 | class CommandCheck(discord.app_commands.CommandTree): 194 | async def interaction_check(self, interaction: discord.Interaction, /) -> bool: 195 | if not interaction.guild: 196 | await interaction.response.send_message("This command can only be used in guilds!") 197 | return False 198 | 199 | return True 200 | 201 | async def get_prefix(bot: commands.Bot, message: discord.Message) -> str: 202 | settings = await func.get_settings(message.guild.id) 203 | prefix = settings.get("prefix", func.settings.bot_prefix) 204 | 205 | # Allow owner to use the bot without a prefix 206 | if prefix and not message.content.startswith(prefix) and (await bot.is_owner(message.author) or message.author.id in func.settings.bot_access_user): 207 | return "" 208 | 209 | return prefix 210 | 211 | # Loading settings and logger 212 | func.settings = Settings(func.open_json("settings.json")) 213 | 214 | LOG_SETTINGS = func.settings.logging 215 | if (LOG_FILE := LOG_SETTINGS.get("file", {})).get("enable", True): 216 | log_path = os.path.abspath(LOG_FILE.get("path", "./logs")) 217 | if not os.path.exists(log_path): 218 | os.makedirs(log_path) 219 | 220 | file_handler = TimedRotatingFileHandler(filename=f'{log_path}/vocard.log', encoding="utf-8", backupCount=LOG_SETTINGS.get("max-history", 30), when="d") 221 | file_handler.namer = lambda name: name.replace(".log", "") + ".log" 222 | file_handler.setFormatter(logging.Formatter('{asctime} [{levelname:<8}] {name}: {message}', '%Y-%m-%d %H:%M:%S', style='{')) 223 | logging.getLogger().addHandler(file_handler) 224 | 225 | for log_name, log_level in LOG_SETTINGS.get("level", {}).items(): 226 | _logger = logging.getLogger(log_name) 227 | _logger.setLevel(log_level) 228 | 229 | # Setup the bot object 230 | intents = discord.Intents.default() 231 | intents.message_content = False if func.settings.bot_prefix is None else True 232 | intents.members = func.settings.ipc_client.get("enable", False) 233 | intents.voice_states = True 234 | 235 | bot = Vocard( 236 | command_prefix=get_prefix, 237 | help_command=None, 238 | tree_cls=CommandCheck, 239 | chunk_guilds_at_startup=False, 240 | activity=discord.Activity(type=discord.ActivityType.listening, name="Starting..."), 241 | case_insensitive=True, 242 | intents=intents 243 | ) 244 | 245 | if __name__ == "__main__": 246 | update.check_version(with_msg=True) 247 | bot.run(func.settings.token, root_logger=True) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | discord.py==2.5.2 2 | motor==3.6.0 3 | dnspython==2.2.1 4 | tldextract==3.2.1 5 | validators==0.18.2 6 | humanize==4.0.0 7 | beautifulsoup4==4.11.1 8 | psutil==5.9.8 9 | aiohttp==3.11.12 -------------------------------------------------------------------------------- /settings Example.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": "YOUR_BOT_TOKEN", 3 | "client_id": "YOUR_BOT_CLIENT_ID", 4 | "genius_token": "YOUR_GENIUS_TOKEN", 5 | "mongodb_url": "YOUR_MONGODB_URL", 6 | "mongodb_name": "YOUR_MONGODB_DB_NAME", 7 | "nodes": { 8 | "DEFAULT": { 9 | "host": "lavalink", 10 | "port": 2333, 11 | "password": "youshallnotpass", 12 | "secure": false, 13 | "identifier": "DEFAULT", 14 | "yt_ratelimit": { 15 | "tokens": [], 16 | "config": { 17 | "retry_time": 10800, 18 | "max_requests": 30 19 | }, 20 | "strategy": "LoadBalance" 21 | } 22 | } 23 | }, 24 | "prefix": "?", 25 | "activity": [ 26 | {"type": "listening", "name": "/help", "status": "online"} 27 | ], 28 | "logging": { 29 | "file": { 30 | "path": "./logs", 31 | "enable": true 32 | }, 33 | "level": { 34 | "discord": "INFO", 35 | "vocard": "INFO", 36 | "ipc_client": "INFO" 37 | }, 38 | "max-history": 30 39 | }, 40 | "bot_access_user": [], 41 | "embed_color":"0xb3b3b3", 42 | "default_max_queue": 1000, 43 | "lyrics_platform": "lrclib", 44 | "ipc_client": { 45 | "host": "127.0.0.1", 46 | "port": 8000, 47 | "password": "YOUR_PASSWORD", 48 | "secure": false, 49 | "enable": false 50 | }, 51 | "sources_settings": { 52 | "youtube": { 53 | "emoji": "<:youtube:826661982760992778>", 54 | "color": "0xFF0000" 55 | }, 56 | "youtubemusic": { 57 | "emoji": "<:youtube:826661982760992778>", 58 | "color": "0xFF0000" 59 | }, 60 | "spotify": { 61 | "emoji": "<:spotify:826661996615172146>", 62 | "color": "0x1DB954" 63 | }, 64 | "soundcloud": { 65 | "emoji": "<:soundcloud:852729280027033632>", 66 | "color": "0xFF7700" 67 | }, 68 | "twitch": { 69 | "emoji": "<:twitch:852729278285086741>", 70 | "color": "0x9B4AFF" 71 | }, 72 | "bandcamp": { 73 | "emoji": "<:bandcamp:864694003811221526>", 74 | "color": "0x6F98A7" 75 | }, 76 | "vimeo": { 77 | "emoji": "<:vimeo:864694001919721473>", 78 | "color": "0x1ABCEA" 79 | }, 80 | "applemusic": { 81 | "emoji": "<:applemusic:994844332374884413>", 82 | "color": "0xE298C4" 83 | }, 84 | "reddit": { 85 | "emoji": "<:reddit:996007566863773717>", 86 | "color": "0xFF5700" 87 | }, 88 | "tiktok": { 89 | "emoji": "<:tiktok:996007689798811698>", 90 | "color": "0x74ECE9" 91 | }, 92 | "others": { 93 | "emoji": "🔗", 94 | "color": "0xb3b3b3" 95 | } 96 | }, 97 | "default_controller": { 98 | "embeds": { 99 | "active": { 100 | "description": "**Now Playing: ```[@@track_name@@]```\nLink: [Click Me](@@track_url@@) | Requester: @@track_requester_mention@@ | DJ: @@dj@@**", 101 | "footer": { 102 | "text": "Queue Length: @@queue_length@@ | Duration: @@track_duration@@ | Volume: @@volume@@% {{loop_mode != 'Off' ?? | Repeat: @@loop_mode@@}}" 103 | }, 104 | "image": "@@track_thumbnail@@", 105 | "author": { 106 | "name": "Music Controller | @@channel_name@@", 107 | "icon_url": "@@bot_icon@@" 108 | }, 109 | "color": "@@track_color@@" 110 | }, 111 | "inactive": { 112 | "title": { 113 | "name": "There are no songs playing right now" 114 | }, 115 | "description": "[Support](@@server_invite_link@@) | [Invite](@@invite_link@@) | [Questionnaire](https://forms.gle/Qm8vjBfg2kp13YGD7)", 116 | "image": "https://i.imgur.com/dIFBwU7.png", 117 | "color": "@@default_embed_color@@" 118 | } 119 | }, 120 | "default_buttons": [ 121 | ["back", "resume", "skip", {"stop": "red"}, "add"], 122 | ["tracks"] 123 | ], 124 | "disableButtonText": false 125 | }, 126 | "default_voice_status_template": "{{@@track_name@@ != 'None' ?? @@track_source_emoji@@ Now Playing: @@track_name@@ // Waiting for song requests}}", 127 | "cooldowns": { 128 | "connect": [2, 30], 129 | "playlist view": [1, 30] 130 | }, 131 | "aliases": { 132 | "connect": ["join"], 133 | "leave": ["stop", "bye"], 134 | "play": ["p"], 135 | "view": ["v"] 136 | } 137 | } -------------------------------------------------------------------------------- /update.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | import argparse 25 | import os 26 | import sys 27 | import requests 28 | import zipfile 29 | import shutil 30 | import subprocess 31 | from io import BytesIO 32 | 33 | ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) 34 | __version__ = "v2.7.1" 35 | 36 | # URLs for update and migration 37 | PYTHON_CMD_NAME = os.path.basename(sys.executable) 38 | print(PYTHON_CMD_NAME) 39 | GITHUB_API_URL = "https://api.github.com/repos/ChocoMeow/Vocard/releases/latest" 40 | VOCARD_URL = "https://github.com/ChocoMeow/Vocard/archive/" 41 | MIGRATION_SCRIPT_URL = f"https://raw.githubusercontent.com/ChocoMeow/Vocard-Magration/main/{__version__}.py" 42 | IGNORE_FILES = ["settings.json", "logs", "last-session.json"] 43 | 44 | class bcolors: 45 | WARNING = '\033[93m' 46 | FAIL = '\033[91m' 47 | OKGREEN = '\033[92m' 48 | ENDC = '\033[0m' 49 | 50 | def check_version(with_msg=False): 51 | """Check for the latest version of the project. 52 | 53 | Args: 54 | with_msg (bool): option to print the message. 55 | 56 | Returns: 57 | str: the latest version. 58 | """ 59 | response = requests.get(GITHUB_API_URL) 60 | latest_version = response.json().get("name", __version__) 61 | if with_msg: 62 | msg = ( 63 | f"{bcolors.OKGREEN}Your bot is up-to-date! - {latest_version}{bcolors.ENDC}" 64 | if latest_version == __version__ 65 | else f"{bcolors.WARNING}Your bot is not up-to-date! The latest version is {latest_version} and you are currently running version {__version__}\nRun `{PYTHON_CMD_NAME} update.py -l` to update your bot!{bcolors.ENDC}" 66 | ) 67 | print(msg) 68 | return latest_version 69 | 70 | def download_file(version=None): 71 | """Download the latest version of the project. 72 | 73 | Args: 74 | version (str): the version to download. If None, download the latest version. 75 | 76 | Returns: 77 | Response: the downloaded zip file content. 78 | """ 79 | version = version if version else check_version() 80 | print(f"Downloading Vocard version: {version}") 81 | response = requests.get(VOCARD_URL + version + ".zip") 82 | if response.status_code == 404: 83 | print(f"{bcolors.FAIL}Warning: Version not found!{bcolors.ENDC}") 84 | exit(1) 85 | print("Download Completed") 86 | return response 87 | 88 | def install(response, version): 89 | """Install the downloaded version of the project. 90 | 91 | Args: 92 | response (Response): the downloaded zip file content. 93 | version (str): the version to install. 94 | """ 95 | user_input = input( 96 | f"{bcolors.WARNING}--------------------------------------------------------------------------\n" 97 | "Note: Before proceeding, please ensure that there are no personal files or\n" 98 | "sensitive information in the directory you're about to delete. This action\n" 99 | "is irreversible, so it's important to double-check that you're making the \n" 100 | f"right decision. {bcolors.ENDC} Continue with caution? (Y/n) " 101 | ) 102 | 103 | if user_input.lower() in ["y", "yes"]: 104 | print("Installing ...") 105 | zfile = zipfile.ZipFile(BytesIO(response.content)) 106 | zfile.extractall(ROOT_DIR) 107 | 108 | # Remove 'v' from the version string for folder name. 109 | version_without_v = version.replace("v", "") 110 | source_dir = os.path.join(ROOT_DIR, f"Vocard-{version_without_v}") 111 | if os.path.exists(source_dir): 112 | for filename in os.listdir(ROOT_DIR): 113 | if filename in IGNORE_FILES + [f"Vocard-{version_without_v}"]: 114 | continue 115 | 116 | filename_path = os.path.join(ROOT_DIR, filename) 117 | if os.path.isdir(filename_path): 118 | shutil.rmtree(filename_path) 119 | else: 120 | os.remove(filename_path) 121 | for filename in os.listdir(source_dir): 122 | shutil.move(os.path.join(source_dir, filename), os.path.join(ROOT_DIR, filename)) 123 | os.rmdir(source_dir) 124 | print(f"{bcolors.OKGREEN}Version {version} installed Successfully! Run `{PYTHON_CMD_NAME} main.py` to start your bot{bcolors.ENDC}") 125 | else: 126 | print("Update canceled!") 127 | 128 | def run_migration(): 129 | """Download, execute, and remove the migration script.""" 130 | confirm = input( 131 | f"{bcolors.WARNING}WARNING: Please ensure you have taken a backup before proceeding.\n" 132 | f"Are you sure you want to run the migration? (Y/n): {bcolors.ENDC} " 133 | ) 134 | if confirm.lower() not in ["y", "yes"]: 135 | print("Migration canceled!") 136 | return 137 | 138 | print("Downloading migration script...") 139 | response = requests.get(MIGRATION_SCRIPT_URL) 140 | if response.status_code != 200: 141 | print(f"{bcolors.FAIL}Failed to download migration script. Status code: {response.status_code}{bcolors.ENDC}") 142 | exit(1) 143 | 144 | migration_filename = "temp_migration.py" 145 | with open(migration_filename, "w", encoding="utf-8") as f: 146 | f.write(response.text) 147 | 148 | print("Executing migration script...") 149 | try: 150 | subprocess.run([PYTHON_CMD_NAME, migration_filename], check=True) 151 | print(f"{bcolors.OKGREEN}Migration script executed successfully.{bcolors.ENDC}") 152 | except subprocess.CalledProcessError as e: 153 | print(f"{bcolors.FAIL}Migration script execution failed: {e}{bcolors.ENDC}") 154 | finally: 155 | if os.path.exists(migration_filename): 156 | os.remove(migration_filename) 157 | print("Temporary migration script deleted.") 158 | 159 | def parse_args(): 160 | """Parse command line arguments.""" 161 | parser = argparse.ArgumentParser(description='Update and migration script for Vocard.') 162 | parser.add_argument('-c', '--check', action='store_true', help='Check the current version of the Vocard') 163 | parser.add_argument('-v', '--version', type=str, help='Install the specified version of the Vocard') 164 | parser.add_argument('-l', '--latest', action='store_true', help='Install the latest version of the Vocard from Github') 165 | parser.add_argument('-b', '--beta', action='store_true', help='Install the beta version of the Vocard from Github') 166 | parser.add_argument('-m', '--migration', action='store_true', help='Download and run the migration script from Github') 167 | return parser.parse_args() 168 | 169 | def main(): 170 | """Main function.""" 171 | args = parse_args() 172 | 173 | if args.check: 174 | check_version(with_msg=True) 175 | 176 | elif args.version: 177 | version = args.version 178 | response = download_file(version) 179 | install(response, version) 180 | 181 | elif args.latest: 182 | response = download_file() 183 | version = check_version() 184 | install(response, version) 185 | 186 | elif args.beta: 187 | response = download_file("refs/heads/beta") 188 | install(response, "beta") 189 | 190 | elif args.migration: 191 | run_migration() 192 | else: 193 | print(f"{bcolors.FAIL}No arguments provided. Run `{PYTHON_CMD_NAME} update.py -h` for help.{bcolors.ENDC}") 194 | 195 | if __name__ == "__main__": 196 | main() -------------------------------------------------------------------------------- /views/__init__.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | from discord.ext import commands 25 | 26 | class ButtonOnCooldown(commands.CommandError): 27 | def __init__(self, retry_after: float) -> None: 28 | self.retry_after = retry_after 29 | 30 | from .controller import InteractiveController 31 | from .search import SearchView 32 | from .help import HelpView 33 | from .list import ListView 34 | from .lyrics import LyricsView 35 | from .playlist import PlaylistView 36 | from .inbox import InboxView 37 | from .link import LinkView 38 | from .debug import DebugView 39 | from .embedBuilder import EmbedBuilderView 40 | -------------------------------------------------------------------------------- /views/help.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | import discord 25 | from discord.ext import commands 26 | 27 | import function as func 28 | 29 | class HelpDropdown(discord.ui.Select): 30 | def __init__(self, categories:list): 31 | self.view: HelpView 32 | 33 | super().__init__( 34 | placeholder="Select Category!", 35 | min_values=1, max_values=1, 36 | options=[ 37 | discord.SelectOption(emoji="🆕", label="News", description="View new updates of Vocard."), 38 | discord.SelectOption(emoji="🕹️", label="Tutorial", description="How to use Vocard."), 39 | ] + [ 40 | discord.SelectOption(emoji=emoji, label=f"{category} Commands", description=f"This is {category.lower()} Category.") 41 | for category, emoji in zip(categories, ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣"]) 42 | ], 43 | custom_id="select" 44 | ) 45 | 46 | async def callback(self, interaction: discord.Interaction) -> None: 47 | embed = self.view.build_embed(self.values[0].split(" ")[0]) 48 | await interaction.response.edit_message(embed=embed) 49 | 50 | class HelpView(discord.ui.View): 51 | def __init__(self, bot: commands.Bot, author: discord.Member) -> None: 52 | super().__init__(timeout=60) 53 | 54 | self.author: discord.Member = author 55 | self.bot: commands.Bot = bot 56 | self.response: discord.Message = None 57 | self.categories: list[str] = [ name.capitalize() for name, cog in bot.cogs.items() if len([c for c in cog.walk_commands()]) ] 58 | 59 | self.add_item(discord.ui.Button(label='Website', emoji='🌎', url='https://vocard.xyz')) 60 | self.add_item(discord.ui.Button(label='Document', emoji=':support:915152950471581696', url='https://docs.vocard.xyz')) 61 | self.add_item(discord.ui.Button(label='Github', emoji=':github:1098265017268322406', url='https://github.com/ChocoMeow/Vocard')) 62 | self.add_item(discord.ui.Button(label='Donate', emoji=':patreon:913397909024800878', url='https://www.patreon.com/Vocard')) 63 | self.add_item(HelpDropdown(self.categories)) 64 | 65 | async def on_error(self, error, item, interaction) -> None: 66 | return 67 | 68 | async def on_timeout(self) -> None: 69 | for child in self.children: 70 | if child.custom_id == "select": 71 | child.disabled = True 72 | try: 73 | await self.response.edit(view=self) 74 | except: 75 | pass 76 | 77 | async def interaction_check(self, interaction: discord.Interaction) -> None: 78 | return interaction.user == self.author 79 | 80 | def build_embed(self, category: str) -> discord.Embed: 81 | category = category.lower() 82 | if category == "news": 83 | embed = discord.Embed(title="Vocard Help Menu", url="https://discord.com/channels/811542332678996008/811909963718459392/1069971173116481636", color=func.settings.embed_color) 84 | embed.add_field( 85 | name=f"Available Categories: [{2 + len(self.categories)}]", 86 | value="```py\n👉 News\n2. Tutorial\n{}```".format("".join(f"{i}. {c}\n" for i, c in enumerate(self.categories, start=3))), 87 | inline=True 88 | ) 89 | 90 | update = "Vocard is a simple music bot. It leads to a comfortable experience which is user-friendly, It supports YouTube, Soundcloud, Spotify, Twitch and more!" 91 | embed.add_field(name="📰 Information:", value=update, inline=True) 92 | 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) 93 | 94 | return embed 95 | 96 | embed = discord.Embed(title=f"Category: {category.capitalize()}", color=func.settings.embed_color) 97 | embed.add_field(name=f"Categories: [{2 + len(self.categories)}]", value="```py\n" + "\n".join(("👉 " if c == category.capitalize() else f"{i}. ") + c for i, c in enumerate(['News', 'Tutorial'] + self.categories, start=1)) + "```", inline=True) 98 | 99 | if category == 'tutorial': 100 | embed.description = "How can use Vocard? Some simple commands you should know now after watching this video." 101 | embed.set_image(url="https://cdn.discordapp.com/attachments/674788144931012638/917656288899514388/final_61aef3aa7836890135c6010c_669380.gif") 102 | else: 103 | cog = [c for _, c in self.bot.cogs.items() if _.lower() == category][0] 104 | 105 | commands = [command for command in cog.walk_commands()] 106 | embed.description = cog.description 107 | embed.add_field( 108 | name=f"{category} Commands: [{len(commands)}]", 109 | value="```{}```".format("".join(f"/{command.qualified_name}\n" for command in commands if not command.qualified_name == cog.qualified_name)) 110 | ) 111 | 112 | return embed -------------------------------------------------------------------------------- /views/inbox.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | import discord, time 25 | import function as func 26 | 27 | from typing import Any 28 | 29 | class Select_message(discord.ui.Select): 30 | def __init__(self, inbox): 31 | self.view: InboxView 32 | 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) ] 33 | 34 | super().__init__( 35 | placeholder="Select a message to view ..", 36 | options=options, custom_id='select' 37 | ) 38 | 39 | async def callback(self, interaction: discord.Interaction): 40 | self.view.current = self.view.inbox[int(self.values[0].split(". ")[0]) - 1] 41 | await self.view.button_change(interaction) 42 | 43 | class InboxView(discord.ui.View): 44 | def __init__(self, author: discord.Member, inbox: list[dict[str, Any]]): 45 | super().__init__(timeout=60) 46 | self.inbox: list[dict[str, Any]] = inbox 47 | self.new_playlist = [] 48 | 49 | self.author: discord.Member = author 50 | self.response: discord.Message = None 51 | self.current = None 52 | 53 | self.add_item(Select_message(inbox)) 54 | 55 | async def interaction_check(self, interaction: discord.Interaction): 56 | return interaction.user == self.author 57 | 58 | def build_embed(self) -> discord.Embed: 59 | embed=discord.Embed( 60 | title=f"📭 All {self.author.display_name}'s Inbox", 61 | 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)) + '```', 62 | color=func.settings.embed_color 63 | ) 64 | 65 | if self.current: 66 | embed.add_field(name="Message Info:", value=f"```{self.current['description']}\nSender ID: {self.current['sender']}\nPlaylist ID: {self.current['referId']}\nInvite Time: {time.strftime('%d-%m %H:%M:%S', time.gmtime(int(self.current['time'])))}```") 67 | return embed 68 | 69 | async def button_change(self, interaction: discord.Interaction): 70 | for child in self.children: 71 | if child.custom_id in ['accept', 'dismiss']: 72 | child.disabled = True if self.current is None else False 73 | elif child.custom_id == "select": 74 | 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) ] 75 | 76 | if not self.inbox: 77 | await interaction.response.edit_message(embed=self.build_embed(), view=None) 78 | return self.stop() 79 | await interaction.response.edit_message(embed=self.build_embed(), view=self) 80 | 81 | async def on_timeout(self): 82 | for child in self.children: 83 | child.disabled = True 84 | try: 85 | await self.response.edit(view=self) 86 | except: 87 | pass 88 | 89 | @discord.ui.button(label='Accept', style=discord.ButtonStyle.green, custom_id="accept", disabled=True) 90 | async def accept_button(self, interaction: discord.Interaction, button: discord.ui.Button): 91 | self.new_playlist.append(self.current) 92 | self.inbox.remove(self.current) 93 | self.current = None 94 | await self.button_change(interaction) 95 | 96 | @discord.ui.button(label='Dismiss', style=discord.ButtonStyle.red, custom_id="dismiss", disabled=True) 97 | async def dismiss_button(self, interaction: discord.Interaction, button: discord.ui.Button): 98 | self.inbox.remove(self.current) 99 | self.current = None 100 | await self.button_change(interaction) 101 | 102 | @discord.ui.button(label='Click Me To Save The Changes', style=discord.ButtonStyle.blurple) 103 | async def save_button(self, interaction: discord.Interaction, button: discord.ui.Button): 104 | await self.response.edit(view=None) 105 | self.stop() -------------------------------------------------------------------------------- /views/link.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | import discord 25 | 26 | class LinkView(discord.ui.View): 27 | def __init__(self, label=None, emoji=None, url=None): 28 | super().__init__(timeout=60) 29 | self.add_item(discord.ui.Button(label=label, emoji=emoji, url=url)) -------------------------------------------------------------------------------- /views/list.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | import discord 25 | import function as func 26 | 27 | from math import ceil 28 | from typing import TYPE_CHECKING 29 | 30 | if TYPE_CHECKING: 31 | from voicelink import Player, Track 32 | 33 | class ListView(discord.ui.View): 34 | def __init__( 35 | self, 36 | player: "Player", 37 | author: discord.Member, 38 | is_queue: bool = True 39 | ) -> None: 40 | super().__init__(timeout=60) 41 | self.player: Player = player 42 | 43 | self.is_queue: bool = is_queue 44 | self.tracks: list[Track] = player.queue.tracks() if is_queue else player.queue.history() 45 | self.response: discord.Message = None 46 | 47 | if not is_queue: 48 | self.tracks.reverse() 49 | self.author: discord.Member = author 50 | 51 | self.page: int = ceil(len(self.tracks) / 7) 52 | self.current_page: int = 1 53 | 54 | try: 55 | self.time: str = func.time(sum([track.length for track in self.tracks])) 56 | except Exception as _: 57 | self.time = "∞" 58 | 59 | async def on_timeout(self) -> None: 60 | for child in self.children: 61 | child.disabled = True 62 | try: 63 | await self.response.edit(view=self) 64 | except: 65 | pass 66 | 67 | async def on_error(self, error, item, interaction) -> None: 68 | return 69 | 70 | async def interaction_check(self, interaction: discord.Interaction) -> bool: 71 | return interaction.user == self.author 72 | 73 | async def build_embed(self) -> discord.Embed: 74 | offset: int = self.current_page * 7 75 | tracks: list[Track] = self.tracks[(offset-7):offset] 76 | texts = await func.get_lang(self.author.guild.id, "viewTitle", "viewDesc", "nowplayingDesc", "live", "queueTitle", "historyTitle", "viewFooter") 77 | 78 | embed = discord.Embed(title=texts[0], color=func.settings.embed_color) 79 | embed.description=texts[1].format(self.player.current.uri, f"```{self.player.current.title}```") if self.player.current else texts[2].format("None") 80 | 81 | embed.description += "\n**" + (texts[4] if self.is_queue else texts[5]) + "**\n" + "\n".join([ 82 | f"{track.emoji} `{i:>2}.` `[" + (texts[3] if track.is_stream else func.time(track.length)) + f']` [{func.truncate_string(track.title)}]({track.uri})' + (track.requester.mention) 83 | for i, track in enumerate(tracks, start=offset-6) 84 | ]) 85 | embed.set_footer(text=texts[6].format(self.current_page, self.page, self.time)) 86 | 87 | return embed 88 | 89 | @discord.ui.button(label='<<', style=discord.ButtonStyle.grey) 90 | async def fast_back_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 91 | if self.current_page != 1: 92 | self.current_page = 1 93 | return await interaction.response.edit_message(embed=await self.build_embed()) 94 | await interaction.response.defer() 95 | 96 | @discord.ui.button(label='Back', style=discord.ButtonStyle.blurple) 97 | async def back_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 98 | if self.current_page > 1: 99 | self.current_page -= 1 100 | return await interaction.response.edit_message(embed=await self.build_embed()) 101 | await interaction.response.defer() 102 | 103 | @discord.ui.button(label='Next', style=discord.ButtonStyle.blurple) 104 | async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 105 | if self.current_page < self.page: 106 | self.current_page += 1 107 | return await interaction.response.edit_message(embed=await self.build_embed()) 108 | await interaction.response.defer() 109 | 110 | @discord.ui.button(label='>>', style=discord.ButtonStyle.grey) 111 | async def fast_next_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 112 | if self.current_page != self.page: 113 | self.current_page = self.page 114 | return await interaction.response.edit_message(embed=await self.build_embed()) 115 | await interaction.response.defer() 116 | 117 | @discord.ui.button(emoji='🗑️', style=discord.ButtonStyle.red) 118 | async def stop_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 119 | await self.response.delete() 120 | self.stop() -------------------------------------------------------------------------------- /views/lyrics.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | import discord 25 | import function as func 26 | 27 | class LyricsDropdown(discord.ui.Select): 28 | def __init__(self, langs: list[str]) -> None: 29 | self.view: LyricsView 30 | 31 | super().__init__( 32 | placeholder="Select A Lyrics Translation", 33 | min_values=1, max_values=1, 34 | options=[discord.SelectOption(label=lang) for lang in langs], 35 | custom_id="selectLyricsLangs" 36 | ) 37 | 38 | async def callback(self, interaction: discord.Interaction) -> None: 39 | self.view.lang = self.values[0] 40 | self.view.current_page = 1 41 | self.view.pages = len(self.view.source.get(self.values[0])) 42 | await interaction.response.edit_message(embed=self.view.build_embed()) 43 | 44 | class LyricsView(discord.ui.View): 45 | def __init__(self, name: str, source: dict, author: discord.Member) -> None: 46 | super().__init__(timeout=60) 47 | 48 | self.name: str = name 49 | self.source: dict[str, list[str]] = source 50 | self.lang: list[str] = list(source.keys())[0] 51 | self.author: discord.Member = author 52 | 53 | self.response: discord.Message = None 54 | self.pages: int = len(self.source.get(self.lang)) 55 | self.current_page: int = 1 56 | self.add_item(LyricsDropdown(list(source.keys()))) 57 | 58 | async def interaction_check(self, interaction: discord.Interaction) -> bool: 59 | return interaction.user == self.author 60 | 61 | async def on_timeout(self) -> None: 62 | for child in self.children: 63 | child.disabled = True 64 | try: 65 | await self.response.edit(view=self) 66 | except: 67 | pass 68 | 69 | async def on_error(self, error, item, interaction) -> None: 70 | return 71 | 72 | def build_embed(self) -> discord.Embed: 73 | chunk = self.source.get(self.lang)[self.current_page - 1] 74 | embed=discord.Embed(description=chunk, color=func.settings.embed_color) 75 | embed.set_author(name=f"Searching Query: {self.name}", icon_url=self.author.display_avatar.url) 76 | embed.set_footer(text=f"Page: {self.current_page}/{self.pages}") 77 | return embed 78 | 79 | @discord.ui.button(label='<<', style=discord.ButtonStyle.grey) 80 | async def fast_back_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 81 | if self.current_page != 1: 82 | self.current_page = 1 83 | return await interaction.response.edit_message(embed=self.build_embed()) 84 | await interaction.response.defer() 85 | 86 | @discord.ui.button(label='Back', style=discord.ButtonStyle.blurple) 87 | async def back_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 88 | if self.current_page > 1: 89 | self.current_page -= 1 90 | return await interaction.response.edit_message(embed=self.build_embed()) 91 | await interaction.response.defer() 92 | 93 | @discord.ui.button(label='Next', style=discord.ButtonStyle.blurple) 94 | async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 95 | if self.current_page < self.pages: 96 | self.current_page += 1 97 | return await interaction.response.edit_message(embed=self.build_embed()) 98 | await interaction.response.defer() 99 | 100 | @discord.ui.button(label='>>', style=discord.ButtonStyle.grey) 101 | async def fast_next_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 102 | if self.current_page != self.pages: 103 | self.current_page = self.pages 104 | return await interaction.response.edit_message(embed=self.build_embed()) 105 | await interaction.response.defer() 106 | 107 | @discord.ui.button(emoji='🗑️', style=discord.ButtonStyle.red) 108 | async def stop_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 109 | await self.response.delete() 110 | self.stop() -------------------------------------------------------------------------------- /views/playlist.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | import discord 25 | import function as func 26 | 27 | from math import ceil 28 | from tldextract import extract 29 | from typing import Any, TYPE_CHECKING 30 | 31 | if TYPE_CHECKING: 32 | from voicelink import Track 33 | 34 | class Select_playlist(discord.ui.Select): 35 | def __init__(self, results): 36 | self.view: PlaylistView 37 | 38 | super().__init__( 39 | placeholder="Select a playlist to view ..", 40 | custom_id="selector", 41 | options=[discord.SelectOption(emoji='🌎', label='All Playlist')] + 42 | [ 43 | discord.SelectOption(emoji=playlist['emoji'], label=f'{index}. {playlist["name"]}', description=f"{playlist['time']} · {playlist['type']}") 44 | for index, playlist in enumerate(results, start=1) if playlist['type'] != 'error' 45 | ] 46 | ) 47 | 48 | async def callback(self, interaction: discord.Interaction) -> None: 49 | if self.values[0] == 'All Playlist': 50 | self.view.current = None 51 | self.view.toggle_btn(True) 52 | return await interaction.response.edit_message(embed=self.view.viewEmbed, view=self.view) 53 | 54 | self.view.current = self.view.results[int(self.values[0].split(". ")[0]) - 1] 55 | self.view.page = ceil(len(self.view.current['tracks']) / 7) 56 | self.view.current_page = 1 57 | self.view.toggle_btn(False) 58 | await interaction.response.edit_message(embed=await self.view.build_embed(), view=self.view) 59 | 60 | class PlaylistView(discord.ui.View): 61 | def __init__( 62 | self, 63 | viewEmbed: discord.Embed, 64 | results: list[dict[str, Any]], 65 | author: discord.Message 66 | ) -> None: 67 | super().__init__(timeout=60) 68 | 69 | self.viewEmbed: discord.Embed = viewEmbed 70 | self.results: list[dict[str, Any]] = results 71 | self.author: discord.Member = author 72 | self.response: discord.Message = None 73 | 74 | self.current: dict[str, Any] = None 75 | self.page: int = 0 76 | self.current_page: int = 1 77 | 78 | self.add_item(Select_playlist(results)) 79 | 80 | async def interaction_check(self, interaction: discord.Interaction) -> bool: 81 | return interaction.user == self.author 82 | 83 | async def on_error(self, error, item, interaction) -> None: 84 | return 85 | 86 | def toggle_btn(self, action: bool) -> None: 87 | for child in self.children: 88 | if child.custom_id not in ("delete", "selector"): 89 | child.disabled = action 90 | 91 | async def build_embed(self) -> discord.Embed: 92 | offset: int = self.current_page * 7 93 | tracks: list[Track] = self.current['tracks'][(offset-7):offset] 94 | texts = await func.get_lang(self.author.guild.id, "playlistView", "playlistViewDesc", "settingsPermTitle", "playlistViewPermsValue", "playlistViewPermsValue2", "playlistViewTrack", "playlistNoTrack", "playlistViewPage") 95 | 96 | embed = discord.Embed(title=texts[0], color=func.settings.embed_color) 97 | embed.description = texts[1].format(self.current['name'], self.current['id'], len(self.current['tracks']), owner if (owner := self.current.get('owner')) else f"{self.author.id} (You)", self.current['type']) + "\n" 98 | 99 | perms = self.current['perms'] 100 | if self.current['type'] == 'share': 101 | embed.description += texts[2] + "\n" + texts[3].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 '✘') 102 | else: 103 | embed.description += texts[2] + "\n" + texts[4].format(', '.join(f'<@{user}>' for user in perms['read'])) 104 | 105 | embed.description += f"\n\n**{texts[5]}:**\n" 106 | if tracks: 107 | if self.current.get("type") == "playlist": 108 | embed.description += "\n".join(f"{func.get_source(track['sourceName'], 'emoji')} `{index:>2}.` `[{func.time(track['length'])}]` [{func.truncate_string(track['title'])}]({track['uri']})" for index, track in enumerate(tracks, start=offset - 6)) 109 | else: 110 | embed.description += '\n'.join(f"{func.get_source(extract(track.info['uri']).domain, 'emoji')} `{index:>2}.` `[{func.time(track.length)}]` [{func.truncate_string(track.title)}]({track.uri})" for index, track in enumerate(tracks, start=offset - 6)) 111 | else: 112 | embed.description += texts[6].format(self.current['name']) 113 | 114 | embed.set_footer(text=texts[7].format(self.current_page, self.page, self.current['time'])) 115 | return embed 116 | 117 | async def on_timeout(self) -> None: 118 | for child in self.children: 119 | child.disabled = True 120 | try: 121 | await self.response.edit(view=self) 122 | except: 123 | pass 124 | 125 | @discord.ui.button(label='<<', style=discord.ButtonStyle.grey, disabled=True) 126 | async def fast_back_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 127 | if not self.current: 128 | return 129 | if self.current_page != 1: 130 | self.current_page = 1 131 | return await interaction.response.edit_message(embed=await self.build_embed()) 132 | await interaction.response.defer() 133 | 134 | @discord.ui.button(label='Back', style=discord.ButtonStyle.blurple, disabled=True) 135 | async def back_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 136 | if not self.current: 137 | return 138 | if self.current_page > 1: 139 | self.current_page -= 1 140 | return await interaction.response.edit_message(embed=await self.build_embed()) 141 | await interaction.response.defer() 142 | 143 | @discord.ui.button(label='Next', style=discord.ButtonStyle.blurple, disabled=True) 144 | async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 145 | if not self.current: 146 | return 147 | if self.current_page < self.page: 148 | self.current_page += 1 149 | return await interaction.response.edit_message(embed=await self.build_embed()) 150 | await interaction.response.defer() 151 | 152 | @discord.ui.button(label='>>', style=discord.ButtonStyle.grey, disabled=True) 153 | async def fast_next_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 154 | if not self.current: 155 | return 156 | if self.current_page != self.page: 157 | self.current_page = self.page 158 | return await interaction.response.edit_message(embed=await self.build_embed()) 159 | await interaction.response.defer() 160 | 161 | @discord.ui.button(emoji='🗑️', custom_id="delete", style=discord.ButtonStyle.red) 162 | async def stop_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: 163 | await self.response.delete() 164 | self.stop() -------------------------------------------------------------------------------- /views/search.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | from __future__ import annotations 24 | 25 | import discord 26 | 27 | from typing import TYPE_CHECKING 28 | if TYPE_CHECKING: 29 | from voicelink import Track 30 | 31 | class SearchDropdown(discord.ui.Select): 32 | def __init__(self, tracks: list[Track], texts: list[str]) -> None: 33 | self.view: SearchView 34 | self.texts: list[str] = texts 35 | 36 | super().__init__( 37 | placeholder=texts[0], 38 | min_values=1, max_values=len(tracks), 39 | options=[ 40 | discord.SelectOption(label=f"{i}. {track.title[:50]}", description=f"{track.author[:50]} · {track.formatted_length}") 41 | for i, track in enumerate(tracks, start=1) 42 | ] 43 | ) 44 | 45 | async def callback(self, interaction: discord.Interaction) -> None: 46 | self.disabled = True 47 | self.placeholder = self.texts[1] 48 | await interaction.response.edit_message(view=self.view) 49 | self.view.values = self.values 50 | self.view.stop() 51 | 52 | class SearchView(discord.ui.View): 53 | def __init__(self, tracks: list[Track], texts: list[str]) -> None: 54 | super().__init__(timeout=60) 55 | 56 | self.response: discord.Message = None 57 | self.values: list[str] = None 58 | self.add_item(SearchDropdown(tracks, texts)) 59 | 60 | async def on_error(self, error, item, interaction): 61 | return 62 | 63 | async def on_timeout(self): 64 | for child in self.children: 65 | child.disabled = True 66 | try: 67 | await self.response.edit(view=self) 68 | except: 69 | pass -------------------------------------------------------------------------------- /voicelink/__init__.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | __version__ = "1.4" 25 | __author__ = 'Vocard Development, ChocoMeow' 26 | __license__ = "MIT" 27 | __copyright__ = "Copyright 2023 - present (c) Vocard Development, ChocoMeow" 28 | 29 | from .enums import SearchType, LoopType 30 | from .events import * 31 | from .exceptions import * 32 | from .filters import * 33 | from .objects import * 34 | from .player import Player, connect_channel 35 | from .pool import * 36 | from .queue import * 37 | from .placeholders import Placeholders, build_embed 38 | from .transformer import encode, decode 39 | -------------------------------------------------------------------------------- /voicelink/enums.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | from enum import Enum, auto 25 | 26 | class LoopType(Enum): 27 | """The enum for the different loop types for Voicelink 28 | 29 | LoopType.OFF: 1 30 | LoopType.TRACK: 2 31 | LoopType.QUEUE: 3 32 | 33 | """ 34 | 35 | OFF = auto() 36 | TRACK = auto() 37 | QUEUE = auto() 38 | 39 | class SearchType(Enum): 40 | """The enum for the different search types for Voicelink. 41 | 42 | SearchType.YOUTUBE searches using regular Youtube, 43 | which is best for all scenarios. 44 | 45 | SearchType.YOUTUBE_MUSIC searches using YouTube Music, 46 | which is best for getting audio-only results. 47 | 48 | SearchType.SPOTIFY searches using Spotify, 49 | which is an alternative to YouTube or YouTube Music. 50 | 51 | SearchType.SOUNDCLOUD searches using SoundCloud, 52 | which is an alternative to YouTube or YouTube Music. 53 | 54 | SearchType.APPLE_MUSIC searches using Apple Music, 55 | which is an alternative to YouTube or YouTube Music. 56 | """ 57 | 58 | YOUTUBE = "ytsearch" 59 | YOUTUBE_MUSIC = "ytmsearch" 60 | SPOTIFY = "spsearch" 61 | SOUNDCLOUD = "scsearch" 62 | APPLE_MUSIC = "amsearch" 63 | 64 | def __str__(self) -> str: 65 | return self.value 66 | 67 | @classmethod 68 | def match(cls, value: str): 69 | """find an enum based on a search string.""" 70 | normalized_value = value.lower().replace("_", "").replace(" ", "") 71 | 72 | for member in cls: 73 | normalized_name = member.name.lower().replace("_", "") 74 | if member.value == value or normalized_name == normalized_value: 75 | return member 76 | return None 77 | 78 | @property 79 | def display_name(self) -> str: 80 | return self.name.replace("_", " ").title() 81 | 82 | class RequestMethod(Enum): 83 | """The enum for the different request methods in Voicelink 84 | """ 85 | GET = "get" 86 | PATCH = "patch" 87 | DELETE = "delete" 88 | POST = "post" 89 | 90 | def __str__(self) -> str: 91 | return self.value 92 | 93 | class NodeAlgorithm(Enum): 94 | """The enum for the different node algorithms in Voicelink. 95 | 96 | The enums in this class are to only differentiate different 97 | methods, since the actual method is handled in the 98 | get_best_node() method. 99 | 100 | NodeAlgorithm.by_ping returns a node based on it's latency, 101 | preferring a node with the lowest response time 102 | 103 | NodeAlgorithm.by_region returns a node based on its voice region, 104 | which the region is specified by the user in the method as an arg. 105 | This method will only work if you set a voice region when you create a node. 106 | """ 107 | 108 | # We don't have to define anything special for these, since these just serve as flags 109 | BY_PING = auto() 110 | BY_REGION = auto() 111 | BY_PLAYERS = auto() 112 | 113 | def __str__(self) -> str: 114 | return self.value -------------------------------------------------------------------------------- /voicelink/events.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | from __future__ import annotations 25 | 26 | from .pool import NodePool 27 | from discord.ext.commands import Bot 28 | from typing import TYPE_CHECKING 29 | 30 | if TYPE_CHECKING: 31 | from .player import Player 32 | 33 | class VoicelinkEvent: 34 | """The base class for all events dispatched by a node. 35 | Every event must be formatted within your bot's code as a listener. 36 | i.e: If you want to listen for when a track starts, the event would be: 37 | ```py 38 | @bot.listen 39 | async def on_voicelink_track_start(self, event): 40 | ``` 41 | """ 42 | name = "event" 43 | handler_args = () 44 | 45 | def dispatch(self, bot: Bot): 46 | bot.dispatch(f"voicelink_{self.name}", *self.handler_args) 47 | 48 | 49 | class TrackStartEvent(VoicelinkEvent): 50 | """Fired when a track has successfully started. 51 | Returns the player associated with the event and the voicelink.Track object. 52 | """ 53 | name = "track_start" 54 | 55 | def __init__(self, data: dict, player: Player): 56 | self.player: Player = player 57 | self.track = self.player._current 58 | 59 | # on_voicelink_track_start(player, track) 60 | self.handler_args = self.player, self.track 61 | 62 | def __repr__(self) -> str: 63 | return f"" 64 | 65 | 66 | class TrackEndEvent(VoicelinkEvent): 67 | """Fired when a track has successfully ended. 68 | Returns the player associated with the event along with the voicelink.Track object and reason. 69 | """ 70 | name = "track_end" 71 | 72 | def __init__(self, data: dict, player: Player): 73 | self.player: Player = player 74 | self.track = self.player._ending_track 75 | self.reason: str = data["reason"] 76 | 77 | # on_voicelink_track_end(player, track, reason) 78 | self.handler_args = self.player, self.track, self.reason 79 | 80 | def __repr__(self) -> str: 81 | return ( 82 | f"" 84 | ) 85 | 86 | 87 | class TrackStuckEvent(VoicelinkEvent): 88 | """Fired when a track is stuck and cannot be played. Returns the player 89 | associated with the event along with the voicelink.Track object 90 | to be further parsed by the end user. 91 | """ 92 | name = "track_stuck" 93 | 94 | def __init__(self, data: dict, player: Player): 95 | self.player: Player = player 96 | self.track = self.player._ending_track 97 | self.threshold: float = data["thresholdMs"] 98 | 99 | # on_voicelink_track_stuck(player, track, threshold) 100 | self.handler_args = self.player, self.track, self.threshold 101 | 102 | def __repr__(self) -> str: 103 | return f"" 105 | 106 | 107 | class TrackExceptionEvent(VoicelinkEvent): 108 | """Fired when a track error has occured. 109 | Returns the player associated with the event along with the error code and exception. 110 | """ 111 | name = "track_exception" 112 | 113 | def __init__(self, data: dict, player: Player): 114 | self.player: Player = player 115 | self.track = self.player._ending_track 116 | self.exception: dict = data.get("exception", { 117 | "severity": "", 118 | "message": "", 119 | "cause": "" 120 | }) 121 | 122 | # on_voicelink_track_exception(player, track, error) 123 | self.handler_args = self.player, self.track, self.exception 124 | 125 | def __repr__(self) -> str: 126 | return f"" 127 | 128 | 129 | class WebSocketClosedPayload: 130 | def __init__(self, data: dict): 131 | self.guild = NodePool.get_node().bot.get_guild(int(data["guildId"])) 132 | self.code: int = data["code"] 133 | self.reason: str = data["code"] 134 | self.by_remote: bool = data["byRemote"] 135 | 136 | def __repr__(self) -> str: 137 | return f"" 139 | 140 | 141 | class WebSocketClosedEvent(VoicelinkEvent): 142 | """Fired when a websocket connection to a node has been closed. 143 | Returns the reason and the error code. 144 | """ 145 | name = "websocket_closed" 146 | 147 | def __init__(self, data: dict, _): 148 | self.payload = WebSocketClosedPayload(data) 149 | 150 | # on_voicelink_websocket_closed(payload) 151 | self.handler_args = self.payload, 152 | 153 | def __repr__(self) -> str: 154 | return f"" 155 | 156 | 157 | class WebSocketOpenEvent(VoicelinkEvent): 158 | """Fired when a websocket connection to a node has been initiated. 159 | Returns the target and the session SSRC. 160 | """ 161 | name = "websocket_open" 162 | 163 | def __init__(self, data: dict, _): 164 | self.target: str = data["target"] 165 | self.ssrc: int = data["ssrc"] 166 | 167 | # on_voicelink_websocket_open(target, ssrc) 168 | self.handler_args = self.target, self.ssrc 169 | 170 | def __repr__(self) -> str: 171 | return f"" 172 | 173 | -------------------------------------------------------------------------------- /voicelink/exceptions.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | class VoicelinkException(Exception): 25 | """Base of all Voicelink exceptions.""" 26 | 27 | 28 | class NodeException(Exception): 29 | """Base exception for nodes.""" 30 | 31 | 32 | class NodeCreationError(NodeException): 33 | """There was a problem while creating the node.""" 34 | 35 | 36 | class NodeConnectionFailure(NodeException): 37 | """There was a problem while connecting to the node.""" 38 | 39 | 40 | class NodeConnectionClosed(NodeException): 41 | """The node's connection is closed.""" 42 | pass 43 | 44 | 45 | class NodeNotAvailable(VoicelinkException): 46 | """The node is currently unavailable.""" 47 | pass 48 | 49 | 50 | class NoNodesAvailable(VoicelinkException): 51 | """There are no nodes currently available.""" 52 | pass 53 | 54 | 55 | class TrackInvalidPosition(VoicelinkException): 56 | """An invalid position was chosen for a track.""" 57 | pass 58 | 59 | 60 | class TrackLoadError(VoicelinkException): 61 | """There was an error while loading a track.""" 62 | pass 63 | 64 | 65 | class FilterInvalidArgument(VoicelinkException): 66 | """An invalid argument was passed to a filter.""" 67 | pass 68 | 69 | class FilterTagAlreadyInUse(VoicelinkException): 70 | """A filter with a tag is already in use by another filter""" 71 | pass 72 | 73 | class FilterTagInvalid(VoicelinkException): 74 | """An invalid tag was passed or Voicelink was unable to find a filter tag""" 75 | pass 76 | 77 | class QueueFull(VoicelinkException): 78 | pass 79 | 80 | class OutofList(VoicelinkException): 81 | pass 82 | 83 | class DuplicateTrack(VoicelinkException): 84 | pass -------------------------------------------------------------------------------- /voicelink/objects.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | import re 25 | from typing import Optional 26 | 27 | from discord import Member 28 | from tldextract import extract 29 | 30 | from .enums import SearchType 31 | from function import ( 32 | get_source, 33 | time as ctime 34 | ) 35 | 36 | from .transformer import encode 37 | 38 | YOUTUBE_REGEX = re.compile(r'(https?://)?(www\.)?youtube\.(com|nl)/watch\?v=([-\w]+)') 39 | 40 | class Track: 41 | """The base track object. Returns critical track information needed for parsing by Lavalink. 42 | You can also pass in commands.Context to get a discord.py Context object in your track. 43 | """ 44 | 45 | __slots__ = ( 46 | "_track_id", 47 | "info", 48 | "identifier", 49 | "title", 50 | "author", 51 | "uri", 52 | "source", 53 | "_search_type", 54 | "thumbnail", 55 | "emoji", 56 | "length", 57 | "requester", 58 | "is_stream", 59 | "is_seekable", 60 | "position", 61 | "end_time" 62 | ) 63 | 64 | def __init__( 65 | self, 66 | *, 67 | track_id: str = None, 68 | info: dict, 69 | requester: Member, 70 | search_type: SearchType = SearchType.YOUTUBE, 71 | ): 72 | self._track_id: Optional[str] = track_id 73 | self.info: dict = info 74 | 75 | self.identifier: str = info.get("identifier") 76 | self.title: str = info.get("title", "Unknown") 77 | self.author: str = info.get("author", "Unknown") 78 | self.uri: str = info.get("uri", "https://discord.com/application-directory/605618911471468554") 79 | self.source: str = info.get("sourceName", extract(self.uri).domain) 80 | self._search_type: SearchType = search_type 81 | 82 | self.thumbnail: str = info.get("artworkUrl") 83 | if not self.thumbnail and YOUTUBE_REGEX.match(self.uri): 84 | self.thumbnail = f"https://img.youtube.com/vi/{self.identifier}/maxresdefault.jpg" 85 | 86 | self.emoji: str = get_source(self.source, "emoji") 87 | self.length: float = info.get("length") 88 | 89 | self.requester: Member = requester 90 | self.is_stream: bool = info.get("isStream", False) 91 | self.is_seekable: bool = info.get("isSeekable", True) 92 | self.position: int = info.get("position", 0) 93 | 94 | self.end_time: Optional[int] = None 95 | 96 | def __eq__(self, other) -> bool: 97 | if not isinstance(other, Track): 98 | return False 99 | 100 | return other.track_id == self.track_id 101 | 102 | def __str__(self) -> str: 103 | return self.title 104 | 105 | def __repr__(self) -> str: 106 | return f" length={self.length}>" 107 | 108 | @property 109 | def track_id(self) -> str: 110 | if not self._track_id: 111 | self._track_id = encode(self.info) 112 | 113 | return self._track_id 114 | 115 | @property 116 | def formatted_length(self) -> str: 117 | return ctime(self.length) 118 | 119 | @property 120 | def data(self) -> dict: 121 | return { 122 | "track_id": self.track_id, 123 | "requester_id": self.requester.id 124 | } 125 | 126 | class Playlist: 127 | """The base playlist object. 128 | Returns critical playlist information needed for parsing by Lavalink. 129 | You can also pass in commands.Context to get a discord.py Context object in your tracks. 130 | """ 131 | 132 | __slots__ = ( 133 | "playlist_info", 134 | "name", 135 | "thumbnail", 136 | "uri", 137 | "tracks" 138 | ) 139 | 140 | def __init__( 141 | self, 142 | *, 143 | playlist_info: dict, 144 | tracks: list, 145 | requester: Member = None, 146 | ): 147 | self.playlist_info: dict = playlist_info 148 | self.name: str = playlist_info.get("name") 149 | self.thumbnail: str = None 150 | self.uri: str = None 151 | 152 | self.tracks = [ 153 | Track(track_id=track["encoded"], info=track["info"], requester=requester) 154 | for track in tracks 155 | ] 156 | 157 | def __str__(self) -> str: 158 | return self.name 159 | 160 | def __repr__(self) -> str: 161 | return f"" 162 | 163 | @property 164 | def track_count(self) -> int: 165 | return len(self.tracks) 166 | -------------------------------------------------------------------------------- /voicelink/placeholders.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | from __future__ import annotations 25 | 26 | import re 27 | import function as func 28 | 29 | from discord import Embed, Client 30 | 31 | from typing import TYPE_CHECKING 32 | 33 | if TYPE_CHECKING: 34 | from .player import Player 35 | from .objects import Track 36 | 37 | def ensure_track(func) -> callable: 38 | def wrapper(self: Placeholders, *args, **kwargs): 39 | current = self.get_current() 40 | if not current: 41 | return "None" 42 | return func(self, current, *args, **kwargs) 43 | return wrapper 44 | 45 | class Placeholders: 46 | def __init__(self, bot: Client, player: Player = None) -> None: 47 | self.bot: Client = bot 48 | self.player: Player = player 49 | 50 | self.variables = { 51 | "channel_name": self.channel_name, 52 | "track_name": self.track_name, 53 | "track_url": self.track_url, 54 | "track_author": self.track_author, 55 | "track_duration": self.track_duration, 56 | "track_thumbnail": self.track_thumbnail, 57 | "track_color": self.track_color, 58 | "track_requester_id": self.track_requester_id, 59 | "track_requester_name": self.track_requester_name, 60 | "track_requester_mention": self.track_requester_mention, 61 | "track_requester_avatar": self.track_requester_avatar, 62 | "track_source_name": self.track_source_name, 63 | "track_source_emoji": self.track_source_emoji, 64 | "queue_length": self.queue_length, 65 | "volume": self.volume, 66 | "dj": self.dj, 67 | "loop_mode": self.loop_mode, 68 | "default_embed_color": self.default_embed_color, 69 | "bot_icon": self.bot_icon, 70 | "server_invite_link": func.settings.invite_link, 71 | "invite_link": f"https://discord.com/oauth2/authorize?client_id={self.bot.user.id}&permissions=2184260928&scope=bot%20applications.commands" 72 | } 73 | 74 | def get_current(self) -> Track: 75 | return self.player.current if self.player else None 76 | 77 | def channel_name(self) -> str: 78 | if not self.player: 79 | return "None" 80 | return self.player.channel.name if self.player.channel else "None" 81 | 82 | @ensure_track 83 | def track_name(self, track: Track) -> str: 84 | return track.title 85 | 86 | @ensure_track 87 | def track_url(self, track: Track) -> str: 88 | return track.uri 89 | 90 | @ensure_track 91 | def track_author(self, track: Track) -> str: 92 | return track.author 93 | 94 | @ensure_track 95 | def track_duration(self, track: Track) -> str: 96 | return self.player.get_msg("live") if track.is_stream else func.time(track.length) 97 | 98 | @ensure_track 99 | def track_requester_id(self, track: Track) -> str: 100 | return str(track.requester.id if track.requester else self.bot.user.id) 101 | 102 | @ensure_track 103 | def track_requester_name(self, track: Track) -> str: 104 | return track.requester.name if track.requester else self.bot.user.display_name 105 | 106 | @ensure_track 107 | def track_requester_mention(self, track: Track) -> str: 108 | return f"<@{track.requester.id if track.requester else self.bot.user.id}>" 109 | 110 | @ensure_track 111 | def track_requester_avatar(self, track: Track) -> str: 112 | return track.requester.display_avatar.url if track.requester else self.bot.user.display_avatar.url 113 | 114 | @ensure_track 115 | def track_color(self, track: Track) -> int: 116 | return int(func.get_source(track.source, "color"), 16) 117 | 118 | @ensure_track 119 | def track_source_name(self, track: Track) -> str: 120 | return track.source 121 | 122 | @ensure_track 123 | def track_source_emoji(self, track: Track) -> str: 124 | return track.emoji 125 | 126 | def track_thumbnail(self) -> str: 127 | if not self.player or not self.player.current: 128 | return "https://i.imgur.com/dIFBwU7.png" 129 | 130 | return self.player.current.thumbnail or "https://cdn.discordapp.com/attachments/674788144931012638/823086668445384704/eq-dribbble.gif" 131 | 132 | def queue_length(self) -> str: 133 | return str(self.player.queue.count) if self.player else "0" 134 | 135 | def dj(self) -> str: 136 | if not self.player: 137 | return self.bot.user.mention 138 | 139 | if dj_id := self.player.settings.get("dj"): 140 | return f"<@&{dj_id}>" 141 | 142 | return self.player.dj.mention 143 | 144 | def volume(self) -> int: 145 | return self.player.volume if self.player else 0 146 | 147 | def loop_mode(self) -> str: 148 | return self.player.queue.repeat if self.player else "Off" 149 | 150 | def default_embed_color(self) -> int: 151 | return func.settings.embed_color 152 | 153 | def bot_icon(self) -> str: 154 | return self.bot.user.display_avatar.url if self.player else "https://i.imgur.com/dIFBwU7.png" 155 | 156 | def replace(self, text: str, variables: dict[str, str]) -> str: 157 | if not text or text.isspace(): return 158 | pattern = r"\{\{(.*?)\}\}" 159 | matches: list[str] = re.findall(pattern, text) 160 | 161 | for match in matches: 162 | parts: list[str] = match.split("??") 163 | expression = parts[0].strip() 164 | true_value, false_value = "", "" 165 | 166 | # Split the true and false values 167 | if "//" in parts[1]: 168 | true_value, false_value = [part.strip() for part in parts[1].split("//")] 169 | else: 170 | true_value = parts[1].strip() 171 | 172 | try: 173 | # Replace variable placeholders with their values 174 | expression = re.sub(r'@@(.*?)@@', lambda x: "'" + variables.get(x.group(1), '') + "'", expression) 175 | expression = re.sub(r"'(\d+)'", lambda x: str(int(x.group(1))), expression) 176 | expression = re.sub(r"'(\d+)'\s*([><=!]+)\s*(\d+)", lambda x: f"{int(x.group(1))} {x.group(2)} {int(x.group(3))}", expression) 177 | 178 | # Evaluate the expression 179 | result = eval(expression, {"__builtins__": None}, variables) 180 | 181 | # Replace the match with the true or false value based on the result 182 | replacement = true_value if result else false_value 183 | text = text.replace("{{" + match + "}}", replacement) 184 | 185 | except: 186 | text = text.replace("{{" + match + "}}", "") 187 | 188 | text = re.sub(r'@@(.*?)@@', lambda x: str(variables.get(x.group(1), '')), text) 189 | return text 190 | 191 | def build_embed(raw: dict[str, dict], placeholder: Placeholders) -> Embed: 192 | embed = Embed() 193 | try: 194 | rv = {key: func() if callable(func) else func for key, func in placeholder.variables.items()} 195 | if author := raw.get("author"): 196 | embed.set_author( 197 | name = placeholder.replace(author.get("name"), rv), 198 | url = placeholder.replace(author.get("url"), rv), 199 | icon_url = placeholder.replace(author.get("icon_url"), rv) 200 | ) 201 | 202 | if title := raw.get("title"): 203 | embed.title = placeholder.replace(title.get("name"), rv) 204 | embed.url = placeholder.replace(title.get("url"), rv) 205 | 206 | if fields := raw.get("fields", []): 207 | for f in fields: 208 | embed.add_field(name=placeholder.replace(f.get("name"), rv), value=placeholder.replace(f.get("value", ""), rv), inline=f.get("inline", False)) 209 | 210 | if footer := raw.get("footer"): 211 | embed.set_footer( 212 | text = placeholder.replace(footer.get("text"), rv), 213 | icon_url = placeholder.replace(footer.get("icon_url"), rv) 214 | ) 215 | 216 | if thumbnail := raw.get("thumbnail"): 217 | embed.set_thumbnail(url = placeholder.replace(thumbnail, rv)) 218 | 219 | if image := raw.get("image"): 220 | embed.set_image(url = placeholder.replace(image, rv)) 221 | 222 | embed.description = placeholder.replace(raw.get("description"), rv) 223 | embed.color = int(placeholder.replace(raw.get("color"), rv)) 224 | 225 | except: 226 | pass 227 | 228 | return embed -------------------------------------------------------------------------------- /voicelink/queue.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | from .exceptions import QueueFull, OutofList 25 | from .objects import Track 26 | from .enums import LoopType 27 | 28 | from typing import Optional, Tuple, Callable, Dict, List 29 | from itertools import cycle 30 | from discord import Member 31 | 32 | class LoopTypeCycle: 33 | def __init__(self) -> None: 34 | self._cycle = cycle(LoopType) 35 | self.current = next(self._cycle) 36 | 37 | def next(self) -> LoopType: 38 | self.current = next(self._cycle) 39 | return self.current 40 | 41 | def set_mode(self, value: LoopType) -> LoopType: 42 | while next(self._cycle) != value: 43 | pass 44 | self.current = value 45 | return value 46 | 47 | @property 48 | def mode(self) -> LoopType: 49 | return self.current 50 | 51 | def __str__(self) -> str: 52 | return self.current.name.capitalize() 53 | 54 | class Queue: 55 | def __init__(self, size: int, allow_duplicate: bool, get_msg: Callable[[str], str]) -> None: 56 | self._queue: List[Track] = [] 57 | self._position: int = 0 58 | self._size: int = size 59 | self._repeat: LoopTypeCycle = LoopTypeCycle() 60 | self._repeat_position: int = 0 61 | self._allow_duplicate: bool = allow_duplicate 62 | 63 | self.get_msg = get_msg 64 | 65 | def get(self) -> Optional[Track]: 66 | track = None 67 | try: 68 | track = self._queue[self._position - 1 if self._repeat.mode == LoopType.TRACK else self._position] 69 | if self._repeat.mode != LoopType.TRACK: 70 | self._position += 1 71 | except: 72 | if self._repeat.mode == LoopType.QUEUE: 73 | try: 74 | track = self._queue[self._repeat_position] 75 | self._position = self._repeat_position + 1 76 | except IndexError: 77 | self._repeat.set_mode(LoopType.OFF) 78 | 79 | return track 80 | 81 | def put(self, item: Track) -> int: 82 | if self.count >= self._size: 83 | raise QueueFull(self.get_msg("voicelinkQueueFull").format(self._size)) 84 | 85 | self._queue.append(item) 86 | return self.count 87 | 88 | def put_at_front(self, item: Track) -> int: 89 | if self.count >= self._size: 90 | raise QueueFull(self.get_msg("voicelinkQueueFull").format(self._size)) 91 | 92 | self._queue.insert(self._position, item) 93 | return 1 94 | 95 | def put_at_index(self, index: int, item: Track) -> None: 96 | if self.count >= self._size: 97 | raise QueueFull(self.get_msg("voicelinkQueueFull").format(self._size)) 98 | 99 | return self._queue.insert(self._position - 1 + index, item) 100 | 101 | def skipto(self, index: int) -> None: 102 | if not 0 < index <= self.count: 103 | raise OutofList(self.get_msg("voicelinkOutofList")) 104 | else: 105 | self._position += index - 1 106 | 107 | def backto(self, index: int) -> None: 108 | if not self._position - index >= 0: 109 | raise OutofList(self.get_msg("voicelinkOutofList")) 110 | else: 111 | self._position -= index 112 | 113 | def history_clear(self, is_playing: bool) -> None: 114 | self._queue[:self._position - 1 if is_playing else self._position] = [] 115 | self._position = 1 if is_playing else 0 116 | 117 | def clear(self) -> None: 118 | del self._queue[self._position:] 119 | 120 | def replace(self, queue_type: str, replacement: list) -> None: 121 | if queue_type == "queue": 122 | self.clear() 123 | self._queue += replacement 124 | elif queue_type == "history": 125 | self._queue[:self._position] = replacement 126 | 127 | def swap(self, track_index1: int, track_index2: int) -> Tuple[Track, Track]: 128 | try: 129 | adjusted_position = self._position - 1 130 | self._queue[adjusted_position + track_index1], self._queue[adjusted_position + track_index2] = self._queue[adjusted_position + track_index2], self._queue[adjusted_position + track_index1] 131 | return self._queue[adjusted_position + track_index1], self._queue[adjusted_position + track_index2] 132 | except IndexError: 133 | raise OutofList(self.get_msg("voicelinkOutofList")) 134 | 135 | def move(self, target: int, to: int) -> Optional[Track]: 136 | if not 0 < target <= self.count or not 0 < to: 137 | raise OutofList(self.get_msg("voicelinkOutofList")) 138 | 139 | try: 140 | item = self._queue[self._position + target - 1] 141 | self._queue.remove(item) 142 | self.put_at_index(to, item) 143 | return item 144 | except: 145 | raise OutofList(self.get_msg("voicelinkOutofList")) 146 | 147 | def remove(self, index: int, index2: int = None, member: Member = None) -> Dict[int, Track]: 148 | pos = self._position - 1 149 | 150 | if index2 is None: 151 | index2 = index 152 | 153 | elif index2 < index: 154 | index, index2 = index2, index 155 | 156 | try: 157 | removed_tracks: Dict[str, Track] = {} 158 | for i, track in enumerate(self._queue[pos + index: pos + index2 + 1]): 159 | if member and track.requester != member: 160 | continue 161 | 162 | self._queue.remove(track) 163 | removed_tracks[pos + index + i] = track 164 | 165 | return removed_tracks 166 | except: 167 | raise OutofList(self.get_msg("voicelinkOutofList")) 168 | 169 | def history(self, incTrack: bool = False) -> List[Track]: 170 | if incTrack: 171 | return self._queue[:self._position] 172 | return self._queue[:self._position - 1] 173 | 174 | def tracks(self, incTrack: bool = False) -> List[Track]: 175 | if incTrack: 176 | return self._queue[self._position - 1:] 177 | return self._queue[self._position:] 178 | 179 | @property 180 | def count(self) -> int: 181 | return len(self._queue[self._position:]) 182 | 183 | @property 184 | def repeat(self) -> str: 185 | return self._repeat.mode.name.capitalize() 186 | 187 | @property 188 | def is_empty(self) -> bool: 189 | try: 190 | self._queue[self._position] 191 | except: 192 | return True 193 | return False 194 | 195 | class FairQueue(Queue): 196 | def __init__(self, size: int, allow_duplicate: bool, get_msg) -> None: 197 | super().__init__(size, allow_duplicate, get_msg) 198 | self._set = set() 199 | 200 | def put(self, item: Track) -> int: 201 | if len(self._queue) >= self._size: 202 | raise QueueFull(self.get_msg("voicelinkQueueFull").format(self._size)) 203 | 204 | tracks = self.tracks(incTrack=True) 205 | lastIndex = len(tracks) 206 | for track in reversed(tracks): 207 | if track.requester == item.requester: 208 | break 209 | lastIndex -= 1 210 | self._set.clear() 211 | for track in tracks[lastIndex:]: 212 | if track.requester in self._set: 213 | break 214 | lastIndex += 1 215 | self._set.add(track.requester) 216 | 217 | self.put_at_index(lastIndex, item) 218 | return lastIndex 219 | -------------------------------------------------------------------------------- /voicelink/ratelimit.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | import time 25 | from abc import ABC, abstractmethod 26 | from typing import List, Optional, Dict, TYPE_CHECKING, Any 27 | 28 | if TYPE_CHECKING: 29 | from .pool import Node 30 | 31 | class YTToken: 32 | def __init__(self, token: str): 33 | self.token: str = token 34 | self.allow_retry_time: float = 0.0 35 | self.requested_times: int = 0 36 | self.is_flagged: bool = False 37 | self.flagged_time: float = 0.0 38 | 39 | @property 40 | def allow_retry(self) -> bool: 41 | """Determine if the token can be used again.""" 42 | return time.time() >= self.allow_retry_time 43 | 44 | class YTRatelimit(ABC): 45 | """ 46 | Abstract base class for YouTube rate limit strategies. 47 | """ 48 | def __init__(self, node: "Node", tokens: List[str]) -> None: 49 | self.node: "Node" = node 50 | self.tokens: List[YTToken] = [YTToken(token) for token in tokens] 51 | self.active_token: Optional[YTToken] = self.tokens[0] if self.tokens else None 52 | 53 | @abstractmethod 54 | async def flag_active_token(self) -> None: 55 | """ 56 | Mark the current active token as flagged when a rate-limit is encountered. 57 | """ 58 | pass 59 | 60 | @abstractmethod 61 | async def handle_request(self) -> None: 62 | """ 63 | Update usage count or perform any necessary pre-request operations. 64 | """ 65 | pass 66 | 67 | async def swap_token(self) -> Optional[YTToken]: 68 | """ 69 | Swap the active token with another token that is either not flagged or ready to retry. 70 | If a new token is found, update it via the node and return it. 71 | """ 72 | for token in self.tokens: 73 | if token != self.active_token and (not token.is_flagged or token.allow_retry): 74 | try: 75 | await self.node.update_refresh_yt_access_token(token) 76 | self.active_token = token 77 | return token 78 | except Exception as e: 79 | self.node._logger.error("Something wrong while updating the youtube access token.", exc_info=e) 80 | 81 | self.node._logger.warning("No active token available for processing the request.") 82 | return None 83 | 84 | class LoadBalance(YTRatelimit): 85 | """ 86 | A rate limiting strategy that load balances requests across tokens. 87 | """ 88 | def __init__(self, node: "Node", config: Dict[str, Any]): 89 | super().__init__(node, tokens=config.get("tokens", [])) 90 | self._config: Dict[str, Any] = config.get("config", {}) 91 | self._retry_time: int = self._config.get("retry_time", 10_800) 92 | self._max_requests: int = self._config.get("max_requests", 30) 93 | 94 | async def flag_active_token(self) -> None: 95 | """ 96 | Flag the active token and set a delay (e.g., 3 hours) until it can be retried. 97 | """ 98 | if self.active_token: 99 | self.active_token.is_flagged = True 100 | self.active_token.flagged_time = time.time() 101 | self.active_token.allow_retry_time = self.active_token.flagged_time + self._retry_time 102 | await self.swap_token() 103 | 104 | async def handle_request(self) -> None: 105 | """ 106 | Increment the active token's usage counter and swap tokens if a threshold is reached. 107 | """ 108 | if not self.active_token: 109 | return await self.swap_token() 110 | 111 | self.active_token.requested_times += 1 112 | if self.active_token.requested_times >= self._max_requests: 113 | self.active_token.requested_times = 0 114 | swapped_token = await self.swap_token() 115 | if swapped_token is None: 116 | return self.node._logger.warning("No available token found after swapping.") 117 | 118 | STRATEGY = { 119 | "LoadBalance": LoadBalance 120 | } -------------------------------------------------------------------------------- /voicelink/transformer.py: -------------------------------------------------------------------------------- 1 | """ 2 | MIT License 3 | 4 | Copyright (c) 2017-present Devoxin 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | """ 24 | 25 | import struct 26 | 27 | from io import BytesIO 28 | from base64 import b64decode, b64encode 29 | from typing import Any, Callable, Dict, Mapping, Optional, Tuple, Final 30 | 31 | V2_KEYSET = {'title', 'author', 'length', 'identifier', 'isStream', 'uri', 'sourceName', 'position'} 32 | V3_KEYSET = V2_KEYSET | {'artworkUrl', 'isrc'} 33 | 34 | class _MissingObj: 35 | __slots__ = () 36 | 37 | def __repr__(self): 38 | return '...' 39 | 40 | MISSING: Any = _MissingObj() 41 | 42 | class DataReader: 43 | __slots__ = ('_buf', '_mark') 44 | 45 | def __init__(self, base64_str: str): 46 | self._buf: Final[BytesIO] = BytesIO(b64decode(base64_str)) 47 | self._mark: Optional[int] = None 48 | 49 | @property 50 | def remaining(self) -> int: 51 | return self._buf.getbuffer().nbytes - self._buf.tell() 52 | 53 | def mark(self) -> None: 54 | self._mark = self._buf.tell() 55 | 56 | def rewind(self) -> None: 57 | if self._mark is None or not isinstance(self._mark, int): 58 | raise IOError('Cannot rewind buffer without a marker!') 59 | 60 | if self._mark < 0: 61 | raise IOError('Cannot rewind buffer to a negative position!') 62 | 63 | self._buf.seek(self._mark) 64 | self._mark = None 65 | 66 | def _read(self, count: int) -> bytes: 67 | return self._buf.read(count) 68 | 69 | def read_byte(self) -> bytes: 70 | return self._read(1) 71 | 72 | def read_boolean(self) -> bool: 73 | result, = struct.unpack('B', self.read_byte()) 74 | return result != 0 75 | 76 | def read_unsigned_short(self) -> int: 77 | result, = struct.unpack('>H', self._read(2)) 78 | return result 79 | 80 | def read_int(self) -> int: 81 | result, = struct.unpack('>i', self._read(4)) 82 | return result 83 | 84 | def read_long(self) -> int: 85 | result, = struct.unpack('>Q', self._read(8)) 86 | return result 87 | 88 | def read_nullable_utf(self, utfm: bool = False) -> Optional[str]: 89 | exists = self.read_boolean() 90 | 91 | if not exists: 92 | return None 93 | 94 | return self.read_utfm() if utfm else self.read_utf().decode() 95 | 96 | def read_utf(self) -> bytes: 97 | text_length = self.read_unsigned_short() 98 | return self._read(text_length) 99 | 100 | def read_utfm(self) -> str: 101 | text_length = self.read_unsigned_short() 102 | utf_string = self._read(text_length) 103 | return read_utfm(text_length, utf_string) 104 | 105 | class DataWriter: 106 | __slots__ = ('_buf',) 107 | 108 | def __init__(self): 109 | self._buf: Final[BytesIO] = BytesIO() 110 | 111 | def _write(self, data): 112 | self._buf.write(data) 113 | 114 | def write_byte(self, byte): 115 | self._buf.write(byte) 116 | 117 | def write_boolean(self, boolean: bool): 118 | enc = struct.pack('B', 1 if boolean else 0) 119 | self.write_byte(enc) 120 | 121 | def write_unsigned_short(self, short: int): 122 | enc = struct.pack('>H', short) 123 | self._write(enc) 124 | 125 | def write_int(self, integer: int): 126 | enc = struct.pack('>i', integer) 127 | self._write(enc) 128 | 129 | def write_long(self, long_value: int): 130 | enc = struct.pack('>Q', long_value) 131 | self._write(enc) 132 | 133 | def write_nullable_utf(self, utf_string: Optional[str]): 134 | self.write_boolean(bool(utf_string)) 135 | 136 | if utf_string: 137 | self.write_utf(utf_string) 138 | 139 | def write_utf(self, utf_string: str): 140 | utf = utf_string.encode('utf8') 141 | byte_len = len(utf) 142 | 143 | if byte_len > 65535: 144 | raise OverflowError('UTF string may not exceed 65535 bytes!') 145 | 146 | self.write_unsigned_short(byte_len) 147 | self._write(utf) 148 | 149 | def finish(self) -> bytes: 150 | with BytesIO() as track_buf: 151 | byte_len = self._buf.getbuffer().nbytes 152 | flags = byte_len | (1 << 30) 153 | enc_flags = struct.pack('>i', flags) 154 | track_buf.write(enc_flags) 155 | 156 | self._buf.seek(0) 157 | track_buf.write(self._buf.read()) 158 | self._buf.close() 159 | 160 | track_buf.seek(0) 161 | return track_buf.read() 162 | 163 | def decode_probe_info(reader: DataReader) -> Mapping[str, Any]: 164 | probe_info = reader.read_utf().decode() 165 | return {'probe_info': probe_info} 166 | 167 | def decode_lavasrc_fields(reader: DataReader) -> Mapping[str, Any]: 168 | if reader.remaining <= 8: 169 | return {} 170 | 171 | album_name = reader.read_nullable_utf() 172 | album_url = reader.read_nullable_utf() 173 | artist_url = reader.read_nullable_utf() 174 | artist_artwork_url = reader.read_nullable_utf() 175 | preview_url = reader.read_nullable_utf() 176 | is_preview = reader.read_boolean() 177 | 178 | return { 179 | 'album_name': album_name, 180 | 'album_url': album_url, 181 | 'artist_url': artist_url, 182 | 'artist_artwork_url': artist_artwork_url, 183 | 'preview_url': preview_url, 184 | 'is_preview': is_preview 185 | } 186 | 187 | DEFAULT_DECODER_MAPPING: Dict[str, Callable[[DataReader], Mapping[str, Any]]] = { 188 | 'http': decode_probe_info, 189 | 'local': decode_probe_info, 190 | 'deezer': decode_lavasrc_fields, 191 | 'spotify': decode_lavasrc_fields, 192 | 'applemusic': decode_lavasrc_fields 193 | } 194 | 195 | def read_utfm(utf_len: int, utf_bytes: bytes) -> str: 196 | chars = [] 197 | count = 0 198 | 199 | while count < utf_len: 200 | char = utf_bytes[count] & 0xff 201 | if char > 127: 202 | break 203 | 204 | count += 1 205 | chars.append(chr(char)) 206 | 207 | while count < utf_len: 208 | char = utf_bytes[count] & 0xff 209 | shift = char >> 4 210 | 211 | if 0 <= shift <= 7: 212 | count += 1 213 | chars.append(chr(char)) 214 | elif 12 <= shift <= 13: 215 | count += 2 216 | if count > utf_len: 217 | raise UnicodeDecodeError('utf8', b'', 0, utf_len, 'malformed input: partial character at end') 218 | char2 = utf_bytes[count - 1] 219 | if (char2 & 0xC0) != 0x80: 220 | raise UnicodeDecodeError('utf8', b'', 0, utf_len, f'malformed input around byte {count}') 221 | 222 | char_shift = ((char & 0x1F) << 6) | (char2 & 0x3F) 223 | chars.append(chr(char_shift)) 224 | elif shift == 14: 225 | count += 3 226 | if count > utf_len: 227 | raise UnicodeDecodeError('utf8', b'', 0, utf_len, 'malformed input: partial character at end') 228 | 229 | char2 = utf_bytes[count - 2] 230 | char3 = utf_bytes[count - 1] 231 | 232 | if (char2 & 0xC0) != 0x80 or (char3 & 0xC0) != 0x80: 233 | raise UnicodeDecodeError('utf8', b'', 0, utf_len, f'malformed input around byte {(count - 1)}') 234 | 235 | char_shift = ((char & 0x0F) << 12) | ((char2 & 0x3F) << 6) | ((char3 & 0x3F) << 0) 236 | chars.append(chr(char_shift)) 237 | else: 238 | raise UnicodeDecodeError('utf8', b'', 0, utf_len, f'malformed input around byte {count}') 239 | 240 | return ''.join(chars).encode('utf-16', 'surrogatepass').decode('utf-16') 241 | 242 | def _read_track_common(reader: DataReader) -> Tuple[str, str, int, str, bool, Optional[str]]: 243 | title = reader.read_utfm() 244 | author = reader.read_utfm() 245 | length = reader.read_long() 246 | identifier = reader.read_utf().decode() 247 | is_stream = reader.read_boolean() 248 | uri = reader.read_nullable_utf() 249 | return (title, author, length, identifier, is_stream, uri) 250 | 251 | def _write_track_common(track: Dict[str, Any], writer: DataWriter): 252 | writer.write_utf(track['title']) 253 | writer.write_utf(track['author']) 254 | writer.write_long(track['length']) 255 | writer.write_utf(track['identifier']) 256 | writer.write_boolean(track['isStream']) 257 | writer.write_nullable_utf(track['uri']) 258 | 259 | def decode( 260 | track: str, 261 | source_decoders: Mapping[str, Callable[[DataReader], Mapping[str, Any]]] = MISSING 262 | ) -> dict: 263 | 264 | decoders = DEFAULT_DECODER_MAPPING.copy() 265 | 266 | if source_decoders is not MISSING: 267 | decoders.update(source_decoders) 268 | 269 | reader = DataReader(track) 270 | 271 | flags = (reader.read_int() & 0xC0000000) >> 30 272 | version, = struct.unpack('B', reader.read_byte()) if flags & 1 != 0 else (1,) 273 | 274 | title, author, length, identifier, is_stream, uri = _read_track_common(reader) 275 | extra_fields = {} 276 | 277 | if version == 3: 278 | extra_fields['artworkUrl'] = reader.read_nullable_utf() 279 | extra_fields['isrc'] = reader.read_nullable_utf() 280 | 281 | source = reader.read_utf().decode() 282 | source_specific_fields = {} 283 | 284 | if source in decoders: 285 | source_specific_fields.update(decoders[source](reader)) 286 | 287 | position = reader.read_long() 288 | 289 | return { 290 | 'title': title, 291 | 'author': author, 292 | 'length': length, 293 | 'identifier': identifier, 294 | 'isStream': is_stream, 295 | 'uri': uri, 296 | 'isSeekable': not is_stream, 297 | 'sourceName': source, 298 | 'position': position, 299 | **extra_fields 300 | } 301 | 302 | def encode( 303 | track: Dict[str, Any], 304 | source_encoders: Mapping[str, Callable[[DataWriter, Dict[str, Any]], None]] = MISSING 305 | ) -> str: 306 | assert V3_KEYSET <= track.keys() 307 | 308 | writer = DataWriter() 309 | version = struct.pack('B', 3) 310 | writer.write_byte(version) 311 | _write_track_common(track, writer) 312 | writer.write_nullable_utf(track['artworkUrl']) 313 | writer.write_nullable_utf(track['isrc']) 314 | writer.write_utf(track['sourceName']) 315 | 316 | if source_encoders is not MISSING and track['sourceName'] in source_encoders: 317 | source_encoders[track['sourceName']](writer, track) 318 | 319 | writer.write_long(track['position']) 320 | 321 | enc = writer.finish() 322 | return b64encode(enc).decode() -------------------------------------------------------------------------------- /voicelink/utils.py: -------------------------------------------------------------------------------- 1 | """MIT License 2 | 3 | Copyright (c) 2023 - present Vocard Development 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | """ 23 | 24 | import random 25 | import time 26 | import socket 27 | from timeit import default_timer as timer 28 | from itertools import zip_longest 29 | 30 | from typing import Dict, Optional 31 | 32 | __all__ = [ 33 | "ExponentialBackoff", 34 | "NodeStats", 35 | "NodeInfoVersion", 36 | "NodeInfo", 37 | "Plugin", 38 | "Ping" 39 | ] 40 | 41 | class ExponentialBackoff: 42 | """ 43 | The MIT License (MIT) 44 | Copyright (c) 2015-present Rapptz 45 | Permission is hereby granted, free of charge, to any person obtaining a 46 | copy of this software and associated documentation files (the "Software"), 47 | to deal in the Software without restriction, including without limitation 48 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 49 | and/or sell copies of the Software, and to permit persons to whom the 50 | Software is furnished to do so, subject to the following conditions: 51 | The above copyright notice and this permission notice shall be included in 52 | all copies or substantial portions of the Software. 53 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 54 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 55 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 56 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 57 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 58 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 59 | DEALINGS IN THE SOFTWARE. 60 | """ 61 | 62 | def __init__(self, base: int = 1, *, integral: bool = False) -> None: 63 | 64 | self._base = base 65 | 66 | self._exp = 0 67 | self._max = 10 68 | self._reset_time = base * 2 ** 11 69 | self._last_invocation = time.monotonic() 70 | 71 | rand = random.Random() 72 | rand.seed() 73 | 74 | self._randfunc = rand.randrange if integral else rand.uniform 75 | 76 | def delay(self) -> float: 77 | 78 | invocation = time.monotonic() 79 | interval = invocation - self._last_invocation 80 | self._last_invocation = invocation 81 | 82 | if interval > self._reset_time: 83 | self._exp = 0 84 | 85 | self._exp = min(self._exp + 1, self._max) 86 | return self._randfunc(0, self._base * 2 ** self._exp) 87 | 88 | 89 | class NodeStats: 90 | """The base class for the node stats object. 91 | Gives critical information on the node, which is updated every minute. 92 | """ 93 | 94 | def __init__(self, data: Dict) -> None: 95 | 96 | memory: Dict = data.get("memory") 97 | self.used: int = memory.get("used") 98 | self.free: int = memory.get("free") 99 | self.reservable: int = memory.get("reservable") 100 | self.allocated: int = memory.get("allocated") 101 | 102 | cpu: Dict = data.get("cpu") 103 | self.cpu_cores: int = cpu.get("cores") 104 | self.cpu_system_load: float = cpu.get("systemLoad") 105 | self.cpu_process_load: float = cpu.get("lavalinkLoad") 106 | 107 | self.players_active: int = data.get("playingPlayers") 108 | self.players_total: int = data.get("players") 109 | self.uptime: int = data.get("uptime") 110 | 111 | def __repr__(self) -> str: 112 | return f"" 113 | 114 | class NodeInfoVersion: 115 | """The base class for the node info object. 116 | Gives version information on the node. 117 | """ 118 | def __init__(self, data: Dict) -> None: 119 | self.semver: str = data.get("semver") 120 | self.major: int = data.get("major") 121 | self.minor: int = data.get("minor") 122 | self.patch: int = data.get("patch") 123 | self.pre_release: Optional[str] = data.get("preRelease") 124 | self.build: Optional[str] = data.get("build") 125 | 126 | class NodeInfo: 127 | """The base class for the node info object. 128 | Gives basic information on the node. 129 | """ 130 | def __init__(self, data: Dict) -> None: 131 | self.version: NodeInfoVersion = NodeInfoVersion(data.get("version")) 132 | self.build_time: int = data.get("buildTime") 133 | self.jvm: str = data.get("jvm") 134 | self.lavaplayer: str = data.get("lavaplayer") 135 | self.plugins: Optional[Dict[str, Plugin]] = [Plugin(plugin_data) for plugin_data in data.get("plugins")] 136 | 137 | class Plugin: 138 | """The base class for the plugin object. 139 | Gives basic information on the plugin. 140 | """ 141 | def __init__(self, data: Dict) -> None: 142 | self.name: str = data.get("name") 143 | self.version: str = data.get("version") 144 | 145 | class Ping: 146 | # Thanks to https://github.com/zhengxiaowai/tcping for the nice ping impl 147 | def __init__(self, host, port, timeout=5): 148 | self.timer = self.Timer() 149 | 150 | self._successed = 0 151 | self._failed = 0 152 | self._conn_time = None 153 | self._host = host 154 | self._port = port 155 | self._timeout = timeout 156 | 157 | class Socket(object): 158 | def __init__(self, family, type_, timeout): 159 | s = socket.socket(family, type_) 160 | s.settimeout(timeout) 161 | self._s = s 162 | 163 | def connect(self, host, port): 164 | self._s.connect((host, int(port))) 165 | 166 | def shutdown(self): 167 | self._s.shutdown(socket.SHUT_RD) 168 | 169 | def close(self): 170 | self._s.close() 171 | 172 | 173 | class Timer(object): 174 | def __init__(self): 175 | self._start = 0 176 | self._stop = 0 177 | 178 | def start(self): 179 | self._start = timer() 180 | 181 | def stop(self): 182 | self._stop = timer() 183 | 184 | def cost(self, funcs, args): 185 | self.start() 186 | for func, arg in zip_longest(funcs, args): 187 | if arg: 188 | func(*arg) 189 | else: 190 | func() 191 | 192 | self.stop() 193 | return self._stop - self._start 194 | 195 | def _create_socket(self, family, type_): 196 | return self.Socket(family, type_, self._timeout) 197 | 198 | def get_ping(self): 199 | s = self._create_socket(socket.AF_INET, socket.SOCK_STREAM) 200 | 201 | cost_time = self.timer.cost( 202 | (s.connect, s.shutdown), 203 | ((self._host, self._port), None)) 204 | s_runtime = 1000 * (cost_time) 205 | 206 | return s_runtime --------------------------------------------------------------------------------