├── .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 |
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 | 
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
--------------------------------------------------------------------------------