diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..d89720d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,52 @@ +name: Bug Report +description: Report broken or incorrect behaviour +labels: unconfirmed bug +body: + - type: markdown + attributes: + value: > + Thank you for submitting a bug report! For real-time support, please join our [Discord community](https://discord.gg/wRCgB7vBQv). + This form is specifically for reporting bugs, and we appreciate your understanding! + + **Note:** This form is for bugs only! + - type: input + attributes: + label: Summary + description: A simple summary of your bug report + validations: + required: true + - type: textarea + attributes: + label: Reproduction Steps + description: What you did to make it happen. + validations: + required: true + - type: textarea + attributes: + label: System Information + description: > + Run `python -m discord -v` and paste this information below. This command requires v1.1.0 or higher of the library. + If this errors out, please provide basic information about your system, such as your operating system and Python version. + validations: + required: true + - type: textarea + attributes: + label: Error Logs + description: Paste the loggings from your console. Include only relevant errors or warnings. + validations: + required: true + - type: checkboxes + attributes: + label: Checklist + description: Let's ensure you've done your due diligence when reporting this issue! + options: + - label: I have searched the open issues for duplicates. + required: true + - label: I have included the entire traceback, if possible. + required: true + - label: I have removed my token from display, if visible. + required: true + - type: textarea + attributes: + label: Additional Context + description: If there is anything else to say, please do so here. \ No newline at end of file diff --git a/.gitignore b/.gitignore index 51c14c6..ceb9c91 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,179 @@ -Vocard.rar +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +**/.DS_Store + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments .env -*.pyc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Custom file settings.json logs \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 91db733..f5ea993 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,33 @@ -FROM python:3.12-slim +# Stage 1: Build +FROM python:3.12-slim-bookworm as builder -# Install build dependencies -RUN apt-get update -y && apt-get install -y gcc python3-dev +# Install build dependencies (gcc, Python headers, etc.) +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + python3-dev \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* -# Set the working directory to /app +# Set the working directory WORKDIR /app -# Copy the current directory contents into the container at /app -COPY . /app +# Copy only the requirements file to take advantage of Docker's caching +COPY requirements.txt . -# Install any needed packages specified in requirements.txt +# Install Python dependencies RUN pip install --no-cache-dir -r requirements.txt -# Run main.py when the container launches +# Stage 2: Runtime +FROM python:3.12-slim-bookworm + +# Set the working directory +WORKDIR /app + +# Copy installed Python packages from the builder stage +COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages + +# Copy the application code +COPY . . + +# Run the application CMD ["python", "-u", "main.py"] \ No newline at end of file diff --git a/README.md b/README.md index 5249a08..afe10e3 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,10 @@ Vocard is a highly customizable Discord music bot, designed to deliver a user-fr * Multiple languages available * Easy to update * Supports docker -* Premium dashboard (in beta) +* [Premium dashboard](https://github.com/ChocoMeow/Vocard-Dashboard) ## Screenshot -![features](https://github.com/user-attachments/assets/f34b542d-be37-4170-bb80-c44748d8eb04) +![features](https://github.com/user-attachments/assets/2a1baf75-d1c8-41d1-a66f-7011e96d5feb) ## Requirements * [Python 3.11+](https://www.python.org/downloads/) diff --git a/addons/__init__.py b/addons/__init__.py index a835660..9bcfb4e 100644 --- a/addons/__init__.py +++ b/addons/__init__.py @@ -1,3 +1,3 @@ -from .lyrics import lyricsPlatform +from .lyrics import LYRICS_PLATFORMS from .placeholders import Placeholders from .settings import Settings \ No newline at end of file diff --git a/addons/lyrics.py b/addons/lyrics.py index 34de9f9..40b8e1d 100644 --- a/addons/lyrics.py +++ b/addons/lyrics.py @@ -1,3 +1,26 @@ +"""MIT License + +Copyright (c) 2023 - present Vocard Development + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + import aiohttp, random, bs4, re import function as func @@ -5,7 +28,7 @@ from abc import ABC, abstractmethod from urllib.parse import quote from math import floor from importlib import import_module -from typing import Optional +from typing import Optional, Type userAgents = '''Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36 Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.1 Safari/537.36 @@ -49,6 +72,7 @@ Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_6; en-US) AppleWebKit/530.6 (KHTM Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_6; en-US) AppleWebKit/530.5 (KHTML, like Gecko) Chrome/ Safari/530.5''' LYRIST_ENDPOINT = "https://lyrist.vercel.app/api/" +LRCLIB_ENDPOINT = "https://lrclib.net/api/" class LyricsPlatform(ABC): @abstractmethod @@ -196,8 +220,26 @@ class Lyrist(LyricsPlatform): except: return None -lyricsPlatform: dict[str, LyricsPlatform] = { +class Lrclib(LyricsPlatform): + async def get(self, url, params: dict = None) -> list[dict]: + try: + async with aiohttp.ClientSession() as session: + resp = await session.get(url=url, headers={'User-Agent': random.choice(userAgents)}, params=params) + if resp.status != 200: + return None + return await resp.json() + except: + return [] + + async def get_lyrics(self, title, artist): + params = {"q": title} + result = await self.get(LRCLIB_ENDPOINT + "search", params) + if result: + return {"default": result[0].get("plainLyrics", "")} + +LYRICS_PLATFORMS: dict[str, Type[LyricsPlatform]] = { "a_zlyrics": A_ZLyrics, "genius": Genius, - "lyrist": Lyrist + "lyrist": Lyrist, + "lrclib": Lrclib } \ No newline at end of file diff --git a/addons/placeholders.py b/addons/placeholders.py index d08d097..a3a2a64 100644 --- a/addons/placeholders.py +++ b/addons/placeholders.py @@ -1,3 +1,26 @@ +"""MIT License + +Copyright (c) 2023 - present Vocard Development + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + from discord.ext import commands from re import findall from importlib import import_module diff --git a/addons/settings.py b/addons/settings.py index 7638101..5e8e4a9 100644 --- a/addons/settings.py +++ b/addons/settings.py @@ -1,3 +1,26 @@ +"""MIT License + +Copyright (c) 2023 - present Vocard Development + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + from typing import ( Dict, List, @@ -9,8 +32,6 @@ class Settings: def __init__(self, settings: Dict) -> None: self.token: str = settings.get("token") self.client_id: int = int(settings.get("client_id", 0)) - self.spotify_client_id: str = settings.get("spotify_client_id") - self.spotify_client_secret: str = settings.get("spotify_client_secret") self.genius_token: str = settings.get("genius_token") self.mongodb_url: str = settings.get("mongodb_url") self.mongodb_name: str = settings.get("mongodb_name") diff --git a/cogs/basic.py b/cogs/basic.py index f25b39d..9eb6d36 100644 --- a/cogs/basic.py +++ b/cogs/basic.py @@ -40,17 +40,11 @@ from function import ( logger ) -from addons import lyricsPlatform +from voicelink import SearchType, LoopType +from addons import LYRICS_PLATFORMS from views import SearchView, ListView, LinkView, LyricsView, HelpView from validators import url -searchPlatform = { - "youtube": "ytsearch", - "youtubemusic": "ytmsearch", - "soundcloud": "scsearch", - "apple": "amsearch", -} - async def nowplay(ctx: commands.Context, player: voicelink.Player): track = player.current if not track: @@ -73,7 +67,7 @@ async def nowplay(ctx: commands.Context, player: voicelink.Player): icon = ":red_circle:" if track.is_stream else (":pause_button:" if player.is_paused else ":arrow_forward:") embed.add_field(name="\u2800", value=f"{icon} {pbar} **[{ctime(player.position)}/{track.formatted_length}]**", inline=False) - return await ctx.send(embed=embed, view=LinkView(texts[2].format(track.source), track.emoji, track.uri)) + return await send(ctx, embed, view=LinkView(texts[2].format(track.source.title()), track.emoji, track.uri)) class Basic(commands.Cog): def __init__(self, bot: commands.Bot) -> None: @@ -92,24 +86,18 @@ class Basic(commands.Cog): return [app_commands.Choice(name=c.capitalize(), value=c) for c in self.bot.cogs if c not in ["Nodes", "Task"] and current in c] async def play_autocomplete(self, interaction: discord.Interaction, current: str) -> list: - if voicelink.pool.URL_REGEX.match(current): return [app_commands.Choice(name=current, value=current)] + if voicelink.pool.URL_REGEX.match(current): + return [] - history: dict[str, str] = {} - for track_id in reversed(await get_user(interaction.user.id, "history")): - track_dict = voicelink.decode(track_id) - history[track_dict["identifier"]] = track_dict - - history_tracks = [app_commands.Choice(name=truncate_string(f"🕒 {track['author']} - {track['title']}", 100), value=track['uri']) for track in history.values() if len(track['uri']) <= 100][:25] - if not current: - return history_tracks - - node = voicelink.NodePool.get_node() - if node and node.spotify_client: - try: - tracks: list[voicelink.Track] = await node.spotifySearch(current, requester=interaction.user) - return [app_commands.Choice(name=truncate_string(f"🎵 {track.author} - {track.title}", 100), value=truncate_string(f"{track.author} - {track.title}", 100)) for track in tracks] - except voicelink.TrackLoadError: + if current: + node = voicelink.NodePool.get_node() + if not node: return [] + tracks: list[voicelink.Track] = await node.get_tracks(current, requester=interaction.user, search_type=SearchType.SPOTIFY) + return [app_commands.Choice(name=truncate_string(f"🎵 {track.author} - {track.title}", 100), value=truncate_string(f"{track.author} - {track.title}", 100)) for track in tracks] if tracks else [] + + history = {track["identifier"]: track for track_id in reversed(await get_user(interaction.user.id, "history")) if (track := voicelink.decode(track_id))["uri"]} + return [app_commands.Choice(name=truncate_string(f"🕒 {track['author']} - {track['title']}", 100), value=track['uri']) for track in history.values() if len(track['uri']) <= 100][:25] @commands.hybrid_command(name="connect", aliases=get_aliases("connect")) @app_commands.describe(channel="Provide a channel to connect.") @@ -154,9 +142,16 @@ class Basic(commands.Cog): else: position = await player.add_track(tracks[0], start_time=format_time(start), end_time=format_time(end)) texts = await get_lang(ctx.guild.id, "live", "trackLoad_pos", "trackLoad") - await ctx.send((f"`{texts[0]}`" if tracks[0].is_stream else "") + (texts[1].format(tracks[0].title, tracks[0].uri, tracks[0].author, tracks[0].formatted_length, position) if position >= 1 and player.is_playing else texts[2].format(tracks[0].title, tracks[0].uri, tracks[0].author, tracks[0].formatted_length)), allowed_mentions=False) - except voicelink.QueueFull as e: - await ctx.send(e) + + stream_content = f"`{texts[0]}`" if tracks[0].is_stream else "" + additional_content = texts[1] if position >= 1 and player.is_playing else texts[2] + + await send( + ctx, + stream_content + additional_content, + tracks[0].title, tracks[0].uri, tracks[0].author, tracks[0].formatted_length, + position if position >= 1 and player.is_playing else None + ) finally: if not player.is_playing: await player.do_next() @@ -195,10 +190,16 @@ class Basic(commands.Cog): else: position = await player.add_track(tracks[0]) texts = await get_lang(interaction.guild.id, "live", "trackLoad_pos", "trackLoad") - await interaction.followup.send((f"`{texts[0]}`" if tracks[0].is_stream else "") + (texts[1].format(tracks[0].title, tracks[0].uri, tracks[0].author, tracks[0].formatted_length, position) if position >= 1 and player.is_playing else texts[2].format(tracks[0].title, tracks[0].uri, tracks[0].author, tracks[0].formatted_length)), allowed_mentions=False) - except voicelink.QueueFull as e: - await interaction.followup.send(e) + stream_content = f"`{texts[0]}`" if tracks[0].is_stream else "" + additional_content = texts[1] if position >= 1 and player.is_playing else texts[2] + + await send( + interaction, + stream_content + additional_content, + tracks[0].title, tracks[0].uri, tracks[0].author, tracks[0].formatted_length, + position if position >= 1 and player.is_playing else None + ) finally: if not player.is_playing: await player.do_next() @@ -209,14 +210,11 @@ class Basic(commands.Cog): platform="Select the platform you want to search." ) @app_commands.choices(platform=[ - app_commands.Choice(name="Youtube", value="Youtube"), - app_commands.Choice(name="Youtube Music", value="YoutubeMusic"), - app_commands.Choice(name="Spotify", value="Spotify"), - app_commands.Choice(name="SoundCloud", value="SoundCloud"), - app_commands.Choice(name="Apple Music", value="Apple") + app_commands.Choice(name=search_type.display_name, value=search_type.name) + for search_type in SearchType ]) @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) - async def search(self, ctx: commands.Context, *, query: str, platform: str = "Youtube"): + async def search(self, ctx: commands.Context, *, query: str, platform: str = SearchType.YOUTUBE.name): "Loads your input and added it to the queue." player: voicelink.Player = ctx.guild.voice_client if not player: @@ -227,22 +225,17 @@ class Basic(commands.Cog): if url(query): return await send(ctx, "noLinkSupport", ephemeral=True) - - platform = platform.lower() - if platform != 'spotify': - query_platform = searchPlatform.get(platform, 'ytsearch') + f":{query}" - tracks = await player.get_tracks(query=query_platform, requester=ctx.author) - else: - tracks = await player.node.spotifySearch(query=query, requester=ctx.author) - + + search_type: SearchType = SearchType.match(platform) or SearchType.YOUTUBE + tracks = await player.get_tracks(query=query, requester=ctx.author, search_type=search_type) if not tracks: return await send(ctx, "noTrackFound") texts = await get_lang(ctx.guild.id, "searchTitle", "searchDesc", "live", "trackLoad_pos", "trackLoad", "searchWait", "searchSuccess") query_track = "\n".join(f"`{index}.` `[{track.formatted_length}]` **{track.title[:35]}**" for index, track in enumerate(tracks[0:10], start=1)) - embed = discord.Embed(title=texts[0].format(query), description=texts[1].format(get_source(platform, "emoji"), platform, len(tracks[0:10]), query_track), color=settings.embed_color) + embed = discord.Embed(title=texts[0].format(query), description=texts[1].format(get_source(search_type.display_name, "emoji"), search_type.display_name, len(tracks[0:10]), query_track), color=settings.embed_color) view = SearchView(tracks=tracks[0:10], texts=[texts[5], texts[6]]) - view.response = await ctx.send(embed=embed, view=view, ephemeral=True) + view.response = await send(ctx, embed, view=view, ephemeral=True) await view.wait() if view.values is not None: @@ -251,7 +244,7 @@ class Basic(commands.Cog): track = tracks[int(value.split(". ")[0]) - 1] position = await player.add_track(track) msg += (f"`{texts[2]}`" if track.is_stream else "") + (texts[3].format(track.title, track.uri, track.author, track.formatted_length, position) if position >= 1 else texts[4].format(track.title, track.uri, track.author, track.formatted_length)) - await ctx.send(msg, allowed_mentions=False) + await send(ctx, msg) if not player.is_playing: await player.do_next() @@ -287,11 +280,16 @@ class Basic(commands.Cog): else: position = await player.add_track(tracks[0], start_time=format_time(start), end_time=format_time(end), at_front=True) texts = await get_lang(ctx.guild.id, "live", "trackLoad_pos", "trackLoad") - await ctx.send((f"`{texts[0]}`" if tracks[0].is_stream else "") + (texts[1].format(tracks[0].title, tracks[0].uri, tracks[0].author, tracks[0].formatted_length, position) if position >= 1 and player.is_playing else texts[2].format(tracks[0].title, tracks[0].uri, tracks[0].author, tracks[0].formatted_length)), allowed_mentions=False) - - except voicelink.QueueFull as e: - await ctx.send(e) + stream_content = f"`{texts[0]}`" if tracks[0].is_stream else "" + additional_content = texts[1] if position >= 1 and player.is_playing else texts[2] + + await send( + ctx, + stream_content + additional_content, + tracks[0].title, tracks[0].uri, tracks[0].author, tracks[0].formatted_length, + position if position >= 1 and player.is_playing else None + ) finally: if not player.is_playing: await player.do_next() @@ -326,14 +324,17 @@ class Basic(commands.Cog): else: texts = await get_lang(ctx.guild.id, "live", "trackLoad") await player.add_track(tracks[0], start_time=format_time(start), end_time=format_time(end), at_front=True) - await ctx.send((f"`{texts[0]}`" if tracks[0].is_stream else "") + texts[1].format(tracks[0].title, tracks[0].uri, tracks[0].author, tracks[0].formatted_length), allowed_mentions=False) - except voicelink.QueueFull as e: - await ctx.send(e) + stream_content = f"`{texts[0]}`" if tracks[0].is_stream else "" + await send( + ctx, + stream_content + texts[1], + tracks[0].title, tracks[0].uri, tracks[0].author, tracks[0].formatted_length, + ) finally: - if player.queue._repeat.mode == voicelink.LoopType.track: - await player.set_repeat(voicelink.LoopType.off.name) + if player.queue._repeat.mode == voicelink.LoopType.TRACK: + await player.set_repeat(voicelink.LoopType.OFF) await player.stop() if player.is_playing else await player.do_next() @@ -410,8 +411,8 @@ class Basic(commands.Cog): player.queue.skipto(index) await send(ctx, "skipped", ctx.author) - if player.queue._repeat.mode == voicelink.LoopType.track: - await player.set_repeat(voicelink.LoopType.off.name) + if player.queue._repeat.mode == voicelink.LoopType.TRACK: + await player.set_repeat(voicelink.LoopType.OFF) await player.stop() @@ -443,8 +444,8 @@ class Basic(commands.Cog): await player.stop() await send(ctx, "backed", ctx.author) - if player.queue._repeat.mode == voicelink.LoopType.track: - await player.set_repeat(voicelink.LoopType.off.name) + if player.queue._repeat.mode == voicelink.LoopType.TRACK: + await player.set_repeat(voicelink.LoopType.OFF) @commands.hybrid_command(name="seek", aliases=get_aliases("seek")) @app_commands.describe(position="Input position. Exmaple: 1:20.") @@ -486,7 +487,7 @@ class Basic(commands.Cog): if player.queue.is_empty: return await nowplay(ctx, player) view = ListView(player=player, author=ctx.author) - view.response = await ctx.send(embed=await view.build_embed(), view=view) + view.response = await send(ctx, await view.build_embed(), view=view) @queue.command(name="export", aliases=get_aliases("export")) @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) @@ -547,10 +548,6 @@ class Basic(commands.Cog): index = await player.add_track(tracks) await send(ctx, "playlistLoad", attachment.filename, index) - - except voicelink.QueueFull as e: - return await ctx.send(e, ephemeral=True) - except Exception as e: logger.error("error", exc_info=e) raise e @@ -574,7 +571,7 @@ class Basic(commands.Cog): return await nowplay(ctx, player) view = ListView(player=player, author=ctx.author, is_queue=False) - view.response = await ctx.send(embed=await view.build_embed(), view=view) + view.response = await send(ctx, await view.build_embed(), view=view) @commands.hybrid_command(name="leave", aliases=get_aliases("leave")) @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) @@ -613,9 +610,8 @@ class Basic(commands.Cog): @commands.hybrid_command(name="loop", aliases=get_aliases("loop")) @app_commands.describe(mode="Choose a looping mode.") @app_commands.choices(mode=[ - app_commands.Choice(name='Off', value='off'), - app_commands.Choice(name='Track', value='track'), - app_commands.Choice(name='Queue', value='queue') + app_commands.Choice(name=loop_type.name.title(), value=loop_type.name) + for loop_type in LoopType ]) @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) async def loop(self, ctx: commands.Context, mode: str): @@ -627,7 +623,7 @@ class Basic(commands.Cog): if not player.is_privileged(ctx.author): return await send(ctx, "missingPerms_mode", ephemeral=True) - await player.set_repeat(mode, ctx.author) + await player.set_repeat(LoopType[mode] if mode in LoopType.__members__ else LoopType.OFF, ctx.author) await send(ctx, "repeat", mode.capitalize()) @commands.hybrid_command(name="clear", aliases=get_aliases("clear")) @@ -669,7 +665,7 @@ class Basic(commands.Cog): await send(ctx, "removed", len(removed_tracks.keys())) @commands.hybrid_command(name="forward", aliases=get_aliases("forward")) - @app_commands.describe(position="Input a amount that you to forward to. Exmaple: 1:20") + @app_commands.describe(position="Input an amount that you to forward to. Exmaple: 1:20") @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) async def forward(self, ctx: commands.Context, position: str = "10"): "Forwards by a certain amount of time in the current track. The default is 10 seconds." @@ -690,7 +686,7 @@ class Basic(commands.Cog): await send(ctx, "forward", ctime(player.position + num)) @commands.hybrid_command(name="rewind", aliases=get_aliases("rewind")) - @app_commands.describe(position="Input a amount that you to rewind to. Exmaple: 1:20") + @app_commands.describe(position="Input an amount that you to rewind to. Exmaple: 1:20") @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) async def rewind(self, ctx: commands.Context, position: str = "10"): "Rewind by a certain amount of time in the current track. The default is 10 seconds." @@ -796,12 +792,14 @@ class Basic(commands.Cog): artist = player.current.author await ctx.defer() - song: dict[str, str] = await lyricsPlatform.get(settings.lyrics_platform)().get_lyrics(title, artist) - if not song: - return await send(ctx, "lyricsNotFound", ephemeral=True) - - view = LyricsView(name=title, source={_: re.findall(r'.*\n(?:.*\n){,22}', v) for _, v in song.items()}, author=ctx.author) - view.response = await ctx.send(embed=view.build_embed(), view=view) + lyrics_platform = LYRICS_PLATFORMS.get(settings.lyrics_platform) + if lyrics_platform: + lyrics = await lyrics_platform().get_lyrics(title, artist) + if not lyrics: + return await send(ctx, "lyricsNotFound", ephemeral=True) + + view = LyricsView(name=title, source={_: re.findall(r'.*\n(?:.*\n){,22}', v or "") for _, v in lyrics.items()}, author=ctx.author) + view.response = await send(ctx, view.build_embed(), view=view) @commands.hybrid_command(name="swapdj", aliases=get_aliases("swapdj")) @app_commands.describe(member="Choose a member to transfer the dj role.") @@ -857,7 +855,7 @@ class Basic(commands.Cog): category = "News" view = HelpView(self.bot, ctx.author) embed = view.build_embed(category) - view.response = await ctx.send(embed=embed, view=view) + view.response = await send(ctx, embed, view=view) @commands.hybrid_command(name="ping", aliases=get_aliases("ping")) @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) @@ -882,7 +880,7 @@ class Basic(commands.Cog): inline=False ) - await ctx.send(embed=embed) + await send(ctx, embed) async def setup(bot: commands.Bot) -> None: await bot.add_cog(Basic(bot)) \ No newline at end of file diff --git a/cogs/listeners.py b/cogs/listeners.py index 9aef1e7..f8a5583 100644 --- a/cogs/listeners.py +++ b/cogs/listeners.py @@ -21,9 +21,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -import voicelink +import os import asyncio import discord +import voicelink import function as func from discord.ext import commands @@ -36,22 +37,101 @@ class Listeners(commands.Cog): self.voicelink = voicelink.NodePool() bot.loop.create_task(self.start_nodes()) + bot.loop.create_task(self.restore_last_session_players()) async def start_nodes(self) -> None: """Connect and intiate nodes.""" - await self.bot.wait_until_ready() for n in func.settings.nodes.values(): try: await self.voicelink.create_node( - bot=self.bot, - spotify_client_id=func.settings.spotify_client_id, - spotify_client_secret=func.settings.spotify_client_secret, + bot=self.bot, logger=func.logger, **n ) except Exception as e: func.logger.error(f'Node {n["identifier"]} is not able to connect! - Reason: {e}') + async def restore_last_session_players(self) -> None: + """Re-establish connections for players from the last session.""" + await self.bot.wait_until_ready() + players = func.open_json(func.LAST_SESSION_FILE_NAME) + if not players: + return + + for data in players: + try: + channel_id = data.get("channel_id") + if not channel_id: + continue + + channel = self.bot.get_channel(channel_id) + if not channel: + continue + elif not any(False if member.bot or member.voice.self_deaf else True for member in channel.members): + continue + + dj_member = channel.guild.get_member(data.get("dj")) + if not dj_member: + continue + + # Get the guild settings + settings = await func.get_settings(channel.guild.id) + + # Connect to the channel and initialize the player. + player: voicelink.Player = await channel.connect( + cls=voicelink.Player(self.bot, channel, func.TempCtx(dj_member, channel), settings) + ) + + # Restore the queue. + queue_data = data.get("queue", {}) + for track_data in queue_data.get("tracks", []): + track_id = track_data.get("track_id") + if not track_id: + continue + + decoded_track = voicelink.decode(track_id) + requester = channel.guild.get_member(track_data.get("requester_id")) + track = voicelink.Track(track_id=track_id, info=decoded_track, requester=requester) + player.queue._queue.append(track) + + # Restore queue settings. + player.queue._position = queue_data.get("position", 0) - 1 + repeat_mode = queue_data.get("repeat_mode", "OFF") + try: + loop_mode = voicelink.LoopType[repeat_mode] + except KeyError: + loop_mode = voicelink.LoopType.OFF + player.queue._repeat.set_mode(loop_mode) + player.queue._repeat_position = queue_data.get("repeat_position") + + # Restore player settings + player.dj = dj_member + player.settings['autoplay'] = data.get('autoplay', False) + + # Resume playback or invoke the controller based on the player's state. + if not player.is_playing: + await player.do_next() + + if is_paused := data.get("is_paused"): + await player.set_pause(is_paused, self.bot.user) + + if position := data.get("position"): + await player.seek(int(position), self.bot.user) + + await asyncio.sleep(5) + + except Exception as e: + func.logger.error(f"Error encountered while restoring a player for channel ID {channel_id}.", exc_info=e) + + # Delete the last session file if it exists. + try: + file_path = os.path.join(func.ROOT_DIR, func.LAST_SESSION_FILE_NAME) + if os.path.exists(file_path): + os.remove(file_path) + + except Exception as del_error: + func.logger.error("Failed to remove session file: %s", file_path, exc_info=del_error) + @commands.Cog.listener() async def on_voicelink_track_end(self, player: voicelink.Player, track, _): await player.do_next() @@ -65,7 +145,7 @@ class Listeners(commands.Cog): async def on_voicelink_track_exception(self, player: voicelink.Player, track, error: dict): try: player._track_is_stuck = True - await player.context.send(f"{error['message']}! The next song will begin in the next 5 seconds.", delete_after=10) + await player.context.send(f"{error['message']} The next song will begin in the next 5 seconds.", delete_after=10) except: pass @@ -102,13 +182,13 @@ class Listeners(commands.Cog): await self.bot.ipc.send({ "op": "updateGuild", "user": { - "user_id": str(member.id), - "avatar_url": member.display_avatar.url, + "userId": str(member.id), + "avatarUrl": member.display_avatar.url, "name": member.name, }, - "channel_name": member.voice.channel.name if is_joined else "", - "guild_id": str(member.guild.id), - "is_joined": is_joined + "channelName": member.voice.channel.name if is_joined else "", + "guildId": str(member.guild.id), + "isJoined": is_joined }) async def setup(bot: commands.Bot) -> None: diff --git a/cogs/playlist.py b/cogs/playlist.py index 4abf0b7..a7f3f6b 100644 --- a/cogs/playlist.py +++ b/cogs/playlist.py @@ -107,7 +107,7 @@ class Playlists(commands.Cog, name="playlist"): async def playlist(self, ctx: commands.Context): view = HelpView(self.bot, ctx.author) embed = view.build_embed(self.qualified_name) - view.response = await ctx.send(embed=embed, view=view) + view.response = send(ctx, embed, view=view) @playlist.command(name="play", aliases=get_aliases("play")) @app_commands.describe( @@ -136,11 +136,11 @@ class Playlists(commands.Cog, name="playlist"): if not result['playlist']['tracks']: return await send(ctx, 'playlistNoTrack', result['playlist']['name'], ephemeral=True) - playtrack = [] + _tracks = [] for track in result['playlist']['tracks'][:max_t]: - playtrack.append(voicelink.Track(track_id=track, info=voicelink.decode(track), requester=ctx.author)) + _tracks.append(voicelink.Track(track_id=track, info=voicelink.decode(track), requester=ctx.author)) - tracks = {"name": result['playlist']['name'], "tracks": playtrack} + tracks = {"name": result['playlist']['name'], "tracks": _tracks} if not tracks: return await send(ctx, 'playlistNoTrack', result['playlist']['name'], ephemeral=True) @@ -212,7 +212,7 @@ class Playlists(commands.Cog, name="playlist"): embed.set_footer(text=text[2]) view = PlaylistView(embed, results, ctx.author) - view.response = await ctx.send(embed=embed, view=view, ephemeral=True) + view.response = await send(ctx, embed, view=view, ephemeral=True) @playlist.command(name="create", aliases=get_aliases("create")) @app_commands.describe( @@ -237,7 +237,7 @@ class Playlists(commands.Cog, name="playlist"): if link: tracks = await voicelink.NodePool.get_node().get_tracks(link, requester=ctx.author) if not isinstance(tracks, voicelink.Playlist): - return await send(ctx, "playlistNotInvaildUrl", ephemeral=True) + return await send(ctx, "playlistNotInvalidUrl", ephemeral=True) data = {'uri': link, 'perms': {'read': []}, 'name': name, 'type': 'link'} if link else {'tracks': [], 'perms': {'read': [], 'write': [], 'remove': []}, 'name': name, 'type': 'playlist'} await update_user(ctx.author.id, {"$set": {f"playlist.{assign_playlist_id([data for data in user])}": data}}) @@ -344,14 +344,14 @@ class Playlists(commands.Cog, name="playlist"): inbox = user['inbox'].copy() view = InboxView(ctx.author, user['inbox']) - view.response = await ctx.send(embed=view.build_embed(), view=view, ephemeral=True) + view.response = await send(ctx, view.build_embed(), view=view, ephemeral=True) await view.wait() if inbox == user['inbox']: return update_data, dId = {}, {dId for dId in user["playlist"]} - for data in view.newplaylist[:(max_p - len(user['playlist']))]: + for data in view.new_playlist[:(max_p - len(user['playlist']))]: addId = assign_playlist_id(dId) await update_user(data['sender'], {"$push": {f"playlist.{data['referId']}.perms.read": ctx.author.id}}) update_data[f'playlist.{addId}'] = { @@ -449,11 +449,11 @@ class Playlists(commands.Cog, name="playlist"): if not result['playlist']['tracks']: return await send(ctx, 'playlistNoTrack', result['playlist']['name'], ephemeral=True) - playtrack = [] + _tracks = [] for track in result['playlist']['tracks']: - playtrack.append(voicelink.Track(track_id=track, info=voicelink.decode(track), requester=ctx.author)) + _tracks.append(voicelink.Track(track_id=track, info=voicelink.decode(track), requester=ctx.author)) - tracks = {"name": result['playlist']['name'], "tracks": playtrack} + tracks = {"name": result['playlist']['name'], "tracks": _tracks} if not tracks: return await send(ctx, 'playlistNoTrack', result['playlist']['name'], ephemeral=True) diff --git a/cogs/settings.py b/cogs/settings.py index 543089f..ed47037 100644 --- a/cogs/settings.py +++ b/cogs/settings.py @@ -58,13 +58,16 @@ class Settings(commands.Cog, name="settings"): async def settings(self, ctx: commands.Context): view = HelpView(self.bot, ctx.author) embed = view.build_embed(self.qualified_name) - view.response = await ctx.send(embed=embed, view=view) + view.response = await send(ctx, embed, view=view) @settings.command(name="prefix", aliases=get_aliases("prefix")) @commands.has_permissions(manage_guild=True) @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) async def prefix(self, ctx: commands.Context, prefix: str): "Change the default prefix for message commands." + if not self.bot.intents.message_content: + return await send(ctx, "missingIntents", "MESSAGE_CONTENT", ephemeral=True) + await update_settings(ctx.guild.id, {"$set": {"prefix": prefix}}) await send(ctx, "setPrefix", prefix, prefix) @@ -170,7 +173,7 @@ class Settings(commands.Cog, name="settings"): ), inline=False ) - await ctx.send(embed=embed) + await send(ctx, embed) @settings.command(name="volume", aliases=get_aliases("volume")) @app_commands.describe(value="Input a integer.") @@ -226,7 +229,7 @@ class Settings(commands.Cog, name="settings"): controller_settings = settings.get("default_controller", func.settings.controller) view = EmbedBuilderView(ctx, controller_settings.get("embeds").copy()) - view.response = await ctx.send(embed=view.build_embed(), view=view) + view.response = await send(ctx, view.build_embed(), view=view) @settings.command(name="controllermsg", aliases=get_aliases("controllermsg")) @commands.has_permissions(manage_guild=True) @@ -243,9 +246,46 @@ class Settings(commands.Cog, name="settings"): @commands.has_permissions(manage_guild=True) @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) async def stageannounce(self, ctx: commands.Context, template: str = None): - """Customize the channel topic template""" + "Customize the channel topic template" await update_settings(ctx.guild.id, {"$set": {'stage_announce_template': template}}) - await send(ctx, "SetStageAnnounceTemplate") + await send(ctx, "setStageAnnounceTemplate") + + @settings.command(name="setupchannel", aliases=get_aliases("setupchannel")) + @app_commands.describe( + channel="Provide a request channel. If not, a text channel will be generated." + ) + @commands.has_permissions(manage_guild=True) + @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) + async def setupchannel(self, ctx: commands.Context, channel: discord.TextChannel = None) -> None: + "Sets up a dedicated channel for song requests in your server." + if not self.bot.intents.message_content: + return await send(ctx, "missingIntents", "MESSAGE_CONTENT", ephemeral=True) + + if not channel: + try: + overwrites = { + ctx.guild.me: discord.PermissionOverwrite( + read_messages=True, + manage_messages=True + ) + } + channel = await ctx.guild.create_text_channel("vocard-song-requests", overwrites=overwrites) + except: + return await send(ctx, "noCreatePermission") + + channel_perms = channel.permissions_for(ctx.me) + if not channel_perms.text() and not channel_perms.manage_messages: + return await send(ctx, "noCreatePermission") + + settings = await func.get_settings(ctx.guild.id) + controller = settings.get("default_controller", func.settings.controller).get("embeds", {}).get("inactive", {}) + message = await channel.send(embed=voicelink.build_embed(controller, voicelink.Placeholders(self.bot))) + + await update_settings(ctx.guild.id, {"$set": {'music_request_channel': { + "text_channel_id": channel.id, + "controller_msg_id": message.id, + }}}) + await send(ctx, "createSongRequestChannel", channel.mention) @app_commands.command(name="debug") async def debug(self, interaction: discord.Interaction): @@ -268,7 +308,7 @@ class Settings(commands.Cog, name="settings"): value=f"```• VERSION: {func.settings.version}\n" \ f"• LATENCY: {self.bot.latency:.2f}ms\n" \ f"• GUILDS: {len(self.bot.guilds)}\n" \ - f"• USERS: {sum([guild.member_count for guild in self.bot.guilds])}\n" \ + f"• USERS: {sum([guild.member_count or 0 for guild in self.bot.guilds])}\n" \ f"• PLAYERS: {len(self.bot.voice_clients)}```", inline=False ) diff --git a/cogs/task.py b/cogs/task.py index 7ed0db7..fe31273 100644 --- a/cogs/task.py +++ b/cogs/task.py @@ -67,44 +67,41 @@ class Task(commands.Cog): @tasks.loop(minutes=5.0) async def player_check(self): - if not self.bot.voice_clients: - return - - player: voicelink.Player - for player in self.bot.voice_clients: - try: - if not player.channel or not player.context or not player.guild: + for identifier, node in voicelink.NodePool._nodes.items(): + for guild_id, player in node._players.copy().items(): + try: + if not player.channel or not player.context or not player.guild: + await player.teardown() + continue + except: await player.teardown() continue - except: - await player.teardown() - continue - - try: - members = player.channel.members - if (not player.is_playing and player.queue.is_empty) or not any(False if member.bot or member.voice.self_deaf else True for member in members): - if not player.settings.get('24/7', False): - await player.teardown() - continue + + try: + members = player.channel.members + if (not player.is_playing and player.queue.is_empty) or not any(False if member.bot or member.voice.self_deaf else True for member in members): + if not player.settings.get('24/7', False): + await player.teardown() + continue + else: + if not player.is_paused: + await player.set_pause(True) else: - if not player.is_paused: - await player.set_pause(True) - else: - if not player.guild.me: - await player.teardown() - continue - elif not player.guild.me.voice: - await player.connect(timeout=0.0, reconnect=True) + if not player.guild.me: + await player.teardown() + continue + elif not player.guild.me.voice: + await player.connect(timeout=0.0, reconnect=True) + + if player.dj not in members: + for m in members: + if not m.bot: + player.dj = m + break + + except Exception as e: + func.logger.error("Error occurred while checking the player!", exc_info=e) - if player.dj not in members: - for m in members: - if not m.bot: - player.dj = m - break - - except Exception as e: - func.logger.error("Error occurred while checking the player!", exc_info=e) - @tasks.loop(hours=12.0) async def cache_cleaner(self): func.SETTINGS_BUFFER.clear() diff --git a/docker-compose.yml b/docker-compose.yml index 07edf48..841cfde 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,61 +1,102 @@ -version: "3.8" +# ------------------------------------------------------------------------------------------------------------ # +# READ THIS BEFORE INSTALL! + +# This is a docker-compose file for running Vocard with Lavalink and MongoDB(optional). +# You can selfhost MongoDB. Just uncomment lines starting with single "#" below in the compose file. +# In order to run this, you need to have Docker and Docker Compose installed. +# You can install Docker from https://docs.docker.com/get-docker/ +# and Docker Compose from https://docs.docker.com/compose/install/ + +# Step 1: Start the installation by creating the future config directory for Vocard. +# example - `root@docker:~# mkdir -p /opt/vocard/config` + +# Use `cd` to navigate to the config directory. +# example - `root@docker:~# cd /opt/vocard/config` + +# Step 3: Choose installation method: Build the image from the Dockerfile or pull it from GitHub(recommended). +# If you chose to pull from Docker Hub, comment the "build" lines and uncomment the "image" line. +# If you chose to build the image from the Dockerfile, do the following: +# uncomment this +# build: +# dockerfile: ./Dockerfile +# and comment this +# image: ghcr.io/choco/vocard:latest +# example - `root@docker:/opt/vocard/config# wget https://github.com/ChocoMeow/Vocard/archive/refs/heads/main.zip` + +# Step 4: Configure application.yml and settings.json in the config directory. +# In order to avoid silly syntax errors it is recommended to use external code editor such as VS Code or Notepad++. +# Then you can upload files to host using tools such as WinSCP or +# using `nano` to create and edit the files directly using hosts terminal. +# NOTE that some terminals DO NOT let you paste, so you can either use WinSCP or SSH app like Putty. + +# example - `root@docker:/opt/vocard/config# nano application.yml` +# example - `root@docker:/opt/vocard/config# nano settings.json` +# To exit nano, press `Ctrl + S`, then `Ctrl + X` to save changes. + +# Step 5: If the values are set correctly, you can start the installation by running the following command +# example - `root@docker:/opt/vocard/config# docker-compose up -d` (could be `docker compose` on some systems) + +# ------------------------------------------ THANK YOU FOR READING! ------------------------------------------ # +name: vocard services: lavalink: - image: ghcr.io/lavalink-devs/lavalink:latest container_name: lavalink + #image: ghcr.io/lavalink-devs/lavalink:latest + build: + dockerfile: ./lavalink/Dockerfile-lavalink restart: unless-stopped environment: - _JAVA_OPTIONS=-Xmx1G - SERVER_PORT=2333 - - LAVALINK_SERVER_PASSWORD=youshallnotpass + # there is no point in changing the password here, since the container is available only in docker network + - LAVALINK_SERVER_PASSWORD=youshallnotpass # Change password if needed (don't forget to change it in healthcheck below and settings.json) volumes: - ## Use "./" if you want to create a mount from your current directory (where docker-compose.yml is located) - ## Having access to files INSIDE the container is complicated. Mount function is used to have access to certain container files or folders. - ## Read more: https://docs.docker.com/storage/bind-mounts/ - - ./application.yml:/opt/Lavalink/application.yml + - ./lavalink/application.yml:/opt/Lavalink/application.yml networks: - - local + - vocard expose: - "2333" + healthcheck: + test: nc -z -v localhost 2333 + interval: 10s + timeout: 5s + retries: 3 - # # You can selfhost MongoDB. Just uncomment lines starting with single "#" below. - # mongo: - # image: mongo:latest - # container_name: mongo - # restart: unless-stopped - # volumes: - # # Use "./" if you want to create a mount from your current directory (where docker-compose.yml is located) - # # Having access to files INSIDE the container is complicated. Mount function is used to have access to certain container files or folders. - # # Read more: https://docs.docker.com/storage/bind-mounts/ - # - ./data/mongo/db:/data/db - # - ./data/mongo/conf:/data/configdb - # environment: - # - MONGO_INITDB_ROOT_USERNAME=admin - # - MONGO_INITDB_ROOT_PASSWORD=admin - # expose: - # - "27017" - # networks: - # - local - # command: ["mongod", "--oplogSize=1024", "--wiredTigerCacheSizeGB=1", "--auth", "--noscripting"] + #mongo: + # container_name: mongo + # image: mongo:latest + # + # restart: unless-stopped + # volumes: + # - ./data/mongo/db:/data/db + # - ./data/mongo/conf:/data/configdb + # environment: + # - MONGO_INITDB_ROOT_USERNAME=admin + # - MONGO_INITDB_ROOT_PASSWORD=admin + # expose: + # - "27017" + # networks: + # - vocard + # command: ["mongod", "--oplogSize=1024", "--wiredTigerCacheSizeGB=1", "--auth", "--noscripting"] vocard: container_name: vocard - volumes: - ## Use "./" if you want to create a mount from your current directory (where docker-compose.yml is located) - ## Having access to files INSIDE the container is complicated. Mount function is used to have access to certain container files or folders. - ## Read more: https://docs.docker.com/storage/bind-mounts/ - - ./settings.json:/app/settings.json + restart: unless-stopped + # If you want to build the image from the Dockerfile, uncomment the "build" lines and comment the "image" line. + # image: ghcr.io/choco/vocard:latest build: dockerfile: ./Dockerfile + volumes: + - ./settings.json:/app/settings.json + networks: + - vocard depends_on: lavalink: - condition: service_started + condition: service_healthy # mongo: # condition: service_started - networks: - - local networks: - local: - name: local + vocard: + name: vocard diff --git a/function.py b/function.py index 6e09f5e..e8fcae5 100644 --- a/function.py +++ b/function.py @@ -1,3 +1,26 @@ +"""MIT License + +Copyright (c) 2023 - present Vocard Development + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + import discord, json, os, copy, logging from discord.ext import commands @@ -34,6 +57,8 @@ LOCAL_LANGS: dict[str, dict[str, str]] = {} #Stores all the localization languag SETTINGS_BUFFER: dict[int, dict[str, Any]] = {} #Cache guild language USERS_BUFFER: dict[str, dict] = {} +MISSING_TRANSLATOR: dict[str, list[str]] = {} + USER_BASE: dict[str, Any] = { 'playlist': { '200': { @@ -48,6 +73,14 @@ USER_BASE: dict[str, Any] = { } ALLOWED_MENTIONS = discord.AllowedMentions().none() +LAST_SESSION_FILE_NAME = "last-session.json" + +#-------------- Vocard Classes -------------- +class TempCtx(): + def __init__(self, author: discord.Member, channel: discord.VoiceChannel) -> None: + self.author: discord.Member = author + self.channel: discord.VoiceChannel = channel + self.guild: discord.Guild = channel.guild #-------------- Vocard Functions -------------- def open_json(path: str) -> dict: @@ -60,9 +93,9 @@ def open_json(path: str) -> dict: def update_json(path: str, new_data: dict) -> None: data = open_json(path) if not data: - return - - data.update(new_data) + data = new_data + else: + data.update(new_data) with open(os.path.join(ROOT_DIR, path), "w") as json_file: json.dump(data, json_file, indent=4) @@ -106,8 +139,8 @@ def format_time(number:str) -> int: return (int(num.tm_hour) * 3600 + int(num.tm_min) * 60 + int(num.tm_sec)) * 1000 def get_source(source: str, type: str) -> str: - source_settings: dict = settings.sources_settings.get(source.lower(), {}) - return source_settings.get(type, ("🔗" if type == "emoji" else settings.embed_color)) + source_settings: dict[str, str] = settings.sources_settings.get(source.lower().replace(" ", ""), settings.sources_settings.get("others")) + return source_settings.get(type) def cooldown_check(ctx: commands.Context) -> Optional[commands.Cooldown]: if ctx.author.id in settings.bot_access_user: @@ -143,30 +176,55 @@ def format_bytes(bytes: int, unit: bool = False): else: return f"{bytes / (1024 ** 3):.1f}" + ("GB" if unit else "") -async def get_lang(guild_id:int, *keys) -> Union[list[str], str]: +async def get_lang(guild_id:int, *keys) -> Optional[Union[list[str], str]]: settings = await get_settings(guild_id) lang = settings.get("lang", "EN") if lang in LANGS and not LANGS[lang]: LANGS[lang] = open_json(os.path.join("langs", f"{lang}.json")) if len(keys) == 1: - return LANGS.get(lang, {}).get(keys[0], "Language pack not found!") - return [LANGS.get(lang, {}).get(key, "Language pack not found!") for key in keys] + return LANGS.get(lang, {}).get(keys[0]) + return [LANGS.get(lang, {}).get(key) for key in keys] -async def send(ctx: Union[commands.Context, discord.Interaction], key: str, *params, delete_after: float = None, ephemeral: bool = False) -> Optional[discord.Message]: - text = await get_lang(ctx.guild.id, key) - text = text.format(*params) +async def send( + ctx: Union[commands.Context, discord.Interaction], + content: Union[str, discord.Embed] = None, + *params, + view: discord.ui.View = None, + delete_after: float = None, + ephemeral: bool = False +) -> Optional[discord.Message]: + if content is None: + content = "No content provided." - if isinstance(ctx, commands.Context): - send_func = ctx.send + # Determine the text to send + if isinstance(content, discord.Embed): + embed = content + text = None else: - if not ctx.response.is_done(): - send_func = ctx.response.send_message - + text = await get_lang(ctx.guild.id, content) + if text: + text = text.format(*params) else: - return await ctx.followup.send(text, ephemeral=ephemeral, allowed_mentions=ALLOWED_MENTIONS) + text = content.format(*params) + embed = None - return await send_func(text, delete_after=delete_after, ephemeral=ephemeral, allowed_mentions=ALLOWED_MENTIONS) + # Determine the sending function + send_func = ( + ctx.send if isinstance(ctx, commands.Context) else + ctx.response.send_message if not ctx.response.is_done() else + ctx.followup.send + ) + + # Check settings for delete_after duration + settings = await get_settings(ctx.guild.id) + if settings and ctx.channel.id == settings.get("music_request_channel", {}).get("text_channel_id"): + delete_after = 10 + + # Send the message or embed + if view: + return await send_func(text, embed=embed, view=view, delete_after=delete_after, ephemeral=ephemeral, allowed_mentions=ALLOWED_MENTIONS) + return await send_func(text, embed=embed, delete_after=delete_after, ephemeral=ephemeral, allowed_mentions=ALLOWED_MENTIONS) async def update_db(db: AsyncIOMotorCollection, tempStore: dict, filter: dict, data: dict) -> bool: for mode, action in data.items(): diff --git a/ipc/client.py b/ipc/client.py index a1b2513..de0c460 100644 --- a/ipc/client.py +++ b/ipc/client.py @@ -65,9 +65,42 @@ class IPCClient: async def send(self, data: dict): if self.is_connected: - self._logger.debug(f"Send Message: {data}") - await self._websocket.send_json(data) + try: + await self._websocket.send_json(data) + self._logger.debug(f"Send Message: {data}") + except ConnectionResetError as _: + await self.disconnect() + await self.connect() + await self._websocket.send_json(data) + self._logger.debug(f"Send Message: {data}") + async def send(self, data: dict): + # Check if the websocket is still open + if self.is_connected: + try: + await self._websocket.send_json(data) + self._logger.debug(f"Sent Message: {data}") + except ConnectionResetError: + self._logger.warning("Connection lost, attempting to reconnect.") + await self._handle_reconnect(data) + except Exception as e: + self._logger.error(f"Failed to send message: {e}") + else: + self._logger.warning("WebSocket is not connected or already closed.") + + async def _handle_reconnect(self, data: dict): + await self.disconnect() + await self.connect() + await asyncio.sleep(1) # Optional delay before retrying + if self.is_connected: + try: + await self._websocket.send_json(data) + self._logger.debug(f"Sent Message on reconnect: {data}") + except Exception as e: + self._logger.error(f"Failed to send message on reconnect: {e}") + else: + self._logger.error("Reconnection failed, not connected.") + async def connect(self): try: if not self._session: @@ -107,4 +140,4 @@ class IPCClient: @property def is_connected(self) -> bool: - return self._is_connected \ No newline at end of file + return self._is_connected and self._websocket and not self._websocket.closed \ No newline at end of file diff --git a/ipc/methods.py b/ipc/methods.py index 9522d5e..8365b2b 100644 --- a/ipc/methods.py +++ b/ipc/methods.py @@ -6,7 +6,7 @@ from typing import List, Dict, Union, Optional from discord import User, Member, VoiceChannel from discord.ext import commands from voicelink import Player, Track, Playlist, NodePool, decode, LoopType, Filters -from addons import lyricsPlatform +from addons import LYRICS_PLATFORMS RATELIMIT_COUNTER: Dict[int, Dict[str, float]] = {} SCOPES = { @@ -22,12 +22,6 @@ SCOPES = { "stage_announce_template": str } -class TempCtx(): - def __init__(self, author: Member, channel: VoiceChannel) -> None: - self.author = author - self.channel = channel - self.guild = channel.guild - class SystemMethod: def __init__(self, function: callable, *, credit: int = 1): self.function: callable = function @@ -44,9 +38,9 @@ def require_permission(only_admin: bool = False): def decorator(func) -> callable: async def wrapper(player: Player, member: Member, dict: Dict) -> Optional[Dict]: if only_admin and not member.guild_permissions.manage_guild: - return error_msg("Only the admins may use this funciton!", user_id=member.id) + return error_msg("Only the admins may use this function!", user_id=member.id) if not player.is_privileged(member): - return error_msg("Only the DJ or admins may use this funciton!", user_id=member.id) + return error_msg("Only the DJ or admins may use this function!", user_id=member.id) return await func(player, member, dict) return wrapper return decorator @@ -54,9 +48,9 @@ def require_permission(only_admin: bool = False): def error_msg(msg: str, *, user_id: int = None, guild_id: int = None, level: str = "info") -> Dict: payload = {"op": "errorMsg", "level": level, "msg": msg} if user_id: - payload["user_id"] = str(user_id) + payload["userId"] = str(user_id) if guild_id: - payload["guild_id"] = str(guild_id) + payload["guildId"] = str(guild_id) return payload @@ -67,14 +61,14 @@ async def connect_channel(member: Member, bot: commands.Bot) -> Player: channel = member.voice.channel try: settings = await func.get_settings(channel.guild.id) - player: Player = await channel.connect(cls=Player(bot, channel, TempCtx(member, channel), settings)) - await player.send_ws({"op": "createPlayer", "member_ids": [str(member.id) for member in channel.members]}) + player: Player = await channel.connect(cls=Player(bot, channel, func.TempCtx(member, channel), settings)) + await player.send_ws({"op": "createPlayer", "memberIds": [str(member.id) for member in channel.members]}) return player except: return async def initBot(bot: commands.Bot, data: Dict) -> Dict: - user_id = int(data.get("user_id")) + user_id = int(data.get("userId")) user = bot.get_user(user_id) if not user: user = await bot.fetch_user(user_id) @@ -82,14 +76,14 @@ async def initBot(bot: commands.Bot, data: Dict) -> Dict: if user: return { "op": "initBot", - "user_id": str(user_id), - "bot_name": bot.user.display_name, - "bot_avatar": bot.user.display_avatar.url, - "bot_id": str(bot.user.id) + "userId": str(user_id), + "botName": bot.user.display_name, + "botAvatar": bot.user.display_avatar.url, + "botId": str(bot.user.id) } async def initUser(bot: commands.Bot, data: Dict) -> Dict: - user_id = int(data.get("user_id")) + user_id = int(data.get("userId")) data = await func.get_user(user_id) for mail in data.get("inbox"): @@ -100,11 +94,11 @@ async def initUser(bot: commands.Bot, data: Dict) -> Dict: if not sender: data.get("inbox").remove(mail) - mail["sender"] = {"avatar_url": sender.display_avatar.url, "name": sender.display_name, "id": str(sender.id)} + mail["sender"] = {"avatarUrl": sender.display_avatar.url, "name": sender.display_name, "id": str(sender.id)} return { "op": "initUser", - "user_id": str(user_id), + "userId": str(user_id), "data": data } @@ -117,29 +111,29 @@ async def initPlayer(player: Player, member: Member, data: Dict) -> Dict: return { "op": "initPlayer", - "guild_id": str(player.guild.id), - "user_id": str(data.get("user_id")), + "guildId": str(player.guild.id), + "userId": str(data.get("userId")), "users": [{ - "user_id": str(member.id), - "avatar_url": member.display_avatar.url, + "userId": str(member.id), + "avatarUrl": member.display_avatar.url, "name": member.name } for member in player.channel.members ], - "tracks": [ {"track_id": track.track_id, "requester_id": str(track.requester.id)} for track in player.queue._queue ], - "repeat_mode": player.queue.repeat.lower(), - "channel_name": player.channel.name, - "current_queue_position": player.queue._position + (0 if player.is_playing else 1), - "current_position": 0 or player.position if player.is_playing else 0, - "is_playing": player.is_playing, - "is_paused": player.is_paused, - "is_dj": player.is_privileged(member, check_user_join=False), + "tracks": [ {"trackId": track.track_id, "requesterId": str(track.requester.id)} for track in player.queue._queue ], + "repeatMode": player.queue.repeat.lower(), + "channelName": player.channel.name, + "currentQueuePosition": player.queue._position + (0 if player.is_playing else 1), + "currentPosition": 0 or player.position if player.is_playing else 0, + "isPlaying": player.is_playing, + "isPaused": player.is_paused, + "isDj": player.is_privileged(member, check_user_join=False), "autoplay": player.settings.get("autoplay", False), "volume": player.volume, "filters": [{"tag": filter.tag, "scope": filter.scope, "payload": filter.payload} for filter in player.filters.get_filters()], - "available_filters": available_filters + "availableFilters": available_filters } async def closeConnection(bot: commands.Bot, data: Dict) -> None: - guild_id = int(data.get("guild_id")) + guild_id = int(data.get("guildId")) guild = bot.get_guild(guild_id) player: Player = guild.voice_client if player: @@ -150,13 +144,13 @@ async def getRecommendation(bot: commands.Bot, data: Dict) -> None: if not node: return - track_data = decode(track_id := data.get("track_id")) + track_data = decode(track_id := data.get("trackId")) track = Track(track_id=track_id, info=track_data, requester=bot.user) tracks: List[Track] = await node.get_recommendations(track, limit=60) return { "op": "getRecommendation", - "user_id": str(data.get("user_id")), + "userId": str(data.get("userId")), "callback": data.get("callback"), "tracks": [track.track_id for track in tracks] if tracks else [] } @@ -178,8 +172,8 @@ async def skipTo(player: Player, member: Member, data: Dict) -> None: if index > 1: player.queue.skipto(index) - if player.queue._repeat.mode == LoopType.track: - await player.set_repeat(LoopType.off.name) + if player.queue._repeat.mode == LoopType.TRACK: + await player.set_repeat(LoopType.OFF) await player.stop() async def backTo(player: Player, member: Member, data: Dict) -> None: @@ -238,14 +232,18 @@ async def getTracks(bot: commands.Bot, data: Dict) -> Dict: query = data.get("query", None) if query: - payload = {"op": "getTracks", "user_id": data.get("user_id"), "callback": data.get("callback")} + payload = {"op": "getTracks", "userId": data.get("userId"), "callback": data.get("callback")} tracks = await NodePool.get_node().get_tracks(query=query, requester=None) if not tracks: return payload payload["tracks"] = [ track.track_id for track in (tracks.tracks if isinstance(tracks, Playlist) else tracks ) ] return payload - + +async def searchAndPlay(player: Player, member: Member, data: Dict) -> None: + payload = await getTracks(player.bot, data) + await addTracks(player, member, payload) + async def shuffleTrack(player: Player, member: Member, data: Dict) -> None: if not player.is_privileged(member): if member in player.shuffle_votes: @@ -268,7 +266,7 @@ async def removeTrack(player: Player, member: Member, data: Dict) -> None: @require_permission() async def clearQueue(player: Player, member: Member, data: Dict) -> None: - queue_type = data.get("queue_type", "").lower() + queue_type = data.get("queueType", "").lower() await player.clear_queue(queue_type, member) @require_permission(only_admin=True) @@ -299,7 +297,7 @@ async def updatePause(player: Player, member: Member, data: Dict) -> None: await player.set_pause(pause, member) @require_permission() -async def updatePosition(player: Player, member: Member, data: Dict) -> None: +async def updatePosition(player: Player, member: Member, data: Dict) -> None: position = data.get("position"); await player.seek(position, member); @@ -316,8 +314,8 @@ async def toggleAutoplay(player: Player, member: Member, data: Dict) -> Dict: return { "op": "toggleAutoplay", "status": check, - "guild_id": player.guild.id, - "requester_id": str(member.id) + "guildId": player.guild.id, + "requesterId": str(member.id) } @require_permission() @@ -374,30 +372,33 @@ async def _getPlaylist(user_id: int, playlist_id: str) -> Dict: return playlist async def getPlaylist(bot: commands.Bot, data: Dict) -> Dict: - user_id = int(data.get("user_id")) - playlist_id = str(data.get("playlist_id")) + user_id = int(data.get("userId")) + playlist_id = str(data.get("playlistId")) - payload = {"op": "loadPlaylist", "playlist_id": playlist_id, "user_id": str(user_id)} + payload = {"op": "loadPlaylist", "playlistId": playlist_id, "userId": str(user_id)} playlist = await _getPlaylist(user_id, playlist_id) payload["tracks"] = playlist["tracks"] if playlist else [] return payload async def updatePlaylist(bot: commands.Bot, data: Dict) -> Dict: - user_id = int(data.get("user_id")) - playlist_id = str(data.get("playlist_id")) + user_id = int(data.get("userId")) + playlist_id = str(data.get("playlistId")) _type = data.get("type") + if not playlist_id and not _type == "createPlaylist": + return error_msg("Unable to process this request without a playlist ID.", user_id=user_id, level="error") + rank, max_p, max_t = func.check_roles() if _type == "createPlaylist": - name, playlist_url = data.get("name"), data.get("playlist_url") + name, playlist_url = data.get("playlistName"), data.get("playlistUrl") if not name: return { "op": "updatePlaylist", "status": "error", "msg": f"You must enter name for this field!", - "field": "create-playlist-name", - "user_id": str(user_id) + "field": "playlistName", + "userId": str(user_id) } playlist = await func.get_user(user_id, "playlist") @@ -406,8 +407,8 @@ async def updatePlaylist(bot: commands.Bot, data: Dict) -> Dict: "op": "updatePlaylist", "status": "error", "msg": f"You cannot create more than '{max_p}' playlists!", - "field": "create-playlist-name", - "user_id": str(user_id) + "field": "playlistName", + "userId": str(user_id) } for playlist_data in playlist.values(): @@ -416,8 +417,8 @@ async def updatePlaylist(bot: commands.Bot, data: Dict) -> Dict: "op": "updatePlaylist", "status": "error", "msg": f"Playlist '{name}' already exists.", - "field": "create-playlist-name", - "user_id": str(user_id) + "field": "playlistName", + "userId": str(user_id) } if playlist_url: @@ -427,19 +428,19 @@ async def updatePlaylist(bot: commands.Bot, data: Dict) -> Dict: "op": "updatePlaylist", "status": "error", "msg": f"Please enter a valid link or public spotify or youtube playlist link.", - "field": "create-playlist-url", - "user_id": str(user_id) + "field": "playlistUrl", + "userId": str(user_id) } - assgined_playlist_id = _assign_playlist_id(list(playlist.keys())) + assigned_playlist_id = _assign_playlist_id(list(playlist.keys())) data = {'uri': playlist_url, 'perms': {'read': []}, 'name': name, 'type': 'link'} if playlist_url else {'tracks': [], 'perms': {'read': [], 'write': [], 'remove': []}, 'name': name, 'type': 'playlist'} - await func.update_user(user_id, {"$set": {f"playlist.{assgined_playlist_id}": data}}) + await func.update_user(user_id, {"$set": {f"playlist.{assigned_playlist_id}": data}}) return { "op": "updatePlaylist", "status": "created", - "playlist_id": assgined_playlist_id, + "playlistId": assigned_playlist_id, "msg": f"You have created '{name}' playlist.", - "user_id": str(user_id), + "userId": str(user_id), "data": data } @@ -454,9 +455,9 @@ async def updatePlaylist(bot: commands.Bot, data: Dict) -> Dict: return { "op": "updatePlaylist", "status": "deleted", - "playlist_id": playlist_id, + "playlistId": playlist_id, "msg": f"You have removed playlist '{playlist['name']}'", - "user_id": str(user_id) + "userId": str(user_id) } elif _type == "renamePlaylist": @@ -466,8 +467,8 @@ async def updatePlaylist(bot: commands.Bot, data: Dict) -> Dict: "op": "updatePlaylist", "status": "error", "msg": f"You must enter name for this field!", - "field": "rename-playlist-name", - "user_id": str(user_id) + "field": "playlistName", + "userId": str(user_id) } playlist = await func.get_user(user_id, "playlist") @@ -477,8 +478,8 @@ async def updatePlaylist(bot: commands.Bot, data: Dict) -> Dict: "op": "updatePlaylist", "status": "error", "msg": f"Playlist '{data['name']}' already exists.", - "field": "rename-playlist-name", - "user_id": str(user_id) + "field": "playlistName", + "userId": str(user_id) } await func.update_user(user_id, {"$set": {f'playlist.{playlist_id}.name': name}}) @@ -486,14 +487,14 @@ async def updatePlaylist(bot: commands.Bot, data: Dict) -> Dict: "op": "updatePlaylist", "status": "renamed", "name": name, - "playlist_id": playlist_id, + "playlistId": playlist_id, "msg": f"You have renamed the playlist to '{name}'.", - "field": "rename-playlist-name", - "user_id": str(user_id) + "field": "playlistName", + "userId": str(user_id) } elif _type == "addTrack": - track_id = data.get("track_id") + track_id = data.get("trackId") if not track_id: return error_msg("No track ID could be located.", user_id=user_id, level='error') @@ -513,18 +514,21 @@ async def updatePlaylist(bot: commands.Bot, data: Dict) -> Dict: return { "op": "updatePlaylist", "status": "addTrack", - "playlist_id": playlist_id, - "track_id": track_id, + "playlistId": playlist_id, + "trackId": track_id, "msg": f"Added {decoded_track.title} into '{playlist['name']}' playlist.", - "user_id": str(user_id) + "userId": str(user_id) } elif _type == "removeTrack": - track_id, track_position = data.get("track_id"), data.get("track_position", 0) + track_id, track_position = data.get("trackId"), data.get("trackPosition", 0) if not track_id: return error_msg("No track ID could be located.", user_id=user_id, level='error') playlist = await _getPlaylist(user_id, playlist_id) + if not playlist: + return error_msg("Playlist not found!", user_id=user_id, level='error') + if playlist['type'] in ['share', 'link']: return error_msg("You cannot remove songs from a linked playlist through Vocard.", user_id=user_id, level='error') @@ -540,36 +544,36 @@ async def updatePlaylist(bot: commands.Bot, data: Dict) -> Dict: return { "op": "updatePlaylist", "status": "removeTrack", - "playlist_id": playlist_id, - "track_position": track_position, - "track_id": track_id, + "playlistId": playlist_id, + "trackPosition": track_position, + "trackId": track_id, "msg": f"Removed '{decoded_track['title']}' from '{playlist['name']}' playlist.", - "user_id": str(user_id) + "userId": str(user_id) } elif _type == "updateInbox": user = await func.get_user(user_id) - is_accpet = data.get("accept", False) + is_accept = data.get("accept", False) - if is_accpet and len(list(user.get("playlist").keys())) >= max_p: + if is_accept and len(list(user.get("playlist").keys())) >= max_p: return error_msg(f"You cannot create more than '{max_p}' playlists!", user_id=user_id, level = "error") - info = data.get("refer_id", "").split("-") + info = data.get("referId", "").split("-") sender_id, refer_id = info[0], info[1] inbox = user.get("inbox") - payload = {"op": "updatePlaylist", "status": "updateInbox", "user_id": str(user_id), "accpet": is_accpet, "sender_id": sender_id, "refer_id": refer_id} + payload = {"op": "updatePlaylist", "status": "updateInbox", "userId": str(user_id), "accept": is_accept, "senderId": sender_id, "referId": refer_id} for index, mail in enumerate(inbox.copy()): if not (str(mail.get("sender")) == sender_id and mail.get("referId") == refer_id): continue del inbox[index] - if is_accpet: + if is_accept: share_playlists = await func.get_user(mail["sender"], "playlist") if refer_id not in share_playlists: return error_msg("The shared playlist couldn’t be found. It’s possible that the user has already deleted it.", user_id=user_id) - assgined_playlist_id = _assign_playlist_id(list(user.get("playlist", []).keys())) + assigned_playlist_id = _assign_playlist_id(list(user.get("playlist", []).keys())) playlist_name = f"Share{time.strftime('%M%S', time.gmtime(int(mail['time'])))}" share_playlist = share_playlists.get(refer_id) share_playlist.update({ @@ -578,7 +582,7 @@ async def updatePlaylist(bot: commands.Bot, data: Dict) -> Dict: }) await func.update_user(mail['sender'], {"$push": {f"playlist.{mail['referId']}.perms.read": user_id}}) await func.update_user(user_id, {"$set": { - f'playlist.{assgined_playlist_id}': { + f'playlist.{assigned_playlist_id}': { 'user': mail['sender'], 'referId': mail['referId'], 'name': playlist_name, 'type': 'share' @@ -587,7 +591,7 @@ async def updatePlaylist(bot: commands.Bot, data: Dict) -> Dict: }}) payload.update({ - "playlist_id": assgined_playlist_id, + "playlistId": assigned_playlist_id, "msg": f"You have created '{playlist_name}' playlist.", "data": share_playlist, }) @@ -596,14 +600,14 @@ async def updatePlaylist(bot: commands.Bot, data: Dict) -> Dict: return payload async def getMutualGuilds(bot: commands.Bot, data: Dict) -> Dict: - user_id = int(data.get("user_id")) + user_id = int(data.get("userId")) - payload = {"op": "getMutualGuilds", "mutualGuilds": {}, "inviteGuilds": {}, "user_id": str(user_id)} + payload = {"op": "getMutualGuilds", "mutualGuilds": {}, "inviteGuilds": {}, "userId": str(user_id)} for guild_id, guild_info in data.get("guilds", {}).items(): if guild := bot.get_guild(int(guild_id)): payload["mutualGuilds"][guild_id] = { **guild_info, - "member_count": guild.member_count + "memberCount": guild.member_count } else: payload["inviteGuilds"][guild_id] = {**guild_info} @@ -611,8 +615,8 @@ async def getMutualGuilds(bot: commands.Bot, data: Dict) -> Dict: return payload async def getSettings(bot: commands.Bot, data: Dict) -> Dict: - user_id = int(data.get("user_id")) - guild_id = int(data.get("guild_id")) + user_id = int(data.get("userId")) + guild_id = int(data.get("guildId")) guild = bot.get_guild(guild_id) if not guild: @@ -636,7 +640,7 @@ async def getSettings(bot: commands.Bot, data: Dict) -> Dict: "settings": settings, "options": { "languages": list(func.LANGS.keys()), - "queue_modes": ["Queue", "FairQueue"], + "queueModes": ["Queue", "FairQueue"], "roles": [role.name for role in guild.roles] }, "guild": { @@ -644,27 +648,30 @@ async def getSettings(bot: commands.Bot, data: Dict) -> Dict: "name": guild.name, "id": str(guild_id) }, - "user_id": str(user_id) + "userId": str(user_id) } async def getLyrics(bot: commands.Bot, data: Dict) -> Dict: title, artist, platform = data.get("title", ""), data.get("artist", ""), data.get("platform", "") - if not platform or platform not in lyricsPlatform: + if not platform or platform not in LYRICS_PLATFORMS: platform = func.settings.lyrics_platform - song: dict[str, str] = await lyricsPlatform.get(platform)().get_lyrics(title, artist) - payload = { - "op": "getLyrics", - "user_id": data.get("user_id"), - "lyrics": {_: re.findall(r'.*\n(?:.*\n){,22}', v) for _, v in song.items()} if song else {}, - "callback": data.get("callback") - } - - return payload + lyrics_platform = LYRICS_PLATFORMS.get(platform) + if lyrics_platform: + lyrics: dict[str, str] = await lyrics_platform().get_lyrics(title, artist) + return { + "op": "getLyrics", + "userId": data.get("userId"), + "title": title, + "artist": artist, + "platform": platform, + "lyrics": {_: re.findall(r'.*\n(?:.*\n){,22}', v or "") for _, v in lyrics.items()} if lyrics else {}, + "callback": data.get("callback") + } async def updateSettings(bot: commands.Bot, data: Dict) -> None: - user_id = int(data.get("user_id")) - guild_id = int(data.get("guild_id")) + user_id = int(data.get("userId")) + guild_id = int(data.get("guildId")) guild = bot.get_guild(guild_id) if not guild: @@ -716,12 +723,13 @@ METHODS: Dict[str, Union[SystemMethod, PlayerMethod]] = { "updatePosition": PlayerMethod(updatePosition), "toggleAutoplay": PlayerMethod(toggleAutoplay), "updateFilter": PlayerMethod(updateFilter), + "searchAndPlay": PlayerMethod(searchAndPlay, credit=5, auto_connect=True) } async def process_methods(ipc_client, bot: commands.Bot, data: Dict) -> None: op: str = data.get("op", "") method = METHODS.get(op) - if not method or not (user_id := data.get("user_id")): + if not method or not (user_id := data.get("userId")): return user_id = int(user_id) @@ -730,7 +738,7 @@ async def process_methods(ipc_client, bot: commands.Bot, data: Dict) -> None: else: if RATELIMIT_COUNTER[user_id]["count"] >= 100: - return await ipc_client.send({"op": "rateLimited", "user_id": str(user_id)}) + return await ipc_client.send({"op": "rateLimited", "userId": str(user_id)}) RATELIMIT_COUNTER[user_id]["count"] += method.credit try: @@ -739,7 +747,7 @@ async def process_methods(ipc_client, bot: commands.Bot, data: Dict) -> None: params = method.params if not (type(method) == SystemMethod): - if guild_id := data.get("guild_id"): + if guild_id := data.get("guildId"): if (guild := bot.get_guild(int(guild_id))): env["guild"] = guild @@ -778,10 +786,12 @@ async def process_methods(ipc_client, bot: commands.Bot, data: Dict) -> None: await ipc_client.send(resp) except Exception as e: + import traceback + traceback.print_exc() payload = { "op": "errorMsg", "level": "error", "msg": str(e), - "user_id": str(user_id) + "userId": str(user_id) } await ipc_client.send(payload) \ No newline at end of file diff --git a/langs/CH.json b/langs/CH.json index d096e80..0d21c97 100644 --- a/langs/CH.json +++ b/langs/CH.json @@ -7,9 +7,11 @@ "noChannel": "沒有語音頻道可供連接。請提供一個語音頻道或加入一個語音頻道。", "alreadyConnected": "已經連接到語音頻道。", "noPermission": "抱歉!我沒有權限加入或在您的語音頻道中發言。", + "noCreatePermission": "抱歉!我沒有權限建立歌曲請求頻道。", "noPlaySource": "找不到任何可播放的來源!", "noPlayer": "在此伺服器上找不到播放器。", "notVote": "此命令需要您的投票!輸入 `/vote` 以獲取更多資訊。", + "missingIntents": "抱歉,此命令無法執行,因為機器人缺少所需的請求意圖:`({0})`.", "languageNotFound": "找不到語言包!請選擇一個現有的語言包。", "changedLanguage": "已成功切換到 `{0}` 語言包。", "setPrefix": "完成!我的前綴在您的伺服器中現在是 `{0}`。嘗試運行 `{1}ping` 來測試它。", @@ -57,7 +59,7 @@ "noPlaylistAcc": "{0} 沒有建立播放清單帳戶。", "overPlaylistCreation": "你不能建立超過 `{0}` 個播放清單!", "playlistExists": "播放清單 [`{0}`] 已存在。", - "playlistNotInvaildUrl": "請輸入有效的連結或公開的 Spotify 或 YouTube 播放清單連結。", + "playlistNotInvalidUrl": "請輸入有效的連結或公開的 Spotify 或 YouTube 播放清單連結。", "playlistCreated": "你已建立 `{0}` 播放清單。輸入 /playlist view 檢視更多資訊。", "playlistRenamed": "你已將 `{0}` 更名為 `{1}`。", "playlistLimitTrack": "你已達到限制!你只能將 `{0}` 首歌曲添加至你的播放清單中。", @@ -114,6 +116,7 @@ "buttonShuffle": "隨機播放", "buttonForward": "前進", "buttonRewind": "後退", + "buttonLyrics": "歌詞", "nowplayingDesc": "**現在播放:**\n```{0}```", "nowplayingField": "接下來播放:", @@ -186,5 +189,6 @@ "invalidEndTime": "無效的結束時間! 時間必須在 `00:00` 和 `{0}` 之間。", "invalidTimeOrder": "結束時間不能小於或等於開始時間。", - "SetStageAnnounceTemplate": "完成!從現在開始,像您現在的語音狀態將根據您的模板命名。您應該在幾秒鐘內看到它更新。" + "setStageAnnounceTemplate": "完成!從現在開始,像您現在的語音狀態將根據您的模板命名。您應該在幾秒鐘內看到它更新。", + "createSongRequestChannel": "一個歌曲請求頻道 ({0}) 已建立!您可以在該頻道中透過歌曲名稱或 URL 開始要求任何歌曲,而無需使用機器人前綴。" } \ No newline at end of file diff --git a/langs/DE.json b/langs/DE.json index 37bd9a0..155b281 100644 --- a/langs/DE.json +++ b/langs/DE.json @@ -1,82 +1,84 @@ { - "unknownException": "⚠️ Etwas ist beim Ausführen des Befehls schiefgelaufen! Bitte versuchen Sie es später erneut oder treten Sie unserem Discord-Server bei, um weitere Unterstützung zu erhalten.", + "unknownException": "⚠️ Beim Ausführen des Befehls ist etwas schiefgelaufen! Bitte versuche es später erneut oder tritt unserem Discord-Server bei, um weiteren Support zu erhalten.", "enabled": "aktiviert", "disabled": "deaktiviert", - "nodeReconnect": "Bitte versuche es erneut, nachdem sich der Knoten wieder verbunden hat.", - "noChannel": "Kein Sprachkanal zum Verbinden gefunden. Bitte stellen Sie entweder einen zur Verfügung oder schließen Sie sich einem an.", + "nodeReconnect": "Bitte versuche es erneut, nachdem sich die Node wieder verbunden hat.", + "noChannel": "Kein Sprachkanal zum Verbinden gefunden. Bitte stelle entweder einen zur Verfügung oder trete einem bei.", "alreadyConnected": "Bereits mit einem Sprachkanal verbunden.", - "noPermission": "Es tut uns leid! Ich bin nicht berechtigt, Ihrem Sprachkanal beizutreten oder darin zu sprechen.", - "noPlaySource": "Kann keine abspielbaren Quellen finden!", - "noPlayer": "Auf diesem Server wurde kein Spieler gefunden.", - "notVote": "Dieser Befehl erfordert Ihre Stimme! Geben Sie `/vote` ein, um weitere Informationen zu erhalten.", - "languageNotFound": "Kein Sprachpaket gefunden. Bitte wählen Sie ein vorhandenes Sprachpaket aus.", + "noPermission": "Es tut mir leid, ich bin nicht berechtigt, dem Sprachkanal beizutreten oder darin zu sprechen.", + "noCreatePermission": "Es tut mir leid, ich habe keine Berechtigung, einen Song Request Channel zu erstellen.", + "noPlaySource": "Ich kann keine abspielbaren Quellen finden!", + "noPlayer": "Auf diesem Server wurden kein Player gefunden.", + "notVote": "Dieser Befehl erfordert Deine Stimme! Gebe `/vote` ein, um weitere Informationen zu erhalten.", + "missingIntents": "Es tut mir leid, dieser Befehl kann nicht ausgeführt werden, da mir der `({0})` Intent fehlt.", + "languageNotFound": "Kein Sprachpaket gefunden. Bitte wähle ein vorhandenes Sprachpaket aus.", "changedLanguage": "Erfolgreich auf das Sprachpaket `{0}` geändert.", - "setPrefix": "Erledigt! Mein Präfix auf Ihrem Server ist jetzt `{0}`. Versuchen Sie, `{1}ping` auszuführen, um es zu testen.", - "setDJ": "Stellen Sie den DJ auf {0}.", - "setqueue": "Stellen Sie den Warteschlangenmodus auf `{0}` ein.", - "247": "Jetzt haben Sie den 24/7-Modus von `{0}`.", - "bypassVote": "Jetzt haben Sie das Abstimmungssystem `{0}` umgangen.", - "setVolume": "Stellen Sie die Lautstärke auf `{0}` % ein.", - "togglecontroller": "Jetzt haben Sie `{0}` den Musikcontroller.", - "toggleDuplicateTrack": "Jetzt haben Sie `{0}`, um doppelte Tracks in der Warteschlange zu verhindern.", - "toggleControllerMsg": "Sie haben jetzt `{0}` Nachrichten vom Musik-Controller.", + "setPrefix": "Erledigt! Mein Präfix ist jetzt `{0}` auf deinem Server. Versuche, `{1}ping` auszuführen, um es zu testen.", + "setDJ": "Stelle den DJ auf {0}.", + "setqueue": "Stelle den Warteschlangenmodus auf `{0}` ein.", + "247": "Der 24/7-Modus wurde erfolgreich `{0}`.", + "bypassVote": "Das Abstimmungssystem wurde `{0}`", + "setVolume": "Stelle die Lautstärke auf `{0}`%.", + "togglecontroller": "Der Musikcontroller wurde erfolgreich `{0}`", + "toggleDuplicateTrack": "Du hast das hinzufügen von doppelten Tracks in der Warteschlange `{0}`", + "toggleControllerMsg": "Nachrichten vom Musik-Controller wurden erfolgreich `{0}`", "settingsMenu": "Servereinstellungen | {0}", "settingsTitle": "❤️ Grundlegende Informationen:", - "settingsValue": "```Präfix: {0}`\nSprache: {1}\nMusik-Controller: {2}\nDJ-Rolle: @{3}\nAbstimmungsumgehung: {4}\nRund um die Uhr: {5}\nStandardvolumen: {6}%\nSpielzeit: {7}```", + "settingsValue": "```Präfix: {0}`\nSprache: {1}\nMusik-Controller: {2}\nDJ-Rolle: @{3}\nAbstimmungsumgehung: {4}\n24/7 Play: {5}\nStandard Lautstärke: {6}%\nSpielzeit: {7}```", "settingsTitle2": "🔗 Warteschlangeninformationen:", - "settingsValue2": "```Warteschlangenmodus: {0}\nMax Lied: {1}\nDoppelte Spur zulassen: {2}```", + "settingsValue2": "```Warteschlangenmodus: {0}\nMaximale Songs: {1}\nDoppelte Tracks zulassen: {2}```", "settingsTitle3": "🎤 Sprachstatusinfo:", "settingsPermTitle": "✨ Berechtigungen:", - "settingsPermValue": "```{0} Administrator\n{1} Gilde verwalten\n{2} Kanal verwalten\n{3} Manage_Messages```", + "settingsPermValue": "```{0} Administrator\n{1} Guild verwalten\n{2} Kanal verwalten\n{3} Manage_Messages```", "pingTitle1": "Bot-Info:", - "pingTitle2": "Spielerinfo:", - "pingfield1": "```Shard-ID: {0}/{1}\nShard-Latenz: {2:.3f} s {3}\nRegion: {4}```", - "pingfield2": "```Knoten: {0} - {1:.3f}s\nSpieler: {2}\nSprachregion: {3}```", + "pingTitle2": "Player Info:", + "pingfield1": "```Shard-ID: {0}/{1}\nShard-Latenz: {2:.3f}s {3}\nRegion: {4}```", + "pingfield2": "```Node: {0} - {1:.3f}s\nPlayer: {2}\nSprachregion: {3}```", "addEffect": "Wende den Effekt `{0}` Filter an.", "clearEffect": "Die Soundeffekte wurden gelöscht!", - "FilterTagAlreadyInUse": "Diese Soundeffekte sind bereits im Einsatz! Bitte verwenden Sie /cleareffect , um sie zu entfernen.", + "FilterTagAlreadyInUse": "Diese Soundeffekte sind bereits im Einsatz! Bitte verwende /cleareffect , um sie zu entfernen.", "playlistViewTitle": "📜 Alle Playlists von {0}", - "playlistViewHeaders": "ID:,Zeit:,Name:,Spuren:", - "playlistFooter": "Geben Sie /playlist play [playlist] ein, um die Playlist in die Warteschlange einzufügen.", - "playlistNotFound": "Wiedergabeliste [`{0}`] nicht gefunden. Geben Sie /playlist view ein, um Ihre gesamte Wiedergabeliste anzuzeigen.", - "playlistNotAccess": "Es tut uns leid! Du bist nicht berechtigt, auf diese Playlist zuzugreifen!", + "playlistViewHeaders": "ID:,Zeit:,Name:,Tracks:", + "playlistFooter": "Gebe /playlist play [playlist] ein, um die Playlist in die Warteschlange einzufügen.", + "playlistNotFound": "Die Wiedergabeliste [`{0}`] wurde nicht gefunden. Gebe /playlist view ein, um Deine gesamte Wiedergabeliste anzuzeigen.", + "playlistNotAccess": "Es tut mir leid! Du bist nicht berechtigt, auf diese Playlist zuzugreifen!", "playlistNoTrack": "Es tut uns leid! Es gibt keine Titel in der Wiedergabeliste [`{0}`].", "playlistNotAllow": "Dieser Befehl ist für verknüpfte und freigegebene Wiedergabelisten nicht zulässig.", - "playlistPlay": "Playlist [`{0}`] mit `{1}` Songs zur Warteschlange hinzugefügt.", - "playlistOverText": "Es tut uns leid! Der Name der Playlist darf 10 Zeichen nicht überschreiten.", - "playlistSameName": "Es tut uns leid! Dieser Name darf nicht mit Ihrem neuen Namen identisch sein.", - "playlistDeleteError": "Sie dürfen die Standard-Wiedergabeliste nicht löschen.", - "playlistRemove": "Sie haben die Wiedergabeliste [`{0}`] entfernt.", - "playlistSendErrorPlayer": "Es tut uns leid! Sie können keine Einladung an sich selbst senden.", - "playlistSendErrorBot": "Es tut uns leid! Sie können keine Einladung an einen Bot senden.", - "playlistBelongs": "Es tut uns leid! Diese Playlist gehört <@{0}>.", - "playlistShare": "Es tut uns leid! Diese Playlist wurde mit {0} geteilt.", - "playlistSent": "Es tut uns leid! Sie haben bereits eine Einladung gesendet.", - "noPlaylistAcc": "{0} hat kein Playlist-Konto erstellt.", - "overPlaylistCreation": "Sie können nicht mehr als `{0}` Wiedergabelisten erstellen!", - "playlistExists": "Playlist [`{0}`] existiert bereits.", - "playlistNotInvaildUrl": "Bitte geben Sie einen gültigen Link oder öffentlichen Spotify- oder YouTube-Playlist-Link ein.", - "playlistCreated": "Sie haben die Wiedergabeliste `{0}` erstellt. Geben Sie /playlist view ein, um weitere Informationen zu erhalten.", - "playlistRenamed": "Sie haben `{0}` in `{1}` umbenannt.", - "playlistLimitTrack": "Sie haben die Grenze erreicht! Du kannst deiner Playlist nur `{0}` Songs hinzufügen.", - "playlistPlaylistLink": "Sie dürfen keinen Playlist-Link verwenden.", - "playlistStream": "Du darfst deiner Playlist keine Streaming-Videos hinzufügen.", - "playlistPositionNotFound": "Position `{0}` kann nicht in Ihrer Playlist [`{1}`] gefunden werden!", + "playlistPlay": "Die Playlist [`{0}`] mit `{1}` Songs wurde zur Warteschlange hinzugefügt.", + "playlistOverText": "Verzeihung, der Name der Playlist darf nicht mehr als 10 Zeichen enthalten.", + "playlistSameName": "Verzeihung, dieser Name darf nicht mit Deinem neuen Namen identisch sein.", + "playlistDeleteError": "Du kannst die Standard-Wiedergabeliste nicht löschen.", + "playlistRemove": "Du hast die Wiedergabeliste [`{0}`] entfernt.", + "playlistSendErrorPlayer": "Entschuldigung, du kannst keine Einladung an dich selber senden.", + "playlistSendErrorBot": "Entschuldigung, Du kannst keine Einladung an eine App senden.", + "playlistBelongs": "Verzeihung, diese Playlist gehört <@{0}>.", + "playlistShare": "Verzeigung, Diese Playlist wurde bereits mit {0} geteilt.", + "playlistSent": "Verzeihung, Du hast bereits eine Einladung gesendet.", + "noPlaylistAcc": "{0} hat noch kein Playlist-Konto erstellt.", + "overPlaylistCreation": "Du kannst nicht mehr als `{0}` Wiedergabelisten erstellen!", + "playlistExists": "Die Playlist [`{0}`] existiert bereits.", + "playlistNotInvalidUrl": "Bitte gebe einen gültigen Link oder öffentlichen Spotify/YouTube-Playlist-Link ein.", + "playlistCreated": "Du hast die Wiedergabeliste `{0}` erstellt. Gebe /playlist view ein, um weitere Informationen zu erhalten.", + "playlistRenamed": "Du hast `{0}` zu `{1}` umbenannt.", + "playlistLimitTrack": "Du hast die Grenze erreicht! Du kannst deiner Playlist nur noch `{0}` Songs hinzufügen.", + "playlistPlaylistLink": "Du kannst keine Playlist-Links verwenden.", + "playlistStream": "Du darfst deiner Playlist keine aktiven Streams hinzufügen.", + "playlistPositionNotFound": "Die Position `{0}` kann in Deiner Playlist [`{1}`] nicht gefunden werden.", "playlistRemoved": "👋 {0} aus {1}s Playlist [{2}] entfernt.", - "playlistClear": "Sie haben Ihre Wiedergabeliste [`{0}`] erfolgreich gelöscht.", + "playlistClear": "Du hast Deine Wiedergabenliste [`{0}`] erfolgreich gelöscht.", "playlistView": "Playlist-Viewer", "playlistViewDesc": "```Name | ID: {0} | {1}\nTitel insgesamt: {2}\nBesitzer: {3}\nTyp: {4}\n```", "playlistViewPermsValue": "📖 Lesen: ✓ ✍🏽 Schreiben: {0} 🗑️ Entfernen: {1}", "playlistViewPermsValue2": "📖 Lesen: {0}", - "playlistViewTrack": "Spuren", + "playlistViewTrack": "Tracks", "playlistViewPage": "Seite: {0}/{1} | Gesamtdauer: {2}", - "inboxFull": "Es tut uns leid! Der Posteingang von {0} ist voll.", - "inboxNoMsg": "Es sind keine Nachrichten in Ihrem Posteingang.", + "inboxFull": "Es tut mir leid! Der Posteingang von {0} ist voll.", + "inboxNoMsg": "Es sind keine Nachrichten in Deinem Posteingang.", "invitationSent": "Einladung an {0} gesendet.", - "notInChannel": "{0}, du musst in {1} sein, um Sprachbefehle zu nutzen. Bitte betrete den Sprachkanal, wenn du dich in Sprache befindest!", + "notInChannel": "{0}, Du musst in {1} sein, um Sprachbefehle nutzen zu können. Bitte betrete den Sprachkanal erneut bei, wenn Du dich im Voice Channel befindest!", "noTrackPlaying": "Es werden derzeit keine Songs abgespielt", "noTrackFound": "Es wurden keine Songs mit dieser Abfrage gefunden! Bitte gib eine gültige URL an.", "noLinkSupport": "Der Suchbefehl unterstützt keine Links!", @@ -85,37 +87,38 @@ "missingPerms_mode": "Nur der DJ oder Admins können den Wiederholungsmodus wechseln.", "missingPerms_queue": "Nur der DJ oder Admins können Tracks aus der Warteschlange entfernen.", "missingPerms_autoplay": "Nur der DJ oder Admins können den Autoplay-Modus aktivieren oder deaktivieren!", - "missingPerms_function": "Nur DJ oder Admin können diese Funktion verwenden.", + "missingPerms_function": "Nur DJ oder Admins können diese Funktion verwenden.", "timeFormatError": "Falsches Zeitformat. Beispiel: 2:42 oder 12:39:31", - "lyricsNotFound": "Songtexte nicht gefunden. Geben Sie /lyrics ein, um die Songtexte zu finden.", + "lyricsNotFound": "Es wurden keine Songtexte gefunden. Gebe /lyrics ein, um die Songtexte zu finden.", "missingTrackInfo": "Einige Track-Informationen fehlen.", - "noVoiceChannel": "Sprachkanal nicht gefunden!", + "noVoiceChannel": "Dieser Sprachkanal wurde nicht gefunden!", - "playlistAddError": "Sie dürfen Ihrer Wiedergabeliste keine Streaming-Videos hinzufügen!", - "playlistAddError2": "Es gab ein Problem beim Hinzufügen von Tracks zur Wiedergabeliste!", - "playlistlimited": "Sie haben das Limit erreicht! Sie können nur {0} Songs zu Ihrer Wiedergabeliste hinzufügen.", - "playlistrepeated": "In Ihrer Wiedergabeliste gibt es bereits den gleichen Track!", - "playlistAdded": "❤️ Hinzugefügt **{0}** in {1}'s Wiedergabeliste [`{2}`]!", + "playlistAddError": "Du darfst Deiner Wiedergabenliste keine aktiven Streams hinzufügen!", + "playlistAddError2": "Es gab ein Problem beim Hinzufügen von Tracks zur Wiedergabenliste!", + "playlistlimited": "Du hast das Limit erreicht! Du kannst nur noch {0} Songs zu Deiner Wiedergabenliste hinzufügen.", + "playlistrepeated": "In Deiner Wiedergabenliste gibt es bereits den gleichen Track!", + "playlistAdded": "❤️ **{0}** wurde in der Wiedergabenliste [`{2}`] von {1} hinzugefügt.", - "playerDropdown": "Wählen Sie einen Song aus, um zu überspringen ...", - "playerFilter": "Wählen Sie einen Filter aus, um ihn anzuwenden ...", + "playerDropdown": "Wähle einen Song aus, um zu überspringen ...", + "playerFilter": "Wähle einen Filter aus, um ihn anzuwenden ...", "buttonBack": "Zurück", "buttonPause": "Pause", - "buttonResume": "Fortsetzen", + "buttonResume": "Weiter", "buttonSkip": "Überspringen", "buttonLeave": "Verlassen", - "buttonLoop": "Schleife", + "buttonLoop": "Endlosschleife", "buttonVolumeUp": "Lauter", "buttonVolumeDown": "Leiser", "buttonVolumeMute": "Stummschalten", "buttonVolumeUnmute": "Stummschaltung aufheben", "buttonAutoPlay": "Autoplay", - "buttonShuffle": "Mischen", - "buttonForward": "Vorwärts", - "buttonRewind": "Rückwärts", + "buttonShuffle": "Zufall", + "buttonForward": "Vorspulen", + "buttonRewind": "Zurückspulen", + "buttonLyrics": "Liedtexte", - "nowplayingDesc": "**Jetzt abspielen:**\n```{0}```", + "nowplayingDesc": "**Jetzt wird abgespielt:**\n```{0}```", "nowplayingField": "Als nächstes:", "nowplayingLink": "Auf {0} anhören", @@ -124,31 +127,31 @@ "live": "LIVE", "playlistLoad": " 🎶 Die Wiedergabeliste **{0}** mit `{1}` Songs wurde zur Warteschlange hinzugefügt.", "trackLoad": "**[{0}](<{1}>)** von **{2}** (`{3}`) wurde zum Abspielen hinzugefügt.\n", - "trackLoad_pos": "**[{0}](<{1}>)** von **{2}** (`{3}`) wurde in der Warteschlange an Position **{4}** hinzugefügt.\n", + "trackLoad_pos": "**[{0}](<{1}>)** von **{2}** (`{3}`) wurde in der Warteschlange zu Position **{4}** hinzugefügt.\n", "searchTitle": "Suchabfrage: {0}", "searchDesc": "➥ Plattform: {0} **{1}**\n➥ Ergebnisse: **{2}**\n\n{3}", - "searchWait": "Wählen Sie den Song aus, den Sie zur Warteschlange hinzufügen möchten.", - "searchTimeout": "Die Suche wurde abgebrochen. Bitte versuchen Sie es später erneut.", - "searchSuccess": "Song wurde zur Warteschlange hinzugefügt.", + "searchWait": "Wähle den Song aus, den Du zur Warteschlange hinzufügen möchtest.", + "searchTimeout": "Die Suche wurde abgebrochen. Bitte versuche es später erneut.", + "searchSuccess": "Der Song wurde zur Warteschlange hinzugefügt.", "queueTitle": "Kommende Warteschlange:", - "historyTitle": "Verlaufswarteschlange:", + "historyTitle": "Vorherige Warteschlange:", "viewTitle": "Musik-Warteschlange", - "viewDesc": "**Jetzt abspielen: [Hier klicken]({0}) ⮯**\n{1}", + "viewDesc": "**Jetzt wird abgespielt: [Hier klicken]({0}) ⮯**\n{1}", "viewFooter": "Seite: {0}/{1} | Gesamtdauer: {2}", "pauseError": "Der Player ist bereits pausiert.", - "pauseVote": "{0} hat für eine Pause des Songs gestimmt. [{1}/{2}]", + "pauseVote": "{0} hat für eine Pause des Songs abgestimmt. [{1}/{2}]", "paused": "`{0}` hat den Player pausiert.", "resumeError": "Der Player ist nicht pausiert.", - "resumeVote": "{0} hat für das Fortsetzen des Songs gestimmt. [{1}/{2}]", + "resumeVote": "{0} hat für das Fortsetzen des Songs abgestimmt. [{1}/{2}]", "resumed": "`{0}` hat den Player fortgesetzt.", - "shuffleError": "Fügen Sie mehr Songs zur Warteschlange hinzu, bevor Sie mischen.", + "shuffleError": "Füge mehr Songs zur Warteschlange hinzu, bevor Du die Songs mischst.", "shuffleVote": "{0} hat für das Mischen der Warteschlange gestimmt. [{1}/{2}]", "shuffled": "Die Warteschlange wurde gemischt.", "skipError": "Es gibt keine Songs, die übersprungen werden können.", - "skipVote": "{0} hat für das Überspringen des Songs gestimmt. [{1}/{2}]", + "skipVote": "{0} hat für das Überspringen des Songs abgestimmt. [{1}/{2}]", "skipped": "`{0}` hat den Song übersprungen.", "backVote": "{0} hat für das Überspringen zum vorherigen Song gestimmt. [{1}/{2}]", @@ -157,34 +160,35 @@ "leaveVote": "{0} hat für das Anhalten des Players gestimmt. [{1}/{2}]", "left": "`{0}` hat den Player angehalten.", - "seek": "Setze den Player auf **{0}**", + "seek": "Der Player wurde auf **{0}** gesetzt.", "repeat": "Der Wiederholungsmodus wurde auf `{0}` gesetzt", "cleared": "Alle Tracks in `{0}` wurden gelöscht", "removed": "`{0}` Tracks wurden aus der Warteschlange entfernt.", - "forward": "Spule den Player vor auf **{0}**", - "rewind": "Spule den Player zurück auf **{0}**", - "replay": "Wiederhole den aktuellen Song.", + "forward": "Der Player wurde auf **{0}** vorgespult.", + "rewind": "Der Player wurde auf **{0}** zurückgespult", + "replay": "Der aktuelle Song wird wiederholt.", "swapped": "`{0}` und `{1}` wurden ausgetauscht.", - "moved": "Verschoben `{0}` zu `{1}`", + "moved": "`{0}` zu `{1}` verschoben", "autoplay": "Der Autoplay-Modus ist jetzt **{0}**", - "notdj": "Du bist kein DJ. Der aktuelle DJ ist {0}.", - "djToMe": "Du kannst den DJ nicht an dich selbst oder einen Bot übertragen.", + "notdj": "Du bist kein DJ, der aktuelle DJ ist {0}.", + "djToMe": "Du kannst den DJ nicht an dich selbst oder einer App übertragen.", "djnotinchannel": "`{0}` ist nicht im Sprachkanal.", "djswap": "Du hast die DJ-Rolle auf `{0}` übertragen.", "chaptersDropdown": "Wähle ein Kapitel zum Überspringen aus...", "noChaptersFound": "Es wurden keine Kapitel gefunden!", - "chatpersNotSupport": "Dieser Befehl unterstützt nur Youtube-Videos!", + "chatpersNotSupport": "Dieser Befehl unterstützt nur YouTube-Videos!", - "voicelinkQueueFull": "Entschuldigung, du hast das Maximum von `{0}` Tracks in der Warteschlange erreicht!", + "voicelinkQueueFull": "Entschuldigung, Du hast das Maximum von `{0}` Tracks in der Warteschlange erreicht.", "voicelinkOutofList": "Bitte gib einen gültigen Track-Index an!", "voicelinkDuplicateTrack": "Entschuldigung, dieser Track ist bereits in der Warteschlange.", "deocdeError": "Beim Dekodieren der Datei ist etwas schief gelaufen!", - "invalidStartTime": "Ungültiger Startzeit! Die Zeit muss innerhalb von `00:00` und `{0}` liegen.", - "invalidEndTime": "Ungültiger Endzeit! Die Zeit muss innerhalb von `00:00` und `{0}` liegen.", - "invalidTimeOrder": "Der Endzeit darf nicht kleiner oder gleich dem Startzeit sein.", + "invalidStartTime": "Ungültige Startzeit! Die Zeit muss innerhalb von `00:00` und `{0}` liegen.", + "invalidEndTime": "Ungültige Endzeit! Die Zeit muss innerhalb von `00:00` und `{0}` liegen.", + "invalidTimeOrder": "Die Endzeit darf nicht kleiner oder gleich dem Startzeit sein.", - "SetStageAnnounceTemplate": "Fertig! Ab sofort wird der Sprachstatus wie der, in dem Sie sich gerade befinden, gemäß Ihrer Vorlage benannt. Sie sollten in wenigen Sekunden eine Aktualisierung sehen." + "setStageAnnounceTemplate": "Fertig! Ab sofort wird der Sprachstatus wie der, in dem Du Dich gerade befindest, gemäß Deiner Vorlage benannt. Du solltest in wenigen Sekunden eine Aktualisierung sehen.", + "createSongRequestChannel": "Der Song Request Channel ({0}) wurde erstellt! Du kannst jeden Song nach Namen oder URL in diesem Kanal anfordern, ohne den Bot-Präfix verwenden zu müssen." } \ No newline at end of file diff --git a/langs/EN.json b/langs/EN.json index 00dad17..336b108 100644 --- a/langs/EN.json +++ b/langs/EN.json @@ -7,9 +7,11 @@ "noChannel": "No voice channel to connect. Please either provide one or join one.", "alreadyConnected": "Already connected to a voice channel.", "noPermission": "Sorry! i don't have permissions to join or speak in your voice channel.", + "noCreatePermission": "Sorry! i don't have permissions to create a song requesting channel.", "noPlaySource": "Can't found any playable sources!", "noPlayer": "No player has found on this server.", "notVote": "This command requires your vote! Type `/vote` for more info.", + "missingIntents": "Sorry, this command cannot be executed because the bot is missing the required request intent: `({0})`.", "languageNotFound": "No language pack found! please select an existing language pack.", "changedLanguage": "Successfully changed to `{0}` language pack.", "setPrefix": "Done! My prefix in your server is now `{0}`. Try running `{1}ping` to test it out.", @@ -57,7 +59,7 @@ "noPlaylistAcc": "{0} didn't create a playlist account.", "overPlaylistCreation": "You cannot create more than `{0}` playlists!", "playlistExists": "Playlist [`{0}`] already exists.", - "playlistNotInvaildUrl": "Please enter a valid link or public spotify or youtube playlist link.", + "playlistNotInvalidUrl": "Please enter a valid link or public spotify or youtube playlist link.", "playlistCreated": "You have created `{0}` playlist. Type /playlist view for more info.", "playlistRenamed": "You have renamed `{0}` to `{1}`.", "playlistLimitTrack": "You have reached the limit! You can only add `{0}` songs to your playlist.", @@ -114,6 +116,7 @@ "buttonShuffle": "Shuffle", "buttonForward": "Forward", "buttonRewind": "Rewind", + "buttonLyrics": "Lyrics", "nowplayingDesc": "**Now Playing:**\n```{0}```", "nowplayingField": "Up Next:", @@ -124,7 +127,7 @@ "live": "LIVE", "playlistLoad": " 🎶 Added the playlist **{0}** with `{1}` songs to the queue.", "trackLoad": "Added **[{0}](<{1}>)** by **{2}** (`{3}`) to begin playing.\n", - "trackLoad_pos": "Added **[{0}](<{1}>)** by **{3}** (`{3}`) to the queue at position **{4}**\n", + "trackLoad_pos": "Added **[{0}](<{1}>)** by **{2}** (`{3}`) to the queue at position **{4}**\n", "searchTitle": "Search Query: {0}", "searchDesc": "➥ Platform: {0} **{1}**\n➥ Results: **{2}**\n\n{3}", @@ -186,5 +189,6 @@ "invalidEndTime": "Invalid end time, it must be between `00:00` and `{0}`", "invalidTimeOrder": "End time cannot be less than or equal to start time", - "SetStageAnnounceTemplate": "Done! From now on, voice status like the one you're in now will be named according to your template. You should see it update in a few seconds." + "setStageAnnounceTemplate": "Done! From now on, voice status like the one you're in now will be named according to your template. You should see it update in a few seconds.", + "createSongRequestChannel": "A song request channel ({0}) has been created! You can start requesting any song by name or URL in that channel, without needing to use the bot prefix." } \ No newline at end of file diff --git a/langs/ES.json b/langs/ES.json index f2a3fcd..3ee7877 100644 --- a/langs/ES.json +++ b/langs/ES.json @@ -7,9 +7,11 @@ "noChannel": "No hay canal de voz al que conectarse. Por favor, proporcione uno o únase a uno.", "alreadyConnected": "Ya conectado a un canal de voz.", "noPermission": "¡Lo siento! No tengo permisos para unirme o hablar en su canal de voz.", + "noCreatePermission": "¡Lo siento! No tengo permisos para crear un canal de solicitud de canciones.", "noPlaySource": "¡No se puede encontrar ninguna fuente reproducible!", "noPlayer": "No se ha encontrado ningún reproductor en este servidor.", "notVote": "¡Este comando requiere su voto! Escriba `/vote` para obtener más información.", + "missingIntents": "Lo siento, este comando no se puede ejecutar porque el bot carece de la intención de solicitud requerida: `({0})`.", "languageNotFound": "¡No se encontró paquete de idioma! por favor seleccione un paquete de idioma existente.", "changedLanguage": "Cambiado con éxito al paquete de idioma `{0}`.", "setPrefix": "¡Listo! Mi prefijo en tu servidor ahora es `{0}`. Intenta ejecutar `{1}ping` para probarlo.", @@ -57,7 +59,7 @@ "noPlaylistAcc": "`{0}` no ha creado una cuenta de lista de reproducción.", "overPlaylistCreation": "¡No puede crear más de `{0}` listas de reproducción!", "playlistExists": "La lista de reproducción [`{0}`] ya existe.", - "playlistNotInvaildUrl": "Ingrese un enlace válido o un enlace público de lista de reproducción de Spotify o YouTube.", + "playlistNotInvalidUrl": "Ingrese un enlace válido o un enlace público de lista de reproducción de Spotify o YouTube.", "playlistCreated": "Ha creado una lista de reproducción llamada `{0}`. Escriba /playlist view para obtener más información.", "playlistRenamed": "Ha cambiado el nombre de `{0}` a `{1}`.", "playlistLimitTrack": "¡Ha alcanzado el límite! Solo puede agregar `{0}` canciones a su lista de reproducción.", @@ -114,6 +116,7 @@ "buttonShuffle": "Aleatorio", "buttonForward": "Adelante", "buttonRewind": "Atrás", + "buttonLyrics": "Letras", "nowplayingDesc": "**Reproduciendo ahora:**\n```{0}```", "nowplayingField": "A continuación:", @@ -186,5 +189,6 @@ "invalidEndTime": "Tiempo de finalización inválido! El tiempo debe estar entre `00:00` y `{0}`.", "invalidTimeOrder": "El tiempo final no puede ser menor o igual que el tiempo de inicio.", - "SetStageAnnounceTemplate": "¡Hecho! A partir de ahora, el estado de voz como el que tienes ahora se nombrará según tu plantilla. Deberías verlo actualizarse en unos segundos." + "setStageAnnounceTemplate": "¡Hecho! A partir de ahora, el estado de voz como el que tienes ahora se nombrará según tu plantilla. Deberías verlo actualizarse en unos segundos.", + "createSongRequestChannel": "¡Se ha creado un canal de solicitudes de canciones ({0})! Puedes empezar a solicitar cualquier canción por nombre o URL en ese canal, sin necesidad de usar el prefijo del bot." } \ No newline at end of file diff --git a/langs/JA.json b/langs/JA.json index 4f008f6..b8d9ebd 100644 --- a/langs/JA.json +++ b/langs/JA.json @@ -7,9 +7,11 @@ "noChannel": "接続する音声チャンネルがありません。提供するか、参加してください。", "alreadyConnected": "すでに音声チャンネルに接続しています。", "noPermission": "申し訳ありません!私はあなたの音声チャンネルに参加または話すための許可がありません。", + "noCreatePermission": "ごめんなさい!曲リクエストチャンネルを作成する権限がありません。", "noPlaySource": "再生可能なソースが見つかりません!", "noPlayer": "このサーバーにプレイヤーが見つかりません。", "notVote": "このコマンドにはあなたの投票が必要です!詳細については、/voteを入力してください。", + "missingIntents": "申し訳ありませんが、このコマンドは実行できません。ボットに必要なリクエストインテントが不足しています:`({0})`.", "languageNotFound": "言語パックが見つかりません。既存の言語パックを選択してください。", "changedLanguage": "「{0}」言語パックに正常に変更しました。", "setPrefix": "完了!あなたのサーバーのプレフィックスは今や「{0}」です。 `{1}ping`を実行してテストしてみてください。", @@ -57,7 +59,7 @@ "noPlaylistAcc": "{0}さんはプレイリストアカウントを作成していません。", "overPlaylistCreation": " {0}個以上のプレイリストを作成することはできません!", "playlistExists": "プレイリスト[{0}]はすでに存在します。", - "playlistNotInvaildUrl": "有効なリンクまたは公開SpotifyまたはYouTubeプレイリストリンクを入力してください。", + "playlistNotInvalidUrl": "有効なリンクまたは公開SpotifyまたはYouTubeプレイリストリンクを入力してください。", "playlistCreated": "{0}プレイリストを作成しました。詳細については、/playlist viewを入力してください。", "playlistRenamed": "{0}を{1}に名前を変更しました。", "playlistLimitTrack": "この制限に達しました!プレイリストには{0}曲しか追加できません。", @@ -114,6 +116,7 @@ "buttonShuffle": "シャッフル", "buttonForward": "進む", "buttonRewind": "戻る", + "buttonLyrics": "歌詞", "nowplayingDesc": "**現在再生中:**\n```{0}```", "nowplayingField": "次に再生する曲:", @@ -186,5 +189,6 @@ "invalidEndTime": "無効な終了時間!時間は `00:00` と `{0}` の間に設定する必要があります。", "invalidTimeOrder": "終了時間は開始時間より大きくない必要があります。", - "SetStageAnnounceTemplate": "完了!これからは、今いるボイスステータスがあなたのテンプレートに従って名前が付けられます。数秒以内に更新されるのを見ることができるはずです。" + "setStageAnnounceTemplate": "完了!これからは、今いるボイスステータスがあなたのテンプレートに従って名前が付けられます。数秒以内に更新されるのを見ることができるはずです。", + "createSongRequestChannel": "曲のリクエストチャンネル ({0}) が作成されました!そのチャンネルで、曲名または URL を使用して任意の曲をリクエストできます。ボットのプレフィックスは必要ありません。" } \ No newline at end of file diff --git a/langs/KO.json b/langs/KO.json index ccb7fc2..5582649 100644 --- a/langs/KO.json +++ b/langs/KO.json @@ -7,9 +7,11 @@ "noChannel": "연결할 음성 채널이 없습니다. 하나를 제공하거나 참여하십시오.", "alreadyConnected": "이미 음성 채널에 연결되어 있습니다.", "noPermission": "죄송합니다! 음성 채널에 참여하거나 말할 권한이 없습니다.", + "noCreatePermission": "죄송합니다! 노래 요청 채널을 생성할 권한이 없습니다.", "noPlaySource": "재생 가능한 소스를 찾을 수 없습니다!", "noPlayer": "이 서버에서 플레이어를 찾을 수 없습니다.", "notVote": "이 명령어를 실행하려면 투표해야합니다! 자세한 내용은 `/vote`를 입력하십시오.", + "missingIntents": "죄송하지만 이 명령을 실행할 수 없습니다. 봇에 필요한 요청 의도가 없습니다:`({0})`.", "languageNotFound": "언어 팩을 찾을 수 없습니다! 기존 언어 팩을 선택하십시오.", "changedLanguage": "성공적으로 `{0}` 언어 팩으로 변경되었습니다.", "setPrefix": "완료되었습니다! 이 서버에서 내 접두사는 이제 `{0}`입니다. `{1}ping`을 실행하여 테스트해보세요.", @@ -57,7 +59,7 @@ "noPlaylistAcc": "{0}님은 재생 목록 계정을 만들지 않았습니다.", "overPlaylistCreation": "더 이상 {0}개 이상의 재생 목록을 만들 수 없습니다!", "playlistExists": "재생 목록 [{0}]이(가) 이미 있습니다.", - "playlistNotInvaildUrl": "유효한 링크 또는 공개 Spotify 또는 YouTube 재생 목록 링크를 입력하세요.", + "playlistNotInvalidUrl": "유효한 링크 또는 공개 Spotify 또는 YouTube 재생 목록 링크를 입력하세요.", "playlistCreated": "{0} 재생 목록을 만들었습니다. 자세한 내용은 /playlist view를 입력하세요.", "playlistRenamed": "{0}을(를) {1}(으)로 이름을 바꿨습니다.", "playlistLimitTrack": "죄송합니다! 재생 목록에 추가할 수 있는 노래는 {0}곡까지입니다.", @@ -114,6 +116,7 @@ "buttonShuffle": "셔플", "buttonForward": "앞으로", "buttonRewind": "뒤로", + "buttonLyrics": "가사", "nowplayingDesc": "**현재 재생중인 곡:**\n```{0}```", "nowplayingField": "다음 곡:", @@ -186,5 +189,6 @@ "invalidEndTime": "효력 없는 종료 시간! 시간은 `00:00` 과 `{0}` 사이에 설정해야 합니다.", "invalidTimeOrder": "종료 시간은 시작 시간보다 클수 있어야 합니다.", - "SetStageAnnounceTemplate": "완료! 이제부터 지금 있는 음성 상태는 귀하의 템플릿에 따라 이름이 지정됩니다. 몇 초 후에 업데이트되는 것을 볼 수 있을 것입니다." + "setStageAnnounceTemplate": "완료! 이제부터 지금 있는 음성 상태는 귀하의 템플릿에 따라 이름이 지정됩니다. 몇 초 후에 업데이트되는 것을 볼 수 있을 것입니다.", + "createSongRequestChannel": "노래 요청 채널 ({0})이 생성되었습니다! 해당 채널에서 노래 제목이나 URL로 원하는 노래를 요청할 수 있으며, 봇 접두사를 사용할 필요가 없습니다." } \ No newline at end of file diff --git a/langs/RU.json b/langs/RU.json index 636794d..3d352bd 100644 --- a/langs/RU.json +++ b/langs/RU.json @@ -7,9 +7,11 @@ "noChannel": "Нет голосового канала для подключения. Пожалуйста, укажите или присоединитесь к одному.", "alreadyConnected": "Уже подключен к голосовому каналу.", "noPermission": "Извините! У меня нет разрешения на подключение или разговор в вашем голосовом канале.", + "noCreatePermission": "Извините! У меня нет прав на создание канала для запроса песен.", "noPlaySource": "Не получилось найти рабочие источники!", "noPlayer": "На этом сервере не найдено ни одного активного плеера.", "notVote": "Эта команда требует вашего голоса! Введите `/vote` для получения дополнительной информации.", + "missingIntents": "Извините, но эту команду нельзя выполнить, так как у бота отсутствует необходимый запрос намерения: `({0})`.", "languageNotFound": "Языковой пакет не найден! Пожалуйста, выберите существующий языковой пакет.", "changedLanguage": "Язык успешно изменен на `{0}`.", "setPrefix": "Готово! Мой префикс на вашем сервере теперь `{0}`. Попробуйте запустить `{1}ping`, чтобы проверить его.", @@ -57,7 +59,7 @@ "noPlaylistAcc": "Пользователь `{0}` не создал учетную запись плейлиста.", "overPlaylistCreation": "Вы не можете создавать больше `{0}` плейлистов!", "playlistExists": "Плейлист [`{0}`] уже существует.", - "playlistNotInvaildUrl": "Пожалуйста, введите действительную ссылку или публичную плейлист-ссылку на Spotify или YouTube.", + "playlistNotInvalidUrl": "Пожалуйста, введите действительную ссылку или публичную плейлист-ссылку на Spotify или YouTube.", "playlistCreated": "Вы создали плейлист `{0}`. Введите /playlist view для получения дополнительной информации.", "playlistRenamed": "Вы переименовали `{0}` в `{1}`.", "playlistLimitTrack": "Вы достигли лимита! Вы можете добавить только `{0}` треков в свой плейлист.", @@ -114,7 +116,8 @@ "buttonShuffle": "Перемешать", "buttonForward": "+10сек", "buttonRewind": "Назад", - + "buttonLyrics": "Тексты песен", + "nowplayingDesc": "**Сейчас играет:**\n```{0}```", "nowplayingField": "Следующее:", "nowplayingLink": "Слушать на {0}", @@ -185,5 +188,6 @@ "invalidEndTime": "Невозможное время конца! Вход времени должен быть внутри `00:00` и `{0}`.", "invalidTimeOrder": "Время конца не может быть меньше или равно времени начала.", - "SetStageAnnounceTemplate": "Готово! С этого момента статус голоса, как тот, в котором вы находитесь сейчас, будет называться в соответствии с вашим шаблоном. Вы должны увидеть обновление через несколько секунд." + "setStageAnnounceTemplate": "Готово! С этого момента статус голоса, как тот, в котором вы находитесь сейчас, будет называться в соответствии с вашим шаблоном. Вы должны увидеть обновление через несколько секунд.", + "createSongRequestChannel": "Канал для запроса песен ({0}) создан! Вы можете начать запрашивать любую песню по названию или URL в этом канале без использования префикса бота." } \ No newline at end of file diff --git a/langs/UA.json b/langs/UA.json index b31b613..0f3c745 100644 --- a/langs/UA.json +++ b/langs/UA.json @@ -7,9 +7,11 @@ "noChannel": "Немає голосового каналу для підключення. Будь ласка, вкажіть або приєднайтеся до одного.", "alreadyConnected": "Уже підключений до голосового каналу.", "noPermission": "Вибачте! У мене немає дозволу на підключення або розмову у вашому голосовому каналі.", + "noCreatePermission": "Вибачте! У мене немає прав для створення каналу запитів на пісні.", "noPlaySource": "Неможливо знайти робочі джерела!", "noPlayer": "На цьому сервері не знайдено жодного активного плеєра.", "notVote": "Ця команда вимагає вашого голосу! Введіть `/vote` для отримання додаткової інформації.", + "missingIntents": "Вибачте, але цю команду не можна виконати, оскільки у бота відсутній необхідний запит на інтенцію: `({0})`.", "languageNotFound": "Мовний пакет не знайдено! Будь ласка, виберіть наявний мовний пакет.", "changedLanguage": "Успішно змінено на мовний пакет `{0}`.", "setPrefix": "Готово! Мій префікс на вашому сервері тепер `{0}`. Спробуйте запустити `{1}ping`, щоб перевірити його.", @@ -57,7 +59,7 @@ "noPlaylistAcc": "{0} не створив обліковий запис плейлиста.", "overPlaylistCreation": "Ви не можете створювати більше `{0}` плейлистів!", "playlistExists": "Плейлист [`{0}`] вже існує.", - "playlistNotInvaildUrl": "Будь ласка, введіть дійсне посилання або публічне плейлист-посилання на Spotify або YouTube.", + "playlistNotInvalidUrl": "Будь ласка, введіть дійсне посилання або публічне плейлист-посилання на Spotify або YouTube.", "playlistCreated": "Ви створили плейлист `{0}`. Введіть /playlist view для отримання додаткової інформації.", "playlistRenamed": "Ви перейменували `{0}` на `{1}`.", "playlistLimitTrack": "Ви досягли ліміту! Ви можете додати тільки `{0}` пісень до свого плейлиста.", @@ -114,6 +116,7 @@ "buttonShuffle": "Перемішати", "buttonForward": "Вперед", "buttonRewind": "Назад", + "buttonLyrics": "Тексти пісень", "nowplayingDesc": "**Зараз грає:**\n```{0}```", "nowplayingField": "Наступне:", @@ -185,5 +188,6 @@ "invalidEndTime": "Недійснений час закінчення! Час має бути в межах `00:00` та `{0}`.", "invalidTimeOrder": "Час закінчення не може бути меншим або рівним часу початку.", - "SetStageAnnounceTemplate": "Готово! Відтепер статус голосу, як той, в якому ви зараз перебуваєте, буде називатися відповідно до вашого шаблону. Ви повинні побачити оновлення через кілька секунд." + "setStageAnnounceTemplate": "Готово! Відтепер статус голосу, як той, в якому ви зараз перебуваєте, буде називатися відповідно до вашого шаблону. Ви повинні побачити оновлення через кілька секунд.", + "createSongRequestChannel": "Канал для запитів пісень ({0}) створено! Ви можете почати запитувати будь-яку пісню за назвою або URL у цьому каналі без необхідності використовувати префікс бота." } \ No newline at end of file diff --git a/lavalink/Dockerfile-lavalink b/lavalink/Dockerfile-lavalink new file mode 100644 index 0000000..265ab4d --- /dev/null +++ b/lavalink/Dockerfile-lavalink @@ -0,0 +1,18 @@ +FROM eclipse-temurin:23-jre-noble + +RUN apt-get update && \ + apt-get install -y netcat-openbsd && \ + rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 322 lavalink && \ + useradd -u 322 -g lavalink -m -d /opt/Lavalink lavalink + +WORKDIR /opt/Lavalink + +RUN wget -O Lavalink.jar https://github.com/lavalink-devs/Lavalink/releases/download/4.0.8/Lavalink.jar + +RUN chown -R lavalink:lavalink /opt/Lavalink + +USER lavalink + +ENTRYPOINT ["java", "-Djdk.tls.client.protocols=TLSv1.1,TLSv1.2", "-jar", "Lavalink.jar"] diff --git a/lavalink/application.yml b/lavalink/application.yml new file mode 100644 index 0000000..5969f11 --- /dev/null +++ b/lavalink/application.yml @@ -0,0 +1,195 @@ +server: # REST and WS server + port: 2333 + address: 0.0.0.0 + http2: + enabled: true # Whether to enable HTTP/2 support +plugins: + youtube: + enabled: true # Whether this source can be used. + allowSearch: true # Whether "ytsearch:" and "ytmsearch:" can be used. + allowDirectVideoIds: true # Whether just video IDs can match. If false, only complete URLs will be loaded. + allowDirectPlaylistIds: true # Whether just playlist IDs can match. If false, only complete URLs will be loaded. + # The clients to use for track loading. See below for a list of valid clients. + # Clients are queried in the order they are given (so the first client is queried first and so on...) + clients: + - MUSIC + - ANDROID_VR + - ANDROID_MUSIC + - WEB + - WEBEMBEDDED + - TVHTML5EMBEDDED + # The below section of the config allows setting specific options for each client, such as the requests they will handle. + # If an option, or client, is unspecified, then the default option value/client values will be used instead. + # If a client is configured, but is not registered above, the options for that client will be ignored. + # WARNING!: THE BELOW CONFIG IS FOR ILLUSTRATION PURPOSES. DO NOT COPY OR USE THIS WITHOUT + # WARNING!: UNDERSTANDING WHAT IT DOES. MISCONFIGURATION WILL HINDER YOUTUBE-SOURCE'S ABILITY TO WORK PROPERLY. + + # Write the names of clients as they are specified under the heading "Available Clients". + clientOptions: + WEB: + # Example: Disabling a client's playback capabilities. + playback: false + videoLoading: false # Disables loading of videos for this client. A client may still be used for playback even if this is set to 'false'. + WEBEMBEDDED: + # Example: Configuring a client to exclusively be used for video loading and playback. + playlistLoading: false # Disables loading of playlists and mixes. + searching: false # Disables the ability to search for videos. + lavasrc: + providers: # Custom providers for track loading. This is the default + # - "dzisrc:%ISRC%" # Deezer ISRC provider + # - "dzsearch:%QUERY%" # Deezer search provider + - "ytsearch:\"%ISRC%\"" # Will be ignored if track does not have an ISRC. See https://en.wikipedia.org/wiki/International_Standard_Recording_Code + - "ytsearch:%QUERY%" # Will be used if track has no ISRC or no track could be found for the ISRC + # you can add multiple other fallback sources here + sources: + spotify: true # Enable Spotify source + applemusic: false # Enable Apple Music source + deezer: false # Enable Deezer source + yandexmusic: false # Enable Yandex Music source + flowerytts: false # Enable Flowery TTS source + youtube: false # Enable YouTube search source (https://github.com/topi314/LavaSearch) + vkmusic: false # Enable Vk Music source + lyrics-sources: + spotify: false # Enable Spotify lyrics source + deezer: false # Enable Deezer lyrics source + youtube: false # Enable YouTube lyrics source + yandexmusic: false # Enable Yandex Music lyrics source + vkmusic: false # Enable Vk Music lyrics source + spotify: + clientId: "" + clientSecret: "" + # spDc: "your sp dc cookie" # the sp dc cookie used for accessing the spotify lyrics api + 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 + playlistLoadLimit: 6 # The number of pages at 100 tracks each + albumLoadLimit: 6 # The number of pages at 50 tracks each + resolveArtistsInSearch: true # Whether to resolve artists in track search results (can be slow) + localFiles: false # Enable local files support with Spotify playlists. Please note `uri` & `isrc` will be `null` & `identifier` will be `"local"` + applemusic: + 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 + mediaAPIToken: "your apple music api token" # apple music api token + # or specify an apple music key + keyID: "your key id" + teamID: "your team id" + musicKitKey: | + -----BEGIN PRIVATE KEY----- + your key + -----END PRIVATE KEY----- + playlistLoadLimit: 6 # The number of pages at 300 tracks each + albumLoadLimit: 6 # The number of pages at 300 tracks each + deezer: + 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) + # arl: "your deezer arl" # the arl cookie used for accessing the deezer api this is optional but required for formats above MP3_128 + 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 + yandexmusic: + accessToken: "your access token" # the token used for accessing the yandex music api. See https://github.com/TopiSenpai/LavaSrc#yandex-music + playlistLoadLimit: 1 # The number of pages at 100 tracks each + albumLoadLimit: 1 # The number of pages at 50 tracks each + artistLoadLimit: 1 # The number of pages at 10 tracks each + flowerytts: + voice: "default voice" # (case-sensitive) get default voice from here https://api.flowery.pw/v1/tts/voices + translate: false # whether to translate the text to the native language of voice + silence: 0 # the silence parameter is in milliseconds. Range is 0 to 10000. The default is 0. + 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.) + audioFormat: "mp3" # supported formats are: mp3, ogg_opus, ogg_vorbis, aac, wav, and flac. Default format is mp3 + youtube: + 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 + vkmusic: + userToken: "your user token" # This token is needed for authorization in the api. Guide: https://github.com/topi314/LavaSrc#vk-music + playlistLoadLimit: 1 # The number of pages at 50 tracks each + artistLoadLimit: 1 # The number of pages at 10 tracks each + recommendationsLoadLimit: 10 # Number of tracks +lavalink: + plugins: + - dependency: "dev.lavalink.youtube:youtube-plugin:1.11.5" + snapshot: false + - dependency: "com.github.topi314.lavasrc:lavasrc-plugin:06b7cab" + snapshot: true +# - dependency: "com.github.example:example-plugin:1.0.0" # required, the coordinates of your plugin +# repository: "https://maven.example.com/releases" # optional, defaults to the Lavalink releases repository by default +# snapshot: false # optional, defaults to false, used to tell Lavalink to use the snapshot repository instead of the release repository +# pluginsDir: "./plugins" # optional, defaults to "./plugins" +# defaultPluginRepository: "https://maven.lavalink.dev/releases" # optional, defaults to the Lavalink release repository +# defaultPluginSnapshotRepository: "https://maven.lavalink.dev/snapshots" # optional, defaults to the Lavalink snapshot repository + server: + password: "youshallnotpass" + sources: + # The default Youtube source is now deprecated and won't receive further updates. Please use https://github.com/lavalink-devs/youtube-source#plugin instead. + youtube: false + bandcamp: true + soundcloud: true + twitch: true + vimeo: true + nico: true + http: true # warning: keeping HTTP enabled without a proxy configured could expose your server's IP address. + local: false + filters: # All filters are enabled by default + volume: true + equalizer: true + karaoke: true + timescale: true + tremolo: true + vibrato: true + distortion: true + rotation: true + channelMix: true + lowPass: true + 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) + 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. + frameBufferDurationMs: 5000 # How many milliseconds of audio to keep buffered + 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. + resamplingQuality: LOW # Quality of resampling operations. Valid values are LOW, MEDIUM and HIGH, where HIGH uses the most CPU. + trackStuckThresholdMs: 10000 # The threshold for how long a track can be stuck. A track is stuck if does not return any audio data. + 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. + youtubePlaylistLoadLimit: 6 # Number of pages at 100 each + playerUpdateInterval: 5 # How frequently to send player updates to clients, in seconds + youtubeSearchEnabled: true + soundcloudSearchEnabled: true + gc-warnings: true + #ratelimit: + #ipBlocks: ["1.0.0.0/8", "..."] # list of ip blocks + #excludedIps: ["...", "..."] # ips which should be explicit excluded from usage by lavalink + #strategy: "RotateOnBan" # RotateOnBan | LoadBalance | NanoSwitch | RotatingNanoSwitch + #searchTriggersFail: true # Whether a search 429 should trigger marking the ip as failing + #retryLimit: -1 # -1 = use default lavaplayer value | 0 = infinity | >0 = retry will happen this numbers times + #youtubeConfig: # Required for avoiding all age restrictions by YouTube, some restricted videos still can be played without. + #email: "" # Email of Google account + #password: "" # Password of Google account + #httpConfig: # Useful for blocking bad-actors from ip-grabbing your music node and attacking it, this way only the http proxy will be attacked + #proxyHost: "localhost" # Hostname of the proxy, (ip or domain) + #proxyPort: 3128 # Proxy port, 3128 is the default for squidProxy + #proxyUser: "" # Optional user for basic authentication fields, leave blank if you don't use basic auth + #proxyPassword: "" # Password for basic authentication + +metrics: + prometheus: + enabled: false + endpoint: /metrics + +sentry: + dsn: "" + environment: "" +# tags: +# some_key: some_value +# another_key: another_value + +logging: + file: + path: ./logs/ + + level: + root: INFO + lavalink: INFO + + request: + enabled: true + includeClientInfo: true + includeHeaders: false + includeQueryString: true + includePayload: true + maxPayloadLength: 10000 + + + logback: + rollingpolicy: + max-file-size: 1GB + max-history: 30 \ No newline at end of file diff --git a/local_langs/zh-TW.json b/local_langs/zh-TW.json index 443b026..d4ae83e 100644 --- a/local_langs/zh-TW.json +++ b/local_langs/zh-TW.json @@ -69,10 +69,10 @@ "Remove tracks requested by a specific member.": "刪除指定成員所要求的歌曲。", "forward": "前進", "Forwards by a certain amount of time in the current track. The default is 10 seconds.": "在目前歌曲中前進一定的時間。預設為 10 秒。", - "Input a amount that you to forward to. Exmaple: 1: 20": "輸入您要前進到的時間。範例:1:20", + "Input an amount that you to forward to. Exmaple: 1:20": "輸入您要前進到的時間。範例:1:20", "rewind": "倒退", "Rewind by a certain amount of time in the current track. The default is 10 seconds.": "在目前歌曲中倒退一定的時間。預設為 10 秒。", - "Input a amount that you to rewind to. Exmaple: 1: 20": "輸入您要倒退到的時間。範例:1:20", + "Input an amount that you to rewind to. Exmaple: 1:20": "輸入您要倒退到的時間。範例:1:20", "replay": "重新播放", "Reset the progress of the current song.": "重設目前歌曲的進度。", "shuffle": "隨機播放", @@ -210,5 +210,22 @@ "cleareffect": "清除效果", "Clear all or specific sound effects.": "清除所有或指定的音效。", "effect": "效果", - "Remove a specific sound effects.": "刪除指定的音效。" + "Remove a specific sound effects.": "刪除指定的音效。", + "start": "開始", + "end": "結束", + "Specify a time you would like to start, e.g. 1:00": "指定您希望開始的時間,例如:1:00。", + "Specify a time you would like to end, e.g. 4:00": "指定您希望結束的時間,例如:4:00。", + "list": "列表", + "Customize the channel topic template": "自訂頻道主題模板", + "template": "模板", + "setupchannel": "設置頻道", + "Sets up a dedicated channel for song requests in your server.": "為您的伺服器設置一個專用的歌曲請求頻道。", + "Provide a request channel. If not, a text channel will be generated.": "提供請求頻道。如果沒有,將生成一個文本頻道。", + "ping": "ping", + "…": "...", + "8d": "8d", + "dj": "dj", + "247": "247", + "stageannounce": "舞台公告", + "Soundcloud": "Soundcloud" } \ No newline at end of file diff --git a/main.py b/main.py index e2c6b9e..202db23 100644 --- a/main.py +++ b/main.py @@ -1,16 +1,39 @@ +"""MIT License + +Copyright (c) 2023 - present Vocard Development + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + import discord import sys import os import aiohttp import update import logging +import voicelink import function as func from discord.ext import commands from ipc import IPCClient from motor.motor_asyncio import AsyncIOMotorClient from logging.handlers import TimedRotatingFileHandler -from voicelink import VoicelinkException from addons import Settings class Translator(discord.app_commands.Translator): @@ -21,8 +44,18 @@ class Translator(discord.app_commands.Translator): func.logger.info("Unload Translator") async def translate(self, string: discord.app_commands.locale_str, locale: discord.Locale, context: discord.app_commands.TranslationContext): - if str(locale) in func.LOCAL_LANGS: - return func.LOCAL_LANGS[str(locale)].get(string.message, None) + locale_key = str(locale) + + if locale_key in func.LOCAL_LANGS: + translated_text = func.LOCAL_LANGS[locale_key].get(string.message) + + if translated_text is None: + missing_translations = func.MISSING_TRANSLATOR.setdefault(locale_key, []) + if string.message not in missing_translations: + missing_translations.append(string.message) + + return translated_text + return None class Vocard(commands.Bot): @@ -32,15 +65,37 @@ class Vocard(commands.Bot): self.ipc: IPCClient async def on_message(self, message: discord.Message, /) -> None: + # Ignore messages from bots or DMs if message.author.bot or not message.guild: return False + # Check if the bot is directly mentioned if self.user.id in message.raw_mentions and not message.mention_everyone: prefix = await self.command_prefix(self, message) if not prefix: return await message.channel.send("I don't have a bot prefix set.") await message.channel.send(f"My prefix is `{prefix}`") + # Fetch guild settings and check if the mesage is in the music request channel + settings = await func.get_settings(message.guild.id) + if settings and (request_channel := settings.get("music_request_channel")): + if message.channel.id == request_channel.get("text_channel_id"): + ctx = await self.get_context(message) + try: + cmd = self.get_command("play") + if message.content: + await cmd(ctx, query=message.content) + + elif message.attachments: + for attachment in message.attachments: + await cmd(ctx, query=attachment.url) + + except Exception as e: + await func.send(ctx, str(e), ephemeral=True) + + finally: + return await message.delete() + await self.process_commands(message) async def connect_db(self) -> None: @@ -65,6 +120,9 @@ class Vocard(commands.Bot): # Connecting to MongoDB await self.connect_db() + # Set translator + await self.tree.set_translator(Translator()) + # Loading all the module in `cogs` folder for module in os.listdir(func.ROOT_DIR + '/cogs'): if module.endswith('.py'): @@ -82,10 +140,10 @@ class Vocard(commands.Bot): func.logger.error(f"Cannot connected to dashboard! - Reason: {e}") if not func.settings.version or func.settings.version != update.__version__: - func.update_json("settings.json", new_data={"version": update.__version__}) - - await self.tree.set_translator(Translator()) await self.tree.sync() + func.update_json("settings.json", new_data={"version": update.__version__}) + for locale_key, values in func.MISSING_TRANSLATOR.items(): + func.logger.warning(f'Missing translation for "{", ".join(values)}" in "{locale_key}"') async def on_ready(self): func.logger.info("------------------") @@ -98,6 +156,7 @@ class Vocard(commands.Bot): func.settings.client_id = self.user.id func.LOCAL_LANGS.clear() + func.MISSING_TRANSLATOR.clear() async def on_command_error(self, ctx: commands.Context, exception, /) -> None: error = getattr(exception, 'original', exception) @@ -122,7 +181,7 @@ class Vocard(commands.Bot): embed.set_footer(icon_url=ctx.me.display_avatar.url, text=f"More Help: {func.settings.invite_link}") return await ctx.reply(embed=embed) - elif not issubclass(error.__class__, VoicelinkException): + elif not issubclass(error.__class__, voicelink.VoicelinkException): error = await func.get_lang(ctx.guild.id, "unknownException") + func.settings.invite_link 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) @@ -139,9 +198,15 @@ class CommandCheck(discord.app_commands.CommandTree): return True -async def get_prefix(bot, message: discord.Message): +async def get_prefix(bot: commands.Bot, message: discord.Message) -> str: settings = await func.get_settings(message.guild.id) - return settings.get("prefix", func.settings.bot_prefix) + prefix = settings.get("prefix", func.settings.bot_prefix) + + # Allow owner to use the bot without a prefix + 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): + return "" + + return prefix # Loading settings and logger func.settings = Settings(func.open_json("settings.json")) @@ -155,16 +220,15 @@ if (LOG_FILE := LOG_SETTINGS.get("file", {})).get("enable", True): file_handler = TimedRotatingFileHandler(filename=f'{log_path}/vocard.log', encoding="utf-8", backupCount=LOG_SETTINGS.get("max-history", 30), when="d") file_handler.namer = lambda name: name.replace(".log", "") + ".log" file_handler.setFormatter(logging.Formatter('{asctime} [{levelname:<8}] {name}: {message}', '%Y-%m-%d %H:%M:%S', style='{')) - - for log_name, log_level in LOG_SETTINGS.get("level", {}).items(): - _logger = logging.getLogger(log_name) - _logger.setLevel(log_level) - logging.getLogger().addHandler(file_handler) +for log_name, log_level in LOG_SETTINGS.get("level", {}).items(): + _logger = logging.getLogger(log_name) + _logger.setLevel(log_level) + # Setup the bot object intents = discord.Intents.default() -intents.message_content = True if func.settings.bot_prefix else False +intents.message_content = False if func.settings.bot_prefix is None else True intents.members = func.settings.ipc_client.get("enable", False) intents.voice_states = True diff --git a/requirements.txt b/requirements.txt index 9ba1748..352250a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,9 @@ -discord.py==2.4.0 +discord.py==2.5.2 motor==3.6.0 dnspython==2.2.1 tldextract==3.2.1 validators==0.18.2 humanize==4.0.0 beautifulsoup4==4.11.1 -psutil==5.9.8 \ No newline at end of file +psutil==5.9.8 +aiohttp==3.11.12 \ No newline at end of file diff --git a/settings Example.json b/settings Example.json index bf586ce..ed29928 100644 --- a/settings Example.json +++ b/settings Example.json @@ -1,8 +1,6 @@ { "token": "YOUR_BOT_TOKEN", "client_id": "YOUR_BOT_CLIENT_ID", - "spotify_client_id": "YOUR_SPOTIFY_CLIENT_ID", - "spotify_client_secret": "YOUR_SPOTIFY_CLIENT_SECRET", "genius_token": "YOUR_GENIUS_TOKEN", "mongodb_url": "YOUR_MONGODB_URL", "mongodb_name": "YOUR_MONGODB_DB_NAME", @@ -12,7 +10,15 @@ "port": 2333, "password": "youshallnotpass", "secure": false, - "identifier": "DEFAULT" + "identifier": "DEFAULT", + "yt_ratelimit": { + "tokens": [], + "config": { + "retry_time": 10800, + "max_requests": 30 + }, + "strategy": "LoadBalance" + } } }, "prefix": "?", @@ -34,7 +40,7 @@ "bot_access_user": [], "embed_color":"0xb3b3b3", "default_max_queue": 1000, - "lyrics_platform": "lyrist", + "lyrics_platform": "lrclib", "ipc_client": { "host": "127.0.0.1", "port": 8000, @@ -47,7 +53,7 @@ "emoji": "<:youtube:826661982760992778>", "color": "0xFF0000" }, - "youtube music": { + "youtubemusic": { "emoji": "<:youtube:826661982760992778>", "color": "0xFF0000" }, @@ -71,7 +77,7 @@ "emoji": "<:vimeo:864694001919721473>", "color": "0x1ABCEA" }, - "apple": { + "applemusic": { "emoji": "<:applemusic:994844332374884413>", "color": "0xE298C4" }, @@ -82,6 +88,10 @@ "tiktok": { "emoji": "<:tiktok:996007689798811698>", "color": "0x74ECE9" + }, + "others": { + "emoji": "🔗", + "color": "0xb3b3b3" } }, "default_controller": { diff --git a/update.py b/update.py index 0c0c4b2..c8a5a5d 100644 --- a/update.py +++ b/update.py @@ -1,12 +1,35 @@ +"""MIT License + +Copyright (c) 2023 - present Vocard Development + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + import requests, zipfile, os, shutil, argparse from io import BytesIO ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) -__version__ = "v2.6.9" +__version__ = "v2.7.0" GITHUB_API_URL = "https://api.github.com/repos/ChocoMeow/Vocard/releases/latest" VOCARD_URL = "https://github.com/ChocoMeow/Vocard/archive/" -IGNORE_FILES = ["settings.json", "logs"] +IGNORE_FILES = ["settings.json", "logs", "last-session.json"] class bcolors: WARNING = '\033[93m' diff --git a/views/__init__.py b/views/__init__.py index f060dd4..883cfe5 100644 --- a/views/__init__.py +++ b/views/__init__.py @@ -1,3 +1,26 @@ +"""MIT License + +Copyright (c) 2023 - present Vocard Development + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + from discord.ext import commands class ButtonOnCooldown(commands.CommandError): diff --git a/views/controller.py b/views/controller.py index 3c75aa5..d403e83 100644 --- a/views/controller.py +++ b/views/controller.py @@ -22,13 +22,14 @@ SOFTWARE. """ import discord +import re import voicelink +import addons +import views import function as func from discord.ext import commands -from typing import Dict - -from . import ButtonOnCooldown +from typing import Dict, Type def key(interaction: discord.Interaction): return interaction.user @@ -45,11 +46,12 @@ class ControlButton(discord.ui.Button): self.disable_button_text: bool = func.settings.controller.get("disableButtonText", False) super().__init__(label=self.player.get_msg(label) if label and not self.disable_button_text else None, **kwargs) - async def send(self, interaction: discord.Interaction, key:str, *params, ephemeral: bool = False) -> None: + async def send(self, interaction: discord.Interaction, key: str, *params, view: discord.ui.View = None, ephemeral: bool = False) -> None: stay = self.player.settings.get("controller_msg", True) return await func.send( interaction, key, *params, - delete_after=None if ephemeral or stay is True else 10, + view=view, + delete_after=None if ephemeral or stay else 10, ephemeral=ephemeral ) @@ -82,8 +84,8 @@ class Back(ControlButton): await self.send(interaction, "backed", interaction.user) - if self.player.queue._repeat.mode == voicelink.LoopType.track: - await self.player.set_repeat(voicelink.LoopType.off.name) + if self.player.queue._repeat.mode == voicelink.LoopType.TRACK: + await self.player.set_repeat(voicelink.LoopType.OFF) class Resume(ControlButton): def __init__(self, **kwargs): @@ -140,8 +142,8 @@ class Skip(ControlButton): await self.send(interaction, "skipped", interaction.user) - if self.player.queue._repeat.mode == voicelink.LoopType.track: - await self.player.set_repeat(voicelink.LoopType.off.name) + if self.player.queue._repeat.mode == voicelink.LoopType.TRACK: + await self.player.set_repeat(voicelink.LoopType.OFF) await self.player.stop() class Stop(ControlButton): @@ -222,7 +224,7 @@ class Loop(ControlButton): self.emoji = self.get_next_loop_emoji(self.player) await interaction.response.edit_message(view=self.view) - await self.send(interaction, 'repeat', mode.capitalize()) + await self.send(interaction, 'repeat', mode.name.capitalize()) class VolumeUp(ControlButton): def __init__(self, **kwargs): @@ -362,6 +364,31 @@ class Rewind(ControlButton): await self.player.seek(position) await self.send(interaction, 'rewind', func.time(position)) +class Lyrics(ControlButton): + def __init__(self, **kwargs): + super().__init__( + emoji="📜", + label="buttonLyrics", + disabled=kwargs["player"].current is None, + **kwargs + ) + + async def callback(self, interaction: discord.Interaction): + if not self.player or not self.player.is_playing: + return await self.send(interaction, "noTrackPlaying", ephemeral=True) + + title = self.player.current.title + artist = self.player.current.author + + lyrics_platform = addons.LYRICS_PLATFORMS.get(func.settings.lyrics_platform) + if lyrics_platform: + lyrics = await lyrics_platform().get_lyrics(title, artist) + if not lyrics: + return await self.send(interaction, "lyricsNotFound", ephemeral=True) + + view = views.LyricsView(name=title, source={_: re.findall(r'.*\n(?:.*\n){,22}', v or "") for _, v in lyrics.items()}, author=interaction.user) + view.response = await self.send(interaction, view.build_embed(), view=view, ephemeral=True) + class Tracks(discord.ui.Select): def __init__(self, player, style, row): @@ -421,7 +448,7 @@ class Effects(discord.ui.Select): await self.player.add_filter(selected_filter, requester=interaction.user) await func.send(interaction, "addEffect", selected_filter.tag) -BUTTONTYPE: Dict[str, ControlButton] = { +BUTTON_TYPE: Dict[str, Type[ControlButton]] = { "back": Back, "resume": Resume, "skip": Skip, @@ -435,11 +462,12 @@ BUTTONTYPE: Dict[str, ControlButton] = { "shuffle": Shuffle, "forward": Forward, "rewind": Rewind, + "lyrics": Lyrics, "tracks": Tracks, "effects": Effects } -BUTTONCOLOR: Dict[str, discord.ButtonStyle] = { +BUTTON_COLORS: Dict[str, discord.ButtonStyle] = { "blue": discord.ButtonStyle.primary, "grey": discord.ButtonStyle.secondary, "red": discord.ButtonStyle.danger, @@ -457,8 +485,8 @@ class InteractiveController(discord.ui.View): if isinstance(btn, Dict): color = list(btn.values())[0] btn = list(btn.keys())[0] - btnClass = BUTTONTYPE.get(btn.lower()) - style = BUTTONCOLOR.get(color.lower(), BUTTONCOLOR["grey"]) + btnClass = BUTTON_TYPE.get(btn.lower()) + style = BUTTON_COLORS.get(color.lower(), BUTTON_COLORS["grey"]) if not btnClass or (self.player.queue.is_empty and btn == "tracks"): continue self.add_item(btnClass(player=player, style=style, row=row)) @@ -476,14 +504,14 @@ class InteractiveController(discord.ui.View): if self.player.channel and self.player.is_user_join(interaction.user): retry_after = self.cooldown.update_rate_limit(interaction) if retry_after: - raise ButtonOnCooldown(retry_after) + raise views.ButtonOnCooldown(retry_after) return True else: await func.send(interaction, "notInChannel", interaction.user.mention, self.player.channel.mention, ephemeral=True) return False async def on_error(self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item): - if isinstance(error, ButtonOnCooldown): + if isinstance(error, views.ButtonOnCooldown): sec = int(error.retry_after) await interaction.response.send_message(f"You're on cooldown for {sec} second{'' if sec == 1 else 's'}!", ephemeral=True) diff --git a/views/debug.py b/views/debug.py index 65ddb9c..0b4c2dd 100644 --- a/views/debug.py +++ b/views/debug.py @@ -23,6 +23,7 @@ SOFTWARE. import discord import io +import os import contextlib import textwrap import traceback @@ -32,7 +33,7 @@ import function as func from typing import Optional from discord.ext import commands -class ExceuteModal(discord.ui.Modal): +class ExecuteModal(discord.ui.Modal): def __init__(self, code: str, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.code: str = code @@ -104,8 +105,6 @@ class AddNodeModal(discord.ui.Modal): try: await voicelink.NodePool.create_node( bot=interaction.client, - spotify_client_id=func.settings.spotify_client_id, - spotify_client_secret=func.settings.spotify_client_secret, logger=func.logger, **config ) @@ -132,14 +131,14 @@ class CogsDropdown(discord.ui.Select): selected = self.values[0].lower() try: if selected == "all": - for name in self.bot.cogs.keys(): + for name in self.bot.cogs.copy().keys(): await self.bot.reload_extension(f"cogs.{name.lower()}") else: await self.bot.reload_extension(f"cogs.{selected}") except Exception as e: return await interaction.response.send_message(f"Unable to reload `{selected}`! Reason: {e}", ephemeral=True) - await interaction.response.send_message(f"Reloaded `{selected}` sucessfully!", ephemeral=True) + await interaction.response.send_message(f"Reloaded `{selected}` successfully!", ephemeral=True) class NodesDropdown(discord.ui.Select): def __init__(self, bot: commands.Bot): @@ -177,7 +176,7 @@ class NodesDropdown(discord.ui.Select): await interaction.response.defer() await self.view.message.edit(embed=self.view.build_embed(), view=self.view) -class ExceutePanel(discord.ui.View): +class ExecutePanel(discord.ui.View): def __init__(self, bot, *, timeout = 180): self.bot: commands.Bot = bot @@ -208,7 +207,7 @@ class ExceutePanel(discord.ui.View): await self.message.edit(view=self) async def execute(self, interaction: discord.Interaction): - modal = ExceuteModal(self.code, title="Enter Your Code") + modal = ExecuteModal(self.code, title="Enter Your Code") await interaction.response.send_modal(modal) await modal.wait() @@ -362,7 +361,7 @@ class CogsView(discord.ui.View): class DebugView(discord.ui.View): def __init__(self, bot, *, timeout: float | None = 180): self.bot: commands.Bot = bot - self.panel: ExceutePanel = ExceutePanel(bot) + self.panel: ExecutePanel = ExecutePanel(bot) super().__init__(timeout=timeout) @@ -384,4 +383,31 @@ class DebugView(discord.ui.View): async def nodes(self, interaction: discord.Interaction, button: discord.ui.Button): view = NodesPanel(self.bot) await interaction.response.send_message(embed=view.build_embed(), view=view, ephemeral=True) - view.message = await interaction.original_response() \ No newline at end of file + view.message = await interaction.original_response() + + @discord.ui.button(label="Stop-Bot", emoji="🔴") + async def stop(self, interaction: discord.Interaction, button: discord.ui.Button): + for name in self.bot.cogs.copy().keys(): + try: + await self.bot.unload_extension(name) + except: + pass + + player_data = [] + for identifier, node in voicelink.NodePool._nodes.items(): + for guild_id, player in node._players.copy().items(): + if not player.guild.me.voice or not player.current: + continue + + player_data.append(player.data) + try: + await player.teardown() + except: + pass + + session_file_path = os.path.join(func.ROOT_DIR, func.LAST_SESSION_FILE_NAME) + if os.path.exists(session_file_path): + os.remove(session_file_path) + + func.update_json(func.LAST_SESSION_FILE_NAME, player_data) + await interaction.client.close() \ No newline at end of file diff --git a/views/embedBuilder.py b/views/embedBuilder.py index 405dd0e..1e93c91 100644 --- a/views/embedBuilder.py +++ b/views/embedBuilder.py @@ -1,3 +1,26 @@ +"""MIT License + +Copyright (c) 2023 - present Vocard Development + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + import discord, copy import function as func diff --git a/views/help.py b/views/help.py index 6402e7a..fcb4005 100644 --- a/views/help.py +++ b/views/help.py @@ -27,7 +27,7 @@ from discord.ext import commands import function as func class HelpDropdown(discord.ui.Select): - def __init__(self, categorys:list): + def __init__(self, categories:list): self.view: HelpView super().__init__( @@ -38,7 +38,7 @@ class HelpDropdown(discord.ui.Select): discord.SelectOption(emoji="🕹️", label="Tutorial", description="How to use Vocard."), ] + [ discord.SelectOption(emoji=emoji, label=f"{category} Commands", description=f"This is {category.lower()} Category.") - for category, emoji in zip(categorys, ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣"]) + for category, emoji in zip(categories, ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣"]) ], custom_id="select" ) @@ -54,13 +54,13 @@ class HelpView(discord.ui.View): self.author: discord.Member = author self.bot: commands.Bot = bot self.response: discord.Message = None - self.categorys: list[str] = [ name.capitalize() for name, cog in bot.cogs.items() if len([c for c in cog.walk_commands()]) ] + self.categories: list[str] = [ name.capitalize() for name, cog in bot.cogs.items() if len([c for c in cog.walk_commands()]) ] self.add_item(discord.ui.Button(label='Website', emoji='🌎', url='https://vocard.xyz')) self.add_item(discord.ui.Button(label='Document', emoji=':support:915152950471581696', url='https://docs.vocard.xyz')) self.add_item(discord.ui.Button(label='Github', emoji=':github:1098265017268322406', url='https://github.com/ChocoMeow/Vocard')) self.add_item(discord.ui.Button(label='Donate', emoji=':patreon:913397909024800878', url='https://www.patreon.com/Vocard')) - self.add_item(HelpDropdown(self.categorys)) + self.add_item(HelpDropdown(self.categories)) async def on_error(self, error, item, interaction) -> None: return @@ -82,8 +82,8 @@ class HelpView(discord.ui.View): if category == "news": embed = discord.Embed(title="Vocard Help Menu", url="https://discord.com/channels/811542332678996008/811909963718459392/1069971173116481636", color=func.settings.embed_color) embed.add_field( - name=f"Available Categories: [{2 + len(self.categorys)}]", - value="```py\n👉 News\n2. Tutorial\n{}```".format("".join(f"{i}. {c}\n" for i, c in enumerate(self.categorys, start=3))), + name=f"Available Categories: [{2 + len(self.categories)}]", + value="```py\n👉 News\n2. Tutorial\n{}```".format("".join(f"{i}. {c}\n" for i, c in enumerate(self.categories, start=3))), inline=True ) @@ -94,7 +94,7 @@ class HelpView(discord.ui.View): return embed embed = discord.Embed(title=f"Category: {category.capitalize()}", color=func.settings.embed_color) - embed.add_field(name=f"Categories: [{2 + len(self.categorys)}]", value="```py\n" + "\n".join(("👉 " if c == category.capitalize() else f"{i}. ") + c for i, c in enumerate(['News', 'Tutorial'] + self.categorys, start=1)) + "```", inline=True) + 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) if category == 'tutorial': embed.description = "How can use Vocard? Some simple commands you should know now after watching this video." diff --git a/views/inbox.py b/views/inbox.py index dc7d00d..7524b7b 100644 --- a/views/inbox.py +++ b/views/inbox.py @@ -44,7 +44,7 @@ class InboxView(discord.ui.View): def __init__(self, author: discord.Member, inbox: list[dict[str, Any]]): super().__init__(timeout=60) self.inbox: list[dict[str, Any]] = inbox - self.newplaylist = [] + self.new_playlist = [] self.author: discord.Member = author self.response: discord.Message = None @@ -88,7 +88,7 @@ class InboxView(discord.ui.View): @discord.ui.button(label='Accept', style=discord.ButtonStyle.green, custom_id="accept", disabled=True) async def accept_button(self, interaction: discord.Interaction, button: discord.ui.Button): - self.newplaylist.append(self.current) + self.new_playlist.append(self.current) self.inbox.remove(self.current) self.current = None await self.button_change(interaction) diff --git a/voicelink/__init__.py b/voicelink/__init__.py index d48084f..e14e65a 100644 --- a/voicelink/__init__.py +++ b/voicelink/__init__.py @@ -24,7 +24,7 @@ SOFTWARE. __version__ = "1.4" __author__ = 'Vocard Development, Choco' __license__ = "MIT" -__copyright__ = "Copyright 2023 (c) Vocard Development, Choco" +__copyright__ = "Copyright 2023 - present (c) Vocard Development, Choco" from .enums import SearchType, LoopType from .events import * diff --git a/voicelink/enums.py b/voicelink/enums.py index c8c53fa..ef74c58 100644 --- a/voicelink/enums.py +++ b/voicelink/enums.py @@ -26,46 +26,66 @@ from enum import Enum, auto class LoopType(Enum): """The enum for the different loop types for Voicelink - LoopType.off: 1 - LoopType.track: 2 - LoopType.queue: 3 + LoopType.OFF: 1 + LoopType.TRACK: 2 + LoopType.QUEUE: 3 """ - off = auto() - track = auto() - queue = auto() + OFF = auto() + TRACK = auto() + QUEUE = auto() class SearchType(Enum): """The enum for the different search types for Voicelink. - This feature is exclusively for the Spotify search feature of Voicelink. - If you are not using this feature, this class is not necessary. - SearchType.ytsearch searches using regular Youtube, + SearchType.YOUTUBE searches using regular Youtube, which is best for all scenarios. - SearchType.ytmsearch searches using YouTube Music, + SearchType.YOUTUBE_MUSIC searches using YouTube Music, which is best for getting audio-only results. + + SearchType.SPOTIFY searches using Spotify, + which is an alternative to YouTube or YouTube Music. - SearchType.scsearch searches using SoundCloud, + SearchType.SOUNDCLOUD searches using SoundCloud, + which is an alternative to YouTube or YouTube Music. + + SearchType.APPLE_MUSIC searches using Apple Music, which is an alternative to YouTube or YouTube Music. """ - ytsearch = "ytsearch" - ytmsearch = "ytmsearch" - scsearch = "scsearch" - amsearch = "amsearch" + YOUTUBE = "ytsearch" + YOUTUBE_MUSIC = "ytmsearch" + SPOTIFY = "spsearch" + SOUNDCLOUD = "scsearch" + APPLE_MUSIC = "amsearch" def __str__(self) -> str: return self.value + + @classmethod + def match(cls, value: str): + """find an enum based on a search string.""" + normalized_value = value.lower().replace("_", "").replace(" ", "") + + for member in cls: + normalized_name = member.name.lower().replace("_", "") + if member.value == value or normalized_name == normalized_value: + return member + return None + + @property + def display_name(self) -> str: + return self.name.replace("_", " ").title() class RequestMethod(Enum): """The enum for the different request methods in Voicelink """ - get = "get" - patch = "patch" - delete = "delete" - post = "post" + GET = "get" + PATCH = "patch" + DELETE = "delete" + POST = "post" def __str__(self) -> str: return self.value @@ -86,9 +106,9 @@ class NodeAlgorithm(Enum): """ # We don't have to define anything special for these, since these just serve as flags - by_ping = auto() - by_region = auto() - by_players = auto() + BY_PING = auto() + BY_REGION = auto() + BY_PLAYERS = auto() def __str__(self) -> str: return self.value \ No newline at end of file diff --git a/voicelink/exceptions.py b/voicelink/exceptions.py index 0c7fca8..59c0570 100644 --- a/voicelink/exceptions.py +++ b/voicelink/exceptions.py @@ -74,26 +74,6 @@ class FilterTagInvalid(VoicelinkException): """An invalid tag was passed or Voicelink was unable to find a filter tag""" pass -class SpotifyAlbumLoadFailed(VoicelinkException): - """The voicelink Spotify client was unable to load an album.""" - pass - - -class SpotifyTrackLoadFailed(VoicelinkException): - """The voicelink Spotify client was unable to load a track.""" - pass - - -class SpotifyPlaylistLoadFailed(VoicelinkException): - """The voicelink Spotify client was unable to load a playlist.""" - pass - - -class InvalidSpotifyClientAuthorization(VoicelinkException): - """No Spotify client authorization was provided for track searching.""" - pass - - class QueueFull(VoicelinkException): pass diff --git a/voicelink/filters.py b/voicelink/filters.py index f20d7d1..74b1568 100644 --- a/voicelink/filters.py +++ b/voicelink/filters.py @@ -287,7 +287,7 @@ class Vibrato(Filter): self._init_with_scope({ "frequency": [0, 14], "depth": [0, 1] - }, tag=tag, frequenc=frequency, depth=depth) + }, tag=tag, frequency=frequency, depth=depth) def __repr__(self): return f" str: return f" length={self.length}>" - def toDict(self) -> dict: - return { - "track_id": self.track_id, - "info": self.info, - "thumbnail": self.thumbnail - } - @property def track_id(self) -> str: if not self._track_id: @@ -135,6 +116,13 @@ class Track: def formatted_length(self) -> str: return ctime(self.length) + @property + def data(self) -> dict: + return { + "track_id": self.track_id, + "requester_id": self.requester.id + } + class Playlist: """The base playlist object. Returns critical playlist information needed for parsing by Lavalink. @@ -143,12 +131,9 @@ class Playlist: __slots__ = ( "playlist_info", - "tracks_raw", - "spotify", "name", - "spotify_playlist", - "_thumbnail", - "_uri", + "thumbnail", + "uri", "tracks" ) @@ -158,29 +143,16 @@ class Playlist: playlist_info: dict, tracks: list, requester: Member = None, - spotify: bool = False, - spotify_playlist: Optional[spPlaylist] = None ): self.playlist_info: dict = playlist_info - self.tracks_raw: list[Track] = tracks - self.spotify: bool = spotify self.name: str = playlist_info.get("name") - self.spotify_playlist: Optional[spPlaylist] = spotify_playlist - - self._thumbnail: str = None - self._uri: str = None + self.thumbnail: str = None + self.uri: str = None - if self.spotify: - self.tracks = tracks - self._thumbnail = self.spotify_playlist.image - self._uri = self.spotify_playlist.uri - else: - self.tracks = [ - Track(track_id=track["encoded"], info=track["info"], requester=requester) - for track in self.tracks_raw - ] - self._thumbnail = None - self._uri = None + self.tracks = [ + Track(track_id=track["encoded"], info=track["info"], requester=requester) + for track in tracks + ] def __str__(self) -> str: return self.name @@ -188,16 +160,6 @@ class Playlist: def __repr__(self) -> str: return f"" - @property - def uri(self) -> Optional[str]: - """Spotify album/playlist URI, or None if not a Spotify object.""" - return self._uri - - @property - def thumbnail(self) -> Optional[str]: - """Spotify album/playlist thumbnail, or None if not a Spotify object.""" - return self._thumbnail - @property def track_count(self) -> int: return len(self.tracks) diff --git a/voicelink/placeholders.py b/voicelink/placeholders.py index d2d8632..b1f361e 100644 --- a/voicelink/placeholders.py +++ b/voicelink/placeholders.py @@ -1,3 +1,26 @@ +"""MIT License + +Copyright (c) 2023 - present Vocard Development + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + from __future__ import annotations import re @@ -34,7 +57,7 @@ class Placeholders: "track_color": self.track_color, "track_requester_id": self.track_requester_id, "track_requester_name": self.track_requester_name, - "track_requester_metion": self.track_requester_mention, + "track_requester_mention": self.track_requester_mention, "track_requester_avatar": self.track_requester_avatar, "track_source_name": self.track_source_name, "track_source_emoji": self.track_source_emoji, diff --git a/voicelink/player.py b/voicelink/player.py index d58dfbb..5db80cc 100644 --- a/voicelink/player.py +++ b/voicelink/player.py @@ -36,15 +36,18 @@ from discord import ( VoiceProtocol, Member, Message, + PartialMessage, Interaction, - errors + errors, + ChannelType ) from discord.ext import commands + from . import events from .enums import SearchType, LoopType, RequestMethod -from .events import VoicelinkEvent, TrackEndEvent, TrackStartEvent -from .exceptions import VoicelinkException, FilterInvalidArgument, TrackInvalidPosition, TrackLoadError, FilterTagAlreadyInUse, DuplicateTrack +from .events import VoicelinkEvent, TrackEndEvent, TrackStartEvent, TrackExceptionEvent +from .exceptions import VoicelinkException, FilterInvalidArgument, TrackInvalidPosition, FilterTagAlreadyInUse, DuplicateTrack from .filters import Filter, Filters from .objects import Track, Playlist from .pool import Node, NodePool @@ -70,8 +73,11 @@ async def connect_channel(ctx: Union[commands.Context, Interaction], channel: Vo channel, ctx, settings )) + if player.volume != 100: + await player.set_volume(player.volume) + if ctx.bot.ipc.is_connected: - await player.send_ws({"op": "createPlayer", "member_ids": [str(member.id) for member in channel.members]}) + await player.send_ws({"op": "createPlayer", "memberIds": [str(member.id) for member in channel.members]}) return player @@ -112,7 +118,7 @@ class Player(VoiceProtocol): self.queue: Queue = eval(self.settings.get("queueType", "Queue"))(self.settings.get("maxQueue", func.settings.max_queue), self.settings.get("duplicateTrack", True), self.get_msg) self._node = NodePool.get_node() - self._current: Track = None + self._current: Optional[Track] = None self._filters: Filters = Filters() self._paused: bool = False self._is_connected: bool = False @@ -126,8 +132,8 @@ class Player(VoiceProtocol): self._voice_state: dict = {} - self.controller: Message = None - self.updating: bool = False + self.controller: Union[Message, PartialMessage] = None + self._updating: bool = False self.pause_votes = set() self.resume_votes = set() @@ -148,21 +154,19 @@ class Player(VoiceProtocol): @property def position(self) -> float: """Property which returns the player's position in a track in milliseconds""" - current = self._current.original - if not self.is_playing or not self._current: return 0 if self.is_paused: - return min(self._last_position, current.length) + return min(self._last_position, self._current.length) difference = (time.time() * 1000) - self._last_update position = self._last_position + difference - if position > current.length: + if position > self._current.length: return 0 - return min(position, current.length) + return min(position, self._current.length) @property def is_playing(self) -> bool: @@ -180,7 +184,7 @@ class Player(VoiceProtocol): return self._is_connected and self._paused @property - def current(self) -> Track: + def current(self) -> Optional[Track]: """Property which returns the currently playing track""" return self._current @@ -218,12 +222,47 @@ class Player(VoiceProtocol): @property def ping(self) -> float: + """Calculates and returns the player's current ping in seconds.""" return round(self._ping / 1000, 2) - + + @property + def autoplay(self) -> bool: + return self.settings.get("autoplay", False) + + @property + def data(self) -> dict: + return { + "guild_id": self._guild.id, + "channel_id": self.channel.id, + "queue": { + "tracks": [track.data for track in self.queue._queue], + "position": self.queue._position, + "repeat_mode": self.queue._repeat.current.name, + "repeat_position": self.queue._repeat_position + }, + "dj": self.dj.id, + "is_paused": self.is_paused, + "position": self.position, + "autoplay": self.autoplay + } + + @property + def is_ipc_connected(self) -> bool: + """Indicates whether the Inter-Process Communication (IPC) connection is active.""" + return self._ipc._is_connected and self._ipc_connection + def get_msg(self, *keys) -> Union[list[str], str]: + """Retrieves a localized message or list of messages based on the given keys + for the guild associated with this player. + """ return func.get_lang_non_async(self.guild.id, *keys) def required(self, leave=False): + """ + Calculates the number of votes required for a specific action in the voice channel. + + If `leave` is True and the channel has three members, the requirement adjusts to 2 votes. + """ if self.settings.get('votedisable'): return 0 @@ -233,18 +272,22 @@ class Player(VoiceProtocol): required = 2 return required - - @property - def is_ipc_connected(self) -> bool: - return self._ipc._is_connected and self._ipc_connection def is_user_join(self, user: Member): + """Checks if a user is present in the voice channel or has 'Manage Server' permission.""" if user not in self.channel.members: if not user.guild_permissions.manage_guild: return False return True def is_privileged(self, user: Member, check_user_join: bool = True) -> bool: + """ + Determines if a user has privileged access. + + Privileged access is granted if the user is in the bot access list, + has 'Manage Server' permission, or meets the DJ role criteria in the settings. + Raises an exception if `check_user_join` is True and the user is not in the channel. + """ if user.id in func.settings.bot_access_user: return True @@ -256,11 +299,20 @@ class Player(VoiceProtocol): return manage_perm or (self.settings['dj'] in [role.id for role in user.roles]) return self.dj.id == user.id or manage_perm + def build_embed(self, current_track: Track = None): + """Builds an embed based on the current track state.""" + controller = self.settings.get("default_controller", func.settings.controller).get("embeds", {}) + raw = controller.get("active" if current_track else "inactive", {}) + + return build_embed(raw, self._ph) + async def send(self, method: RequestMethod, query: str = None, data: Union[Dict, str] = {}) -> Dict: + """Sends an HTTP request to the node with the given method, query, and data.""" uri: str = f"sessions/{self._node._session_id}/players/{self._guild.id}" + (f"?{query}" if query else "") return await self._node.send(method, query=uri, data=data) async def _update_state(self, data: dict) -> None: + """Updates the player's state based on the provided data.""" state: dict = data.get("state") self._last_update = time.time() * 1000 self._is_connected = state.get("connected") @@ -271,12 +323,13 @@ class Player(VoiceProtocol): if self.is_ipc_connected: await self.send_ws({ "op": "playerUpdate", - "last_update": self._last_update, - "is_connected": self._is_connected, - "last_position": self._last_position + "lastUpdate": self._last_update, + "isConnected": self._is_connected, + "lastPosition": self._last_position }) async def _dispatch_voice_update(self, voice_data: Dict[str, Any] = None): + """Dispatches a voice update to the node.""" if {"sessionId", "event"} != self._voice_state.keys(): self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) dispatched voice update failed {voice_data}") return @@ -289,14 +342,16 @@ class Player(VoiceProtocol): "sessionId": state['sessionId'], } - await self.send(method=RequestMethod.patch, data={"voice": data}) + await self.send(method=RequestMethod.PATCH, data={"voice": data}) self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) dispatched voice update to {state['event']['endpoint']} with data {data}") async def on_voice_server_update(self, data: dict): + """Handles a voice server update event.""" self._voice_state.update({"event": data}) await self._dispatch_voice_update(self._voice_state) async def on_voice_state_update(self, data: dict): + """Handles a voice state update event.""" self._voice_state.update({"sessionId": data.get("session_id")}) if not (channel_id := data.get("channel_id")): @@ -312,11 +367,16 @@ class Player(VoiceProtocol): await self._dispatch_voice_update({**self._voice_state, "event": data}) async def _dispatch_event(self, data: dict): + """Dispatches an event based on the type of event data received.""" event_type = data.get("type") event: VoicelinkEvent = getattr(events, event_type)(data, self) if isinstance(event, TrackEndEvent) and event.reason != "replaced": self._current = None + + if isinstance(event, TrackExceptionEvent) and event.exception["message"] == "This content isn’t available.": + if self._node.yt_ratelimit: + await self._node.yt_ratelimit.flag_active_token() event.dispatch(self._bot) @@ -326,6 +386,7 @@ class Player(VoiceProtocol): self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) dispatched event {event_type}.") async def do_next(self): + """Processes the next track in the queue.""" if self._current or self.is_playing or not self.channel: return @@ -349,7 +410,7 @@ class Player(VoiceProtocol): track = self.queue.get() if not track: - if self.settings.get("autoplay", False) and await self.get_recommendations(): + if self.autoplay and await self.get_recommendations(): return await self.do_next() else: try: @@ -364,56 +425,61 @@ class Player(VoiceProtocol): "$push": {"history": {"$each": [track.track_id], "$slice": -25}} })) - if self.settings.get('controller', True): - await self.invoke_controller() - + await self.invoke_controller() await self.update_voice_status() if self.is_ipc_connected: await self.send_ws({ "op": "trackUpdate", - "current_queue_position": self.queue._position if track else self.queue._position + 1, - "track_id": track.track_id if track else None, - "is_paused": self._paused + "currentQueuePosition": self.queue._position if track else self.queue._position + 1, + "trackId": track.track_id if track else None, + "isPaused": self._paused }) async def invoke_controller(self): - if self.updating or not self.channel: + """Sends or updates the music controller message in the designated channel.""" + if not self.settings.get('controller', True): return - self.updating = True + if self._updating or not self.channel: + return + + self._updating = True - try: - embed, view = await self.build_embed(), InteractiveController(self) + try: + embed, view = self.build_embed(self.current), InteractiveController(self) if not self.controller: - self.controller = await self.context.channel.send(embed=embed, view=view) + if request_channel_data := self.settings.get("music_request_channel"): + channel = self.bot.get_channel(request_channel_data.get("text_channel_id")) + if channel: + self.controller = channel.get_partial_message(request_channel_data.get("controller_msg_id")) + try: + await self.controller.edit(embed=embed, view=view) + except errors.NotFound: + self.controller = None + + # Send a new controller message if none exists + if not self.controller: + self.controller = await self.context.channel.send(embed=embed, view=view) elif not await self.is_position_fresh(): - try: - await self.controller.delete() - except: - pass + await self.controller.delete() self.controller = await self.context.channel.send(embed=embed, view=view) else: await self.controller.edit(embed=embed, view=view) - - except errors.Forbidden: - pass + except errors.Forbidden: + self._logger.warning(f"Missing permission to update the music controller on {self.guild.name}({self.guild.id})") + except Exception as e: self._logger.error(f"Something went wrong while sending music controller to {self.guild.name}({self.guild.id})", exc_info=e) - pass - - self.updating = False - - async def build_embed(self): - controller = self.settings.get("default_controller", func.settings.controller).get("embeds", {}) - raw = controller.get("active" if self.current else "inactive", {}) - return build_embed(raw, self._ph) + finally: + self._updating = False async def is_position_fresh(self): + """Checks if the current controller message is among the most recent messages.""" try: async for message in self.context.channel.history(limit=5): if message.id == self.controller.id: @@ -424,19 +490,24 @@ class Player(VoiceProtocol): return False async def teardown(self): - await func.update_settings( - self.guild.id, - {"$set": { + """Cleans up the player and associated resources.""" + try: + await func.update_settings(self.guild.id, {"$set": { "lastActice": (timeNow := round(time.time())), "playTime": round(self.settings.get("playTime", 0) + ((timeNow - self.joinTime) / 60), 2) - }} - ) - await self.update_voice_status(remove_status=True) - if self.is_ipc_connected: - await self.send_ws({"op": "playerClose"}) + }}) + + if self.is_ipc_connected: + await self.send_ws({"op": "playerClose"}) + except: + pass try: - await self.controller.delete() + await self.update_voice_status(remove_status=True) + if self.controller and self.controller.id == self.settings.get("music_request_channel", {}).get("controller_msg_id"): + await self.controller.edit(embed=self.build_embed(), view=None) + else: + await self.controller.delete() except: pass @@ -450,20 +521,17 @@ class Player(VoiceProtocol): query: str, *, requester: Member, - search_type: SearchType = SearchType.ytsearch + search_type: SearchType = SearchType.YOUTUBE ) -> Union[List[Track], Playlist]: """Fetches tracks from the node's REST api to parse into Lavalink. - If you passed in Spotify API credentials when you created the node, - you can also pass in a Spotify URL of a playlist, album or track and it will be parsed - accordingly. - You can also pass in a discord.py Context object to get a Context object on any track you search. """ return await self._node.get_tracks(query, requester=requester, search_type=search_type) async def connect(self, *, timeout: float, reconnect: bool, self_deaf: bool = True, self_mute: bool = False): + """Connects the player to a voice channel.""" await self.guild.change_voice_state(channel=self.channel, self_deaf=True, self_mute=self_mute) self._node._players[self.guild.id] = self self._is_connected = True @@ -474,7 +542,7 @@ class Player(VoiceProtocol): async def stop(self): """Stops the currently playing track.""" self._current = None - await self.send(method=RequestMethod.patch, data={'encodedTrack': None}) + await self.send(method=RequestMethod.PATCH, data={'encodedTrack': None}) async def disconnect(self, *, force: bool = False): """Disconnects the player from voice.""" @@ -498,7 +566,7 @@ class Player(VoiceProtocol): assert self.channel is None and not self.is_connected self._node._players.pop(self.guild.id) - await self.send(method=RequestMethod.delete) + await self.send(method=RequestMethod.DELETE) async def play( self, @@ -508,36 +576,29 @@ class Player(VoiceProtocol): end: int = 0, ignore_if_playing: bool = False ) -> Track: - """Plays a track. If a Spotify track is passed in, it will be handled accordingly.""" + """Plays a track.""" if not self._node: return track - if track.spotify: - if not track.original: - search_results = await self._node.get_tracks(f"ytmsearch:{track.author} - {track.title}", requester=track.requester) - if not search_results: - raise TrackLoadError("Can't find a playable source!") - track.original = search_results[0] - data = { - "encodedTrack": track.original.track_id if track.original else track.track_id, + "encodedTrack": track.track_id, "position": str(start if start else track.position) } if end or track.end_time: data["endTime"] = str(end if end else track.end_time) - - await self.send(method=RequestMethod.patch, query=f"noReplace={ignore_if_playing}", data=data) + + await self.send(method=RequestMethod.PATCH, query=f"noReplace={ignore_if_playing}", data=data) + if self._node.yt_ratelimit: + await self._node.yt_ratelimit.handle_request() self._current = track - if self.volume != 100: - await self.set_volume(self.volume) - self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) playing {track.title} from uri {track.uri} with a length of {track.length}") return self._current def _validate_time(self, track: Track, start_time: int, end_time: int) -> None: + """Validates the start and end times for a track.""" if start_time or end_time: if not end_time: end_time = track.length @@ -555,6 +616,7 @@ class Player(VoiceProtocol): track.end_time = end_time async def add_track(self, raw_tracks: Union[Track, List[Track]], *, start_time: int = 0, end_time: int = 0, at_front: bool = False, duplicate: bool = True) -> int: + """Adds one or more tracks to the queue.""" tracks: List[Track] = [] _duplicate_tracks = [] if self.queue._allow_duplicate and duplicate else [track.uri for track in self.queue._queue] raw_tracks = raw_tracks[0] if isinstance(raw_tracks, List) and len(raw_tracks) == 1 else raw_tracks @@ -586,22 +648,26 @@ class Player(VoiceProtocol): return len(tracks) if is_list else position async def remove_track(self, index: int, index2: int = None, remove_target: Member = None, requester: Member = None) -> Dict[int, Track]: + """Removes one or more tracks from the queue.""" removed_tracks = self.queue.remove(index, index2, remove_target) if removed_tracks and self.is_ipc_connected: await self.send_ws({ "op": "removeTrack", "indexes": list(removed_tracks.keys()), - "first_track_id": list(removed_tracks.values())[0].track_id + "firstTrackId": list(removed_tracks.values())[0].track_id }, requester=requester) return removed_tracks async def seek(self, position: float, requester: Member = None) -> float: """Seeks to a position in the currently playing track milliseconds""" - if position < 0 or position > self._current.original.length: + if not self._current: + raise VoicelinkException("Nothing is playing right now") + + if position < 0 or position > self._current.length: raise TrackInvalidPosition("Seek position must be between 0 and the track length") - await self.send(method=RequestMethod.patch, data={"position": position}) + await self.send(method=RequestMethod.PATCH, data={"position": position}) if self.is_ipc_connected: await self.send_ws({"op": "updatePosition", "position": position}, requester) @@ -613,7 +679,7 @@ class Player(VoiceProtocol): self._paused = pause self.pause_votes.clear() if pause else self.resume_votes.clear() - await self.send(method=RequestMethod.patch, data={"paused": pause}) + await self.send(method=RequestMethod.PATCH, data={"paused": pause}) if self.is_ipc_connected: await self.send_ws({"op": "updatePause", "pause": pause}, requester) @@ -623,7 +689,7 @@ class Player(VoiceProtocol): async def set_volume(self, volume: int, requester: Member = None) -> int: """Sets the volume of the player as an integer. Lavalink accepts values from 0 to 500.""" - await self.send(method=RequestMethod.patch, data={"volume": volume}) + await self.send(method=RequestMethod.PATCH, data={"volume": volume}) self._volume = volume if self.is_ipc_connected: @@ -644,13 +710,14 @@ class Player(VoiceProtocol): if self.is_ipc_connected: await self.send_ws({ "op": "shuffleTrack", - "tracks": [{"track_id": track.track_id, "requester_id": str(track.requester.id)} for track in replacement], - "queue_type": queue_type + "tracks": [{"trackId": track.track_id, "requesterId": str(track.requester.id)} for track in replacement], + "queueType": queue_type }, requester) self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) has been shuffled the queue.") async def swap_track(self, index1: int, index2: int, requester: Member = None) -> Tuple[Track, Track]: + """Swaps two tracks in the queue at the specified indices.""" track1, track2 = self.queue.swap(index1, index2) if self.is_ipc_connected: await self.send_ws({ @@ -661,6 +728,7 @@ class Player(VoiceProtocol): return track1, track2 async def move_track(self, index: int, new_index: int, requester: Member = None) -> Optional[Track]: + """Moves a track from its current position to a new position in the queue.""" moved_track = self.queue.move(index, new_index) if self.is_ipc_connected: @@ -668,34 +736,31 @@ class Player(VoiceProtocol): return moved_track - async def set_repeat(self, mode: str = None, requester: Member = None) -> str: + async def set_repeat(self, mode: LoopType = None, requester: Member = None) -> LoopType: + """Sets the repeat mode for the queue.""" if not mode: - mode = self.queue._repeat.next().name - - is_found = False - for type in LoopType: - if type.name.lower() == mode.lower(): - self.queue._repeat.set_mode(type) - is_found = True - break - - if not is_found: + mode = self.queue._repeat.next() + + if not isinstance(mode, LoopType): raise VoicelinkException("Invalid repeat mode.") + self.queue._repeat.set_mode(mode) + if self.is_ipc_connected: - await self.send_ws({"op": "repeatTrack", "repeatMode": mode}, requester) + await self.send_ws({"op": "repeatTrack", "repeatMode": mode.name.lower()}, requester) - self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) has been update the repeat mode to {mode}.") + self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) has been update the repeat mode to {mode.name.lower()}.") return mode async def add_filter(self, filter: Filter, requester: Member = None, fast_apply: bool = False) -> Filters: + """Adds a filter to the player's audio stream.""" try: self._filters.add_filter(filter=filter) except FilterTagAlreadyInUse: raise FilterTagAlreadyInUse(self.get_msg("FilterTagAlreadyInUse")) payload = self._filters.get_all_payloads() - await self.send(method=RequestMethod.patch, data={"filters": payload}) + await self.send(method=RequestMethod.PATCH, data={"filters": payload}) if fast_apply: await self.seek(self.position) @@ -710,6 +775,7 @@ class Player(VoiceProtocol): return self._filters async def clear_queue(self, queue_type: str, requester: Member = None) -> None: + """Clears the queue or the history of tracks.""" queue_type = queue_type.lower() if queue_type == 'history': self.queue.history_clear(self.is_playing) @@ -719,13 +785,13 @@ class Player(VoiceProtocol): if self.is_ipc_connected: await self.send_ws({ "op": "clearQueue", - "queue_type": queue_type + "queueType": queue_type }, requester) async def remove_filter(self, filter_tag: str, requester: Member = None, fast_apply: bool = False) -> Filters: self._filters.remove_filter(filter_tag=filter_tag) payload = self._filters.get_all_payloads() - await self.send(method=RequestMethod.patch, data={"filters": payload}) + await self.send(method=RequestMethod.PATCH, data={"filters": payload}) if fast_apply: await self.seek(self.position) @@ -740,11 +806,12 @@ class Player(VoiceProtocol): return self._filters async def reset_filter(self, *, requester: Member = None, fast_apply=False) -> None: + """Resets all filters applied to the player's audio stream.""" if not self._filters: raise FilterInvalidArgument("You must have filters applied first in order to use this method.") self._filters.reset_filters() - await self.send(method=RequestMethod.patch, data={"filters": {}}) + await self.send(method=RequestMethod.PATCH, data={"filters": {}}) if fast_apply: await self.seek(self.position) @@ -757,7 +824,7 @@ class Player(VoiceProtocol): self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) has been removed all filters.") async def change_node(self, identifier: str = None) -> None: - """Change node.""" + """Changes the audio processing node for the guild..""" try: node = NodePool.get_node(identifier=identifier) except: @@ -768,16 +835,13 @@ class Player(VoiceProtocol): self._node._players[self.guild.id] = self await self._dispatch_voice_update(self._voice_state) - + if self.current: await self.play(self.current, start=self.position) self._last_update = time.time() * 1000 if self.is_paused: await self.set_pause(True) - - if self.volume != 100: - await self.set_volume(self.volume) async def get_recommendations(self, *, track: Optional[Track] = None) -> bool: """Get recommendations from Youtube or Spotify.""" @@ -796,6 +860,7 @@ class Player(VoiceProtocol): return False async def update_voice_status(self, remove_status: bool = False) -> None: + """Updates the voice status of the channel based on the specified template.""" template = self.settings.get("stage_announce_template", func.settings.voice_status_template) if not template or not self.channel: return @@ -804,7 +869,8 @@ class Player(VoiceProtocol): rv = {key: func() if callable(func) else func for key, func in self._ph.variables.items()} status = None if remove_status else self._ph.replace(text=template, variables=rv) # if self.channel.status != status: - await self.channel.edit(status=status) + if self.channel.type == ChannelType.voice: + await self.channel.edit(status=status) except Exception as e: self._logger.error( @@ -815,7 +881,8 @@ class Player(VoiceProtocol): ) async def send_ws(self, payload, requester: Member = None): - payload['guild_id'] = str(self.guild.id) + """Sends a WebSocket payload to the bot's IPC (Inter-Process Communication) system.""" + payload['guildId'] = str(self.guild.id) if requester: - payload['requester_id'] = str(requester.id) + payload['requesterId'] = str(requester.id) await self.bot.ipc.send(payload) \ No newline at end of file diff --git a/voicelink/pool.py b/voicelink/pool.py index 5b258f8..ec625d0 100644 --- a/voicelink/pool.py +++ b/voicelink/pool.py @@ -31,17 +31,15 @@ import logging from discord import Client, Member from discord.ext.commands import Bot -from typing import Dict, Optional, TYPE_CHECKING, Union, List +from typing import Dict, Optional, Union, List, Any, TYPE_CHECKING from urllib.parse import quote from . import ( - __version__, - spotify, + __version__ ) from .enums import SearchType, NodeAlgorithm from .exceptions import ( - InvalidSpotifyClientAuthorization, NodeConnectionFailure, NodeCreationError, NodeException, @@ -52,19 +50,11 @@ from .exceptions import ( from .objects import Playlist, Track from .utils import ExponentialBackoff, NodeStats, NodeInfo, Ping from .enums import RequestMethod +from .ratelimit import YTRatelimit, YTToken, STRATEGY if TYPE_CHECKING: from .player import Player -SPOTIFY_URL_REGEX = re.compile( - r"https?://open.spotify.com/(?Palbum|playlist|track|artist)/(?P[a-zA-Z0-9]+)" -) - -DISCORD_MP3_URL_REGEX = re.compile( - r"https?://cdn.discordapp.com/attachments/(?P[0-9]+)/" - r"(?P[0-9]+)/(?P[a-zA-Z0-9_.]+)+" -) - URL_REGEX = re.compile( r"https?://(?:www\.)?.+" ) @@ -73,8 +63,7 @@ NODE_VERSION = "v4" class Node: """The base class for a node. - This node object represents a Lavalink node. - To enable Spotify searching, pass in a proper Spotify Client ID and Spotify Client Secret + This node object represents a Lavalink node. """ def __init__( @@ -88,9 +77,8 @@ class Node: identifier: str, secure: bool = False, heartbeat: int = 30, + yt_ratelimit: dict = None, session: Optional[aiohttp.ClientSession] = None, - spotify_client_id: Optional[str] = None, - spotify_client_secret: Optional[str] = None, resume_key: Optional[str] = None, logger: Optional[logging.Logger] = None ): @@ -103,7 +91,7 @@ class Node: self._heartbeat: int = heartbeat self._secure: bool = secure self._logger: Optional[logging.Logger] = logger - + self._websocket_uri: str = f"{'wss' if self._secure else 'ws'}://{self._host}:{self._port}/" + NODE_VERSION + "/websocket" self._rest_uri: str = f"{'https' if self._secure else 'http'}://{self._host}:{self._port}" @@ -125,10 +113,8 @@ class Node: self._players: Dict[int, Player] = {} self._info: Optional[NodeInfo] = None - self._spotify_client_id: Optional[str] = spotify_client_id - self._spotify_client_secret: Optional[str] = spotify_client_secret - self._spotify_client: Optional[spotify.Client] = None - + self.yt_ratelimit: Optional[YTRatelimit] = STRATEGY.get(yt_ratelimit.get("strategy"))(self, yt_ratelimit) if yt_ratelimit else None + self._bot.add_listener(self._update_handler, "on_socket_response") def __repr__(self): @@ -141,18 +127,6 @@ class Node: """Takes a guild ID as a parameter. Returns a voicelink Player object.""" return self._players.get(guild_id, None) - @property - def spotify_client(self) -> Optional[spotify.Client]: - if not self._spotify_client: - if not self._spotify_client_id or not self._spotify_client_secret: - return None - - self._spotify_client = spotify.Client( - self._spotify_client_id, self._spotify_client_secret - ) - - return self._spotify_client - @property def is_connected(self) -> bool: """"Property which returns whether this node is connected or not""" @@ -213,29 +187,44 @@ class Node: await player.on_voice_state_update(data["d"]) except KeyError: return - + async def _listen(self) -> None: - backoff = ExponentialBackoff(base=7) + backoff = ExponentialBackoff(base=7) while True: try: msg = await self._websocket.receive() - except: - break - if msg.type == aiohttp.WSMsgType.CLOSED: - self._available = False - retry = backoff.delay() - self._logger.info(f"Trying to reconnect node [{self._identifier}] with {round(retry)}s") - await asyncio.sleep(retry) - if not self.is_connected: - try: - await self.connect() - except: - pass - else: + if msg.type == aiohttp.WSMsgType.CLOSED: + self._available = False + self._logger.warning(f"WebSocket closed for node [{self._identifier}]") + break + + elif msg.type == aiohttp.WSMsgType.ERROR: + self._logger.error(f"WebSocket error for node [{self._identifier}]") + break + self._bot.loop.create_task(self._handle_payload(msg.json())) + except aiohttp.ClientConnectionError as e: + self._logger.error(f"Connection error: {e}") + self._available = False + break + + except Exception as e: + self._logger.exception(f"Unexpected error: {e}") + self._available = False + break + + while not self._available: + retry = backoff.delay() + self._logger.info(f"Trying to reconnect node [{self._identifier}] in {round(retry)}s") + await asyncio.sleep(retry) + try: + await self.connect() + except Exception as e: + self._logger.error(f"Reconnection failed: {e}") + async def _handle_payload(self, data: dict) -> None: op = data.get("op", None) if not op: @@ -271,22 +260,26 @@ class Node: if resp.status >= 300: raise NodeException(f"Getting errors from Lavalink REST api") - if method == RequestMethod.delete: + if method == RequestMethod.DELETE: return await resp.json(content_type=None) return await resp.json() - + async def connect(self) -> Node: """Initiates a connection with a Lavalink node and adds it to the node pool.""" try: + if self._available: + self._logger.info(f"Node [{self._identifier}] already connected.") + return + self._websocket = await self._session.ws_connect( self._websocket_uri, headers=self._headers, heartbeat=self._heartbeat ) self._task = self._bot.loop.create_task(self._listen()) self._available = True - self._info = NodeInfo(await self.send(RequestMethod.get, query="info")) + self._info = NodeInfo(await self.send(RequestMethod.GET, query="info")) self._logger.info(f"Node [{self._identifier}] is connected!") @@ -326,6 +319,7 @@ class Node: async def reconnect(self) -> None: await asyncio.sleep(10) for player in self.players.copy().values(): + await asyncio.sleep(3) try: if player._voice_state: await player._dispatch_voice_update(player._voice_state) @@ -351,202 +345,79 @@ class Node: Context object on the track it builds. """ - async with self._session.get( - f"{self._rest_uri}/" + NODE_VERSION + "/decodetrack?", - headers={"Authorization": self._password}, - params={"track": identifier} - ) as resp: - if not resp.status == 200: - raise TrackLoadError( - f"Failed to build track. Check if the identifier is correct and try again." - ) - - data: dict = await resp.json() - return Track(track_id=identifier, info=data, requester=requester) + data = await self.send(RequestMethod.GET, f"decodetrack?encodedTrack={identifier}") + return Track(track_id=identifier, info=data, requester=requester) async def get_tracks( self, query: str, *, requester: Member, - search_type: SearchType = SearchType.ytsearch + search_type: SearchType = SearchType.YOUTUBE ) -> Union[List[Track], Playlist]: - """Fetches tracks from the node's REST api to parse into Lavalink. + """ + Fetches tracks from the node's REST api to parse into Lavalink. - If you passed in Spotify API credentials, you can also pass in a - Spotify URL of a playlist, album or track and it will be parsed accordingly. - - You can also pass in a discord.py Context object to get a - Context object on any track you search. + You can also pass in a discord.py Context object to get a + Context object on any track you search. """ - if not URL_REGEX.match(query) and not re.match(r"(?:ytm?|sc)search:.", query): + if not URL_REGEX.match(query) and ':' not in query: query = f"{search_type}:{query}" - if SPOTIFY_URL_REGEX.match(query): - try: - if not self.spotify_client: - raise InvalidSpotifyClientAuthorization( - "You did not provide proper Spotify client authorization credentials. " - "If you would like to use the Spotify searching feature, " - "please obtain Spotify API credentials here: https://developer.spotify.com/" - ) - - spotify_results = await self.spotify_client.search(query=query) - except Exception as _: - raise TrackLoadError("Not able to find the provided Spotify entity, is it private?") - - if isinstance(spotify_results, spotify.Track): - return [ - Track( - track_id=None, - info=spotify_results.to_dict(), - requester=requester, - search_type=search_type, - spotify_track=spotify_results, - ) - ] - - tracks = [ - Track( - track_id=None, - info=track.to_dict(), - requester=requester, - search_type=search_type, - spotify_track=track, - ) for track in spotify_results.tracks if track.uri - ] - - return Playlist( - playlist_info={"name": spotify_results.name, "selectedTrack": 0}, - tracks=tracks, - requester=requester, - spotify=True, - spotify_playlist=spotify_results - ) - - elif DISCORD_MP3_URL_REGEX.match(query): - async with self._session.get( - url=f"{self._rest_uri}/" + NODE_VERSION + f"/loadtracks?identifier={quote(query)}", - headers={"Authorization": self._password} - ) as response: - data: dict = await response.json() - - try: - track: dict = data["data"] - except: - raise TrackLoadError("Not able to find the provided track.") - - return [ - Track( - track_id=track["encoded"], - info=track["info"], - requester=requester - ) - ] - else: - async with self._session.get( - url=f"{self._rest_uri}/" + NODE_VERSION + f"/loadtracks?identifier={quote(query)}", - headers={"Authorization": self._password} - ) as response: - data = await response.json() - - load_type = data.get("loadType") + response: dict[str, Any] = await self.send(RequestMethod.GET, f"loadtracks?identifier={quote(query)}") + data = response.get("data") + load_type = response.get("loadType") if not load_type: raise TrackLoadError("There was an error while trying to load this track.") - - elif load_type == "error": - exception = data["data"] - raise TrackLoadError(f"{exception['message']} [{exception['severity']}]") - + elif load_type == "empty": return None - elif load_type == "playlist": - data = data.get("data") - - return Playlist( - playlist_info=data["info"], - tracks=data["tracks"], - requester=requester - ) + elif load_type == "error": + raise TrackLoadError(f"{data['message']} [{data['severity']}]") + + elif load_type in ("playlist", "recommendations"): + return Playlist(playlist_info=data["info"], tracks=data["tracks"], requester=requester) elif load_type == "search": - return [ - Track( - track_id=track["encoded"], - info=track["info"], - requester=requester - ) - for track in data["data"] - ] + return [Track(track_id=track["encoded"], info=track["info"], requester=requester) for track in data] elif load_type == "track": - track = data["data"] - return [ - Track( - track_id=track["encoded"], - info=track["info"], - requester=requester - ) - ] - - async def spotifySearch(self, query: str, *, requester: Member) -> Optional[List[Track]]: - try: - if not self.spotify_client: - raise InvalidSpotifyClientAuthorization( - "You did not provide proper Spotify client authorization credentials. " - "If you would like to use the Spotify searching feature, " - "please obtain Spotify API credentials here: https://developer.spotify.com/" - ) - - tracks = await self._spotify_client.track_search(query=query) - except Exception as _: - raise TrackLoadError("Not able to find the provided Spotify entity, is it private?") - - return [ - Track( - track_id=None, - requester=requester, - search_type=SearchType.ytsearch, - spotify_track=track, - info=track.to_dict() - ) - for track in tracks ] + return [Track(track_id=data["encoded"], info=data["info"], requester=requester)] async def get_recommendations(self, track: Track, limit: int = 20) -> List[Optional[Track]]: - if track.spotify: - if not self.spotify_client: - return [] - - spotify_tracks = await self.spotify_client.similar_track(seed_tracks=track.identifier, limit=limit) - - tracks = [ - Track( - track_id=None, - search_type=SearchType.ytsearch, - spotify_track=track, - info=track.to_dict(), - requester=self.bot.user - ) - for track in spotify_tracks - ] + query = "" + if track.source == "youtube": + query = f"https://www.youtube.com/watch?v={track.identifier}&list=RD{track.identifier}" - else: - if track.source != 'youtube': - return [] - - tracks = await self.get_tracks( - f"https://www.youtube.com/watch?v={track.identifier}&list=RD{track.identifier}", - requester=self.bot.user - ) + elif track.source == "spotify": + query = f"sprec:seed_tracks={track.identifier}" + if not query: + return [] + + tracks = await self.get_tracks(query=query, requester=self.bot.user) if isinstance(tracks, Playlist): tracks = tracks.tracks return tracks[:limit] if limit else tracks + + async def update_refresh_yt_access_token(self, token: YTToken) -> dict: + if not self._available: + raise NodeNotAvailable(f"The node '{self._identifier}' is unavailable.") + uri: str = f"{self._rest_uri}/youtube" + async with self._session.request( + method="POST", + url=uri, + headers={"Authorization": self._password}, + json={"refreshToken": token.token} + ) as resp: + if resp.status >= 300: + raise NodeException(f"Getting errors from Lavalink REST api") + class NodePool: """The base class for the node pool. This holds all the nodes that are to be used by the bot. @@ -572,12 +443,12 @@ class NodePool: This option is preferred if you want to choose the best node from a multi-node setup using either the node's latency or the node's voice region. - Use NodeAlgorithm.by_ping if you want to get the best node + Use NodeAlgorithm.BY_PING if you want to get the best node based on the node's latency. Use NodeAlgorithm.by_region if you want to get the best node based on the node's voice region. This method will only work if you set a voice region when you create a node. - Use NodeAlgorithm.by_players if you want to get the best node + Use NodeAlgorithm.BY_PLAYERS if you want to get the best node based on how players it has. This method will return a node with the least amount of players """ @@ -586,11 +457,11 @@ class NodePool: if not available_nodes: raise NoNodesAvailable("There are no nodes available.") - if algorithm == NodeAlgorithm.by_ping: + if algorithm == NodeAlgorithm.BY_PING: tested_nodes = {node: node.latency for node in available_nodes} return min(tested_nodes, key=tested_nodes.get) - elif algorithm == NodeAlgorithm.by_players: + elif algorithm == NodeAlgorithm.BY_PLAYERS: tested_nodes = {node: len(node.players.keys()) for node in available_nodes} return min(tested_nodes, key=tested_nodes.get) @@ -624,14 +495,12 @@ class NodePool: identifier: str, secure: bool = False, heartbeat: int = 30, - spotify_client_id: Optional[str] = None, - spotify_client_secret: Optional[str] = None, + yt_ratelimit: dict = None, session: Optional[aiohttp.ClientSession] = None, resume_key: Optional[str] = None, logger: Optional[logging.Logger] = None ) -> Node: """Creates a Node object to be then added into the node pool. - For Spotify searching capabilites, pass in valid Spotify API credentials. """ if identifier in cls._nodes.keys(): raise NodeCreationError(f"A node with identifier '{identifier}' already exists.") @@ -641,9 +510,8 @@ class NodePool: node = Node( pool=cls, bot=bot, host=host, port=port, password=password, - identifier=identifier, secure=secure, heartbeat=heartbeat, spotify_client_id=spotify_client_id, - session=session, spotify_client_secret=spotify_client_secret, - resume_key=resume_key, logger=logger + identifier=identifier, secure=secure, heartbeat=heartbeat, yt_ratelimit=yt_ratelimit, + session=session, resume_key=resume_key, logger=logger ) await node.connect() diff --git a/voicelink/queue.py b/voicelink/queue.py index 79bd41a..10beee0 100644 --- a/voicelink/queue.py +++ b/voicelink/queue.py @@ -25,7 +25,7 @@ from .exceptions import QueueFull, OutofList from .objects import Track from .enums import LoopType -from typing import Optional, Tuple, List, Callable, Dict +from typing import Optional, Tuple, Callable, Dict, List from itertools import cycle from discord import Member @@ -65,16 +65,16 @@ class Queue: def get(self) -> Optional[Track]: track = None try: - track = self._queue[self._position - 1 if self._repeat.mode == LoopType.track else self._position] - if self._repeat.mode != LoopType.track: + track = self._queue[self._position - 1 if self._repeat.mode == LoopType.TRACK else self._position] + if self._repeat.mode != LoopType.TRACK: self._position += 1 except: - if self._repeat.mode == LoopType.queue: + if self._repeat.mode == LoopType.QUEUE: try: track = self._queue[self._repeat_position] self._position = self._repeat_position + 1 except IndexError: - self._repeat.set_mode(LoopType.off) + self._repeat.set_mode(LoopType.OFF) return track diff --git a/voicelink/ratelimit.py b/voicelink/ratelimit.py new file mode 100644 index 0000000..171a512 --- /dev/null +++ b/voicelink/ratelimit.py @@ -0,0 +1,120 @@ +"""MIT License + +Copyright (c) 2023 - present Vocard Development + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import time +from abc import ABC, abstractmethod +from typing import List, Optional, Dict, TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .pool import Node + +class YTToken: + def __init__(self, token: str): + self.token: str = token + self.allow_retry_time: float = 0.0 + self.requested_times: int = 0 + self.is_flagged: bool = False + self.flagged_time: float = 0.0 + + @property + def allow_retry(self) -> bool: + """Determine if the token can be used again.""" + return time.time() >= self.allow_retry_time + +class YTRatelimit(ABC): + """ + Abstract base class for YouTube rate limit strategies. + """ + def __init__(self, node: "Node", tokens: List[str]) -> None: + self.node: "Node" = node + self.tokens: List[YTToken] = [YTToken(token) for token in tokens] + self.active_token: Optional[YTToken] = self.tokens[0] if self.tokens else None + + @abstractmethod + async def flag_active_token(self) -> None: + """ + Mark the current active token as flagged when a rate-limit is encountered. + """ + pass + + @abstractmethod + async def handle_request(self) -> None: + """ + Update usage count or perform any necessary pre-request operations. + """ + pass + + async def swap_token(self) -> Optional[YTToken]: + """ + Swap the active token with another token that is either not flagged or ready to retry. + If a new token is found, update it via the node and return it. + """ + for token in self.tokens: + if token != self.active_token and (not token.is_flagged or token.allow_retry): + try: + await self.node.update_refresh_yt_access_token(token) + self.active_token = token + return token + except Exception as e: + self.node._logger.error("Something wrong while updating the youtube access token.", exc_info=e) + + self.node._logger.warning("No active token available for processing the request.") + return None + +class LoadBalance(YTRatelimit): + """ + A rate limiting strategy that load balances requests across tokens. + """ + def __init__(self, node: "Node", config: Dict[str, Any]): + super().__init__(node, tokens=config.get("tokens", [])) + self._config: Dict[str, Any] = config.get("config", {}) + self._retry_time: int = self._config.get("retry_time", 10_800) + self._max_requests: int = self._config.get("max_requests", 30) + + async def flag_active_token(self) -> None: + """ + Flag the active token and set a delay (e.g., 3 hours) until it can be retried. + """ + if self.active_token: + self.active_token.is_flagged = True + self.active_token.flagged_time = time.time() + self.active_token.allow_retry_time = self.active_token.flagged_time + self._retry_time + await self.swap_token() + + async def handle_request(self) -> None: + """ + Increment the active token's usage counter and swap tokens if a threshold is reached. + """ + if not self.active_token: + return await self.swap_token() + + self.active_token.requested_times += 1 + if self.active_token.requested_times >= self._max_requests: + self.active_token.requested_times = 0 + swapped_token = await self.swap_token() + if swapped_token is None: + return self.node._logger.warning("No available token found after swapping.") + +STRATEGY = { + "LoadBalance": LoadBalance +} \ No newline at end of file diff --git a/voicelink/spotify/__init__.py b/voicelink/spotify/__init__.py deleted file mode 100644 index a7a4a10..0000000 --- a/voicelink/spotify/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -"""MIT License - -Copyright (c) 2022 Vocard Development - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -""" - -from .exceptions import InvalidSpotifyURL, SpotifyRequestException -from .objects import * -from .client import Client diff --git a/voicelink/spotify/client.py b/voicelink/spotify/client.py deleted file mode 100644 index 3d1dff2..0000000 --- a/voicelink/spotify/client.py +++ /dev/null @@ -1,156 +0,0 @@ -"""MIT License - -Copyright (c) 2022 Vocard Development - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -""" - -import re -import time -import aiohttp - -from base64 import b64encode -from typing import List, Union, Dict, Any -from .objects import Track, Album, Artist, Playlist, Category -from .exceptions import InvalidSpotifyURL, SpotifyRequestException - -BASE_URL = "https://api.spotify.com/v1/" -GRANT_URL = "https://accounts.spotify.com/api/token" -REQUEST_URL = BASE_URL + "{type}s/{id}" -SEARCH_URL = BASE_URL + "search?q={query}&type={type}&limit={limit}" -SUGGESTION_URL = BASE_URL + "recommendations?limit={limit}&seed_tracks={seed_tracks}" -SPOTIFY_URL_REGEX = re.compile( - r"https?://open.spotify.com/(?Palbum|playlist|track|artist)/(?P[a-zA-Z0-9]+)" -) - -class Client: - """The base client for the Spotify module of Voicelink. - This class will do all the heavy lifting of getting all the metadata - for any Spotify URL you throw at it. - """ - - def __init__(self, client_id: str, client_secret: str) -> None: - self._client_id: str = client_id - self._client_secret: str = client_secret - - self.session: aiohttp.ClientSession = aiohttp.ClientSession() - - self._bearer_token: str = None - self._expiry: int = 0 - self._auth_token: str = b64encode(f"{self._client_id}:{self._client_secret}".encode()) - self._grant_headers: Dict[str, str] = {"Authorization": f"Basic {self._auth_token.decode()}"} - self._bearer_headers: Dict[str, str] = None - - self._categories: List[Category] = [] - - async def _fetch_bearer_token(self) -> None: - _data = {"grant_type": "client_credentials"} - - async with self.session.post(GRANT_URL, data=_data, headers=self._grant_headers) as resp: - if resp.status != 200: - raise SpotifyRequestException( - f"Error fetching bearer token: {resp.status} {resp.reason}" - ) - - data: Dict = await resp.json() - - self._bearer_token = data["access_token"] - self._expiry = time.time() + (int(data["expires_in"]) - 10) - self._bearer_headers = {"Authorization": f"Bearer {self._bearer_token}"} - - async def get_request(self, url: str) -> Dict: - if not self._bearer_token or time.time() >= self._expiry: - await self._fetch_bearer_token() - - async with self.session.get(url, headers=self._bearer_headers) as resp: - if resp.status != 200: - raise SpotifyRequestException( - f"Error while fetching results: {resp.status} {resp.reason}" - ) - - return await resp.json() - - async def track_search(self, query: str, track: str = "track", limit: int = 10) -> List[Track]: - request_url = SEARCH_URL.format(query=query, type=track, limit=limit) - data = await self.get_request(request_url) - return [ Track(track) for track in data['tracks']['items'] ] - - async def similar_track(self, seed_tracks: str, *, limit: int = 10) -> List[Track]: - request_url = SUGGESTION_URL.format(limit=limit, seed_tracks=seed_tracks) - data = await self.get_request(request_url) - return [ Track(track) for track in data['tracks'] ] - - async def search(self, *, query: str) -> Union[Track, Album, Playlist]: - result = SPOTIFY_URL_REGEX.match(query) - spotify_type = result.group("type") - spotify_id = result.group("id") - - if not result: - raise InvalidSpotifyURL("The Spotify link provided is not valid.") - - request_url = REQUEST_URL.format(type=spotify_type, id=spotify_id) - if isArtist := (spotify_type == "artist"): - request_url += "/top-tracks?market=US" - - data = await self.get_request(request_url) - - if spotify_type == "track": - return Track(data) - elif spotify_type == "album": - return Album(data) - elif isArtist: - return Artist(data) - else: - tracks = [ - Track(track["track"]) - for track in data["tracks"]["items"] if track["track"] is not None - ] - - if not tracks: - raise SpotifyRequestException("This playlist is empty and therefore cannot be queued.") - - next_page_url = data["tracks"]["next"] - - while next_page_url is not None: - async with self.session.get(next_page_url, headers=self._bearer_headers) as resp: - if resp.status != 200: - raise SpotifyRequestException( - f"Error while fetching results: {resp.status} {resp.reason}" - ) - - next_data: Dict = await resp.json() - - tracks += [ - Track(track["track"]) - for track in next_data["items"] if track["track"] is not None - ] - next_page_url = next_data["next"] - - return Playlist(data, tracks) - - async def get_categories(self) -> List[Category]: - if not self._categories: - request_url = f"{BASE_URL}browse/categories" - data = await self.get_request(request_url) - self._categories = [Category(item) for item in data.get("items", [])] - - return self._categories - - async def close(self) -> None: - await self.session.close() \ No newline at end of file diff --git a/voicelink/spotify/exceptions.py b/voicelink/spotify/exceptions.py deleted file mode 100644 index e421fbf..0000000 --- a/voicelink/spotify/exceptions.py +++ /dev/null @@ -1,8 +0,0 @@ -class SpotifyRequestException(Exception): - """An error occurred when making a request to the Spotify API""" - pass - - -class InvalidSpotifyURL(Exception): - """An invalid Spotify URL was passed""" - pass diff --git a/voicelink/spotify/objects.py b/voicelink/spotify/objects.py deleted file mode 100644 index 9a26062..0000000 --- a/voicelink/spotify/objects.py +++ /dev/null @@ -1,135 +0,0 @@ -class Track: - """The base class for a Spotify Track""" - - __slots__ = ( - "name", - "artists", - "artist_id", - "length", - "id", - "image", - "uri" - ) - - def __init__(self, data: dict, image = None) -> None: - self.name: str = data.get('name', 'Unknown') - self.artists: str = ", ".join(filter(None, (artist["name"] for artist in data.get('artists')))) - self.artist_id: list[str] = [artist["id"] for artist in data.get('artists')] - self.length: int = data.get('duration_ms') - self.id: str = data.get('id') - self.image: str = images[0]["url"] if (images := data.get("album", {}).get("images")) else image - self.uri: str = None if data["is_local"] else data["external_urls"]["spotify"] - - def to_dict(self) -> dict: - return { - "title": self.name, - "author": self.artists, - "length": self.length, - "identifier": self.id, - "artist_id": self.artist_id, - "uri": self.uri, - "isStream": False, - "isSeekable": True, - "position": 0, - "artworkUrl": self.image - } - - def __repr__(self) -> str: - return ( - f"" - ) - -class Album: - """The base class for a Spotify album""" - - __slots__ = ( - "name", - "artists", - "image", - "tracks", - "total_tracks", - "id", - "uri" - ) - - def __init__(self, data: dict) -> None: - self.name: str = data.get('name', 'Unknown') - self.artists: str = ", ".join(filter(None, (artist["name"] for artist in data.get('artists')))) - self.image: str = data["images"][0]["url"] - self.tracks: list[Track] = [Track(track, image=self.image) for track in data["tracks"]["items"]] - self.total_tracks: int = data["total_tracks"] - self.id: str = data.get('id') - self.uri: str = data["external_urls"]["spotify"] - - def __repr__(self) -> str: - return ( - f"" - ) - -class Artist: - """The base class for a Spotify playlist""" - - __slots__ = ( - "tracks", - "image", - "total_tracks", - "owner", - "id", - "uri", - "name" - ) - def __init__(self, data: dict) -> None: - self.tracks: list[Track] = [Track(track) for track in data['tracks']] - if self.tracks: - self.image: str = self.tracks[0].image - self.total_tracks: int = len(self.tracks) - self.owner: str = self.tracks[0].artists - self.id: str = self.tracks[0].artist_id - self.uri: str = data['tracks'][0]['album']['artists'][0]['external_urls']['spotify'] - self.name: str = f"Top tracks - {self.owner}" - - def __repr__(self) -> str: - return ( - f"" - ) - -class Playlist: - """The base class for a Spotify playlist""" - - __slots__ = ( - "name", - "tracks", - "owner", - "total_tracks", - "id", - "image", - "uri" - ) - - def __init__(self, data: dict, tracks: list[Track]) -> None: - self.name: str = data.get('name', 'Unknown') - self.tracks: list[Track] = tracks - self.owner: str = data["owner"]["display_name"] - self.total_tracks: int = data["tracks"]["total"] - self.id: str = data.get('id') - self.image: str = data["images"][0]["url"] if len(data.get("images", [])) else None - self.uri: str = data["external_urls"]["spotify"] - - def __repr__(self) -> str: - return ( - f"" - ) - -class Category: - def __init__(self, data: dict) -> None: - self.href: str = data.get("href") - self.id: str = data.get("id") - self.name: str = data.get("name") - self.icon: str = data.get("icon", [])[0].get("url") - - def __repr__(self) -> str: - return (f"