diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..ef029df Binary files /dev/null and b/.DS_Store differ diff --git a/.env Example b/.env Example deleted file mode 100644 index 0e40e42..0000000 --- a/.env Example +++ /dev/null @@ -1,18 +0,0 @@ -TOKEN = YOUR_BOT_TOKEN -CLIENT_ID = YOUR_BOT_CLIENT_ID -CLIENT_SECRET_ID = YOUR_BOT_CLIENT_SECRET_ID - -BUG_REPORT_CHANNEL_ID = YOUR_DISCORD_CHANNEL_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 - -DASHBOARD_HOST = 0.0.0.0 -DASHBOARD_PORT = 5555 -DASHBOARD_SECRET_KEY = DASHBOARD_SECRET_KEY -DASHBOARD_SECURE = False \ No newline at end of file diff --git a/PLACEHOLDERS.md b/PLACEHOLDERS.md deleted file mode 100644 index 77bbbe6..0000000 --- a/PLACEHOLDERS.md +++ /dev/null @@ -1,179 +0,0 @@ -# Bot Activity (Activity are updated every 10 minutes) -> Usage: {"action": "@@value@@"} - -Valuables - -| Value Name | Description | -| --- | --- | -| guilds | Number of guilds in the bot | -| users | Number of users in the voice channel. | -| players | Number of players playing. | -| nodes | Number of nodes are connected. | - -Actions - -| Action Name | Description | -| --- | --- | -| play | Playing status. | -| listen | Listening status. | -| watch | Watching status. | -| stream | Streaming status. | - -## Examples -Change the value of `activity` in `settings.json`. - -1. Example 1 -image - -```json -"activity":[ - {"listen": "/help"} -] -``` - -2. Example 2 -image - -```json -"activity":[ - {"watch": "@@players@@ players"} -] -``` - -3. Example 3 (Multiple activities) - -```json -"activity":[ - {"listen": "/help"}, - {"watch": "@@players@@ players"} -] -``` - -# Controller Embeds -> Usage: @@value@@ - -Valuables - -| Value Name | Description | -| --- | --- | -| channel_name | The name of the channel the bot is playing on. | -| track_name | The name of the track currently playing. | -| track_url | The url of the track currently playing. | -| track_author | The name of the author of the track currently playing. | -| track_duration | The length of the track currently playing. | -| track_thumbnail | The thumbnail of the track currently playing. | -| track_color | The color associated with the source of the currently playing track. For example, YouTube tracks are indicated in red. This color can be customized in the settings.json file | -| requester | The mention name of the requester of the currently playing track. | -| requester_name | The name of the requester of the currently playing track. | -| requester_avatar | The avatar url of the requester of the currently playing track. | -| queue_length | Number of queue lengths | -| volume | Music volume. | -| dj | DJ role. `(It can be a user or role)` | -| loop_mode | Current repeat mode. | -| default_embed_color | Default embed color. `(color_code in settings.json)` | -| bot_icon | The avatar of the bot. | -| server_invite_link | The invite url of the support server | -| invite_link | The invite url of the bot. | - -Expression -> {{ EXPRESSION ?? TRUE // FALSE }} - -## Examples -Change the value of `default_controller` in `settings.json`. - -1. Example 1 -image -image - -```json -"default_controller": { - "embeds": { - "active": { - "description": "**Now Playing: ```[@@track_name@@]```\nLink: [Click Me](@@track_url@@) | Requester: @@requester@@ | DJ: @@dj@@**", - "footer": { - "text": "Queue Length: @@queue_length@@ | Duration: @@track_duration@@ | Volume: @@volume@@% {{loop_mode != 'Off' ?? | Repeat: @@loop_mode@@}}" - }, - "image": "@@track_thumbnail@@", - "author": { - "name": "Music Controller | @@channel_name@@", - "icon_url": "@@bot_icon@@" - }, - "color": "@@track_color@@" - }, - "inactive": { - "title": { - "name": "There are no songs playing right now" - }, - "description": "[Support](@@server_invite_link@@) | [Invite](@@invite_link@@) | [Questionnaire](https://forms.gle/Qm8vjBfg2kp13YGD7)", - "image": "https://i.imgur.com/dIFBwU7.png", - "color": "@@default_embed_color@@" - } - } -}, -``` - -# Controller Buttons -> Usage: "button" or {"button": "color"} - -Buttons - -| Button Name | Description | -| --- | --- | -| Back | Skips back to the previous song. | -| Resume | Resume or pause the music. | -| Skip | Skips to the next song. | -| Stop | Disconnects the bot from your voice channel and chears the queue. | -| Loop | Changes Loop mode. `[Off, Track, Queue]` | -| Add | Add the playing track in to your default custom playlist. | -| VolumeUp | Increase player volume by 20%. | -| VolumeDown | Decrease player volume by 20%. | -| VolumeMute | Mute or unmute the player. | -| Autoplay | Enable or disable autoplay mode. | -| Shuffle | Randomizes the tracks in the queue. | -| Forward | Forward 30 seconds in the current track. | -| Rewind | Rewind 30 seconds in the current track. | -| Tracks | If there are tracks in the queue, a drop-down list will be appear. Up to 10 tracks. `(This will take one row)`| - -Colors - -| Color Name | Description | -| --- | --- | -| Grey | Color the button grey. | -| Red | Color the button red. | -| Blue | Color the button blue. | -| Green | Color the button green. | - -## Examples -Change the value of `default_buttons` in `default_controller`. - -1. Example 1 -image - -```json -"default_buttons": [ - ["back", "resume", "skip", {"stop": "red"}, "add"], - ["tracks"] -] -``` - -2. Example 2 -image - -```json -"default_buttons": [ - ["back", "resume", "skip", {"stop": "red"}, {"add": "green"}], - [{"loop": "green"}, {"volumeup": "blue"}, {"volumedown": "blue"}, {"volumemute": "red"}], - ["tracks"] -] -``` - -3. Example 3 -image - -```json -"default_buttons": [ - ["autoplay", "shuffle", {"loop": "green"}, "add"], - ["back", "resume", "skip", {"stop": "red"}], - ["volumeup", "volumedown", {"mute": "red"}] -] -``` diff --git a/README.md b/README.md index 4d76fcc..35e9741 100644 --- a/README.md +++ b/README.md @@ -2,62 +2,35 @@ Discord -# Vocard (Discord Music Bot) -> Vocard is a simple custom Disocrd Music Bot built with Python & [discord.py](https://discordpy.readthedocs.io/en/stable/)
-Demo: [Discord Bot Demo](https://discord.com/api/oauth2/authorize?client_id=890399639008866355&permissions=36708608&scope=bot%20applications.commands), -[Dashboard Demo](https://vocard.xyz) +# Vocard Bot +Vocard is a highly customizable Discord music bot, designed to deliver a user-friendly experience. It offers support for a wide range of streaming platforms including Youtube, Soundcloud, Spotify, Twitch, and more. -# Host for you? - - Patreon - +## Features +* Fast song loading +* Works with slash and message commands +* Lightweight design +* Smooth playback +* Clean and nice interface +* Supports many music platforms (YouTube, SoundCloud, etc.) +* Built-in playlist support +* Fully customizable settings +* Lyrics support +* Various sound effects +* Multiple languages available +* Easy to update +* Supports docker +* Premium dashboard (in beta) -# Tutorial -Click on the image below to watch the tutorial on Youtube. - -[![Discord Music Bot](https://img.youtube.com/vi/f_Z0RLRZzWw/maxresdefault.jpg)](https://www.youtube.com/watch?v=f_Z0RLRZzWw) - ## Screenshot -### Discord Bot - - - - - -### Dashboard - - +![features](https://github.com/user-attachments/assets/5d1dadb3-c706-4906-b1b6-fdee192705d5) ## Requirements -* [Python 3.10+](https://www.python.org/downloads/) +* [Python 3.11+](https://www.python.org/downloads/) * [Lavalink Server (Requires 4.0.0+)](https://github.com/freyacodes/Lavalink) -## Quick Start -```sh -git clone https://github.com/ChocoMeow/Vocard.git #Clone the repository -cd Vocard #Go to the directory -python -m pip install -r requirements.txt #Install required packages -``` -After installing all packages, you must configure the bot before to start! [How To Configure](https://docs.vocard.xyz/configuration/)
-Start your bot with `python main.py` +## Setup +Please see the [Setup Page](https://docs.vocard.xyz) in the docs to run this bot yourself! -## Configuration -1. Rename `.env Example` to `.env` and [customize your env](https://docs.vocard.xyz/configuration/#settings-up-env). -2. Rename `settings Example.json` to `settings.json` and [customize your settings](https://docs.vocard.xyz/configuration/#settings-up-settingsjson). -3. Now, you can start your bot with `python main.py`. +## Need Help? +Join the [Vocard Support Discord](https://discord.gg/wRCgB7vBQv) for help or questions. -## How to update? (For Windows and Linux) -***Note: Make sure there are no personal files in the directory! Otherwise it will be deleted.*** -```sh -# Check the current version -python update.py -c - -# Install the latest version -python update.py -l - -# Install the specified version -python update.py -v VERSION - -# Install the beta version -python update.py -b -``` \ No newline at end of file diff --git a/addons/__init__.py b/addons/__init__.py index a19a379..a835660 100644 --- a/addons/__init__.py +++ b/addons/__init__.py @@ -1,3 +1,3 @@ from .lyrics import lyricsPlatform from .placeholders import Placeholders -from .settings import Settings, TOKENS \ No newline at end of file +from .settings import Settings \ No newline at end of file diff --git a/addons/lyrics.py b/addons/lyrics.py index 4fd3320..34de9f9 100644 --- a/addons/lyrics.py +++ b/addons/lyrics.py @@ -85,7 +85,7 @@ class A_ZLyrics(LyricsPlatform): lyrics = max(divs, key=len).strip() if not lyrics: - return print("Lyrics not found") + return None lyrics_parts = re.split(r"(\[[\w\S_ ]+\:])", lyrics) lyrics_parts = [item for item in lyrics_parts if item != ""] @@ -173,7 +173,7 @@ class A_ZLyrics(LyricsPlatform): class Genius(LyricsPlatform): def __init__(self) -> None: self.module = import_module("lyricsgenius") - self.genius = self.module.Genius(func.tokens.genius_token) + self.genius = self.module.Genius(func.settings.genius_token) async def get_lyrics(self, title: str, artist: str) -> Optional[dict[str, str]]: song = self.genius.search_song(title=title, artist=artist) diff --git a/addons/settings.py b/addons/settings.py index d4efccd..7638101 100644 --- a/addons/settings.py +++ b/addons/settings.py @@ -1,34 +1,33 @@ -import os -from dotenv import load_dotenv +from typing import ( + Dict, + List, + Any, + Union +) class Settings: - def __init__(self, settings: dict) -> None: + 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") + self.invite_link: str = "https://discord.gg/wRCgB7vBQv" - self.nodes: dict = settings.get("nodes", {}) + self.nodes: Dict[str, Dict[str, Union[str, int, bool]]] = settings.get("nodes", {}) self.max_queue: int = settings.get("default_max_queue", 1000) self.bot_prefix: str = settings.get("prefix", "") - self.activity: dict = settings.get("activity", [{"listen": "/help"}]) + self.activity: List[Dict[str, str]] = settings.get("activity", [{"listen": "/help"}]) + self.logging: Dict[Union[str, Dict[str, Union[str, bool]]]] = settings.get("logging", {}) self.embed_color: str = int(settings.get("embed_color", "0xb3b3b3"), 16) - self.bot_access_user: list = settings.get("bot_access_user", []) - self.sources_settings: dict = settings.get("sources_settings", {}) - self.cooldowns_settings: dict = settings.get("cooldowns", {}) - self.aliases_settings: dict = settings.get("aliases", {}) - self.controller: dict = settings.get("default_controller", {}) + self.bot_access_user: List[int] = settings.get("bot_access_user", []) + self.sources_settings: Dict[Dict[str, str]] = settings.get("sources_settings", {}) + self.cooldowns_settings: Dict[str, List[int]] = settings.get("cooldowns", {}) + self.aliases_settings: Dict[str, List[str]] = settings.get("aliases", {}) + self.controller: Dict[str, Dict[str, Any]] = settings.get("default_controller", {}) + self.voice_status_template: str = settings.get("default_voice_status_template", "") self.lyrics_platform: str = settings.get("lyrics_platform", "A_ZLyrics").lower() - self.ipc_server: dict = settings.get("ipc_server", {}) - self.version: str = settings.get("version", "") - -class TOKENS: - def __init__(self) -> None: - load_dotenv() - - self.token = os.getenv("TOKEN") - self.client_id = os.getenv("CLIENT_ID") - self.client_secret_id = os.getenv("CLIENT_SECRET_ID") - self.secret_key = os.getenv("DASHBOARD_SECRET_KEY") - self.bug_report_channel_id = int(os.getenv("BUG_REPORT_CHANNEL_ID")) - self.spotify_client_id = os.getenv("SPOTIFY_CLIENT_ID") - self.spotify_client_secret = os.getenv("SPOTIFY_CLIENT_SECRET") - self.genius_token = os.getenv("GENIUS_TOKEN") - self.mongodb_url = os.getenv("MONGODB_URL") - self.mongodb_name = os.getenv("MONGODB_NAME") \ No newline at end of file + self.ipc_client: Dict[str, Union[str, bool, int]] = settings.get("ipc_client", {}) + self.version: str = settings.get("version", "") \ No newline at end of file diff --git a/cogs/basic.py b/cogs/basic.py index aacddd0..e6ed3a6 100644 --- a/cogs/basic.py +++ b/cogs/basic.py @@ -30,13 +30,14 @@ from function import ( settings, send, time as ctime, - formatTime, + format_time, get_source, get_user, get_lang, truncate_string, cooldown_check, - get_aliases + get_aliases, + logger ) from addons import lyricsPlatform @@ -91,22 +92,25 @@ 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 + 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()][:25] + 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: - tracks: list[voicelink.Track] = await node.spotifySearch(current, requester=interaction.user) - return history_tracks[:5] + [app_commands.Choice(name=f"🎵 {track.author} - {track.title}", value=f"{track.author} - {track.title}") for track in tracks] - + try: + tracks: list[voicelink.Track] = await node.spotifySearch(current, requester=interaction.user) + return history_tracks[:5] + [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: + return history_tracks + @commands.hybrid_command(name="connect", aliases=get_aliases("connect")) @app_commands.describe(channel="Provide a channel to connect.") @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) @@ -120,10 +124,14 @@ class Basic(commands.Cog): await send(ctx, 'connect', player.channel) @commands.hybrid_command(name="play", aliases=get_aliases("play")) - @app_commands.describe(query="Input a query or a searchable link.") + @app_commands.describe( + query="Input a query or a searchable link.", + start="Specify a time you would like to start, e.g. 1:00", + end="Specify a time you would like to end, e.g. 4:00" + ) @app_commands.autocomplete(query=play_autocomplete) @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) - async def play(self, ctx: commands.Context, *, query: str) -> None: + async def play(self, ctx: commands.Context, *, query: str, start: str = "0", end: str = "0") -> None: "Loads your input and added it to the queue." player: voicelink.Player = ctx.guild.voice_client if not player: @@ -132,16 +140,19 @@ class Basic(commands.Cog): if not player.is_user_join(ctx.author): return await send(ctx, "notInChannel", ctx.author.mention, player.channel.mention, ephemeral=True) + if ctx.interaction: + await ctx.interaction.response.defer() + tracks = await player.get_tracks(query, requester=ctx.author) if not tracks: return await send(ctx, "noTrackFound") try: if isinstance(tracks, voicelink.Playlist): - index = await player.add_track(tracks.tracks) + index = await player.add_track(tracks.tracks, start_time=format_time(start), end_time=format_time(end)) await send(ctx, "playlistLoad", tracks.name, index) else: - position = await player.add_track(tracks[0]) + 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: @@ -172,6 +183,7 @@ class Basic(commands.Cog): if not player.is_user_join(interaction.user): return await send(interaction, "notInChannel", interaction.user.mention, player.channel.mention, ephemeral=True) + await interaction.response.defer() tracks = await player.get_tracks(query, requester=interaction.user) if not tracks: return await send(interaction, "noTrackFound") @@ -183,12 +195,9 @@ 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.response.send_message((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) + 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.response.send_message(e) - - except Exception as e: - return await interaction.response.send_message(e, ephemeral=True) + await interaction.followup.send(e) finally: if not player.is_playing: @@ -248,9 +257,14 @@ class Basic(commands.Cog): await player.do_next() @commands.hybrid_command(name="playtop", aliases=get_aliases("playtop")) - @app_commands.describe(query="Input a query or a searchable link.") + @app_commands.describe( + query="Input a query or a searchable link.", + start="Specify a time you would like to start, e.g. 1:00", + end="Specify a time you would like to end, e.g. 4:00" + ) + @app_commands.autocomplete(query=play_autocomplete) @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) - async def playtop(self, ctx: commands.Context, *, query: str): + async def playtop(self, ctx: commands.Context, *, query: str, start: str = "0", end: str = "0"): "Adds a song with the given url or query on the top of the queue." player: voicelink.Player = ctx.guild.voice_client if not player: @@ -258,17 +272,20 @@ class Basic(commands.Cog): if not player.is_user_join(ctx.author): return await send(ctx, "notInChannel", ctx.author.mention, player.channel.mention, ephemeral=True) - + + if ctx.interaction: + await ctx.interaction.response.defer() + tracks = await player.get_tracks(query, requester=ctx.author) if not tracks: return await send(ctx, "noTrackFound") try: if isinstance(tracks, voicelink.Playlist): - index = await player.add_track(tracks.tracks, at_font=True) + index = await player.add_track(tracks.tracks, start_time=format_time(start), end_time=format_time(end), at_front=True) await send(ctx, "playlistLoad", tracks.name, index) else: - position = await player.add_track(tracks[0], at_font=True) + 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) @@ -280,9 +297,13 @@ class Basic(commands.Cog): await player.do_next() @commands.hybrid_command(name="forceplay", aliases=get_aliases("forceplay")) - @app_commands.describe(query="Input a query or a searchable link.") + @app_commands.describe( + query="Input a query or a searchable link.", + start="Specify a time you would like to start, e.g. 1:00", + end="Specify a time you would like to end, e.g. 4:00" + ) @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) - async def forceplay(self, ctx: commands.Context, query: str): + async def forceplay(self, ctx: commands.Context, *, query: str, start: str = "0", end: str = "0"): "Enforce playback using the given URL or query." player: voicelink.Player = ctx.guild.voice_client if not player: @@ -290,6 +311,9 @@ class Basic(commands.Cog): if not player.is_privileged(ctx.author): return await send(ctx, "missingPerms_function", ephemeral=True) + + if ctx.interaction: + await ctx.interaction.response.defer() tracks = await player.get_tracks(query, requester=ctx.author) if not tracks: @@ -297,11 +321,11 @@ class Basic(commands.Cog): try: if isinstance(tracks, voicelink.Playlist): - index = await player.add_track(tracks.tracks, at_font=True) + index = await player.add_track(tracks.tracks, start_time=format_time(start), end_time=format_time(end), at_front=True) await send(ctx, "playlistLoad", tracks.name, index) else: texts = await get_lang(ctx.guild.id, "live", "trackLoad") - await player.add_track(tracks[0], at_font=True) + 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: @@ -311,7 +335,7 @@ class Basic(commands.Cog): if player.queue._repeat.mode == voicelink.LoopType.track: await player.set_repeat(voicelink.LoopType.off.name) - await player.stop() if player.is_playing else await player.do_next() + await player.stop() if player.is_playing else await player.do_next() @commands.hybrid_command(name="pause", aliases=get_aliases("pause")) @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) @@ -327,15 +351,12 @@ class Basic(commands.Cog): if not player.is_privileged(ctx.author): if ctx.author in player.pause_votes: return await send(ctx, "voted", ephemeral=True) - else: - player.pause_votes.add(ctx.author) - if len(player.pause_votes) >= (required := player.required()): - pass - else: - return await send(ctx, "pauseVote", ctx.author, len(player.pause_votes), required) + + player.pause_votes.add(ctx.author) + if len(player.pause_votes) < (required := player.required()): + return await send(ctx, "pauseVote", ctx.author, len(player.pause_votes), required) await player.set_pause(True, ctx.author) - player.pause_votes.clear() await send(ctx, "paused", ctx.author) @commands.hybrid_command(name="resume", aliases=get_aliases("resume")) @@ -352,15 +373,12 @@ class Basic(commands.Cog): if not player.is_privileged(ctx.author): if ctx.author in player.resume_votes: return await send(ctx, "voted", ephemeral=True) - else: - player.resume_votes.add(ctx.author) - if len(player.resume_votes) >= (required := player.required()): - pass - else: - return await send(ctx, "resumeVote", ctx.author, len(player.resume_votes), required) + + player.resume_votes.add(ctx.author) + if len(player.resume_votes) < (required := player.required()): + return await send(ctx, "resumeVote", ctx.author, len(player.resume_votes), required) await player.set_pause(False, ctx.author) - player.resume_votes.clear() await send(ctx, "resumed", ctx.author) @commands.hybrid_command(name="skip", aliases=get_aliases("skip")) @@ -372,6 +390,9 @@ class Basic(commands.Cog): if not player: return await send(ctx, "noPlayer", ephemeral=True) + if not player.node._available: + return await send(ctx, "nodeReconnect") + if not player.is_playing: return await send(ctx, "skipError", ephemeral=True) @@ -382,19 +403,13 @@ class Basic(commands.Cog): return await send(ctx, "voted", ephemeral=True) else: player.skip_votes.add(ctx.author) - if len(player.skip_votes) >= (required := player.required()): - pass - else: + if len(player.skip_votes) < (required := player.required()): return await send(ctx, "skipVote", ctx.author, len(player.skip_votes), required) - if not player.node._available: - return await send(ctx, "nodeReconnect") - if index: 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) @@ -409,18 +424,16 @@ class Basic(commands.Cog): if not player: return await send(ctx, "noPlayer", ephemeral=True) + if not player.node._available: + return await send(ctx, "nodeReconnectode") + if not player.is_privileged(ctx.author): if ctx.author in player.previous_votes: return await send(ctx, "voted", ephemeral=True) - else: - player.previous_votes.add(ctx.author) - if len(player.previous_votes) >= (required := player.required()): - pass - else: - return await send(ctx, "backVote", ctx.author, len(player.previous_votes), required) - - if not player.node._available: - return await send(ctx, "nnodeReconnectode") + + player.previous_votes.add(ctx.author) + if len(player.previous_votes) < (required := player.required()): + return await send(ctx, "backVote", ctx.author, len(player.previous_votes), required) if not player.is_playing: player.queue.backto(index) @@ -430,7 +443,6 @@ 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) @@ -449,8 +461,7 @@ class Basic(commands.Cog): if not player.current or player.position == 0: return await send(ctx, "noTrackPlaying", ephemeral=True) - num = formatTime(position) - if num is None: + if not (num := format_time(position)): return await send(ctx, "timeFormatError", ephemeral=True) await player.seek(num, ctx.author) @@ -530,7 +541,7 @@ class Basic(commands.Cog): track_ids = bytes.split(b"\n")[-1] track_ids = track_ids.decode().split(",") - tracks = (voicelink.Track(track_id=track_id, info=voicelink.decode(track_id), requester=ctx.author) for track_id in track_ids) + tracks = [voicelink.Track(track_id=track_id, info=voicelink.decode(track_id), requester=ctx.author) for track_id in track_ids] if not tracks: return await send(ctx, "noTrackFound") @@ -540,8 +551,9 @@ class Basic(commands.Cog): except voicelink.QueueFull as e: return await ctx.send(e, ephemeral=True) - except: - return await send(ctx, "decodeError", ephemeral=True) + except Exception as e: + logger.error("error", exc_info=e) + raise e finally: if not player.is_playing: @@ -615,7 +627,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) + await player.set_repeat(mode, ctx.author) await send(ctx, "repeat", mode.capitalize()) @commands.hybrid_command(name="clear", aliases=get_aliases("clear")) @@ -634,13 +646,7 @@ class Basic(commands.Cog): if not player.is_privileged(ctx.author): return await send(ctx, "missingPerms_queue", ephemeral=True) - queue = queue.lower() - if queue == 'history': - player.queue.history_clear(player.is_playing) - else: - queue = "queue" - player.queue.clear() - + await player.clear_queue(queue, ctx.author) await send(ctx, "cleared", queue.capitalize()) @commands.hybrid_command(name="remove", aliases=get_aliases("remove")) @@ -659,17 +665,8 @@ class Basic(commands.Cog): if not player.is_privileged(ctx.author): return await send(ctx, "missingPerms_queue", ephemeral=True) - removedTrack = player.queue.remove(position1, position2, member=member) - - if player.is_ipc_connected and removedTrack: - await player.send_ws({ - "op": "removeTrack", - "positions": [track["position"] for track in removedTrack], - "track_ids": [track["track"].track_id for track in removedTrack], - "current_queue_position": player.queue._position - }, requester=ctx.author) - - await send(ctx, "removed", len(removedTrack)) + removed_tracks = await player.remove_track(position1, position2, remove_target=member, requester=ctx.author) + 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") @@ -685,9 +682,8 @@ class Basic(commands.Cog): if not player.current: return await send(ctx, "noTrackPlaying", ephemeral=True) - - num = formatTime(position) - if num is None: + + if not (num := format_time(position)): return await send(ctx, "timeFormatError", ephemeral=True) await player.seek(int(player.position + num)) @@ -708,8 +704,7 @@ class Basic(commands.Cog): if not player.current: return await send(ctx, "noTrackPlaying", ephemeral=True) - num = formatTime(position) - if num is None: + if not (num := format_time(position)): return await send(ctx, "timeFormatError", ephemeral=True) await player.seek(int(player.position - num)) @@ -743,12 +738,10 @@ class Basic(commands.Cog): if not player.is_privileged(ctx.author): if ctx.author in player.shuffle_votes: return await send(ctx, "voted", ephemeral=True) - else: - player.shuffle_votes.add(ctx.author) - if len(player.shuffle_votes) >= (required := player.required()): - pass - else: - return await send(ctx, "shuffleVote", ctx.author, len(player.shuffle_votes), required) + + player.shuffle_votes.add(ctx.author) + if len(player.shuffle_votes) < (required := player.required()): + return await send(ctx, "shuffleVote", ctx.author, len(player.shuffle_votes), required) await player.shuffle("queue", ctx.author) await send(ctx, "shuffled") @@ -768,12 +761,7 @@ class Basic(commands.Cog): if not player.is_privileged(ctx.author): return await send(ctx, "missingPerms_pos", ephemeral=True) - track1, track2 = player.queue.swap(position1, position2) - await player.send_ws({ - "op": "swapTrack", - "position1": {"index": position1, "track_id": track1.track_id}, - "position2": {"index": position2, "track_id": track2.track_id} - }, requester=ctx.author) + track1, track2 = await player.swap_track(position1, position2, ctx.author) await send(ctx, "swapped", track1.title, track2.title) @commands.hybrid_command(name="move", aliases=get_aliases("move")) @@ -791,12 +779,7 @@ class Basic(commands.Cog): if not player.is_privileged(ctx.author): return await send(ctx, "missingPerms_pos", ephemeral=True) - moved_track = player.queue.move(target, to) - await player.send_ws({ - "op": "moveTrack", - "position": {"index": target, "track_id": moved_track.track_id}, - "newPosition": {"index": to} - }, requester=ctx.author) + moved_track = await player.move_track(target, to, ctx.author) await send(ctx, "moved", moved_track, to) @commands.hybrid_command(name="lyrics", aliases=get_aliases("lyrics")) @@ -861,6 +844,9 @@ class Basic(commands.Cog): if not player.is_playing: await player.do_next() + + if player.is_ipc_connected: + await player.send_ws({"op": "toggleAutoplay", "status": check}) @commands.hybrid_command(name="help", aliases=get_aliases("help")) @app_commands.autocomplete(category=help_autocomplete) diff --git a/cogs/effect.py b/cogs/effect.py index cd4a2b1..5953a9c 100644 --- a/cogs/effect.py +++ b/cogs/effect.py @@ -69,8 +69,10 @@ class Effect(commands.Cog): if player.filters.has_filter(filter_tag="speed"): player.filters.remove_filter(filter_tag="speed") - await player.add_filter(voicelink.Timescale(tag="speed", speed=value)) - await ctx.send(f"You set the speed to **{value}**.") + + effect = voicelink.Timescale(tag="speed", speed=value) + await player.add_filter(effect, ctx.author) + await send(ctx, "addEffect", effect.tag) @commands.hybrid_command(name="karaoke", aliases=get_aliases("karaoke")) @app_commands.describe( @@ -86,8 +88,10 @@ class Effect(commands.Cog): if player.filters.has_filter(filter_tag="karaoke"): player.filters.remove_filter(filter_tag="karaoke") - await player.add_filter(voicelink.Karaoke(tag="karaoke", level=level, mono_level=monolevel, filter_band=filterband, filter_width=filterwidth)) - await send(ctx, "karaoke", level, monolevel, filterband, filterwidth) + + effect = voicelink.Karaoke(tag="karaoke", level=level, mono_level=monolevel, filter_band=filterband, filter_width=filterwidth) + await player.add_filter(effect, ctx.author) + await send(ctx, "addEffect", effect.tag) @commands.hybrid_command(name="tremolo", aliases=get_aliases("tremolo")) @app_commands.describe( @@ -101,8 +105,10 @@ class Effect(commands.Cog): if player.filters.has_filter(filter_tag="tremolo"): player.filters.remove_filter(filter_tag="tremolo") - await player.add_filter(voicelink.Tremolo(tag="tremolo", frequency=frequency, depth=depth)) - await send(ctx, "tremolo&vibrato", frequency, depth) + + effect = voicelink.Tremolo(tag="tremolo", frequency=frequency, depth=depth) + await player.add_filter(effect, ctx.author) + await send(ctx, "addEffect", effect.tag) @commands.hybrid_command(name="vibrato", aliases=get_aliases("vibrato")) @app_commands.describe( @@ -116,8 +122,10 @@ class Effect(commands.Cog): if player.filters.has_filter(filter_tag="vibrato"): player.filters.remove_filter(filter_tag="vibrato") - await player.add_filter(voicelink.Vibrato(tag="vibrato", frequency=frequency, depth=depth)) - await send(ctx, "tremolo&vibrato", frequency, depth) + + effect = voicelink.Vibrato(tag="vibrato", frequency=frequency, depth=depth) + await player.add_filter(effect, ctx.author) + await send(ctx, "addEffect", effect.tag) @commands.hybrid_command(name="rotation", aliases=get_aliases("rotation")) @app_commands.describe(hertz="The hertz of the rotation. Default is `0.2`") @@ -128,8 +136,10 @@ class Effect(commands.Cog): if player.filters.has_filter(filter_tag="rotation"): player.filters.remove_filter(filter_tag="rotation") - await player.add_filter(voicelink.Rotation(tag="rotation", rotation_hertz=hertz)) - await send(ctx, "rotation", hertz) + + effect = voicelink.Rotation(tag="rotation", rotation_hertz=hertz) + await player.add_filter(effect, ctx.author) + await send(ctx, "addEffect", effect.tag) @commands.hybrid_command(name="distortion", aliases=get_aliases("distortion")) @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) @@ -139,8 +149,10 @@ class Effect(commands.Cog): if player.filters.has_filter(filter_tag="distortion"): player.filters.remove_filter(filter_tag="distortion") - await player.add_filter(voicelink.Distortion(tag="distortion", sin_offset=0.0, sin_scale=1.0, cos_offset=0.0, cos_scale=1.0, tan_offset=0.0, tan_scale=1.0, offset=0.0, scale=1.0)) - await send(ctx, "distortion") + + effect = voicelink.Distortion(tag="distortion", sin_offset=0.0, sin_scale=1.0, cos_offset=0.0, cos_scale=1.0, tan_offset=0.0, tan_scale=1.0, offset=0.0, scale=1.0) + await player.add_filter(effect, ctx.author) + await send(ctx, "addEffect", effect.tag) @commands.hybrid_command(name="lowpass", aliases=get_aliases("lowpass")) @app_commands.describe(smoothing="The level of the lowPass. Default is `20.0`") @@ -151,8 +163,10 @@ class Effect(commands.Cog): if player.filters.has_filter(filter_tag="lowpass"): player.filters.remove_filter(filter_tag="lowpass") - await player.add_filter(voicelink.LowPass(tag="lowpass", smoothing=smoothing)) - await send(ctx, "lowpass", smoothing) + + effect = voicelink.LowPass(tag="lowpass", smoothing=smoothing) + await player.add_filter(effect, ctx.author) + await send(ctx, "addEffect", effect.tag) @commands.hybrid_command(name="channelmix", aliases=get_aliases("channelmix")) @app_commands.describe( @@ -168,8 +182,10 @@ class Effect(commands.Cog): if player.filters.has_filter(filter_tag="channelmix"): player.filters.remove_filter(filter_tag="channelmix") - await player.add_filter(voicelink.ChannelMix(tag="channelmix", left_to_left=left_to_left, right_to_right=right_to_right, left_to_right=left_to_right, right_to_left=right_to_left)) - await send(ctx, "channelmix", left_to_left, right_to_right, left_to_right, right_to_left) + + effect = voicelink.ChannelMix(tag="channelmix", left_to_left=left_to_left, right_to_right=right_to_right, left_to_right=left_to_right, right_to_left=right_to_left) + await player.add_filter(effect, ctx.author) + await send(ctx, "addEffect", effect.tag) @commands.hybrid_command(name="nightcore", aliases=get_aliases("nightcore")) @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) @@ -177,8 +193,9 @@ class Effect(commands.Cog): "Add nightcore filter into your player." player = await check_access(ctx) - await player.add_filter(voicelink.Timescale.nightcore()) - await send(ctx, "nightcore") + effect = voicelink.Timescale.nightcore() + await player.add_filter(effect, ctx.author) + await send(ctx, "addEffect", effect.tag) @commands.hybrid_command(name="8d", aliases=get_aliases("8d")) @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) @@ -186,8 +203,9 @@ class Effect(commands.Cog): "Add 8D filter into your player." player = await check_access(ctx) - await player.add_filter(voicelink.Rotation.nightD()) - await send(ctx, "8d") + effect = voicelink.Rotation.nightD() + await player.add_filter(effect, ctx.author) + await send(ctx, "addEffect", effect.tag) @commands.hybrid_command(name="vaporwave", aliases=get_aliases("vaporwave")) @commands.dynamic_cooldown(cooldown_check, commands.BucketType.guild) @@ -195,8 +213,9 @@ class Effect(commands.Cog): "Add vaporwave filter into your player." player = await check_access(ctx) - await player.add_filter(voicelink.Timescale.vaporwave()) - await send(ctx, "vaporwave") + effect = voicelink.Timescale.vaporwave() + await player.add_filter(effect, ctx.author) + await send(ctx, "addEffect", effect.tag) @commands.hybrid_command(name="cleareffect", aliases=get_aliases("cleareffect")) @app_commands.describe(effect="Remove a specific sound effects.") @@ -211,7 +230,7 @@ class Effect(commands.Cog): else: await player.reset_filter() - await send(ctx, "cleareffect") + await send(ctx, "clearEffect") async def setup(bot: commands.Bot) -> None: await bot.add_cog(Effect(bot)) diff --git a/cogs/listeners.py b/cogs/listeners.py index 51957cf..9aef1e7 100644 --- a/cogs/listeners.py +++ b/cogs/listeners.py @@ -44,12 +44,13 @@ class Listeners(commands.Cog): try: await self.voicelink.create_node( bot=self.bot, - spotify_client_id=func.tokens.spotify_client_id, - spotify_client_secret=func.tokens.spotify_client_secret, + spotify_client_id=func.settings.spotify_client_id, + spotify_client_secret=func.settings.spotify_client_secret, + logger=func.logger, **n ) except Exception as e: - print(f'Node {n["identifier"]} is not able to connect! - Reason: {e}') + func.logger.error(f'Node {n["identifier"]} is not able to connect! - Reason: {e}') @commands.Cog.listener() async def on_voicelink_track_end(self, player: voicelink.Player, track, _): @@ -79,8 +80,7 @@ class Listeners(commands.Cog): player: voicelink.Player = member.guild.voice_client if not player: return - - guild = member.guild.id + is_joined = True if not before.channel and after.channel: @@ -94,16 +94,20 @@ class Listeners(commands.Cog): if after.channel.id != player.channel.id: is_joined = False - if player.is_ipc_connected: + if is_joined and player.settings.get("24/7", False): + if player.is_paused and len([m for m in player.channel.members if not m.bot]) == 1: + await player.set_pause(False, member) + + if self.bot.ipc._is_connected: await self.bot.ipc.send({ "op": "updateGuild", "user": { - "user_id": member.id, + "user_id": str(member.id), "avatar_url": member.display_avatar.url, "name": member.name, }, "channel_name": member.voice.channel.name if is_joined else "", - "guild_id": guild, + "guild_id": str(member.guild.id), "is_joined": is_joined }) diff --git a/cogs/playlist.py b/cogs/playlist.py index 8f387b0..4abf0b7 100644 --- a/cogs/playlist.py +++ b/cogs/playlist.py @@ -21,7 +21,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -import discord, voicelink +import discord, voicelink, time from io import StringIO from discord import app_commands @@ -35,13 +35,13 @@ from function import ( get_lang, settings, get_aliases, - cooldown_check + cooldown_check, + logger ) -from datetime import datetime from views import PlaylistView, InboxView, HelpView -def assign_playlistId(existed: list) -> str: +def assign_playlist_id(existed: list) -> str: for i in range(200, 210): if str(i) not in existed: return str(i) @@ -195,18 +195,18 @@ class Playlists(commands.Cog, name="playlist"): text = await get_lang(ctx.guild.id, "playlistViewTitle", "playlistViewHeaders", "playlistFooter") embed = discord.Embed( title=text[0].format(ctx.author.display_name), - description='```prolog\n %4s %10s %10s %10s\n' % tuple(text[1].split(",")), + description='```prolog\n %4s %10s %12s %10s\n' % tuple(text[1].split(",")), color=settings.embed_color ) for index in range(max_p): try: info = results[index] - track_info = (info['emoji'], info['id'], f"[{info['time']}]", info['name'], f"{len(info['tracks'])}/{max_t}") + track_info = (info['emoji'], info['id'], f"[{info['time']}]", info['name'], f"{len(info['tracks'])}") except IndexError: - track_info = ("🎵", "-"*3, "[--:--]", "-"*6, f"-/{max_t}") + track_info = ("🎵", "-"*3, "[--:--]", "-"*6, f"-") - embed.description += '%0s %3s. %10s %10s %10s\n' % track_info + embed.description += '%0s %3s. %10s %12s %10s\n' % track_info embed.description += "```" embed.set_footer(text=text[2]) @@ -240,7 +240,7 @@ class Playlists(commands.Cog, name="playlist"): return await send(ctx, "playlistNotInvaildUrl", 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_playlistId([data for data in user])}": data}}) + await update_user(ctx.author.id, {"$set": {f"playlist.{assign_playlist_id([data for data in user])}": data}}) await send(ctx, "playlistCreated", name) @playlist.command(name="delete", aliases=get_aliases("delete")) @@ -297,7 +297,7 @@ class Playlists(commands.Cog, name="playlist"): {"$push": {"inbox": { 'sender': ctx.author.id, 'referId': result['id'], - 'time': datetime.now(), + 'time': time.time(), 'title': f'Playlist invitation from {ctx.author}', 'description': f"You are invited to use this playlist.\nPlaylist Name: {result['playlist']['name']}\nPlaylist type: {result['playlist']['type']}", 'type': 'invite' @@ -352,11 +352,12 @@ class Playlists(commands.Cog, name="playlist"): update_data, dId = {}, {dId for dId in user["playlist"]} for data in view.newplaylist[:(max_p - len(user['playlist']))]: - addId = assign_playlistId(dId) + 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}'] = { 'user': data['sender'], 'referId': data['referId'], - 'name': f"Share{data['time'].strftime('%M%S')}", 'type': 'share' + 'name': f"Share{time.strftime('%M%S', time.gmtime(int(data['time'])))}", + 'type': 'share' } update_data["inbox"] = view.inbox dId.add(addId) @@ -501,11 +502,12 @@ class Playlists(commands.Cog, name="playlist"): track_ids = track_ids.decode().split(",") data = {'tracks': track_ids, 'perms': {'read': [], 'write': [], 'remove': []}, 'name': name, 'type': 'playlist'} - await update_user(ctx.author.id, {"$set": {f"playlist.{assign_playlistId([data for data in user])}": data}}) + await update_user(ctx.author.id, {"$set": {f"playlist.{assign_playlist_id([data for data in user])}": data}}) await send(ctx, 'playlistCreated', name) - except: - return await send(ctx, "decodeError", ephemeral=True) + except Exception as e: + logger.error("Decode Error", exc_info=e) + raise e async def setup(bot: commands.Bot) -> None: await bot.add_cog(Playlists(bot)) \ No newline at end of file diff --git a/cogs/settings.py b/cogs/settings.py index afbbfb4..543089f 100644 --- a/cogs/settings.py +++ b/cogs/settings.py @@ -26,7 +26,6 @@ import voicelink import psutil import function as func -from typing import Tuple from discord import app_commands from discord.ext import commands from function import ( @@ -37,16 +36,14 @@ from function import ( get_lang, time as ctime, get_aliases, - cooldown_check + cooldown_check, + format_bytes ) + from views import DebugView, HelpView, EmbedBuilderView -def formatBytes(bytes: int, unit: bool = False): - if bytes <= 1_000_000_000: - return f"{bytes / (1024 ** 2):.1f}" + ("MB" if unit else "") - - else: - return f"{bytes / (1024 ** 3):.1f}" + ("GB" if unit else "") +def status_icon(status: bool) -> str: + return "✅" if status else "❌" class Settings(commands.Cog, name="settings"): def __init__(self, bot) -> None: @@ -137,17 +134,18 @@ class Settings(commands.Cog, name="settings"): "Show all the bot settings in your server." settings = await get_settings(ctx.guild.id) - texts = await get_lang(ctx.guild.id, "settingsMenu", "settingsTitle", "settingsValue", "settingsTitle2", "settingsValue2", "settingsPermTitle", "settingsPermValue") + texts = await get_lang(ctx.guild.id, "settingsMenu", "settingsTitle", "settingsValue", "settingsTitle2", "settingsValue2", "settingsTitle3", "settingsPermTitle", "settingsPermValue") embed = discord.Embed(color=func.settings.embed_color) embed.set_author(name=texts[0].format(ctx.guild.name), icon_url=self.bot.user.display_avatar.url) if ctx.guild.icon: embed.set_thumbnail(url=ctx.guild.icon.url) + dj_role = ctx.guild.get_role(settings.get('dj', 0)) embed.add_field(name=texts[1], value=texts[2].format( - settings.get('prefix', func.settings.bot_prefix) or "None", + settings.get('prefix', func.settings.bot_prefix) or 'None', settings.get('lang', 'EN'), settings.get('controller', True), - f"<@&{settings['dj']}>" if 'dj' in settings else '`None`', + dj_role.name if dj_role else 'None', settings.get('votedisable', False), settings.get('24/7', False), settings.get('volume', 100), @@ -160,12 +158,17 @@ class Settings(commands.Cog, name="settings"): settings.get("duplicateTrack", True) )) + if stage_template := settings.get("stage_announce_template"): + embed.add_field(name=texts[5], value=f"```{stage_template}```", inline=False) + perms = ctx.guild.me.guild_permissions - embed.add_field(name=texts[5], value=texts[6].format( - '' if perms.administrator else '', - '' if perms.manage_guild else '', - '' if perms.manage_channels else '', - '' if perms.manage_messages else ''), inline=False + embed.add_field(name=texts[6], value=texts[7].format( + status_icon(perms.administrator), + status_icon(perms.manage_guild), + status_icon(perms.manage_channels), + status_icon(perms.manage_messages) + ), + inline=False ) await ctx.send(embed=embed) @@ -236,25 +239,34 @@ class Settings(commands.Cog, name="settings"): await update_settings(ctx.guild.id, {"$set": {'controller_msg': toggle}}) await send(ctx, 'toggleControllerMsg', await get_lang(ctx.guild.id, "enabled" if toggle else "disabled")) + @settings.command(name="stageannounce", aliases=get_aliases("stageannounce")) + @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""" + await update_settings(ctx.guild.id, {"$set": {'stage_announce_template': template}}) + await send(ctx, "SetStageAnnounceTemplate") + @app_commands.command(name="debug") async def debug(self, interaction: discord.Interaction): if interaction.user.id not in func.settings.bot_access_user: return await interaction.response.send_message("You are not able to use this command!") memory = psutil.virtual_memory() - disk = psutil.disk_usage('/') + disk = psutil.disk_usage(func.ROOT_DIR) available_memory, total_memory = memory.available, memory.total used_disk_space, total_disk_space = disk.used, disk.total embed = discord.Embed(title="📄 Debug Panel", color=func.settings.embed_color) embed.description = "```== System Info ==\n" \ f"• CPU: {psutil.cpu_freq().current}Mhz ({psutil.cpu_percent()}%)\n" \ - f"• RAM: {formatBytes(total_memory - available_memory)}/{formatBytes(total_memory, True)} ({memory.percent}%)\n" \ - f"• DISK: {formatBytes(total_disk_space - used_disk_space)}/{formatBytes(total_disk_space, True)} ({disk.percent}%)```" + f"• RAM: {format_bytes(total_memory - available_memory)}/{format_bytes(total_memory, True)} ({memory.percent}%)\n" \ + f"• DISK: {format_bytes(total_disk_space - used_disk_space)}/{format_bytes(total_disk_space, True)} ({disk.percent}%)```" embed.add_field( name="🤖 Bot Information", - value=f"```• LATENCY: {self.bot.latency:.2f}ms\n" \ + 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"• PLAYERS: {len(self.bot.voice_clients)}```", @@ -263,17 +275,23 @@ class Settings(commands.Cog, name="settings"): node: voicelink.Node for name, node in voicelink.NodePool._nodes.items(): - total_memory = node.stats.used + node.stats.free - embed.add_field( - name=f"{name} Node - " + ("🟢 Connected" if node._available else "🔴 Disconnected"), - value=f"```• ADDRESS: {node._host}:{node._port}\n" \ - f"• PLAYERS: {len(node._players)}\n" \ - f"• CPU: {node.stats.cpu_process_load:.1f}%\n" \ - f"• RAM: {formatBytes(node.stats.free)}/{formatBytes(total_memory, True)} ({(node.stats.free/total_memory) * 100:.1f}%)\n" - f"• LATENCY: {node.latency:.2f}ms\n" \ - f"• UPTIME: {func.time(node.stats.uptime)}```", - inline=True - ) + if node._available: + total_memory = node.stats.used + node.stats.free + embed.add_field( + name=f"{name} Node - 🟢 Connected", + value=f"```• ADDRESS: {node._host}:{node._port}\n" \ + f"• PLAYERS: {len(node._players)}\n" \ + f"• CPU: {node.stats.cpu_process_load:.1f}%\n" \ + f"• RAM: {format_bytes(node.stats.free)}/{format_bytes(total_memory, True)} ({(node.stats.free/total_memory) * 100:.1f}%)\n" + f"• LATENCY: {node.latency:.2f}ms\n" \ + f"• UPTIME: {func.time(node.stats.uptime)}```" + ) + else: + embed.add_field( + name=f"{name} Node - 🔴 Disconnected", + value=f"```• ADDRESS: {node._host}:{node._port}\n" \ + f"• PLAYERS: {len(node._players)}\nNo extra data is available for display```", + ) await interaction.response.send_message(embed=embed, view=DebugView(self.bot), ephemeral=True) diff --git a/cogs/task.py b/cogs/task.py index 8ee4dac..7ed0db7 100644 --- a/cogs/task.py +++ b/cogs/task.py @@ -26,7 +26,6 @@ import discord import function as func from discord.ext import commands, tasks -from datetime import datetime from addons import Placeholders class Task(commands.Cog): @@ -36,12 +35,6 @@ class Task(commands.Cog): self.player_check.start() self.cache_cleaner.start() - self.act_type = { - "play": discord.ActivityType.playing, - "listen": discord.ActivityType.listening, - "watch": discord.ActivityType.watching, - "stream": discord.ActivityType.streaming - } self.current_act = 0 self.placeholder = Placeholders(bot) @@ -56,18 +49,21 @@ class Task(commands.Cog): try: act_data = func.settings.activity[(self.current_act + 1) % len(func.settings.activity) - 1] - act_original = self.bot.activity - act_type = self.act_type.get(list(act_data.keys())[0].lower(), discord.ActivityType.playing) - act_name = self.placeholder.replace(list(act_data.values())[0]) + act_type = getattr(discord.ActivityType, act_data.get("type", "").lower(), discord.ActivityType.playing) + act_name = self.placeholder.replace(act_data.get("name", "")) + + status_type = getattr(discord.Status, act_data.get("status", "").lower(), None) if act_original.type != act_type or act_original.name != act_name: - new_act = discord.Activity(type=act_type, name=act_name) - await self.bot.change_presence(activity=new_act) + self.bot.activity = discord.Activity(type=act_type, name=act_name) + await self.bot.change_presence(activity=self.bot.activity, status=status_type) self.current_act = (self.current_act + 1) % len(func.settings.activity) - except: - pass + func.logger.info(f"Changed the bot status to {act_name}") + + except Exception as e: + func.logger.error("Error occurred while changing the bot status!", exc_info=e) @tasks.loop(minutes=5.0) async def player_check(self): @@ -84,44 +80,35 @@ class Task(commands.Cog): await player.teardown() continue - members = player.channel.members - if (not player.is_playing and player.queue.is_empty) or not any(False if member.bot or member.voice.self_deaf else True for member in members): - if not player.settings.get('24/7', False): - await player.teardown() - continue - else: - if not player.is_paused: - await player.set_pause(True) - else: - if not player.guild.me: - await player.teardown() - continue - elif not player.guild.me.voice: - await player.connect(timeout=0.0, reconnect=True) - try: + members = player.channel.members + if (not player.is_playing and player.queue.is_empty) or not any(False if member.bot or member.voice.self_deaf else True for member in members): + if not player.settings.get('24/7', False): + await player.teardown() + continue + else: + if not player.is_paused: + await player.set_pause(True) + else: + if not player.guild.me: + await player.teardown() + continue + elif not player.guild.me.voice: + await player.connect(timeout=0.0, reconnect=True) + if player.dj not in members: for m in members: if not m.bot: player.dj = m break - except: - pass + + 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() func.USERS_BUFFER.clear() - errorFile = func.gen_report() - if errorFile: - report_channel = self.bot.get_channel(func.report_channel_id) - if report_channel: - try: - await report_channel.send(content=f"Report Before: ", file=errorFile) - except Exception as e: - print(f"Report could not be sent (Reason: {e})") - func.ERROR_LOGS.clear() - async def setup(bot: commands.Bot): - await bot.add_cog(Task(bot)) + await bot.add_cog(Task(bot)) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 58601f9..07edf48 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,24 +9,25 @@ services: - _JAVA_OPTIONS=-Xmx1G - SERVER_PORT=2333 - LAVALINK_SERVER_PASSWORD=youshallnotpass - healthcheck: - test: 'curl -H "Authorization: youshallnotpass" -s http://localhost:2333/version' - interval: 10s - timeout: 10s - retries: 5 - start_period: 10s 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 networks: - local expose: - "2333" + # # 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: @@ -39,21 +40,19 @@ services: # command: ["mongod", "--oplogSize=1024", "--wiredTigerCacheSizeGB=1", "--auth", "--noscripting"] vocard: - image: 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 build: dockerfile: ./Dockerfile - volumes: - - ./settings.json:/app/settings.json - - ./.env:/app/.env - - ./logs/supervisor/:/var/log depends_on: lavalink: - condition: service_healthy + condition: service_started # mongo: # condition: service_started - ports: - - "5555:5555" networks: - local diff --git a/function.py b/function.py index 06bffdb..6e09f5e 100644 --- a/function.py +++ b/function.py @@ -1,11 +1,15 @@ -import discord, json, os, copy +import discord, json, os, copy, logging from discord.ext import commands -from datetime import datetime from time import strptime -from io import BytesIO -from typing import Optional, Union, Dict, Any -from addons import Settings, TOKENS +from addons import Settings + +from typing import ( + Optional, + Union, + Dict, + Any +) from motor.motor_asyncio import ( AsyncIOMotorClient, @@ -18,20 +22,19 @@ if not os.path.exists(os.path.join(ROOT_DIR, "settings.json")): raise Exception("Settings file not set!") #--------------- Cache Var --------------- -tokens: TOKENS = TOKENS() settings: Settings +logger: logging.Logger = logging.getLogger("vocard") MONGO_DB: AsyncIOMotorClient SETTINGS_DB: AsyncIOMotorCollection USERS_DB: AsyncIOMotorCollection -ERROR_LOGS: dict[int, dict[int, str]] = {} #Stores error that not a Voicelink Exception LANGS: dict[str, dict[str, str]] = {} #Stores all the languages in ./langs LOCAL_LANGS: dict[str, dict[str, str]] = {} #Stores all the localization languages in ./local_langs SETTINGS_BUFFER: dict[int, dict[str, Any]] = {} #Cache guild language USERS_BUFFER: dict[str, dict] = {} -USERS_BASE: dict[str, Any] = { +USER_BASE: dict[str, Any] = { 'playlist': { '200': { 'tracks':[], @@ -75,16 +78,20 @@ def langs_setup() -> None: return -def time(millis:int) -> str: - seconds=(millis/1000)%60 - minutes=(millis/(1000*60))%60 - hours=(millis/(1000*60*60))%24 - if hours > 1: - return "%02d:%02d:%02d" % (hours, minutes, seconds) +def time(millis: int) -> str: + seconds = (millis // 1000) % 60 + minutes = (millis // (1000 * 60)) % 60 + hours = (millis // (1000 * 60 * 60)) % 24 + days = millis // (1000 * 60 * 60 * 24) + + if days > 0: + return "%d days, %02d:%02d:%02d" % (days, hours, minutes, seconds) + elif hours > 0: + return "%d:%02d:%02d" % (hours, minutes, seconds) else: return "%02d:%02d" % (minutes, seconds) -def formatTime(number:str) -> Optional[int]: +def format_time(number:str) -> int: try: try: num = strptime(number, '%M:%S') @@ -94,7 +101,7 @@ def formatTime(number:str) -> Optional[int]: except ValueError: num = strptime(number, '%H:%M:%S') except: - return None + return 0 return (int(num.tm_hour) * 3600 + int(num.tm_min) * 60 + int(num.tm_sec)) * 1000 @@ -102,21 +109,6 @@ 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)) -def gen_report() -> Optional[discord.File]: - if ERROR_LOGS: - errorText = "" - for guild_id, error in ERROR_LOGS.items(): - errorText += f"Guild ID: {guild_id}\n" + "-" * 30 + "\n" - for index, (key, value) in enumerate(error.items() , start=1): - errorText += f"Error No: {index}, Time: {datetime.fromtimestamp(key)}\n" + value + "-" * 30 + "\n\n" - - buffer = BytesIO(errorText.encode('utf-8')) - file = discord.File(buffer, filename='report.txt') - buffer.close() - - return file - return None - def cooldown_check(ctx: commands.Context) -> Optional[commands.Cooldown]: if ctx.author.id in settings.bot_access_user: return None @@ -144,6 +136,13 @@ def get_lang_non_async(guild_id: int, *keys) -> Union[list[str], str]: 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] +def format_bytes(bytes: int, unit: bool = False): + if bytes <= 1_000_000_000: + return f"{bytes / (1024 ** 2):.1f}" + ("MB" if unit else "") + + else: + return f"{bytes / (1024 ** 3):.1f}" + ("GB" if unit else "") + async def get_lang(guild_id:int, *keys) -> Union[list[str], str]: settings = await get_settings(guild_id) lang = settings.get("lang", "EN") @@ -158,7 +157,15 @@ async def send(ctx: Union[commands.Context, discord.Interaction], key: str, *par text = await get_lang(ctx.guild.id, key) text = text.format(*params) - send_func = ctx.send if isinstance(ctx, commands.Context) else (ctx.followup.send if ctx.response.is_done() else ctx.response.send_message) + if isinstance(ctx, commands.Context): + send_func = ctx.send + else: + if not ctx.response.is_done(): + send_func = ctx.response.send_message + + else: + return await ctx.followup.send(text, ephemeral=ephemeral, allowed_mentions=ALLOWED_MENTIONS) + return await send_func(text, delete_after=delete_after, ephemeral=ephemeral, allowed_mentions=ALLOWED_MENTIONS) async def update_db(db: AsyncIOMotorCollection, tempStore: dict, filter: dict, data: dict) -> bool: @@ -225,13 +232,13 @@ async def get_user(user_id: int, d_type: Optional[str] = None, need_copy: bool = if not user: user = await USERS_DB.find_one({"_id": user_id}) if not user: - user = {"_id": user_id, **USERS_BASE} + user = {"_id": user_id, **USER_BASE} await USERS_DB.insert_one(user) USERS_BUFFER[user_id] = user if d_type: - user = user.setdefault(d_type, copy.deepcopy(USERS_BASE.get(d_type))) + user = user.setdefault(d_type, copy.deepcopy(USER_BASE.get(d_type))) return copy.deepcopy(user) if need_copy else user diff --git a/ipc/__init__.py b/ipc/__init__.py new file mode 100644 index 0000000..f29640f --- /dev/null +++ b/ipc/__init__.py @@ -0,0 +1 @@ +from .client import IPCClient \ No newline at end of file diff --git a/ipc/client.py b/ipc/client.py new file mode 100644 index 0000000..a1b2513 --- /dev/null +++ b/ipc/client.py @@ -0,0 +1,110 @@ +import aiohttp +import asyncio +import logging +import function as func + +from discord.ext import commands +from typing import Optional + +from .methods import process_methods + +class IPCClient: + def __init__( + self, + bot: commands.Bot, + host: str, + port: int, + password: str, + heartbeat: int = 30, + secure: bool = False, + *arg, + **kwargs + ) -> None: + + self._bot: commands.Bot = bot + self._host: str = host + self._port: int = port + self._password: str = password + self._heartbeat: int = heartbeat + self._is_secure: bool = secure + self._is_connected: bool = False + self._is_connecting: bool = False + self._logger: logging.Logger = logging.getLogger("ipc_client") + + self._websocket_url: str = f"{'wss' if self._is_secure else 'ws'}://{self._host}:{self._port}/ws_bot" + self._session: Optional[aiohttp.ClientSession] = None + self._websocket: Optional[aiohttp.ClientWebSocketResponse] = None + self._task: Optional[asyncio.Task] = None + + self._heanders = { + "Authorization": self._password, + "User-Id": str(bot.user.id), + "Client-Version": func.settings.version + } + + async def _listen(self) -> None: + while True: + try: + msg = await self._websocket.receive() + self._logger.debug(f"Received Message: {msg}") + except: + break + + if msg.type in [aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED]: + self._is_connected = False + self._logger.info("Connection closed. Trying to reconnect in 10s.") + await asyncio.sleep(10) + + if not self._is_connected: + try: + await self.connect() + except Exception as e: + self._logger.error("Reconnection failed.") + else: + self._bot.loop.create_task(process_methods(self, self._bot, msg.json())) + + async def send(self, data: dict): + if self.is_connected: + self._logger.debug(f"Send Message: {data}") + await self._websocket.send_json(data) + + async def connect(self): + try: + if not self._session: + self._session = aiohttp.ClientSession() + + if self._is_connecting or self._is_connected: + return + + self._is_connecting = True + self._websocket = await self._session.ws_connect( + self._websocket_url, headers=self._heanders, heartbeat=self._heartbeat + ) + + self._task = self._bot.loop.create_task(self._listen()) + self._is_connected = True + + self._logger.info("Connected to dashboard!") + + except aiohttp.ClientConnectorError: + raise Exception("Connection failed.") + + except aiohttp.WSServerHandshakeError as e: + self._logger.error("Access forbidden: Missing bot ID, version mismatch, or invalid password.") + + except Exception as e: + self._logger.error("Error occurred while connecting to dashboard.", exc_info=e) + + finally: + self._is_connecting = False + + return self + + async def disconnect(self) -> None: + self._is_connected = False + self._task.cancel() + self._logger.info("Disconnected to dashboard!") + + @property + def is_connected(self) -> bool: + return self._is_connected \ No newline at end of file diff --git a/ipc/methods.py b/ipc/methods.py new file mode 100644 index 0000000..9522d5e --- /dev/null +++ b/ipc/methods.py @@ -0,0 +1,787 @@ +import time, re +import function as func + +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 + +RATELIMIT_COUNTER: Dict[int, Dict[str, float]] = {} +SCOPES = { + "prefix": str, + "lang": str, + "queueType": str, + "dj": int, + "controller": bool, + "24/7": bool, + "votedisable": bool, + "duplicateTrack": bool, + "default_controller": dict, + "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 + self.params: List[str] = ["bot", "data"] + self.credit: int = credit + +class PlayerMethod(SystemMethod): + def __init__(self, function, *, credit: int = 1, auto_connect: bool = False): + super().__init__(function, credit=credit) + self.params: List[str] = ["player", "member", "data"] + self.auto_connect: bool = auto_connect + +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) + if not player.is_privileged(member): + return error_msg("Only the DJ or admins may use this funciton!", user_id=member.id) + return await func(player, member, dict) + return wrapper + return decorator + +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) + if guild_id: + payload["guild_id"] = str(guild_id) + + return payload + +async def connect_channel(member: Member, bot: commands.Bot) -> Player: + if not member.voice: + return + + 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]}) + return player + except: + return + +async def initBot(bot: commands.Bot, data: Dict) -> Dict: + user_id = int(data.get("user_id")) + user = bot.get_user(user_id) + if not user: + user = await bot.fetch_user(user_id) + + 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) + } + +async def initUser(bot: commands.Bot, data: Dict) -> Dict: + user_id = int(data.get("user_id")) + data = await func.get_user(user_id) + + for mail in data.get("inbox"): + sender = bot.get_user(mail.get("sender")) + if not sender: + sender = await bot.fetch_user(mail.get("sender")) + + if not sender: + data.get("inbox").remove(mail) + + mail["sender"] = {"avatar_url": sender.display_avatar.url, "name": sender.display_name, "id": str(sender.id)} + + return { + "op": "initUser", + "user_id": str(user_id), + "data": data + } + +async def initPlayer(player: Player, member: Member, data: Dict) -> Dict: + player._ipc_connection = True + available_filters = [] + for name, filter_cls in Filters.get_available_filters().items(): + filter = filter_cls() + available_filters.append({"tag": name, "scope": filter.scope, "payload": filter.payload}) + + return { + "op": "initPlayer", + "guild_id": str(player.guild.id), + "user_id": str(data.get("user_id")), + "users": [{ + "user_id": str(member.id), + "avatar_url": 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), + "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 + } + +async def closeConnection(bot: commands.Bot, data: Dict) -> None: + guild_id = int(data.get("guild_id")) + guild = bot.get_guild(guild_id) + player: Player = guild.voice_client + if player: + player._ipc_connection = False + +async def getRecommendation(bot: commands.Bot, data: Dict) -> None: + node = NodePool.get_node() + if not node: + return + + track_data = decode(track_id := data.get("track_id")) + 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")), + "callback": data.get("callback"), + "tracks": [track.track_id for track in tracks] if tracks else [] + } + +async def skipTo(player: Player, member: Member, data: Dict) -> None: + if not player.is_privileged(member): + if player.current and member == player.current.requester: + pass + + elif member in player.skip_votes: + return error_msg(player.get_msg('voted'), user_id=member.id) + + else: + player.skip_votes.add(member) + if len(player.skip_votes) < (required := player.required()): + return error_msg(player.get_msg('skipVote').format(member, len(player.skip_votes), required), guild_id=player.guild.id) + + index = data.get("index", 1) + if index > 1: + player.queue.skipto(index) + + if player.queue._repeat.mode == LoopType.track: + await player.set_repeat(LoopType.off.name) + await player.stop() + +async def backTo(player: Player, member: Member, data: Dict) -> None: + if not player.is_privileged(member): + if player.current and member == player.current.requester: + pass + + elif member in player.skip_votes: + return error_msg(player.get_msg('voted'), user_id=member.id) + + else: + player.skip_votes.add(member) + if len(player.skip_votes) < (required := player.required()): + return error_msg(player.get_msg('backVote').format(member, len(player.skip_votes), required), guild_id=player.guild.id) + + index = data.get("index", 1) + if not player.is_playing: + player.queue.backto(index) + await player.do_next() + else: + player.queue.backto(index + 1) + await player.stop() + +@require_permission() +async def moveTrack(player: Player, member: Member, data: Dict) -> None: + index = data.get("index") + new_index = data.get("newIndex") + if index == new_index: + return + + await player.move_track(index, new_index, member) + +async def addTracks(player: Player, member: Member, data: Dict) -> None: + _type = data.get("type", "addToQueue") + tracks = [Track( + track_id=track_id, + info=decode(track_id), + requester=member + ) for track_id in data.get("tracks", [])] + + if _type == "addToQueue": + await player.add_track(tracks) + + elif _type == "forcePlay": + await player.add_track(tracks, at_front=True) + if player.is_playing: + return await player.stop() + + elif _type == "addNext": + await player.add_track(tracks, at_front=True) + + if not player.is_playing: + await player.do_next() + +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")} + 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 shuffleTrack(player: Player, member: Member, data: Dict) -> None: + if not player.is_privileged(member): + if member in player.shuffle_votes: + return error_msg(player.get_msg('voted'), user_id=member.id) + + player.shuffle_votes.add(member) + if len(player.shuffle_votes) < (required := player.required()): + return error_msg(player.get_msg('shuffleVote').format(member, len(player.skip_votes), required), guild_id=player.guild.id) + + await player.shuffle(data.get("type", "queue"), member) + +@require_permission() +async def repeatTrack(player: Player, member: Member, data: Dict) -> None: + await player.set_repeat(requester=member) + +@require_permission() +async def removeTrack(player: Player, member: Member, data: Dict) -> None: + index, index2 = data.get("index"), data.get("index2") + await player.remove_track(index, index2, requester=member) + +@require_permission() +async def clearQueue(player: Player, member: Member, data: Dict) -> None: + queue_type = data.get("queue_type", "").lower() + await player.clear_queue(queue_type, member) + +@require_permission(only_admin=True) +async def updateVolume(player: Player, member: Member, data: Dict) -> None: + volume = data.get("volume", 100) + await func.update_settings(player.guild.id, {"$set": {"volume": volume}}) + await player.set_volume(volume=volume, requester=member) + +async def updatePause(player: Player, member: Member, data: Dict) -> None: + pause = data.get("pause", True) + if not player.is_privileged(member): + if pause: + if member in player.pause_votes: + return error_msg(player.get_msg('voted'), user_id=member.id) + + player.pause_votes.add(member) + if len(player.pause_votes) < (required := player.required()): + return error_msg(player.get_msg('pauseVote').format(member, len(player.pause_votes), required), guild_id=player.guild.id) + + else: + if member in player.resume_votes: + return error_msg(player.get_msg('voted'), user_id=member.id) + + player.resume_votes.add(member) + if len(player.resume_votes) < (required := player.required()): + return error_msg(player.get_msg('resumeVote').format(member, len(player.resume_votes), required), guild_id=player.guild.id) + + await player.set_pause(pause, member) + +@require_permission() +async def updatePosition(player: Player, member: Member, data: Dict) -> None: + position = data.get("position"); + await player.seek(position, member); + +async def toggleAutoplay(player: Player, member: Member, data: Dict) -> Dict: + if not player.is_privileged(member): + return error_msg(player.get_msg('missingPerms_autoplay')) + + check = data.get("status", False) + player.settings['autoplay'] = check + + if not player.is_playing: + await player.do_next() + + return { + "op": "toggleAutoplay", + "status": check, + "guild_id": player.guild.id, + "requester_id": str(member.id) + } + +@require_permission() +async def updateFilter(player: Player, member: Member, data: Dict) -> None: + updateType = data.get("type", "add") + filter_tag = data.get("tag") + + if updateType == "add": + available_filters = Filters.get_available_filters() + filter_cls = available_filters.get(filter_tag) + if not filter_cls: + return + + payload = {} + if "payload" in data: + payload = data.get("payload").get(list(data.get("payload").keys()[0]), {}) + if player.filters.has_filter(filter_tag=filter_tag): + player.filters.remove_filter(filter_tag=filter_tag) + await player.add_filter(filter=filter_cls(**payload), requester=member) + + elif updateType == "remove": + await player.remove_filter(filter_tag=filter_tag, requester=member) + + else: + await player.reset_filter(requester=member) + +async def _loadPlaylist(playlist: Dict) -> Optional[List[Track]]: + if playlist.get("type") == "link": + tracks: List[Track]= await NodePool.get_node().get_tracks(playlist.get("uri"), requester=None) + if tracks: + return [track.track_id for track in (tracks.tracks if isinstance(tracks, Playlist) else tracks)] + else: + return playlist.get("tracks", []) + +def _assign_playlist_id(existed: list) -> str: + for i in range(200, 210): + if str(i) not in existed: + return str(i) + +async def _getPlaylist(user_id: int, playlist_id: str) -> Dict: + playlists = await func.get_user(user_id, "playlist") + playlist = playlists.get(playlist_id) + if not playlist: + return + + if playlist["type"] == "share": + target_user = await func.get_user(playlist["user"], "playlist") + target_playlist = target_user.get(playlist["referId"]) + if target_playlist and user_id in target_playlist.get("perms", {}).get("read", []): + playlist["tracks"] = await _loadPlaylist(target_playlist) + else: + playlist["tracks"] = await _loadPlaylist(playlist) + + 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")) + + payload = {"op": "loadPlaylist", "playlist_id": playlist_id, "user_id": 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")) + _type = data.get("type") + + rank, max_p, max_t = func.check_roles() + if _type == "createPlaylist": + name, playlist_url = data.get("name"), data.get("playlist_url") + 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) + } + + playlist = await func.get_user(user_id, "playlist") + if len(list(playlist.keys())) >= max_p: + return { + "op": "updatePlaylist", + "status": "error", + "msg": f"You cannot create more than '{max_p}' playlists!", + "field": "create-playlist-name", + "user_id": str(user_id) + } + + for playlist_data in playlist.values(): + if playlist_data['name'].lower() == name.lower(): + return { + "op": "updatePlaylist", + "status": "error", + "msg": f"Playlist '{name}' already exists.", + "field": "create-playlist-name", + "user_id": str(user_id) + } + + if playlist_url: + tracks = await NodePool.get_node().get_tracks(playlist_url, requester=None) + if not isinstance(tracks, Playlist): + return { + "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) + } + + assgined_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}}) + return { + "op": "updatePlaylist", + "status": "created", + "playlist_id": assgined_playlist_id, + "msg": f"You have created '{name}' playlist.", + "user_id": str(user_id), + "data": data + } + + elif _type == "removePlaylist": + playlist = await _getPlaylist(user_id, playlist_id) + if playlist: + if playlist['type'] == 'share': + await func.update_user(playlist['user'], {"$pull": {f"playlist.{playlist['referId']}.perms.read": user_id}}) + + await func.update_user(user_id, {"$unset": {f"playlist.{playlist_id}": 1}}) + + return { + "op": "updatePlaylist", + "status": "deleted", + "playlist_id": playlist_id, + "msg": f"You have removed playlist '{playlist['name']}'", + "user_id": str(user_id) + } + + elif _type == "renamePlaylist": + name = data.get("name") + if not name: + return { + "op": "updatePlaylist", + "status": "error", + "msg": f"You must enter name for this field!", + "field": "rename-playlist-name", + "user_id": str(user_id) + } + + playlist = await func.get_user(user_id, "playlist") + for data in playlist.values(): + if data['name'].lower() == name.lower(): + return { + "op": "updatePlaylist", + "status": "error", + "msg": f"Playlist '{data['name']}' already exists.", + "field": "rename-playlist-name", + "user_id": str(user_id) + } + + await func.update_user(user_id, {"$set": {f'playlist.{playlist_id}.name': name}}) + return { + "op": "updatePlaylist", + "status": "renamed", + "name": name, + "playlist_id": playlist_id, + "msg": f"You have renamed the playlist to '{name}'.", + "field": "rename-playlist-name", + "user_id": str(user_id) + } + + elif _type == "addTrack": + track_id = data.get("track_id") + 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 playlist['type'] in ['share', 'link']: + return error_msg("You cannot add songs to a linked playlist through Vocard.", user_id=user_id, level='error') + + rank, max_p, max_t = func.check_roles() + if len(playlist['tracks']) >= max_t: + return error_msg(f"You have reached the limit! You can only add {max_t} songs to your playlist.", user_id=user_id) + + decoded_track = Track(track_id=track_id, info=decode(track_id), requester=None) + if decoded_track.is_stream: + return error_msg("You are not allowed to add streaming videos to your playlist.", user_id=user_id) + + await func.update_user(user_id, {"$push": {f'playlist.{playlist_id}.tracks': track_id}}) + return { + "op": "updatePlaylist", + "status": "addTrack", + "playlist_id": playlist_id, + "track_id": track_id, + "msg": f"Added {decoded_track.title} into '{playlist['name']}' playlist.", + "user_id": str(user_id) + } + + elif _type == "removeTrack": + track_id, track_position = data.get("track_id"), data.get("track_position", 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 playlist['type'] in ['share', 'link']: + return error_msg("You cannot remove songs from a linked playlist through Vocard.", user_id=user_id, level='error') + + if not 0 <= track_position < len(playlist['tracks']): + return error_msg("Cannot find the position from your playlist.", user_id=user_id, level="error") + + if playlist['tracks'][track_position] != track_id: + return error_msg("Something wrong while removing the track from your playlist.", user_id=user_id, level='error') + + await func.update_user(user_id, {"$pull": {f'playlist.{playlist_id}.tracks': playlist['tracks'][track_position]}}) + + decoded_track = decode(playlist['tracks'][track_position]) + return { + "op": "updatePlaylist", + "status": "removeTrack", + "playlist_id": playlist_id, + "track_position": track_position, + "track_id": track_id, + "msg": f"Removed '{decoded_track['title']}' from '{playlist['name']}' playlist.", + "user_id": str(user_id) + } + + elif _type == "updateInbox": + user = await func.get_user(user_id) + is_accpet = data.get("accept", False) + + if is_accpet 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("-") + 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} + 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: + 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())) + playlist_name = f"Share{time.strftime('%M%S', time.gmtime(int(mail['time'])))}" + share_playlist = share_playlists.get(refer_id) + share_playlist.update({ + "name": playlist_name, + "type": "share" + }) + 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}': { + 'user': mail['sender'], 'referId': mail['referId'], + 'name': playlist_name, + 'type': 'share' + }, + "inbox": inbox + }}) + + payload.update({ + "playlist_id": assgined_playlist_id, + "msg": f"You have created '{playlist_name}' playlist.", + "data": share_playlist, + }) + + await func.update_user(user_id, {"$set": {"inbox": inbox}}) + return payload + +async def getMutualGuilds(bot: commands.Bot, data: Dict) -> Dict: + user_id = int(data.get("user_id")) + + payload = {"op": "getMutualGuilds", "mutualGuilds": {}, "inviteGuilds": {}, "user_id": 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 + } + else: + payload["inviteGuilds"][guild_id] = {**guild_info} + + 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")) + + guild = bot.get_guild(guild_id) + if not guild: + return error_msg("Vocard don't have access to requested guild.", user_id=user_id, level="error") + + member = guild.get_member(user_id) + if not member: + return error_msg("You are not in the requested guild.", user_id=user_id, level="error") + + if not member.guild_permissions.manage_guild: + return error_msg("You don't have permission to access the settings.", user_id=user_id, level='error') + + settings = await func.get_settings(guild_id) + if "dj" in settings: + role = guild.get_role(settings["dj"]) + if role: + settings["dj"] = role.name + + return { + "op": "getSettings", + "settings": settings, + "options": { + "languages": list(func.LANGS.keys()), + "queue_modes": ["Queue", "FairQueue"], + "roles": [role.name for role in guild.roles] + }, + "guild": { + "avatar": guild.icon.url if guild.icon else None, + "name": guild.name, + "id": str(guild_id) + }, + "user_id": 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: + 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 + +async def updateSettings(bot: commands.Bot, data: Dict) -> None: + user_id = int(data.get("user_id")) + guild_id = int(data.get("guild_id")) + + guild = bot.get_guild(guild_id) + if not guild: + return error_msg("Vocard don't have access to required guild.", user_id=user_id, level="error") + + member = guild.get_member(user_id) + if not member: + return error_msg("You are not in the required guild.", user_id=user_id, level="error") + + if not member.guild_permissions.manage_guild: + return error_msg("You don't have permission to change the settings.", user_id=user_id, level='error') + + data = data.get("settings", {}) + if "dj" in data: + for role in guild.roles: + if role.name.lower() == data["dj"]: + data["dj"] = role.id + break + + for key, value in data.copy().items(): + if key not in SCOPES or not isinstance(value, SCOPES[key]): + del data[key] + + await func.update_settings(guild.id, {"$set": data}) + +METHODS: Dict[str, Union[SystemMethod, PlayerMethod]] = { + "initBot": SystemMethod(initBot, credit=0), + "initUser": SystemMethod(initUser, credit=2), + "getPlaylist": SystemMethod(getPlaylist), + "updatePlaylist": SystemMethod(updatePlaylist, credit=2), + "getMutualGuilds": SystemMethod(getMutualGuilds), + "getSettings": SystemMethod(getSettings), + "getLyrics": SystemMethod(getLyrics), + "updateSettings": SystemMethod(updateSettings), + "getRecommendation": SystemMethod(getRecommendation, credit=5), + "closeConnection": SystemMethod(closeConnection, credit=0), + "getTracks": SystemMethod(getTracks, credit=5), + "initPlayer": PlayerMethod(initPlayer), + "skipTo": PlayerMethod(skipTo), + "backTo": PlayerMethod(backTo), + "moveTrack": PlayerMethod(moveTrack), + "addTracks": PlayerMethod(addTracks, auto_connect=True), + "shuffleTrack": PlayerMethod(shuffleTrack, credit=3), + "repeatTrack": PlayerMethod(repeatTrack), + "removeTrack": PlayerMethod(removeTrack), + "clearQueue": PlayerMethod(clearQueue), + "updateVolume": PlayerMethod(updateVolume, credit=2), + "updatePause": PlayerMethod(updatePause), + "updatePosition": PlayerMethod(updatePosition), + "toggleAutoplay": PlayerMethod(toggleAutoplay), + "updateFilter": PlayerMethod(updateFilter), +} + +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")): + return + + user_id = int(user_id) + if user_id not in RATELIMIT_COUNTER or (time.time() - RATELIMIT_COUNTER[user_id]["time"]) >= 300: + RATELIMIT_COUNTER[user_id] = {"time": time.time(), "count": 0} + + else: + if RATELIMIT_COUNTER[user_id]["count"] >= 100: + return await ipc_client.send({"op": "rateLimited", "user_id": str(user_id)}) + RATELIMIT_COUNTER[user_id]["count"] += method.credit + + try: + env: Dict = {"bot": bot, "data": data} + args: List = [] + + params = method.params + if not (type(method) == SystemMethod): + if guild_id := data.get("guild_id"): + if (guild := bot.get_guild(int(guild_id))): + env["guild"] = guild + + else: + user: User = bot.get_user(user_id) + if not user: + return + + for guild in user.mutual_guilds: + member = guild.get_member(user_id) + if member.voice and member.voice.channel: + env["guild"] = guild + env["member"] = member + break + + if "member" in params and "member" not in env: + if not (guild := env.get("guild")) or not (member := guild.get_member(user_id)): + return + env["member"] = member + + if "player" in params: + if not (guild := env.get("guild")) or not (player := guild.voice_client): + if not method.auto_connect or not (member := env.get("member")): + return + player = await connect_channel(member, bot) + + if not player or player.channel.id != member.voice.channel.id: + return + + env["player"] = player + + for param in params: + args.append(env.get(param)) + + if resp := await method.function(*args): + await ipc_client.send(resp) + + except Exception as e: + payload = { + "op": "errorMsg", + "level": "error", + "msg": str(e), + "user_id": 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 5cef1eb..d096e80 100644 --- a/langs/CH.json +++ b/langs/CH.json @@ -23,25 +23,18 @@ "toggleControllerMsg": "現在您已從音樂控制器 `{0}` 消息。", "settingsMenu": "伺服器設置 | {0}", "settingsTitle": "❤️ 基本資訊:", - "settingsValue": "前綴:`{0}`\n語言:`{1}`\n啟用音樂控制器:`{2}`\nDJ 角色:{3}\n投票繞過:`{4}`\n24/7:`{5}`\n默認音量:`{6}%`\n播放時間:`{7}`", + "settingsValue": "```前綴:{0}\n語言:{1}\n音樂控制器:{2}\nDJ 角色:@{3}\n投票繞過:{4}\n24/7:{5}\n默認音量:{6}%\n播放時間:{7}```", "settingsTitle2": "🔗 隊列資訊:", - "settingsValue2": "隊列模式:`{0}`\n最大歌曲數:`{1}`\n允許重複曲目:`{2}`", + "settingsValue2": "```隊列模式:{0}\n最大歌曲數:{1}\n允許重複曲目:{2}```", + "settingsTitle3": "🎤 語音狀態資訊:", "settingsPermTitle": "✨ 權限:", - "settingsPermValue": "{0} 管理員\n{1} 管理伺服器\n{2} 管理頻道\n{3} 管理訊息", + "settingsPermValue": "```{0} 管理員\n{1} 管理伺服器\n{2} 管理頻道\n{3} 管理訊息```", "pingTitle1": "機器人資訊:", "pingTitle2": "播放器信息:", "pingfield1": "```分片 ID: {0}/{1}\n分片延遲: {2:.3f}s {3}\n區域: {4}```", "pingfield2": "```節點: {0} - {1:.3f}s\n播放器數量: {2}\n語音區域: {3}```", - "karaoke": "你已更新等級到 **{0}**,單聲道等級到 **{1}**,濾波頻帶到 **{2}**,濾波寬度到 **{3}**", - "tremolo&vibrato": "你已更新頻率到 {0},深度到 {1}", - "rotation": "你已更新旋轉 Hz 到 {0}", - "distortion": "你已啟用失真。", - "lowpass": "你已更新低通濾波器到 {0}", - "channelmix": "你已更新聲道混音。左左: {0},右右: {1},左右: {2},右左: {3}", - "nightcore": "你已加入夜蒼聲音效果。", - "8d": "你已加入 8D 聲音效果。", - "vaporwave": "你已加入 vaporwave 聲音效果。", - "cleareffect": "聲音效果已清除!", + "addEffect": "套用音效`{0}`濾鏡。", + "clearEffect": "聲音效果已清除!", "FilterTagAlreadyInUse": "此聲音效果已在使用中!請使用 /cleareffect 移除它。", "playlistViewTitle": "📜 所有 {0} 的播放清單", @@ -105,6 +98,7 @@ "playlistAdded": "❤️ 已將 **{0}** 添加到 {1} 的播放清單中 [`{2}`]!", "playerDropdown": "選擇要跳轉到的音軌...", + "playerFilter": "選擇要套用的篩選器...", "buttonBack": "返回", "buttonPause": "暫停", @@ -187,5 +181,10 @@ "voicelinkOutofList": "請提供有效的歌曲索引!", "voicelinkDuplicateTrack": "抱歉,此歌曲已在隊列中。", - "deocdeError": "解碼文件時出現問題!" + "deocdeError": "解碼文件時出現問題!", + "invalidStartTime": "無效的開始時間! 時間必須在 `00:00` 和 `{0}` 之間。", + "invalidEndTime": "無效的結束時間! 時間必須在 `00:00` 和 `{0}` 之間。", + "invalidTimeOrder": "結束時間不能小於或等於開始時間。", + + "SetStageAnnounceTemplate": "完成!從現在開始,像您現在的語音狀態將根據您的模板命名。您應該在幾秒鐘內看到它更新。" } \ No newline at end of file diff --git a/langs/DE.json b/langs/DE.json index b5602da..37bd9a0 100644 --- a/langs/DE.json +++ b/langs/DE.json @@ -23,25 +23,18 @@ "toggleControllerMsg": "Sie haben jetzt `{0}` Nachrichten vom Musik-Controller.", "settingsMenu": "Servereinstellungen | {0}", "settingsTitle": "❤️ Grundlegende Informationen:", - "settingsValue": "Präfix: `{0}`\nSprache: `{1}`\nMusik-Controller aktivieren: `{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}\nRund um die Uhr: {5}\nStandardvolumen: {6}%\nSpielzeit: {7}```", "settingsTitle2": "🔗 Warteschlangeninformationen:", - "settingsValue2": "Warteschlangenmodus: `{0}`\nMax Lied: `{1}`\nDoppelte Spur zulassen: `{2}`", + "settingsValue2": "```Warteschlangenmodus: {0}\nMax Lied: {1}\nDoppelte Spur 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} Gilde verwalten\n{2} Kanal verwalten\n{3} Manage_Messages```", "pingTitle1": "Bot-Info:", "pingTitle2": "Spielerinfo:", "pingfield1": "```Shard-ID: {0}/{1}\nShard-Latenz: {2:.3f} s {3}\nRegion: {4}```", "pingfield2": "```Knoten: {0} - {1:.3f}s\nSpieler: {2}\nSprachregion: {3}```", - "karaoke": "Sie setzen den Pegel auf **{0}**, monoLevel auf **{1}**, filterBand auf **{2}** und filterWidth auf **{3}**.", - "tremolo&vibrato": "Sie stellen die Frequenz auf **{0}** und die Tiefe auf **{1}** ein.", - "rotation": "Sie setzen die RotationHz auf **{0}**.", - "distortion": "Sie haben die Verzerrung aktiviert.", - "lowpass": "Sie setzen den LowPass auf **{0}**.", - "channelmix": "Sie haben den channelMix aktualisiert. Von links nach links: **{0}**, von rechts nach rechts: **{1}**, von links nach rechts: **{2}**, von rechts nach links: ** {3}**.", - "nightcore": "Sie haben Nightcore-Soundeffekte hinzugefügt.", - "8d": "Sie haben 8D-Soundeffekte hinzugefügt.", - "vaporwave": "Sie haben Dampfwellen-Soundeffekte hinzugefügt.", - "cleareffect": "Die Soundeffekte wurden gelöscht!", + "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.", "playlistViewTitle": "📜 Alle Playlists von {0}", @@ -105,6 +98,7 @@ "playlistAdded": "❤️ Hinzugefügt **{0}** in {1}'s Wiedergabeliste [`{2}`]!", "playerDropdown": "Wählen Sie einen Song aus, um zu überspringen ...", + "playerFilter": "Wählen Sie einen Filter aus, um ihn anzuwenden ...", "buttonBack": "Zurück", "buttonPause": "Pause", @@ -187,5 +181,10 @@ "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!" + "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.", + + "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." } \ No newline at end of file diff --git a/langs/EN.json b/langs/EN.json index ef0b595..00dad17 100644 --- a/langs/EN.json +++ b/langs/EN.json @@ -23,25 +23,18 @@ "toggleControllerMsg": "Now you have `{0}` messages from the music controller.", "settingsMenu": "Server Settings | {0}", "settingsTitle": "❤️ Basic Info:", - "settingsValue": "Prefix: `{0}`\nLanguage: `{1}`\nEnable Music Controller: `{2}`\nDJ Role: {3}\nVote Bypass: `{4}`\n24/7: `{5}`\nDefault Volume: `{6}%`\nPlay Time: `{7}`", + "settingsValue": "```Prefix: {0}\nLanguage: {1}\nMusic Controller: {2}\nDJ Role: @{3}\nVote Bypass: {4}\n24/7: {5}\nDefault Volume: {6}%\nPlay Time: {7}```", "settingsTitle2": "🔗 Queue info:", - "settingsValue2": "Queue Mode: `{0}`\nMax Song: `{1}`\nAllow Duplicate Track: `{2}`", + "settingsValue2": "```Queue Mode: {0}\nMax Song: {1}\nDuplicate Track: {2}```", + "settingsTitle3": "🎤 Voice Status Info:", "settingsPermTitle": "✨ Permissions:", - "settingsPermValue": "{0} Administrator\n{1} Manage_Guild\n{2} Manage_Channel\n{3} Manage_Messages", + "settingsPermValue": "```{0} Administrator\n{1} Manage_Guild\n{2} Manage_Channel\n{3} Manage_Messages```", "pingTitle1": "Bot Info:", "pingTitle2": "Player Info:", "pingfield1": "```Shard ID: {0}/{1}\nShard Latency: {2:.3f}s {3}\nRegion: {4}```", "pingfield2": "```Node: {0} - {1:.3f}s\nPlayers: {2}\nVoice Region: {3}```", - "karaoke": "You have updated the level to **{0}**, monoLevel to **{1}**, filterBand to **{2}** and filterWidth to **{3}**", - "tremolo&vibrato": "You updated the frequency to **{0}** and depth to **{1}**", - "rotation": "You have updated the rotationHz to **{0}**", - "distortion": "You have enabled distortion.", - "lowpass": "You have updated the lowPass to **{0}**", - "channelmix": "You have updated the channelMix. Left-to-left: **{0}**, Right-to-right: **{1}**, Left-to-right: **{2}**, Right-to-left: **{3}**", - "nightcore": "You have added nightcore sound effects.", - "8d": "You have added 8D sound effects.", - "vaporwave": "You have added vaporwave sound effects.", - "cleareffect": "The sound effects have been cleared!", + "addEffect": "Apply the effect `{0}` filter.", + "clearEffect": "The sound effects have been cleared!", "FilterTagAlreadyInUse": "This sound effects has already in use! Please use /cleareffect to remove it.", "playlistViewTitle": "📜 All {0}'s Playlists", @@ -105,6 +98,7 @@ "playlistAdded": "❤️ Added **{0}** into {1}'s playlist [`{2}`]!", "playerDropdown": "Select a song to skip to ...", + "playerFilter": "Select a filter to apply ...", "buttonBack": "Back", "buttonPause": "Pause", @@ -187,5 +181,10 @@ "voicelinkOutofList": "Please provide a valid track index!", "voicelinkDuplicateTrack": "Sorry, this track is already in the queue.", - "deocdeError": "Something went wrong while decoding the file!" + "deocdeError": "Something went wrong while decoding the file!", + "invalidStartTime": "Invalid start time, it must be between `00:00` and `{0}`", + "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." } \ No newline at end of file diff --git a/langs/ES.json b/langs/ES.json index f9a81e9..f2a3fcd 100644 --- a/langs/ES.json +++ b/langs/ES.json @@ -23,25 +23,18 @@ "toggleControllerMsg": "Ahora tiene `{0}` mensajes del controlador de música.", "settingsMenu": "Configuración del servidor | {0}", "settingsTitle": "❤️ Información básica:", - "settingsValue": "Prefijo: `{0}`\nIdioma: `{1}`\nHabilitar controlador de música: `{2}`\nRol de DJ: {3}\nBypass de votación: `{4}`\n24/7: `{5}`\nVolumen predeterminado: `{6}%`\nTiempo de reproducción: `{7}`", + "settingsValue": "```Prefijo: {0}\nIdioma: {1}\nHabilitar controlador de música: {2}\nRol de DJ: @{3}\nBypass de votación: {4}\n24/7: {5}\nVolumen predeterminado: {6}%\nTiempo de reproducción: {7}```", "settingsTitle2": "🔗 Información de la cola:", - "settingsValue2": "Modo de cola: `{0}`\nMáximo de canciones: `{1}`\nPermitir pistas duplicadas: `{2}`", + "settingsValue2": "```Modo de cola: {0}\nMáximo de canciones: {1}\nPermitir pistas duplicadas: {2}```", + "settingsTitle3": "🎤 Información del estado de voz:", "settingsPermTitle": "✨ Permisos:", - "settingsPermValue": "{0} Administrador\n{1} Administrar servidor\n{2} Administrar canal\n{3} Administrar mensajes", + "settingsPermValue": "```{0} Administrador\n{1} Administrar servidor\n{2} Administrar canal\n{3} Administrar mensajes```", "pingTitle1": "Información del bot:", "pingTitle2": "Información del reproductor:", "pingfield1": "```ID de fragmento: {0}/{1}\nLatencia de fragmento: {2:.3f}s {3}\nRegión: {4}```", "pingfield2": "```Nodo: {0} - {1:.3f}s\nReproductores: {2}\nRegión de voz: {3}```", - "karaoke": "Ha actualizado el nivel a **{0}**, monoLevel a **{1}**, filterBand a **{2}** y filterWidth a **{3}**.", - "tremolo&vibrato": "Ha actualizado la frecuencia a **{0}** y la profundidad a **{1}**.", - "rotation": "Ha actualizado la rotación Hz a **{0}**.", - "distortion": "Ha habilitado la distorsión.", - "lowpass": "Ha actualizado el paso bajo a **{0}**", - "channelmix": "Ha actualizado la mezcla de canales. De izquierda a izquierda: **{0}**, de derecha a derecha: **{1}**, de izquierda a derecha: **{2}**, de derecha a izquierda: **{3}**.", - "nightcore": "Ha agregado efectos de sonido nightcore.", - "8d": "Ha agregado efectos de sonido 8D.", - "vaporwave": "Ha agregado efectos de sonido vaporwave.", - "cleareffect": "¡Los efectos de sonido se han borrado!", + "addEffect": "plica el efecto `{0}` filtro.", + "clearEffect": "¡Los efectos de sonido se han borrado!", "FilterTagAlreadyInUse": "¡Este efecto de sonido ya está en uso! Utilice /cleareffect para eliminarlo.", "playlistViewTitle": "📜 Todas las listas de reproducción de {0}", @@ -105,6 +98,7 @@ "playlistAdded": "❤️ Agregado **{0}** a la lista de reproducción de {1} [`{2}`]!", "playerDropdown": "Seleccione una canción para saltar a ...", + "playerFilter": "Seleccione un filtro para aplicar ...", "buttonBack": "Atrás", "buttonPause": "Pausa", @@ -187,5 +181,10 @@ "voicelinkOutofList": "¡Proporcione un índice de pista válido!", "voicelinkDuplicateTrack": "Lo siento, esta canción ya está en la cola.", - "deocdeError": "¡Algo salió mal al decodificar el archivo!" + "deocdeError": "¡Algo salió mal al decodificar el archivo!", + "invalidStartTime": "Tiempo de inicio inválido! El tiempo debe estar entre `00:00` y `{0}`.", + "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." } \ No newline at end of file diff --git a/langs/JA.json b/langs/JA.json index d718e91..4f008f6 100644 --- a/langs/JA.json +++ b/langs/JA.json @@ -23,25 +23,18 @@ "toggleControllerMsg": "現在、音楽コントローラーから `{0}` 件のメッセージがあります。", "settingsMenu": "サーバー設定| {0}", "settingsTitle": "❤️ 基本情報:", - "settingsValue": "プレフィックス:「{0}」\n言語:「{1}」\n音楽コントローラーを有効にする:「{2}」\nDJロール:{3}\n投票バイパス:「{4}」\n24/7:「{5}」\nデフォルトボリューム:「{6}%」\n再生時間:「{7}」", + "settingsValue": "```プレフィックス:{0}\n言語:{1}\n音楽コントローラーを有効にする:{2}\nDJロール:@{3}\n投票バイパス:{4}\n24/7:{5}\nデフォルトボリューム:{6}%\n再生時間:{7}```", "settingsTitle2": "🔗 キュー情報:", - "settingsValue2": "キューモード:「{0}」\n最大曲数:「{1}」\n重複トラックを許可する:「{2}」", + "settingsValue2": "```キューモード:{0}\n最大曲数:{1}\n重複トラックを許可する:{2}```", + "settingsTitle3": "🎤 音声ステータス情報:", "settingsPermTitle": "✨ 権限:", - "settingsPermValue": "{0} 管理者\n{1} ギルド管理\n{2} チャンネル管理\n{3} メッセージ管理", + "settingsPermValue": "```{0} 管理者\n{1} ギルド管理\n{2} チャンネル管理\n{3} メッセージ管理```", "pingTitle1": "ボット情報:", "pingTitle2": "プレーヤー情報:", "pingfield1": "```シャードID:{0}/{1}\nシャードレイテンシ:{2:.3f}s {3}\nリージョン:{4}```", "pingfield2": "```ノード:{0} - {1:.3f}s\nプレイヤー数:{2}\n音声リージョン:{3}```", - "karaoke": "**{0}**のレベル、**{1}**のモノレベル、**{2}**のフィルターバンド、および**{3}**のフィルター幅を更新しました。", - "tremolo&vibrato": "周波数を**{0}**、深度を**{1}**に更新しました。", - "rotation": "回転Hzを**{0}**に更新しました。", - "distortion": "歪みを有効にしました。", - "lowpass": "lowPassを**{0}**に更新しました。", - "channelmix": "チャンネルミックスを更新しました。左から左へ:**{0}**、右から右へ:**{1}**、左から右へ:**{2}**、右から左へ:**{3}**", - "nightcore": "ナイトコアサウンドエフェクトを追加しました。", - "8d": "8Dサウンドエフェクトを追加しました。", - "vaporwave": "ヴェイパーウェーブサウンドエフェクトを追加しました。", - "cleareffect": "効果音がクリアされました!", + "addEffect": "`{0}` フィルターを適用します。", + "clearEffect": "効果音がクリアされました!", "FilterTagAlreadyInUse": "このサウンドエフェクトはすでに使用されています!削除するには/cleareffect を使用してください。", "playlistViewTitle": "📜 {0}のすべてのプレイリスト", @@ -105,6 +98,7 @@ "playlistAdded": "❤️ **{0}**を{1}のプレイリスト[`{2}`]に追加しました!", "playerDropdown": "スキップする曲を選択してください...", + "playerFilter": "適用するフィルターを選択してください...", "buttonBack": "戻る", "buttonPause": "一時停止", @@ -187,5 +181,10 @@ "voicelinkOutofList": "有効なトラックインデックスを指定してください!", "voicelinkDuplicateTrack": "申し訳ありませんが、このトラックは既にキューに存在します。", - "deocdeError": "ファイルのデコード中に問題が発生しました!" + "deocdeError": "ファイルのデコード中に問題が発生しました!", + "invalidStartTime": "無効な開始時間!時間は `00:00` と `{0}` の間に設定する必要があります。", + "invalidEndTime": "無効な終了時間!時間は `00:00` と `{0}` の間に設定する必要があります。", + "invalidTimeOrder": "終了時間は開始時間より大きくない必要があります。", + + "SetStageAnnounceTemplate": "完了!これからは、今いるボイスステータスがあなたのテンプレートに従って名前が付けられます。数秒以内に更新されるのを見ることができるはずです。" } \ No newline at end of file diff --git a/langs/KO.json b/langs/KO.json index 170b717..ccb7fc2 100644 --- a/langs/KO.json +++ b/langs/KO.json @@ -23,25 +23,18 @@ "toggleControllerMsg": "이제 음악 컨트롤러로부터 `{0}` 메시지가 있습니다.", "settingsMenu": "서버 설정 | {0}", "settingsTitle": "❤️ 기본 정보:", - "settingsValue": "접두사: `{0}`\n언어: `{1}`\n음악 컨트롤러 사용: `{2}`\nDJ 역할: {3}\n투표 우회: `{4}`\n24/7 모드: `{5}`\n기본 볼륨: `{6}%`\n재생 시간: `{7}`", + "settingsValue": "```접두사: {0}\n언어: {1}\n음악 컨트롤러 사용: {2}\nDJ 역할: @{3}\n투표 우회: {4}\n24/7 모드: {5}\n기본 볼륨: {6}%\n재생 시간: {7}```", "settingsTitle2": "🔗 대기열 정보:", - "settingsValue2": "큐 모드: `{0}`\n최대 노래: `{1}`\n중복된 트랙 허용: `{2}`", + "settingsValue2": "```큐 모드: {0}\n최대 노래: {1}\n중복된 트랙 허용: {2}```", + "settingsTitle3": "🎤 음성 상태 정보:", "settingsPermTitle": "✨ 권한:", - "settingsPermValue": "{0} 관리자\n{1} 서버 관리\n{2} 채널 관리\n{3} 메시지 관리", + "settingsPermValue": "```{0} 관리자\n{1} 서버 관리\n{2} 채널 관리\n{3} 메시지 관리```", "pingTitle1": "봇 정보:", "pingTitle2": "플레이어 정보:", "pingfield1": "```쉬드 ID: {0}/{1}\n쉬드 대기 시간: {2:.3f}s {3}\n지역: {4}```", "pingfield2": "```노드: {0} - {1:.3f}s\n플레이어: {2}\n음성 지역: {3}```", - "karaoke": "**{0}** 레벨, 모노 레벨 **{1}**, 필터 밴드 **{2}**, 필터 너비 **{3}**으로 업데이트되었습니다.", - "tremolo&vibrato": "주파수를 **{0}**으로, 깊이를 **{1}**으로 업데이트했습니다.", - "rotation": "회전 주파수를 **{0}**으로 업데이트했습니다.", - "distortion": "왜곡이 활성화되었습니다.", - "lowpass": "LowPass를 **{0}**으로 업데이트했습니다.", - "channelmix": "채널 믹스를 업데이트했습니다. 왼쪽-왼쪽: **{0}**, 오른쪽-오른쪽: **{1}**, 왼쪽-오른쪽: **{2}**, 오른쪽-왼쪽: **{3}**", - "nightcore": "나이트코어 효과를 추가했습니다.", - "8d": "8D 효과를 추가했습니다.", - "vaporwave": "Vaporwave 효과를 추가했습니다.", - "cleareffect": "효과가 삭제되었습니다!", + "addEffect": "`{0}` 필터를 적용하세요.", + "clearEffect": "효과가 삭제되었습니다!", "FilterTagAlreadyInUse": "이 필터는 이미 사용 중입니다! 삭제하려면 /cleareffect 를 사용하십시오.", "playlistViewTitle": "📜 {0}의 모든 재생 목록", @@ -105,6 +98,7 @@ "playlistAdded": "❤️ **{0}**을(를) {1}의 재생목록 [`{2}`] 에 추가했습니다!", "playerDropdown": "건너뛰기 할 노래를 선택하세요...", + "playerFilter": "적용할 필터를 선택하세요...", "buttonBack": "이전", "buttonPause": "일시정지", @@ -187,5 +181,10 @@ "voicelinkOutofList": "유효한 트랙 인덱스를 제공해주세요!", "voicelinkDuplicateTrack": "죄송합니다. 이 트랙은 이미 큐에 있습니다.", - "deocdeError": "파일 디코딩 중 문제가 발생했습니다!" + "deocdeError": "파일 디코딩 중 문제가 발생했습니다!", + "invalidStartTime": "효력 없는 시작 시간! 시간은 `00:00` 과 `{0}` 사이에 설정해야 합니다.", + "invalidEndTime": "효력 없는 종료 시간! 시간은 `00:00` 과 `{0}` 사이에 설정해야 합니다.", + "invalidTimeOrder": "종료 시간은 시작 시간보다 클수 있어야 합니다.", + + "SetStageAnnounceTemplate": "완료! 이제부터 지금 있는 음성 상태는 귀하의 템플릿에 따라 이름이 지정됩니다. 몇 초 후에 업데이트되는 것을 볼 수 있을 것입니다." } \ No newline at end of file diff --git a/langs/RU.json b/langs/RU.json index c06ccfa..636794d 100644 --- a/langs/RU.json +++ b/langs/RU.json @@ -23,25 +23,18 @@ "toggleControllerMsg": "Теперь у вас `{0}` сообщения от контроллера музыки.", "settingsMenu": "Настройки сервера | {0}", "settingsTitle": "❤️ Основная информация:", - "settingsValue": "Префикс: `{0}`\nЯзык: `{1}`\nВключить контроллер музыки: `{2}`\nDJ роль: {3}\nОбход голосования: `{4}`\n24/7: `{5}`\nГромкость по умолчанию: `{6}%`\nВремя проигрывания: `{7}`", + "settingsValue": "```Префикс: {0}\nЯзык: {1}\nВключить контроллер музыки: {2}\nDJ роль: @{3}\nОбход голосования: {4}\n24/7: {5}\nГромкость по умолчанию: {6}%\nВремя проигрывания: {7}```", "settingsTitle2": "🔗 Информация об очереди:", - "settingsValue2": "Режим очереди: `{0}`\nМаксимальное количество песен: `{1}`\nРазрешить дублирование треков: `{2}`", + "settingsValue2": "```Режим очереди: {0}\nМаксимальное количество песен: {1}\nРазрешить дублирование треков: {2}```", + "settingsTitle3": "🎤 Информация о состоянии голоса:", "settingsPermTitle": "✨ Права:", - "settingsPermValue": "{0} Администратор\n{1} Управление_Сервером\n{2} Управление_Каналом\n{3} Управление_Сообщениями", + "settingsPermValue": "```{0} Администратор\n{1} Управление_Сервером\n{2} Управление_Каналом\n{3} Управление_Сообщениями```", "pingTitle1": "Информация о боте:", "pingTitle2": "Информация о плеере:", "pingfield1": "```ID Шарда: {0}/{1}\nЗадержка Шарда: {2:.3f}s {3}\nРегион: {4}```", "pingfield2": "```Сервис: {0} - {1:.3f}s\nИгроки: {2}\nРегион Голоса: {3}```", - "karaoke": "Вы обновили уровень на **{0}**, моноуровень на **{1}**, фильтрующая полоса на **{2}** и ширина фильтра на **{3}**", - "tremolo&vibrato": "Вы обновили значение эффекта tremolo на **{0}** и vibrato на **{1}**", - "rotation": "Вы обновили значение эффекта rotation на **{0}**", - "distortion": "Вы включили эффект rotation.", - "lowpass": "Вы обновили значение эффекта lowpass на **{0}**", - "channelmix": "Вы обновили ChannelMix. Левый-на-левый: **{0}**, Правый-на-правый: **{1}**, Левый-на-правый: **{2}**, Правый-на-левый: **{3}**", - "nightcore": "Вы включили эффект Nightcore.", - "8d": "Вы включили эффект 8D.", - "vaporwave": "Вы включили эффект Vaporwave.", - "cleareffect": "Звуковые эффекты были очищены!", + "addEffect": "Примените эффект `{0}` фильтр.", + "clearEffect": "Звуковые эффекты были очищены!", "FilterTagAlreadyInUse": "Этот звуковой эффект уже используется! Пожалуйста, используйте /cleareffect <Тег>, чтобы удалить его.", "playlistViewTitle": "📜 Все плейлисты пользователя {0}", @@ -61,7 +54,7 @@ "playlistBelongs": "Извините! Этот плейлист принадлежит <@{0}>.", "playlistShare": "Извините! Этим плейлистом уже поделились с {0}.", "playlistSent": "Извините! Вы уже отправили приглашение ранее.", - "noPlaylistAcc": "{0} не создал учетную запись плейлиста.", + "noPlaylistAcc": "Пользователь `{0}` не создал учетную запись плейлиста.", "overPlaylistCreation": "Вы не можете создавать больше `{0}` плейлистов!", "playlistExists": "Плейлист [`{0}`] уже существует.", "playlistNotInvaildUrl": "Пожалуйста, введите действительную ссылку или публичную плейлист-ссылку на Spotify или YouTube.", @@ -70,8 +63,8 @@ "playlistLimitTrack": "Вы достигли лимита! Вы можете добавить только `{0}` треков в свой плейлист.", "playlistPlaylistLink": "Вам не разрешено использовать ссылку на плейлист.", "playlistStream": "Вам не разрешено добавлять потоковые видео в свой плейлист.", - "playlistPositionNotFound": "Не удается найти позицию `{0}` в вашем плейлисте [`{1}`]!", - "playlistRemoved": "👋 Удалено **{0}** из плейлиста {1} [`{2}`].", + "playlistPositionNotFound": "Не получилось найти позицию `{0}` в вашем плейлисте [`{1}`]!", + "playlistRemoved": "👋 Удален **{0}** из плейлиста {1} [`{2}`].", "playlistClear": "Вы успешно очистили свой плейлист [`{0}`].", "playlistView": "Просмотр плейлистов", "playlistViewDesc": "```Имя | ID: {0} | {1}\nВсего треков: {2}\nВладелец: {3}\nТип: {4}\n```", @@ -83,9 +76,9 @@ "inboxNoMsg": "В вашем почтовом ящике нет сообщений.", "invitationSent": "Приглашение отправлено {0}.", - "notInChannel": "{0}, для использования голосовых команд вы должны находиться в {1}. Пожалуйста, перезайдите, если вы уже в голосе!", + "notInChannel": "{0}, для использования голосовых команд вы должны находиться в {1}. Пожалуйста, перезайдите, если вы уже находитесь в голосовом канале!", "noTrackPlaying": "Сейчас нет играющих треков.", - "noTrackFound": "Треки с таким запросом не найдены! Пожалуйста, укажите действительную ссылку.", + "noTrackFound": "Треки по такому запросу не найдены! Пожалуйста, укажите действительную ссылку.", "noLinkSupport": "Команда поиска не поддерживает ссылки!", "voted": "Вы проголосовали!", "missingPerms_pos": "Только DJ или администраторы могут изменять позицию.", @@ -105,6 +98,7 @@ "playlistAdded": "❤️ Добавлен **{0}** в плейлист пользователя {1} [`{2}`]!", "playerDropdown": "Выберите трек для перехода ...", + "playerFilter": "Выберите фильтр для применения ...", "buttonBack": "Назад", "buttonPause": "Пауза", @@ -120,7 +114,7 @@ "buttonShuffle": "Перемешать", "buttonForward": "+10сек", "buttonRewind": "Назад", - + "nowplayingDesc": "**Сейчас играет:**\n```{0}```", "nowplayingField": "Следующее:", "nowplayingLink": "Слушать на {0}", @@ -129,7 +123,7 @@ "live": "В ЭФИРЕ", "playlistLoad": "🎶 Добавлен плейлист **{0}** с `{1}` треками в очередь.", - "trackLoad": "Добавлен **[{0}](<{1}>)** от **{2}** (`{3}`) для начала проигрывания.\n", + "trackLoad": "Добавлен **[{0}](<{1}>)** от **{2}** (`{3}`) в очередь.\n", "trackLoad_pos": "Добавлен **[{0}](<{1}>)** от **{2}** (`{3}`) в очередь на позицию **{4}**\n", "searchTitle": "Поиск: {0}", "searchDesc": "➥ Платформа: {0} **{1}**\n➥ Результаты: **{2}**\n\n{3}", @@ -144,13 +138,13 @@ "viewFooter": "Страница: {0}/{1} | Общая продолжительность: {2}", "pauseError": "Плеер уже на паузе.", - "pauseVote": "Пользователь `{0}` проголосовал за паузу трека. [{1}/{2}]", + "pauseVote": "Пользователь `{0}` проголосовал за паузу плеера. [{1}/{2}]", "paused": "Пользователь `{0}` поставил плеер на паузу.", "resumeError": "Плеер не на паузе.", "resumeVote": "Пользователь `{0}` проголосовал за продолжение трека. [{1}/{2}]", "resumed": "Пользователь `{0}` убрал плеер с паузы.", "shuffleError": "Недостаточно треков для перемешивания", - "shuffleVote": "Пользователь `{0}` проголосовал за перемешивание очереди. [{1}/{2}]", + "shuffleVote": "Пользователь `{0}` проголосовал за перемешивание треков. [{1}/{2}]", "shuffled": "Очередь перемешана.", "skipError": "Нет треков для пропуска.", "skipVote": "Пользователь `{0}` проголосовал за пропуск трека. [{1}/{2}]", @@ -160,7 +154,7 @@ "backed": "Пользователь `{0}` вернулся к предыдущему треку.", "leaveVote": "Пользователь `{0}` проголосовал за остановку плеера. [{1}/{2}]", - "left": "`{0}` остановил плеер.", + "left": "Пользователь `{0}` остановил плеер.", "seek": "Установить плеер на **{0}**", "repeat": "Режим повтора `{0}`", @@ -186,5 +180,10 @@ "voicelinkOutofList": "Пожалуйста, предоставьте действительный индекс трека!", "voicelinkDuplicateTrack": "Извините, этот трек уже есть в очереди.", - "deocdeError": "Что-то пошло не так при декодировании файла!" -} + "deocdeError": "Что-то пошло не так при декодировании файла!", + "invalidStartTime": "Hевозможное время начала! Вход времени должен быть внутри `00:00` и `{0}`.", + "invalidEndTime": "Невозможное время конца! Вход времени должен быть внутри `00:00` и `{0}`.", + "invalidTimeOrder": "Время конца не может быть меньше или равно времени начала.", + + "SetStageAnnounceTemplate": "Готово! С этого момента статус голоса, как тот, в котором вы находитесь сейчас, будет называться в соответствии с вашим шаблоном. Вы должны увидеть обновление через несколько секунд." +} \ No newline at end of file diff --git a/langs/UA.json b/langs/UA.json index 8cc2e69..b31b613 100644 --- a/langs/UA.json +++ b/langs/UA.json @@ -23,25 +23,18 @@ "toggleControllerMsg": "Тепер у вас `{0}` повідомлення від контролера музики.", "settingsMenu": "Налаштування сервера | {0}", "settingsTitle": "❤️ Основна інформація:", - "settingsValue": "Префікс: `{0}`\nМова: `{1}`\nУвімкнути контролер музики: `{2}`\nDJ роль: {3}\nОбхід голосування: `{4}`\n24/7: `{5}`\nГромкість за замовчуванням: `{6}%`\nЧас програвання: `{7}`", + "settingsValue": "```Префікс: {0}\nМова: {1}\nУвімкнути контролер музики: {2}\nDJ роль: {3}\nОбхід голосування: {4}\n24/7: {5}\nГромкість за замовчуванням: {6}%\nЧас програвання: {7}```", "settingsTitle2": "🔗 Інформація про чергу:", - "settingsValue2": "Режим черги: `{0}`\nМаксимальна кількість пісень: `{1}`\nДозволити дублювання треків: `{2}`", + "settingsValue2": "```Режим черги: {0}\nМаксимальна кількість пісень: {1}\nДозволити дублювання треків: {2}```", + "settingsTitle3": "🎤 Інформація про стан голосу:", "settingsPermTitle": "✨ Права:", - "settingsPermValue": "{0} Адміністратор\n{1} Керування_Сервером\n{2} Керування_Каналом\n{3} Керування_Сообщениями", + "settingsPermValue": "```{0} Адміністратор\n{1} Керування_Сервером\n{2} Керування_Каналом\n{3} Керування_Сообщениями```", "pingTitle1": "Інформація про бота:", "pingTitle2": "Інформація про плеєр:", "pingfield1": "````ID Шарда: {0}/{1}\nЗатримка Шарда: {2:.3f}s {3}\nРегіон: {4}````", "pingfield2": "```Вузол: {0} - {1:.3f}s\nГравці: {2}\nРегіон Голосу: {3}```", - "karaoke": "Ви оновили рівень на **{0}**, моно-рівень на **{1}**, фільтрувальну смугу на **{2}** і ширину фільтра на **{3}**", - "tremolo&vibrato": "Ви оновили значення ефекту tremolo на **{0}** і vibrato на **{1}**", - "rotation": "Ви оновили значення ефекту rotation на **{0}***", - "distortion": "Ви ввімкнули ефект rotation.", - "lowpass": "Ви оновили значення ефекту lowpass на **{0}**", - "channelmix": "Ви оновили ChannelMix. Лівий-на-лівий: **{0}**, Правий-на-правий: **{1}**, Лівий-на-правий: **{2}**, Правий-на-лівий: **{3}**", - "nightcore": "Ви ввімкнули ефект Nightcore.", - "8d": "Ви ввімкнули ефект 8D.", - "vaporwave": "Ви ввімкнули ефект Vaporwave.", - "cleareffect": "Звукові ефекти були очищені!", + "addEffect": "Застосуйте ефект `{0}` фільтр.", + "clearEffect": "Звукові ефекти були очищені!", "FilterTagAlreadyInUse": "Цей звуковий ефект уже використовується! Будь ласка, використовуйте /cleareffect <Тег>, щоб видалити його.", "playlistViewTitle": "📜 Усі плейлисти користувача {0}", @@ -105,6 +98,7 @@ "playlistAdded": "❤️ Додано **{0}** до плейлиста користувача {1} [`{2}`]!", "playerDropdown": "Виберіть пісню для переходу ...", + "playerFilter": "Виберіть фільтр для застосування ...", "buttonBack": "Назад", "buttonPause": "Призупинити", @@ -186,5 +180,10 @@ "voicelinkOutofList": "Будь ласка, надайте дійсний індекс треку!", "voicelinkDuplicateTrack": "Вибачте, цей трек уже є в черзі.", - "deocdeError": "Щось пішло не так під час декодування файлу!" -} + "deocdeError": "Щось пішло не так під час декодування файлу!", + "invalidStartTime": "Недійснений час початку! Час має бути в межах `00:00` та `{0}`.", + "invalidEndTime": "Недійснений час закінчення! Час має бути в межах `00:00` та `{0}`.", + "invalidTimeOrder": "Час закінчення не може бути меншим або рівним часу початку.", + + "SetStageAnnounceTemplate": "Готово! Відтепер статус голосу, як той, в якому ви зараз перебуваєте, буде називатися відповідно до вашого шаблону. Ви повинні побачити оновлення через кілька секунд." +} \ No newline at end of file diff --git a/local_langs/zh-TW.json b/local_langs/zh-TW.json index 4e24035..443b026 100644 --- a/local_langs/zh-TW.json +++ b/local_langs/zh-TW.json @@ -1,217 +1,214 @@ { - "language": "語言", - "dj": "dj", - "247": "247", - "bypassvote": "繞過投票", - "serversettings": "服務器設置", - "volume": "音量", - "togglecontroller": "切換控制器", - "ping": "發信", - "debug": "調試", - "connect": "連接", + "connect": "連線", + "Connect to a voice channel.": "連線到語音頻道。", + "channel": "頻道", + "Provide a channel to connect.": "提供要連線的頻道。", "play": "播放", - "search": "搜索", - "playtop": "播放頂端", - "pause": "暫停", - "resume": "繼續", - "skip": "跳過", - "back": "退回", - "seek": "尋找", - "queue": "隊列", - "history": "歷史隊列", - "leave": "離開", - "nowplaying": "正在播放", - "loop": "循環", - "clear": "清除", - "remove": "移除", - "forward": "向前", - "rewind": "倒帶", - "replay": "重播", - "shuffle": "洗牌", - "move": "移動", - "lyrics": "歌詞", - "swapdj": "交換dj", - "chapters": "章節", - "autoplay": "自動播放", - "help": "幫助", - "donate": "捐贈", - "vote": "投票", - "speed": "速度", - "karaoke": "卡拉ok", - "tremolo": "震音", - "vibrato": "顫音", - "rotation": "迴轉", - "distortion": "失真", - "lowpass": "低通", - "channelmix": "通道混合", - "nightcore": "夜心", - "effectclear": "效果清晰", - "view": "查看", - "create": "創建", - "delete": "刪除", - "share": "分享", - "rename": "改名", - "inbox": "收件箱", - "add": "添加", - "You can choose your preferred language, the bot message will change to the language you set.": "您可以選擇首選語言,機器人消息將更改為您設置的語言。", - "…": "…", - "Set a DJ role or remove DJ role.": "設置 DJ 角色或刪除 DJ 角色。", - "role": "角色", - "Toggles 24/7 mode, which disables automatic inactivity-based disconnects.": "切換 24/7 模式,該模式禁用基於不活動的自動斷開連接。", - "Toggles voting system.": "切換投票系統。", - "Show all the bot settings in your server.": "顯示服務器中的所有機器人設置。", - "Set the player's volume.": "設置播放器的音量。", - "value": "價值", - "Input a integer.": "輸入一個整數。", - "Toggles the music controller.": "切換音樂控制器。", - "Test if the bot is alive, and see the delay between your commands and my response.": "測試機器人是否還活著,看看你的命令和我的響應之間的延遲。", - "Connect to a voice channel.": "連接到語音通道。", - "channel": "渠道", - "Provide a channel to connect.": "提供連接渠道。", - "Loads your input and added it to the queue.": "加載您的輸入並將其添加到隊列中。", - "query": "詢問", - "Input a query or a searchable link.": "輸入查詢或可搜索鏈接。", + "Loads your input and added it to the queue.": "載入您的輸入並將其加入排隊。", + "query": "查詢", + "Input a query or a searchable link.": "輸入查詢或可搜尋的連結。", + "search": "搜尋", "Input the name of the song.": "輸入歌曲名稱。", "platform": "平台", - "Select the platform you want to search.": "選擇您要搜索的平台。", + "Select the platform you want to search.": "選擇您要搜尋的平台。", "Youtube": "YouTube", "Youtube Music": "YouTube 音樂", - "SoundCloud": "聲雲", "Spotify": "Spotify", - "Apple Music": "蘋果音樂", - "Adds a song with the given url or query on the top of the queue.": "在隊列頂部添加具有給定網址或查詢的歌曲。", - "Input the name of the song. (Without URL)": "輸入歌曲名稱。(無網址)", + "SoundCloud": "SoundCloud", + "Apple Music": "Apple 音樂", + "playtop": "將歌曲加入頂部", + "Adds a song with the given url or query on the top of the queue.": "將指定的 URL 或查詢加入排隊的頂部。", + "forceplay": "強制播放", + "Enforce playback using the given URL or query.": "使用指定的 URL 或查詢強制播放。", + "pause": "暫停", "Pause the music.": "暫停音樂。", - "Resume the music.": "恢復音樂。", - "Skips to the next song or skips to the specified song.": "跳到下一首歌曲或跳到指定的歌曲。", - "index": "指數", - "Enter a index that you want to skip to.": "輸入要跳到的索引。", - "Skips back to the previous song or skips to the specified previous song.": "跳回上一首歌曲或跳到指定的上一首歌曲。", - "Enter a index that you want to skip back to.": "輸入要跳回的索引。", - "Change the player position.": "改變玩家位置。", + "resume": "繼續", + "Resume the music.": "繼續音樂。", + "skip": "跳過", + "Skips to the next song or skips to the specified song.": "跳過到下一首歌曲或跳過到指定的歌曲。", + "index": "索引", + "Enter a index that you want to skip to.": "輸入您要跳過到的索引。", + "back": "返回", + "Skips back to the previous song or skips to the specified previous song.": "跳回上一首歌曲或跳回到指定的上一首歌曲。", + "Enter a index that you want to skip back to.": "輸入您要跳回到的索引。", + "seek": "搜尋", + "Change the player position.": "更改播放器的位置。", "position": "位置", - "Input position. Exmaple: 1:20.": "輸入位置。示例:1:20。", - "Display the players queue songs in your queue.": "在您的隊列中顯示玩家隊列歌曲。", - "Display the players queue songs in your history queue.": "在您的歷史隊列中顯示玩家隊列歌曲。", - "Disconnects the bot from your voice channel and chears the queue.": "斷開機器人與您的語音通道的連接並監聽隊列。", - "Shows details of the current track.": "顯示當前曲目的詳細信息。", - "Changes Loop mode.": "更改循環模式。", + "Input position. Exmaple: 1:20.": "輸入位置。範例:1:20。", + "queue": "排隊", + "Display the players queue songs in your queue.": "顯示您的排隊歌曲。", + "export": "匯出", + "Exports the entire queue to a text file": "將整個排隊匯出到文字檔。", + "import": "匯入", + "Imports the text file and adds the track to the current queue.": "匯入文字檔並將歌曲加入目前的排隊。", + "attachment": "附件", + "history": "歷史", + "Display the players queue songs in your history queue.": "顯示您的歷史排隊歌曲。", + "leave": "離開", + "Disconnects the bot from your voice channel and chears the queue.": "從您的語音頻道斷開機器人連線並清除排隊。", + "nowplaying": "正在播放", + "Shows details of the current track.": "顯示目前歌曲的詳細資訊。", + "loop": "迴圈", + "Changes Loop mode.": "更改迴圈模式。", "mode": "模式", - "Choose a looping mode.": "選擇循環模式。", - "Off": "關掉", - "Track": "曲目", - "Queue": "隊列", - "Remove all the tracks in your queue or history queue.": "刪除隊列或歷史隊列中的所有曲目。", - "Choose a queue that you want to clear.": "選擇要清除的隊列。", + "Choose a looping mode.": "選擇迴圈模式。", + "Off": "關閉", + "Track": "歌曲", + "Queue": "排隊", + "clear": "清除", + "Remove all the tracks in your queue or history queue.": "清除您的排隊或歷史排隊中的所有歌曲。", + "Choose a queue that you want to clear.": "選擇您要清除的排隊。", "History": "歷史", - "Removes specified track or a range of tracks from the queue.": "從隊列中刪除指定的曲目或一系列曲目。", - "Input a position from the queue to be removed.": "從隊列中輸入要刪除的位置。", + "remove": "移除", + "Removes specified track or a range of tracks from the queue.": "刪除指定的歌曲或從排隊中刪除一系列歌曲。", + "position1": "位置1", + "Input a position from the queue to be removed.": "輸入要刪除的歌曲的位置。", "position2": "位置2", - "Set the range of the queue to be removed.": "設置要刪除的隊列範圍。", - "Forwards by a certain amount of time in the current track. The default is 10 seconds.": "在當前曲目中前進一定時間。默認值為 10 秒。", - "Input a amount that you to forward to. Exmaple: 1:20": "輸入您要轉發的時間。示例:1:20", - "Rewind by a certain amount of time in the current track. The default is 10 seconds.": "在當前曲目中倒退一定時間。默認值為 10 秒。", - "Input a amount that you to rewind to. Exmaple: 1:20": "輸入您要倒回的時間。示例:1:20", - "Reset the progress of the current song.": "重置當前歌曲的進度。", - "Randomizes the tracks in the queue.": "隨機化隊列中的曲目。", - "Moves the specified song to the specified position.": "將指定歌曲移動到指定位置。", - "track": "曲目", - "The track to move. Example: 2": "要移動的曲目。示例:2", - "The new position to move the track to. Exmaple: 1": "要將曲目移動到的新位置。示例:1", - "Displays lyrics for the playing track.": "顯示播放曲目的歌詞。", - "name": "歌名", - "Searches for your query and displays the reutned lyrics.": "搜索您的查詢並顯示 reutned 歌詞。", - "author": "作者", - "Seaches for lyrics by author.": "按作者搜索歌詞。", - "Transfer dj to another.": "將 dj 轉移到另一個。", + "Set the range of the queue to be removed.": "設定要刪除的排隊範圍。", "member": "成員", - "Choose a member to transfer the dj role.": "選擇一個成員來轉移 dj 角色。", - "Lists all chapters of the currently playing song (if any).": "列出當前播放歌曲的所有章節(如果有)。", - "Toggles autoplay mode, it will automatically queue the best songs to play.": "切換自動播放模式,它會自動排隊播放最好的歌曲。", + "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", + "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", + "replay": "重新播放", + "Reset the progress of the current song.": "重設目前歌曲的進度。", + "shuffle": "隨機播放", + "Randomizes the tracks in the queue.": "隨機排隊中的歌曲。", + "swap": "交換", + "Swaps the specified song to the specified song.": "交換指定的歌曲到另一個指定的歌曲。", + "The track to swap. Example: 2": "要交換的歌曲。範例:2", + "The track to swap with position1. Exmaple: 1": "與位置 1 交換的歌曲。範例:1", + "move": "移動", + "Moves the specified song to the specified position.": "將指定的歌曲移到指定的位置。", + "target": "目標", + "The track to move. Example: 2": "要移動的歌曲。範例:2", + "to": "到", + "The new position to move the track to. Exmaple: 1": "要移動到的新位置。範例:1", + "lyrics": "歌詞", + "Displays lyrics for the playing track.": "顯示播放中歌曲的歌詞。", + "title": "標題", + "Searches for your query and displays the reutned lyrics.": "搜尋您的查詢並顯示返回的歌詞。", + "artist": "藝術家", + "swapdj": "交換dj", + "Transfer dj to another.": "將 DJ 轉移給另一個。", + "Choose a member to transfer the dj role.": "選擇一個成員來轉移 DJ 角色。", + "autoplay": "自動播放", + "Toggles autoplay mode, it will automatically queue the best songs to play.": "切換自動播放模式,將自動將最好的歌曲加入排隊播放。", + "help": "幫助", "Lists all the commands in Vocard.": "列出 Vocard 中的所有命令。", - "category": "類別", - "Basic": "基本", - "Tutorial": "教程", - "DJ": "DJ", - "Admin": "管理員", - "Show all the donate plan on Vocard.": "在 Vocard 上顯示所有捐贈計劃。", - "You can buy plan by voting Vocard on Top.gg.": "您可以通過在 Top.gg 上投票 Vocard 來購買計劃。", - "Sets the player's playback speed.": "設置播放器的播放速度。", - "The value to set the speed to. Default is `1.0`": "將速度設置為的值。默認為`1.0`", - "Uses equalization to eliminate part of a band, usually targeting vocals.": "使用均衡來消除樂隊的一部分,通常針對人聲。", - "level": "等級", - "The level of the karaoke. Default is `1.0`": "卡拉OK的水平。默認為`1.0`", - "monolevel": "單級", - "The monolevel of the karaoke. Default is `1.0`": "卡拉OK的單聲道。默認為`1.0`", - "filterband": "濾帶", - "The filter band of the karaoke. Default is `220.0`": "卡拉 OK 的濾波器頻段。默認為`220.0`", - "filterwidth": "過濾器寬度", - "The filter band of the karaoke. Default is `100.0`": "卡拉 OK 的濾波器頻段。默認為`100.0`", - "Uses amplification to create a shuddering effect, where the volume quickly oscillates.": "使用放大來產生顫抖的效果,音量會快速振盪。", - "frequency": "頻率", - "The frequency of the tremolo. Default is `2.0`": "顫音的頻率。默認為`2.0`", - "depth": "深度", - "The depth of the tremolo. Default is `0.5`": "顫音的深度。默認為`0.5`", - "Similar to tremolo. While tremolo oscillates the volume, vibrato oscillates the pitch.": "類似於顫音。顫音使音量振盪,而顫音則使音高振盪。", - "The frequency of the vibrato. Default is `2.0`": "顫音的頻率。默認為`2.0`", - "The Depth of the vibrato. Default is `0.5`": "顫音的深度。默認為`0.5`", - "Rotates the sound around the stereo channels/user headphones aka Audio Panning.": "圍繞立體聲通道/用戶耳機旋轉聲音,即音頻平移。", - "hertz": "赫茲", - "The hertz of the rotation. Default is `0.2`": "旋轉的赫茲。默認為`0.2`", - "Distortion effect. It can generate some pretty unique audio effects.": "失真效果。它可以產生一些非常獨特的音頻效果。", - "Filter which supresses higher frequencies and allows lower frequencies to pass.": "抑制較高頻率並允許較低頻率通過的濾波器。", - "smoothing": "平滑", - "The level of the lowPass. Default is `20.0`": "低通的水平。默認為`20.0`", - "Filter which manually adjusts the panning of the audio.": "手動調整音頻平移的過濾器。", - "left_to_left": "左到左", - "Sounds from left to left. Default is `1.0`": "聲音從左到左。默認為`1.0`", - "right_to_right": "右到右", - "Sounds from right to right. Default is `1.0`": "聲音從右到右。默認為`1.0`", - "left_to_right": "左到右", - "Sounds from left to right. Default is `0.0`": "聲音從左到右。默認為`0.0`", - "right_to_left": "右到左", - "Sounds from right to left. Default is `0.0`": "聲音從右到左。默認為`0.0`", - "Add nightcore filter into your player.": "將夜核心過濾器添加到您的播放器中。", - "Clear all the sound effect.": "清除所有音效。", + "category": "分類", + "Test if the bot is alive, and see the delay between your commands and my response.": "測試機器人是否活躍,並查看您的命令和我的回應之間的延遲。", "playlist": "播放列表", - "Play all songs from your favorite playlist.": "播放您喜愛的播放列表中的所有歌曲。", - "Input the name of your custom playlist": "輸入您的自定義播放列表的名稱", - "Play the specific track from your custom playlist.": "播放自定義播放列表中的特定曲目。", - "List all your playlist and all songs in your favourite playlist.": "列出您喜歡的播放列表中的所有播放列表和所有歌曲。", - "Create your custom playlist.": "創建您的自定義播放列表。", - "Give a name to your playlist.": "為您的播放列表命名。", - "link": "網址", - "Provide a playlist link if you are creating link playlist.": "如果您正在創建鏈接播放列表,請提供播放列錶鍊接。", - "Delete your custom playlist.": "刪除您的自定義播放列表。", + "Play all songs from your favorite playlist.": "播放您最愛的播放列表中的所有歌曲。", + "name": "名稱", + "Input the name of your custom playlist": "輸入您自定義播放列表的名稱", + "value": "值", + "Play the specific track from your custom playlist.": "播放您自定義播放列表中的指定歌曲。", + "view": "查看", + "List all your playlist and all songs in your favourite playlist.": "列出您所有的播放列表和您最愛的播放列表中的所有歌曲。", + "create": "創建", + "Create your custom playlist.": "創建您自定義的播放列表。", + "Give a name to your playlist.": "給您的播放列表命名。", + "link": "鏈接", + "Provide a playlist link if you are creating link playlist.": "如果您正在創建鏈接播放列表,請提供播放列表鏈接。", + "delete": "刪除", + "Delete your custom playlist.": "刪除您自定義的播放列表。", "The name of the playlist.": "播放列表的名稱。", - "Share your custom playlist with your friends.": "與您的朋友分享您的自定義播放列表。", + "share": "分享", + "Share your custom playlist with your friends.": "與您的朋友分享自定義的播放列表。", "The user id of your friend.": "您朋友的用戶 ID。", - "The name of the playlist that you want to share.": "您要共享的播放列表的名稱。", - "Rename your custom playlist.": "重命名您的自定義播放列表。", - "The name of your playlist.": "您的播放列表的名稱。", - "newname": "新名字", - "The new name of your playlist.": "播放列表的新名稱。", - "Show your playlist invitation.": "顯示您的播放列表邀請。", - "Add tracks in to your custom playlist.": "將曲目添加到您的自定義播放列表中。", - "Remove song from your favorite playlist.": "從您最喜歡的播放列表中刪除歌曲。", - "Input a position from the playlist to be removed.": "從播放列表中輸入要刪除的位置。", - "Remove all songs from your favorite playlist.": "從您喜歡的播放列表中刪除所有歌曲。", - "8d": "8d", - "Add 8D filter into your player.": "在播放器中添加 8D 濾鏡。", - "cleareffect": "清除音效", - "Clear all or specific sound effects.": "清除所有或特定音效。", - "effect": "音效", - "Remove a specific sound effects.": "移除特定音效。", - "vaporwave": "蒸汽波", - "Add vaporwave filter into your player.": "將蒸汽波過濾器添加到您的播放器中。", - "Change to another type of queue mode.": "更改為另一種隊列模式。", + "The name of the playlist that you want to share.": "您想要分享的播放列表名稱。", + "rename": "重新命名", + "Rename your custom playlist.": "重新命名自定義的播放列表。", + "The name of your playlist.": "您的播放列表名稱。", + "newname": "新名稱", + "The new name of your playlist.": "您的播放列表的新名稱。", + "inbox": "收件箱", + "Show your playlist invitation.": "顯示播放列表邀請。", + "add": "添加", + "Add tracks in to your custom playlist.": "將歌曲添加到自定義的播放列表。", + "Remove song from your favorite playlist.": "從最愛的播放列表中刪除歌曲。", + "Input a position from the playlist to be removed.": "輸入要刪除的歌曲的位置。", + "Remove all songs from your favorite playlist.": "刪除最愛的播放列表中的所有歌曲。", + "Exports the entire playlist to a text file": "將整個播放列表匯出到文字檔。", + "settings": "設定", + "prefix": "前綴", + "Change the default prefix for message commands.": "更改消息命令的預設前綴。", + "language": "語言", + "You can choose your preferred language, the bot message will change to the language you set.": "您可以選擇喜好的語言,機器人訊息將會改為您所設定的語言。", + "Set a DJ role or remove DJ role.": "設置或移除 DJ 角色。", + "role": "角色", + "Change to another type of queue mode.": "更改為另一种隊列模式。", "FairQueue": "公平隊列", - "Playlist": "播放列表", - "duplicatetrack": "重複軌道", - "toggle": "切換", - "Enable": "啟用", - "Disable": "停用", - "Toggle Vocard to prevent duplicate songs from queuing.": "切換 Vocard 以防止重複歌曲排隊。", - "Remove tracks requested by a specific member.": "刪除特定成員請求的曲目。", - "settings": "設置" + "Toggles 24/7 mode, which disables automatic inactivity-based disconnects.": "切換 24/7 模式,禁用自動休眠斷線。", + "bypassvote": "繞過投票", + "Toggles voting system.": "切換投票系統。", + "Show all the bot settings in your server.": "顯示機器人所有設定在您的服務器中。", + "volume": "音量", + "Set the player's volume.": "設置播放器的音量。", + "Input a integer.": "輸入一個整數。", + "togglecontroller": "切換控制器", + "Toggles the music controller.": "切換音樂控制器。", + "duplicatetrack": "重複歌曲", + "Toggle Vocard to prevent duplicate songs from queuing.": "切換 Vocard 以防止重複歌曲加入隊列。", + "customcontroller": "自定義控制器", + "Customizes music controller embeds.": "自定義音樂控制器嵌入。", + "controllermsg": "控制器訊息", + "Toggles to send a message when clicking the button in the music controller.": "切換發送訊息當點擊音樂控制器中的按鈕。", + "debug": "除錯", + "speed": "速度", + "Sets the player's playback speed": "設置播放器的播放速度。", + "The value to set the speed to. Default is `1.0`": "設置速度的值。預設為 `1.0`。", + "karaoke": "卡拉ok", + "Uses equalization to eliminate part of a band, usually targeting vocals.": "使用均衡器消除頻帶的一部分,通常針對人聲。", + "level": "級別", + "The level of the karaoke. Default is `1.0`": "卡拉 OK 的級別。預設為 `1.0`。", + "monolevel": "單聲道級別", + "The monolevel of the karaoke. Default is `1.0`": "卡拉 OK 的單聲道級別。預設為 `1.0`。", + "filterband": "濾波頻帶", + "The filter band of the karaoke. Default is `220.0`": "卡拉 OK 的濾波頻帶。預設為 `220.0`。", + "filterwidth": "濾波寬度", + "The filter band of the karaoke. Default is `100.0`": "卡拉 OK 的濾波頻帶。預設為 `100.0`", + "tremolo": "顫音", + "Uses amplification to create a shuddering effect, where the volume quickly oscillates.": "使用放大來創建顫音效果,音量快速振盪。", + "frequency": "頻率", + "The frequency of the tremolo. Default is `2.0`": "顫音的頻率。預設為 `2.0`", + "depth": "深度", + "The depth of the tremolo. Default is `0.5`": "顫音的深度。預設為 `0.5`", + "vibrato": "振動", + "Similar to tremolo. While tremolo oscillates the volume, vibrato oscillates the pitch.": "與顫音相似。顫音振盪音量,而振動振盪音高。", + "The frequency of the vibrato. Default is `2.0`": "振動的頻率。預設為 `2.0`", + "The Depth of the vibrato. Default is `0.5`": "振動的深度。預設為 `0.5`", + "rotation": "旋轉", + "Rotates the sound around the stereo channels/user headphones aka Audio Panning.": "旋轉聲音在立體聲道/用戶耳機中,也就是音頻泛音。", + "hertz": "赫茲", + "The hertz of the rotation. Default is `0.2`": "旋轉的赫茲。預設為 `0.2`", + "distortion": "失真", + "Distortion effect. It can generate some pretty unique audio effects.": "失真效果。可以生成一些非常獨特的音頻效果。", + "lowpass": "低通", + "Filter which supresses higher frequencies and allows lower frequencies to pass.": "濾波器,抑制高頻率,允許低頻率通過。", + "smoothing": "平滑", + "The level of the lowPass. Default is `20.0`": "低通的平滑級別。預設為 `20.0`", + "channelmix": "頻道混合", + "Filter which manually adjusts the panning of the audio.": "濾波器,手動調整音頻的泛音。", + "left_to_left": "左到左", + "Sounds from left to left. Default is `1.0`": "左聲道到左聲道。預設為 `1.0`", + "right_to_right": "右到右", + "Sounds from right to right. Default is `1.0`": "右聲道到右聲道。預設為 `1.0`", + "left_to_right": "左到右", + "Sounds from left to right. Default is `0.0`": "左聲道到右聲道。預設為 `0.0`", + "right_to_left": "右到左", + "Sounds from right to left. Default is `0.0`": "右聲道到左聲道。預設為 `0.0`", + "nightcore": "夜核", + "Add nightcore filter into your player.": "將夜核濾波器添加到您的播放器中。", + "Add 8D filter into your player.": "將 8D 濾波器添加到您的播放器中。", + "vaporwave": "水蒸波", + "Add vaporwave filter into your player.": "將水蒸波濾波器添加到您的播放器中。", + "cleareffect": "清除效果", + "Clear all or specific sound effects.": "清除所有或指定的音效。", + "effect": "效果", + "Remove a specific sound effects.": "刪除指定的音效。" } \ No newline at end of file diff --git a/main.py b/main.py index 329682e..e2c6b9e 100644 --- a/main.py +++ b/main.py @@ -1,24 +1,24 @@ import discord import sys import os -import traceback import aiohttp import update +import logging import function as func from discord.ext import commands -from web import IPCServer +from ipc import IPCClient from motor.motor_asyncio import AsyncIOMotorClient -from datetime import datetime +from logging.handlers import TimedRotatingFileHandler from voicelink import VoicelinkException from addons import Settings class Translator(discord.app_commands.Translator): async def load(self): - print("Loaded Translator") + func.logger.info("Loaded Translator") async def unload(self): - print("Unload 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: @@ -29,12 +29,7 @@ class Vocard(commands.Bot): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.ipc = IPCServer( - self, - host=func.settings.ipc_server["host"], - port=func.settings.ipc_server["port"], - secret_key=func.tokens.secret_key - ) + self.ipc: IPCClient async def on_message(self, message: discord.Message, /) -> None: if message.author.bot or not message.guild: @@ -49,16 +44,17 @@ class Vocard(commands.Bot): await self.process_commands(message) async def connect_db(self) -> None: - if not ((db_name := func.tokens.mongodb_name) and (db_url := func.tokens.mongodb_url)): + if not ((db_name := func.settings.mongodb_name) and (db_url := func.settings.mongodb_url)): raise Exception("MONGODB_NAME and MONGODB_URL can't not be empty in settings.json") try: func.MONGO_DB = AsyncIOMotorClient(host=db_url) await func.MONGO_DB.server_info() - print("Successfully connected to MongoDB!") + func.logger.info(f"Successfully connected to [{db_name}] MongoDB!") except Exception as e: - raise Exception("Not able to connect MongoDB! Reason:", e) + func.logger.error("Not able to connect MongoDB! Reason:", exc_info=e) + exit() func.SETTINGS_DB = func.MONGO_DB[db_name]["Settings"] func.USERS_DB = func.MONGO_DB[db_name]["Users"] @@ -74,12 +70,16 @@ class Vocard(commands.Bot): if module.endswith('.py'): try: await self.load_extension(f"cogs.{module[:-3]}") - print(f"Loaded {module[:-3]}") + func.logger.info(f"Loaded {module[:-3]}") except Exception as e: - print(traceback.format_exc()) + func.logger.error(f"Something went wrong while loading {module[:-3]} cog.", exc_info=e) - if func.settings.ipc_server.get("enable", False): - await self.ipc.start() + self.ipc = IPCClient(self, **func.settings.ipc_client) + if func.settings.ipc_client.get("enable", False): + try: + await self.ipc.connect() + except Exception as e: + 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__}) @@ -88,15 +88,15 @@ class Vocard(commands.Bot): await self.tree.sync() async def on_ready(self): - print("------------------") - print(f"Logging As {self.user}") - print(f"Bot ID: {self.user.id}") - print("------------------") - print(f"Discord Version: {discord.__version__}") - print(f"Python Version: {sys.version}") - print("------------------") + func.logger.info("------------------") + func.logger.info(f"Logging As {self.user}") + func.logger.info(f"Bot ID: {self.user.id}") + func.logger.info("------------------") + func.logger.info(f"Discord Version: {discord.__version__}") + func.logger.info(f"Python Version: {sys.version}") + func.logger.info("------------------") - func.tokens.client_id = self.user.id + func.settings.client_id = self.user.id func.LOCAL_LANGS.clear() async def on_command_error(self, ctx: commands.Context, exception, /) -> None: @@ -124,10 +124,8 @@ class Vocard(commands.Bot): elif not issubclass(error.__class__, VoicelinkException): error = await func.get_lang(ctx.guild.id, "unknownException") + func.settings.invite_link - if (guildId := ctx.guild.id) not in func.ERROR_LOGS: - func.ERROR_LOGS[guildId] = {} - func.ERROR_LOGS[guildId][round(datetime.timestamp(datetime.now()))] = "".join(traceback.format_exception(type(exception), exception, exception.__traceback__)) - + 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) + try: return await ctx.reply(error, ephemeral=True) except: @@ -139,30 +137,42 @@ class CommandCheck(discord.app_commands.CommandTree): await interaction.response.send_message("This command can only be used in guilds!") return False - return await super().interaction_check(interaction) - + return True + async def get_prefix(bot, message: discord.Message): settings = await func.get_settings(message.guild.id) return settings.get("prefix", func.settings.bot_prefix) -# Loading settings +# Loading settings and logger func.settings = Settings(func.open_json("settings.json")) +LOG_SETTINGS = func.settings.logging +if (LOG_FILE := LOG_SETTINGS.get("file", {})).get("enable", True): + log_path = os.path.abspath(LOG_FILE.get("path", "./logs")) + if not os.path.exists(log_path): + os.makedirs(log_path) + + 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) + # Setup the bot object intents = discord.Intents.default() intents.message_content = True if func.settings.bot_prefix else False -intents.members = True -member_cache = discord.MemberCacheFlags( - voice=True, - joined=False -) +intents.members = func.settings.ipc_client.get("enable", False) +intents.voice_states = True bot = Vocard( command_prefix=get_prefix, help_command=None, tree_cls=CommandCheck, chunk_guilds_at_startup=False, - member_cache_flags=member_cache, activity=discord.Activity(type=discord.ActivityType.listening, name="Starting..."), case_insensitive=True, intents=intents @@ -170,4 +180,4 @@ bot = Vocard( if __name__ == "__main__": update.check_version(with_msg=True) - bot.run(func.tokens.token, log_handler=None) \ No newline at end of file + bot.run(func.settings.token, root_logger=True) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 49db6db..4e4da3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,8 @@ -discord.py==2.3.2 +discord.py==2.4.0 motor==3.3.2 dnspython==2.2.1 tldextract==3.2.1 validators==0.18.2 humanize==4.0.0 -python-dotenv==0.20.0 beautifulsoup4==4.11.1 -websockets==10.4 -Flask==2.2.3 -Flask-SocketIO==5.3.2 -psutil==5.9.5 -werkzeug==2.2.2 +psutil==5.9.8 \ No newline at end of file diff --git a/settings Example.json b/settings Example.json index 2920f03..d9d60b2 100644 --- a/settings Example.json +++ b/settings Example.json @@ -1,4 +1,11 @@ { + "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", "nodes": { "DEFAULT": { "host": "lavalink", @@ -9,16 +16,30 @@ } }, "prefix": "?", - "activity":[ - {"listen": "/help"} + "activity": [ + {"type": "listening", "name": "/help", "status": "online"} ], + "logging": { + "file": { + "path": "./logs", + "enable": true + }, + "level": { + "discord": "INFO", + "vocard": "INFO", + "ipc_client": "INFO" + }, + "max-history": 30 + }, "bot_access_user": [], "embed_color":"0xb3b3b3", "default_max_queue": 1000, - "lyrics_platform": "A_ZLyrics", - "ipc_server": { + "lyrics_platform": "lyrist", + "ipc_client": { "host": "127.0.0.1", "port": 8000, + "password": "YOUR_PASSWORD", + "secure": false, "enable": false }, "sources_settings": { @@ -66,7 +87,7 @@ "default_controller": { "embeds": { "active": { - "description": "**Now Playing: ```[@@track_name@@]```\nLink: [Click Me](@@track_url@@) | Requester: @@requester@@ | DJ: @@dj@@**", + "description": "**Now Playing: ```[@@track_name@@]```\nLink: [Click Me](@@track_url@@) | Requester: <#@@track_requester_id@@> | DJ: @@dj@@**", "footer": { "text": "Queue Length: @@queue_length@@ | Duration: @@track_duration@@ | Volume: @@volume@@% {{loop_mode != 'Off' ?? | Repeat: @@loop_mode@@}}" }, @@ -92,6 +113,7 @@ ], "disableButtonText": false }, + "default_voice_status_template": "{{@@track_name@@ != 'None' ?? @@track_source_emoji@@ Now Playing: @@track_name@@ // Waiting for song requests}}", "cooldowns": { "connect": [2, 30], "playlist view": [1, 30] diff --git a/supervisord.conf b/supervisord.conf deleted file mode 100644 index 3a75ad4..0000000 --- a/supervisord.conf +++ /dev/null @@ -1,17 +0,0 @@ -[supervisord] -nodaemon=true -user=root - -[program:main] -command=python -u /app/main.py -autostart=true -autorestart=true -stderr_logfile=/var/log/main.err.log -stdout_logfile=/var/log/main.out.log - -[program:webapp] -command=python -u /app/web/webapp.py -autostart=true -autorestart=true -stderr_logfile=/var/log/webapp.err.log -stdout_logfile=/var/log/webapp.out.log \ No newline at end of file diff --git a/update.py b/update.py index 482af02..0c0c4b2 100644 --- a/update.py +++ b/update.py @@ -2,11 +2,11 @@ import requests, zipfile, os, shutil, argparse from io import BytesIO ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) -__version__ = "v2.6.8a" +__version__ = "v2.6.9" GITHUB_API_URL = "https://api.github.com/repos/ChocoMeow/Vocard/releases/latest" VOCARD_URL = "https://github.com/ChocoMeow/Vocard/archive/" -IGNORE_FILES = ["settings.json", ".env"] +IGNORE_FILES = ["settings.json", "logs"] class bcolors: WARNING = '\033[93m' diff --git a/views/controller.py b/views/controller.py index 3a65e7b..3c75aa5 100644 --- a/views/controller.py +++ b/views/controller.py @@ -26,15 +26,10 @@ import voicelink import function as func from discord.ext import commands -from . import ButtonOnCooldown -from function import ( - get_user, - update_user, - check_roles -) - from typing import Dict +from . import ButtonOnCooldown + def key(interaction: discord.Interaction): return interaction.user @@ -48,7 +43,7 @@ class ControlButton(discord.ui.Button): self.player: voicelink.Player = player self.disable_button_text: bool = func.settings.controller.get("disableButtonText", False) - super().__init__(label=player.get_msg(label) if label and not self.disable_button_text else None, **kwargs) + 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: stay = self.player.settings.get("controller_msg", True) @@ -114,7 +109,6 @@ class Resume(ControlButton): if len(votes) < (required := self.player.required()): return await self.send(interaction, f"{vote_type}Vote", interaction.user, len(votes), required) - votes.clear() self.emoji = emoji if not self.disable_button_text: self.label = await func.get_lang(interaction.guild.id, button) @@ -186,14 +180,14 @@ class Add(ControlButton): return await self.send(interaction, "noTrackPlaying") if track.is_stream: return await self.send(interaction, "playlistAddError") - user = await get_user(interaction.user.id, 'playlist') - rank, max_p, max_t = check_roles() + user = await func.get_user(interaction.user.id, 'playlist') + rank, max_p, max_t = func.check_roles() if len(user['200']['tracks']) >= max_t: return await self.send(interaction, "playlistlimited", max_t, ephemeral=True) if track.track_id in user['200']['tracks']: return await self.send(interaction, "playlistrepeated", ephemeral=True) - respond = await update_user(interaction.user.id, {"$push": {'playlist.200.tracks': track.track_id}}) + respond = await func.update_user(interaction.user.id, {"$push": {'playlist.200.tracks': track.track_id}}) if respond: await self.send(interaction, "playlistAdded", track.title, interaction.user.mention, user['200']['name'], ephemeral=True) else: @@ -201,19 +195,35 @@ class Add(ControlButton): class Loop(ControlButton): def __init__(self, **kwargs): + self.btn_emojis: dict[str, str] = { + "off": "🔁", + "track": "🔂", + "queue": "🔁" + } + super().__init__( - emoji="🔁", + emoji=self.get_next_loop_emoji(kwargs["player"]), label="buttonLoop", **kwargs ) + def get_next_loop_emoji(self, player) -> str: + current_repeat_mode = player.queue.repeat.lower() + if current_repeat_mode not in self.btn_emojis: + raise ValueError(f"Invalid repeat mode: {current_repeat_mode}") + + return self.btn_emojis[current_repeat_mode] + async def callback(self, interaction: discord.Interaction): if not self.player.is_privileged(interaction.user): return await self.send(interaction, 'missingPerms_mode', ephemeral=True) - mode = await self.player.set_repeat() + mode = await self.player.set_repeat(requester=interaction.user) + self.emoji = self.get_next_loop_emoji(self.player) + + await interaction.response.edit_message(view=self.view) await self.send(interaction, 'repeat', mode.capitalize()) - + class VolumeUp(ControlButton): def __init__(self, **kwargs): super().__init__( @@ -364,8 +374,7 @@ class Tracks(discord.ui.Select): options.append(discord.SelectOption(label=f"{index}. {track.title[:40]}", description=f"{track.author[:30]} · " + ("Live" if track.is_stream else track.formatted_length), emoji=track.emoji)) super().__init__( - placeholder=player.get_msg("playerDropdown"), - min_values=1, max_values=1, + placeholder=self.player.get_msg("playerDropdown"), options=options, row=row ) @@ -380,7 +389,39 @@ class Tracks(discord.ui.Select): if self.player.settings.get("controller_msg", True): await func.send(interaction, "skipped", interaction.user) -btnType = { +class Effects(discord.ui.Select): + def __init__(self, player, style, row): + + self.player: voicelink.Player = player + + options = [discord.SelectOption(label="None", value="None")] + for name in voicelink.Filters.get_available_filters(): + options.append(discord.SelectOption(label=name.capitalize(), value=name)) + + super().__init__( + placeholder=self.player.get_msg("playerFilter"), + options=options, + row=row + ) + + async def callback(self, interaction: discord.Interaction): + if not self.player.is_privileged(interaction.user): + return await func.send(interaction, "missingPerms_function", ephemeral=True) + + avalibable_filters = voicelink.Filters.get_available_filters() + if self.values[0] == "None": + await self.player.reset_filter(requester=interaction.user) + return await func.send(interaction, "clearEffect") + + selected_filter = avalibable_filters.get(self.values[0].lower())() + if self.player.filters.has_filter(filter_tag=selected_filter.tag): + await self.player.remove_filter(filter_tag=selected_filter.tag, requester=interaction.user) + await func.send(interaction, "clearEffect") + else: + await self.player.add_filter(selected_filter, requester=interaction.user) + await func.send(interaction, "addEffect", selected_filter.tag) + +BUTTONTYPE: Dict[str, ControlButton] = { "back": Back, "resume": Resume, "skip": Skip, @@ -390,14 +431,15 @@ btnType = { "volumeup": VolumeUp, "volumedown": VolumeDown, "volumemute": VolumeMute, - "tracks": Tracks, "autoplay": AutoPlay, "shuffle": Shuffle, "forward": Forward, - "rewind": Rewind + "rewind": Rewind, + "tracks": Tracks, + "effects": Effects } -btnColor = { +BUTTONCOLOR: Dict[str, discord.ButtonStyle] = { "blue": discord.ButtonStyle.primary, "grey": discord.ButtonStyle.secondary, "red": discord.ButtonStyle.danger, @@ -415,8 +457,8 @@ class InteractiveController(discord.ui.View): if isinstance(btn, Dict): color = list(btn.values())[0] btn = list(btn.keys())[0] - btnClass = btnType.get(btn.lower()) - style = btnColor.get(color.lower(), btnColor["grey"]) + btnClass = BUTTONTYPE.get(btn.lower()) + style = BUTTONCOLOR.get(color.lower(), BUTTONCOLOR["grey"]) if not btnClass or (self.player.queue.is_empty and btn == "tracks"): continue self.add_item(btnClass(player=player, style=style, row=row)) @@ -448,4 +490,4 @@ class InteractiveController(discord.ui.View): elif isinstance(error, Exception): await interaction.response.send_message(error) - return + return \ No newline at end of file diff --git a/views/debug.py b/views/debug.py index 0f9b14a..65ddb9c 100644 --- a/views/debug.py +++ b/views/debug.py @@ -22,12 +22,14 @@ SOFTWARE. """ import discord -import function import io import contextlib import textwrap import traceback +import voicelink +import function as func +from typing import Optional from discord.ext import commands class ExceuteModal(discord.ui.Modal): @@ -49,13 +51,76 @@ class ExceuteModal(discord.ui.Modal): self.code = self.children[0].value self.stop() +class AddNodeModal(discord.ui.Modal): + def __init__(self, view, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.view: NodesPanel = view + + self.add_item( + discord.ui.TextInput( + label="Host", + placeholder="Enter the lavalink host e.g 0.0.0.0" + ) + ) + self.add_item( + discord.ui.TextInput( + label="Port", + placeholder="Enter the lavalink port e.g 2333" + ) + ) + self.add_item( + discord.ui.TextInput( + label="Password", + placeholder="Enter the lavalink password" + ) + ) + self.add_item( + discord.ui.TextInput( + label="Secure", + placeholder="Specify if your Lavalink uses SSL. Enter 'true' or 'false'" + ) + ) + self.add_item( + discord.ui.TextInput( + label="Identifier", + placeholder="Enter a name for your lavalink server" + ) + ) + + async def on_submit(self, interaction: discord.Interaction): + try: + config = { + "host": self.children[0].value, + "port": int(self.children[1].value), + "password": self.children[2].value, + "secure": self.children[3].value.lower() == "true", + "identifier": self.children[4].value + } + except Exception: + return await interaction.response.send_message("Some of your input is invalid! Please try again.", ephemeral=True) + + await interaction.response.defer() + 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 + ) + await interaction.followup.send(f"Node {self.children[4].value} is connected!", ephemeral=True) + await self.view.message.edit(embed=self.view.build_embed(), view=self.view) + + except Exception as e: + return await interaction.followup.send(e, ephemeral=True) + class CogsDropdown(discord.ui.Select): def __init__(self, bot: commands.Bot): self.bot: commands.Bot = bot super().__init__( placeholder="Select a cog to reload...", - min_values=1, max_values=1, options=[discord.SelectOption(label="All", description="All the cogs")] + [ discord.SelectOption(label=name.capitalize(), description=cog.description[:50]) @@ -76,6 +141,42 @@ class CogsDropdown(discord.ui.Select): await interaction.response.send_message(f"Reloaded `{selected}` sucessfully!", ephemeral=True) +class NodesDropdown(discord.ui.Select): + def __init__(self, bot: commands.Bot): + self.bot: commands.Bot = bot + self.view: NodesPanel + + super().__init__( + placeholder="Select a node to edit...", + options=self.get_nodes() + ) + + def get_nodes(self) -> list[discord.SelectOption]: + nodes = [ + discord.SelectOption( + label=name, + description=("🟢 Connected" if node._available else "🔴 Disconnected") + f" - Players: {node.player_count} ({node.latency if node._available else 0:.2f}ms)") + for name, node in voicelink.NodePool._nodes.items() + ] + + if not nodes: + nodes = [discord.SelectOption(label="The node could not be found!")] + + return nodes + + def update(self) -> None: + self.options = self.get_nodes() + + async def callback(self, interaction: discord.Interaction) -> None: + selected_node = self.values[0] + node = voicelink.NodePool._nodes.get(selected_node, None) + if not node: + return await interaction.response.send_message("The node could not be found!", ephemeral=True) + + self.view.selected_node = node + await interaction.response.defer() + await self.view.message.edit(embed=self.view.build_embed(), view=self.view) + class ExceutePanel(discord.ui.View): def __init__(self, bot, *, timeout = 180): self.bot: commands.Bot = bot @@ -160,12 +261,104 @@ class ExceutePanel(discord.ui.View): result = ''.join(traceback.format_exception(self._error, self._error, self._error.__traceback__)) await self.message.edit(content=f"```py\n{result}```") +class NodesPanel(discord.ui.View): + def __init__(self, bot, *, timeout: float | None = 180): + super().__init__(timeout=timeout) + self.message: Optional[discord.Message] = None + self.selected_node: Optional[voicelink.Node] = None + + self.add_item(NodesDropdown(bot)) + + def update_btn_status(self) -> None: + for child in self._children: + if isinstance(child, discord.ui.Button) and child.label != "Add": + child.disabled = self.selected_node is None + + if isinstance(child, discord.ui.Select): + child.update() + + def build_embed(self) -> discord.Embed: + self.update_btn_status() + embed = discord.Embed(title="📡 Nodes Panel", color=func.settings.embed_color) + + if not voicelink.NodePool._nodes: + embed.description = "```There are no nodes are connected!```" + + else: + for name, node in voicelink.NodePool._nodes.items(): + if self.selected_node and self.selected_node._identifier != node._identifier: + continue + + if node._available: + total_memory = node.stats.used + node.stats.free + embed.add_field( + name=f"{name} Node - 🟢 Connected", + value=f"```• ADDRESS: {node._host}:{node._port}\n" \ + f"• PLAYERS: {len(node._players)}\n" \ + f"• CPU: {node.stats.cpu_process_load:.1f}%\n" \ + f"• RAM: {func.format_bytes(node.stats.free)}/{func.format_bytes(total_memory, True)} ({(node.stats.free/total_memory) * 100:.1f}%)\n" + f"• LATENCY: {node.latency:.2f}ms\n" \ + f"• UPTIME: {func.time(node.stats.uptime)}```" + ) + else: + embed.add_field( + name=f"{name} Node - 🔴 Disconnected", + value=f"```• ADDRESS: {node._host}:{node._port}\n" \ + f"• PLAYERS: {len(node._players)}\nNo extra data is available for display```", + ) + + return embed + + async def on_error(self, interaction: discord.Interaction, error, item) -> None: + return await interaction.followup.send(error, ephemeral=True) + + @discord.ui.button(label="Add", emoji="➕", style=discord.ButtonStyle.green) + async def add(self, interaction: discord.Interaction, button: discord.ui.Button): + modal = AddNodeModal(self, title="Create Node") + await interaction.response.send_modal(modal) + + @discord.ui.button(label="Remove", emoji="➖", style=discord.ButtonStyle.red, disabled=True) + async def remove(self, interaction: discord.Interaction, button: discord.ui.Button): + if not self.selected_node: + return await interaction.response.send_message("Please ensure that you have selected a node!", ephemeral=True) + + identifier = self.selected_node._identifier + await self.selected_node.disconnect(remove_from_pool=True) + + self.selected_node = None + + await self.message.edit(embed=self.build_embed(), view=self) + await interaction.response.send_message(f"Removed {identifier} Node from the bot.", ephemeral=True) + + @discord.ui.button(label="Reconnect", disabled=True, row=1) + async def reconnect(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.defer() + if self.selected_node.is_connected: + await self.selected_node.disconnect() + await self.selected_node.connect() + await self.message.edit(embed=self.build_embed(), view=self) + + @discord.ui.button(label="Connect", disabled=True, row=1) + async def connect(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.defer() + if not self.selected_node.is_connected: + await self.selected_node.connect() + await self.message.edit(embed=self.build_embed(), view=self) + + @discord.ui.button(label="Disconnect", style=discord.ButtonStyle.red, disabled=True, row=1) + async def disconnect(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.defer() + if self.selected_node.is_connected: + await self.selected_node.disconnect() + + await self.message.edit(embed=self.build_embed(), view=self) + class CogsView(discord.ui.View): def __init__(self, bot, *, timeout: float | None = 180): super().__init__(timeout=timeout) self.add_item(CogsDropdown(bot)) - + class DebugView(discord.ui.View): def __init__(self, bot, *, timeout: float | None = 180): self.bot: commands.Bot = bot @@ -177,13 +370,18 @@ class DebugView(discord.ui.View): async def run_command(self, interaction: discord.Interaction, button: discord.ui.Button): await self.panel.execute(interaction) - @discord.ui.button(label='Cogs', emoji="🔃") + @discord.ui.button(label='Cogs', emoji="⚙️") async def reload_cog(self, interaction: discord.Interaction, button: discord.ui.Button): return await interaction.response.send_message("Reload Cogs", view=CogsView(self.bot), ephemeral=True) - @discord.ui.button(label='Send Logs', emoji="📥", style=discord.ButtonStyle.red) - async def send_error_logs(self, interaction: discord.Interaction, button: discord.ui.Button): - if not function.ERROR_LOGS: - return await interaction.response.send_message("Sorry there are not error logs!", ephemeral=True) - - await interaction.response.send_message(file=function.gen_report(), ephemeral=True) \ No newline at end of file + @discord.ui.button(label="Re-Sync", emoji="🔄") + async def sync(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_message("🔄 Synchronizing all your commands and language settings!", ephemeral=True) + await self.bot.tree.sync() + await interaction.edit_original_response(content="✅ All commands and settings have been successfully synchronized!") + + @discord.ui.button(label="Nodes", emoji="📡") + 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 diff --git a/views/help.py b/views/help.py index 1d5682a..6402e7a 100644 --- a/views/help.py +++ b/views/help.py @@ -56,8 +56,8 @@ class HelpView(discord.ui.View): 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.add_item(discord.ui.Button(label='Support', emoji=':support:915152950471581696', url=func.settings.invite_link)) - self.add_item(discord.ui.Button(label='Invite', emoji=':invite:915152589056790589', url=f'https://discord.com/oauth2/authorize?client_id={bot.user.id}&permissions=2184260928&scope=bot%20applications.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)) diff --git a/views/inbox.py b/views/inbox.py index 6ef5fa9..dc7d00d 100644 --- a/views/inbox.py +++ b/views/inbox.py @@ -21,7 +21,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -import discord +import discord, time import function as func from typing import Any @@ -63,7 +63,7 @@ class InboxView(discord.ui.View): ) if self.current: - embed.add_field(name="Message Info:", value=f"```{self.current['description']}\nSender ID: {self.current['sender']}\nPlaylist ID: {self.current['referId']}\nInvite Time: {self.current['time'].strftime('%d-%m %H:%M:%S')}```") + embed.add_field(name="Message Info:", value=f"```{self.current['description']}\nSender ID: {self.current['sender']}\nPlaylist ID: {self.current['referId']}\nInvite Time: {time.strftime('%d-%m %H:%M:%S', time.gmtime(int(self.current['time'])))}```") return embed async def button_change(self, interaction: discord.Interaction): diff --git a/voicelink/enums.py b/voicelink/enums.py index 205f016..c8c53fa 100644 --- a/voicelink/enums.py +++ b/voicelink/enums.py @@ -59,6 +59,17 @@ class SearchType(Enum): def __str__(self) -> str: return self.value +class RequestMethod(Enum): + """The enum for the different request methods in Voicelink + """ + get = "get" + patch = "patch" + delete = "delete" + post = "post" + + def __str__(self) -> str: + return self.value + class NodeAlgorithm(Enum): """The enum for the different node algorithms in Voicelink. @@ -77,6 +88,7 @@ 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() def __str__(self) -> str: return self.value \ No newline at end of file diff --git a/voicelink/filters.py b/voicelink/filters.py index 7751793..f20d7d1 100644 --- a/voicelink/filters.py +++ b/voicelink/filters.py @@ -37,9 +37,19 @@ class Filter: these filters will not work. """ def __init__(self): - self.payload: Dict[str, List] = None + self.payload: Dict[str, float] = None + self.scope: Dict[str, List[int]] = None self.tag: str = None + def _init_with_scope(self, scope: Dict[str, List[int]], **kwargs): + self.scope = scope + for prop, (min_val, max_val) in scope.items(): + setattr(self, prop, kwargs.get(prop, scope[prop][0])) + if not min_val <= getattr(self, prop) <= max_val: + raise FilterInvalidArgument(f"{self.__class__.__name__} {prop} must be between {min_val} and {max_val}.") + self.tag = kwargs.get("tag") + self.payload = {self.__class__.__name__.lower(): {prop: getattr(self, prop) for prop in scope}} + class Filters: def __init__(self) -> None: self._filters: List[Filter] = [] @@ -71,6 +81,20 @@ class Filters: def get_filters(self) -> List[Filter]: return self._filters + + @classmethod + def get_available_filters(cls) -> Dict[str, Filter]: + return { + "karaoke": Karaoke, + "tremolo": Tremolo, + "vibrato": Vibrato, + "rotation": Rotation, + "distortion": Distortion, + "lowpass": LowPass, + "nightcore": Timescale.nightcore, + "vaporwave": Timescale.vaporwave, + "8d": Rotation.nightD, + } class Equalizer(Filter): """ @@ -80,7 +104,7 @@ class Equalizer(Filter): The format for the levels is: List[Tuple[int, float]] """ - def __init__(self, *, tag: str, levels: list): + def __init__(self, *, tag: str = "equalizer", levels: list): super().__init__() self.eq = self._factory(levels) @@ -156,7 +180,6 @@ class Equalizer(Filter): ] return cls(tag="piano", levels=levels) - class Timescale(Filter): """Filter which changes the speed and pitch of a track. You can make some very nice effects with this filter, @@ -167,32 +190,17 @@ class Timescale(Filter): def __init__( self, *, - tag: str, + tag: str = "timescale", speed: float = 1.0, pitch: float = 1.0, rate: float = 1.0 ): super().__init__() - - if speed < 0: - raise FilterInvalidArgument("Timescale speed must be more than 0.") - if pitch < 0: - raise FilterInvalidArgument("Timescale pitch must be more than 0.") - if rate < 0: - raise FilterInvalidArgument("Timescale rate must be more than 0.") - - self.speed = speed - self.pitch = pitch - self.rate = rate - self.tag = tag - - self.payload = { - "timescale": { - "speed": self.speed, - "pitch": self.pitch, - "rate": self.rate - } - } + self._init_with_scope({ + "speed": [0, 5], + "pitch": [0, 5], + "rate": [0, 5] + }, tag=tag, speed=speed, pitch=pitch, rate=rate) @classmethod def vaporwave(cls): @@ -215,8 +223,7 @@ class Timescale(Filter): return cls(tag="nightcore", speed=1.25, pitch=1.3) def __repr__(self): - return f"" - + return f"" class Karaoke(Filter): """Filter which filters the vocal track from any song and leaves the instrumental. @@ -226,35 +233,22 @@ class Karaoke(Filter): def __init__( self, *, - tag: str, + tag: str = "karaoke", level: float = 1.0, mono_level: float = 1.0, filter_band: float = 220.0, filter_width: float = 100.0 ): super().__init__() - - self.level = level - self.mono_level = mono_level - self.filter_band = filter_band - self.filter_width = filter_width - self.tag = tag - - self.payload = { - "karaoke": { - "level": self.level, - "monoLevel": self.mono_level, - "filterBand": self.filter_band, - "filterWidth": self.filter_width - } - } + self._init_with_scope({ + "level": [0, 5], + "monoLevel": [0, 5], + "filterBand": [0, 500], + "filterWidth": [0, 300] + }, tag=tag, level=level, mono_level=mono_level, filter_band=filter_band, filter_width=filter_width) def __repr__(self): - return ( - f"" - ) - + return (f" 1: - raise FilterInvalidArgument("Tremolo depth must be between 0 and 1.") - - self.frequency = frequency - self.depth = depth - self.tag = tag - - self.payload = { - "tremolo": { - "frequency": self.frequency, - "depth": self.depth - } - } + self._init_with_scope({ + "frequency": [0, 5], + "depth": [0, 1] + }, tag=tag, frequency=frequency, depth=depth) def __repr__(self): - return f"" - + return f" 14: - raise FilterInvalidArgument("Vibrato frequency must be between 0 and 14.") - if depth < 0 or depth > 1: - raise FilterInvalidArgument("Vibrato depth must be between 0 and 1.") - - self.frequency = frequency - self.depth = depth - self.tag = tag - - self.payload = { - "vibrato": { - "frequency": self.frequency, - "depth": self.depth - } - } + self._init_with_scope({ + "frequency": [0, 14], + "depth": [0, 1] + }, tag=tag, frequenc=frequency, depth=depth) def __repr__(self): - return f"" - + return f" str: - return f"" - + return f" left_to_left > 1: - raise ValueError("'left_to_left' value must be more than or equal to 0 or less than or equal to 1.") - if 0 > right_to_right > 1: - raise ValueError("'right_to_right' value must be more than or equal to 0 or less than or equal to 1.") - if 0 > left_to_right > 1: - raise ValueError("'left_to_right' value must be more than or equal to 0 or less than or equal to 1.") - if 0 > right_to_left > 1: - raise ValueError("'right_to_left' value must be more than or equal to 0 or less than or equal to 1.") - - self.left_to_left = left_to_left - self.left_to_right = left_to_right - self.right_to_left = right_to_left - self.right_to_right = right_to_right - self.tag = tag - - self.payload = { - "channelMix": { - "leftToLeft": self.left_to_left, - "leftToRight": self.left_to_right, - "rightToLeft": self.right_to_left, - "rightToRight": self.right_to_right - } - } + self._init_with_scope({ + "leftToLeft": [0, 1], + "leftToRight": [0, 1], + "rightToLeft": [0, 1], + "rightToRight": [0, 1] + }, tag=tag, leftToLeft=left_to_left, leftToRight=left_to_right, rightToLeft=right_to_left, rightToRight=right_to_right) def __repr__(self) -> str: - return ( - f"" - ) + return (f" str: - return ( - f" " - f"cos_offset={self.cos_offset} cos_scale={self.cos_scale} tan_offset={self.tan_offset} " - f"tan_scale={self.tan_scale} offset={self.offset} scale={self.scale}" - ) - + return (f" str: - return f"" - - + return f"" \ No newline at end of file diff --git a/voicelink/objects.py b/voicelink/objects.py index ca7c13e..de5e1bd 100644 --- a/voicelink/objects.py +++ b/voicelink/objects.py @@ -62,7 +62,8 @@ class Track: "requester", "is_stream", "is_seekable", - "position" + "position", + "end_time" ) def __init__( @@ -102,6 +103,8 @@ class Track: self.is_seekable: bool = info.get("isSeekable", True) self.position: int = info.get("position", 0) + self.end_time: Optional[int] = None + def __eq__(self, other) -> bool: if not isinstance(other, Track): return False diff --git a/voicelink/placeholders.py b/voicelink/placeholders.py index 3f5ab50..6ffcb70 100644 --- a/voicelink/placeholders.py +++ b/voicelink/placeholders.py @@ -32,9 +32,11 @@ class Placeholders: "track_duration": self.track_duration, "track_thumbnail": self.track_thumbnail, "track_color": self.track_color, - "requester": self.requester, - "requester_name": self.requester_name, - "requester_avatar": self.requester_avatar, + "track_requester_id": self.track_requester_id, + "track_requester_name": self.track_requester_name, + "track_requester_avatar": self.track_requester_avatar, + "track_source_name": self.track_source_name, + "track_source_emoji": self.track_source_emoji, "queue_length": self.queue_length, "volume": self.volume, "dj": self.dj, @@ -70,21 +72,29 @@ class Placeholders: return self.player.get_msg("live") if track.is_stream else func.time(track.length) @ensure_track - def requester(self, track: Track) -> str: - return track.requester.mention if track.requester else self.bot.user.mention + def track_requester_id(self, track: Track) -> str: + return str(track.requester.id if track.requester else self.bot.user.id) @ensure_track - def requester_name(self, track: Track) -> str: + def track_requester_name(self, track: Track) -> str: return track.requester.name if track.requester else self.bot.user.display_name @ensure_track - def requester_avatar(self, track: Track) -> str: + def track_requester_avatar(self, track: Track) -> str: return track.requester.display_avatar.url if track.requester else self.bot.user.display_avatar.url @ensure_track def track_color(self, track: Track) -> int: return int(func.get_source(track.source, "color"), 16) + @ensure_track + def track_source_name(self, track: Track) -> str: + return track.source + + @ensure_track + def track_source_emoji(self, track: Track) -> str: + return track.emoji + def track_thumbnail(self) -> str: if not self.player or not self.player.current: return "https://i.imgur.com/dIFBwU7.png" diff --git a/voicelink/player.py b/voicelink/player.py index 85beb4b..d58dfbb 100644 --- a/voicelink/player.py +++ b/voicelink/player.py @@ -21,19 +21,13 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -import time +import time, logging import function as func from math import ceil from asyncio import sleep from views import InteractiveController -from typing import ( - Any, - Dict, - Optional, - Union, - List -) +from typing import Any, Dict, List, Optional, Union, Tuple from discord import ( Client, @@ -41,14 +35,14 @@ from discord import ( VoiceChannel, VoiceProtocol, Member, - ui, Message, - Interaction + Interaction, + errors ) from discord.ext import commands from . import events -from .enums import SearchType, LoopType +from .enums import SearchType, LoopType, RequestMethod from .events import VoicelinkEvent, TrackEndEvent, TrackStartEvent from .exceptions import VoicelinkException, FilterInvalidArgument, TrackInvalidPosition, TrackLoadError, FilterTagAlreadyInUse, DuplicateTrack from .filters import Filter, Filters @@ -75,8 +69,9 @@ async def connect_channel(ctx: Union[commands.Context, Interaction], channel: Vo ctx.bot if isinstance(ctx, commands.Context) else ctx.client, channel, ctx, settings )) - - await player.send_ws({"op": "createPlayer", "members_id": [member.id for member in channel.members]}) + + if ctx.bot.ipc.is_connected: + await player.send_ws({"op": "createPlayer", "member_ids": [str(member.id) for member in channel.members]}) return player @@ -103,11 +98,13 @@ class Player(VoiceProtocol): ): self.client: Client = client self._bot: Client = client + self._ipc = self._bot.ipc + self._ipc_connection = False + self.context = ctx self.dj: Member = ctx.user if isinstance(ctx, Interaction) else ctx.author self.channel: VoiceChannel = channel self._guild = channel.guild if channel else None - self._ipc_connection: bool = False self.settings: dict = settings self.joinTime: float = round(time.time()) @@ -139,7 +136,8 @@ class Player(VoiceProtocol): self.shuffle_votes = set() self.stop_votes = set() - self.ph = Placeholders(client, self) + self._ph = Placeholders(client, self) + self._logger: Optional[logging.Logger] = self._node._logger def __repr__(self): return ( @@ -238,7 +236,7 @@ class Player(VoiceProtocol): @property def is_ipc_connected(self) -> bool: - return bool(self._ipc_connection and len(self.bot.ipc.connections)) + return self._ipc._is_connected and self._ipc_connection def is_user_join(self, user: Member): if user not in self.channel.members: @@ -258,12 +256,18 @@ 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 + async def send(self, method: RequestMethod, query: str = None, data: Union[Dict, str] = {}) -> Dict: + 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: state: dict = data.get("state") self._last_update = time.time() * 1000 self._is_connected = state.get("connected") self._last_position = state.get("position") self._ping = state.get("ping") + self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) update state with data {data}") + if self.is_ipc_connected: await self.send_ws({ "op": "playerUpdate", @@ -272,18 +276,21 @@ class Player(VoiceProtocol): "last_position": self._last_position }) - async def _dispatch_voice_update(self, voice_data: Dict[str, Any]): + async def _dispatch_voice_update(self, voice_data: Dict[str, Any] = None): 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 - await self._node.send( - method=0, guild_id=self._guild.id, - data = {"voice": { - "token": voice_data['event']['token'], - "endpoint": voice_data['event']['endpoint'], - "sessionId": voice_data['sessionId'], - }} - ) + state = voice_data or self._voice_state + + data = { + "token": state['event']['token'], + "endpoint": state['event']['endpoint'], + "sessionId": state['sessionId'], + } + + 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): self._voice_state.update({"event": data}) @@ -316,8 +323,10 @@ class Player(VoiceProtocol): if isinstance(event, TrackStartEvent): self._ending_track = self._current + self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) dispatched event {event_type}.") + async def do_next(self): - if self.is_playing or not self.channel: + if self._current or self.is_playing or not self.channel: return if self._paused: @@ -337,7 +346,7 @@ class Player(VoiceProtocol): self.shuffle_votes.clear() self.stop_votes.clear() - track: Track = self.queue.get() + track = self.queue.get() if not track: if self.settings.get("autoplay", False) and await self.get_recommendations(): @@ -345,18 +354,21 @@ class Player(VoiceProtocol): else: try: await self.play(track, start=track.position) - except: + except Exception as e: + self._logger.error(f"Something went wrong while playing music in {self.guild.name}({self.guild.id})", exc_info=e) await sleep(5) return await self.do_next() if not track.requester.bot: - await func.update_user(track.requester.id, { + self._bot.loop.create_task(func.update_user(track.requester.id, { "$push": {"history": {"$each": [track.track_id], "$slice": -25}} - }) + })) if self.settings.get('controller', True): await self.invoke_controller() + await self.update_voice_status() + if self.is_ipc_connected: await self.send_ws({ "op": "trackUpdate", @@ -371,28 +383,27 @@ class Player(VoiceProtocol): self.updating = True - if not self.controller: - try: - self.controller = await self.context.channel.send(embed=await self.build_embed(), view=InteractiveController(self)) - except: - pass + try: + embed, view = await self.build_embed(), InteractiveController(self) + 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: - ui.View.from_message(self.controller).stop() - try: - self.controller = await self.context.channel.send(embed= await self.build_embed(), view=InteractiveController(self)) - except: - pass - else: - embed = await self.build_embed() - if embed: + elif not await self.is_position_fresh(): try: - await self.controller.edit(embed=embed, view=InteractiveController(self)) + await self.controller.delete() except: pass + 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 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 @@ -400,7 +411,7 @@ class Player(VoiceProtocol): 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) + return build_embed(raw, self._ph) async def is_position_fresh(self): try: @@ -420,7 +431,7 @@ class Player(VoiceProtocol): "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"}) @@ -457,10 +468,13 @@ class Player(VoiceProtocol): self._node._players[self.guild.id] = self self._is_connected = True + if self.channel: + self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) has been connected to {self.channel.name}({self.channel.id}).") + async def stop(self): """Stops the currently playing track.""" self._current = None - await self._node.send(method=0, guild_id=self._guild.id, data={'encodedTrack': None}) + await self.send(method=RequestMethod.patch, data={'encodedTrack': None}) async def disconnect(self, *, force: bool = False): """Disconnects the player from voice.""" @@ -470,6 +484,8 @@ class Player(VoiceProtocol): self.cleanup() self._is_connected = False self.channel = None + + self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) has been disconnected from a voice channel.") async def destroy(self): """Disconnects and destroys the player, and runs internal cleanup.""" @@ -482,8 +498,8 @@ class Player(VoiceProtocol): assert self.channel is None and not self.is_connected self._node._players.pop(self.guild.id) - await self._node.send(method=1, guild_id=self._guild.id) - + await self.send(method=RequestMethod.delete) + async def play( self, track: Track, @@ -498,88 +514,126 @@ class Player(VoiceProtocol): if track.spotify: if not track.original: - search: Track = (await self._node.get_tracks( - f"ytsearch:{track.author} - {track.title}", - requester=track.requester - )) - - if not search: - raise TrackLoadError("Can't not found a playable source!") + 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] - track.original = search[0] - data = { "encodedTrack": track.original.track_id if track.original else track.track_id, - "position": str(start) + "position": str(start if start else track.position) } - if end > 0: - data["endTime"] = str(end) - - await self._node.send( - method=0, guild_id=self._guild.id, - data=data, - query=f"noReplace={ignore_if_playing}" - ) + 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) 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 - async def add_track(self, raw_tracks: Union[Track, List[Track]], *, at_font: bool = False, duplicate: bool = True) -> int: - tracks = [] + def _validate_time(self, track: Track, start_time: int, end_time: int) -> None: + if start_time or end_time: + if not end_time: + end_time = track.length - _duplicate_tracks = () if self.queue._allow_duplicate and duplicate else (track.uri for track in self.queue._queue) + if start_time >= end_time: + raise VoicelinkException(self.get_msg("invalidStartTime")) + + track_length = track.length + if not 0 <= start_time <= track_length: + raise VoicelinkException(self.get_msg("invalidStartTime", func.time(track_length))) + if not 0 <= end_time <= track_length: + raise VoicelinkException(self.get_msg("invalidEndTime", func.time(track_length))) + + track.position = start_time + 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: + 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 try: - if (isList := isinstance(raw_tracks, List)): + if (is_list := isinstance(raw_tracks, List)): for track in raw_tracks: if track.uri in _duplicate_tracks: continue - self.queue.put_at_front(track) if at_font else self.queue.put(track) + + self._validate_time(track, start_time, end_time) + self.queue.put_at_front(track) if at_front else self.queue.put(track) tracks.append(track) + _duplicate_tracks.append(track.uri) else: if raw_tracks.uri in _duplicate_tracks: raise DuplicateTrack(self.get_msg("voicelinkDuplicateTrack")) - position = self.queue.put_at_front(raw_tracks) if at_font else self.queue.put(raw_tracks) + self._validate_time(raw_tracks, start_time, end_time) + position = self.queue.put_at_front(raw_tracks) if at_front else self.queue.put(raw_tracks) tracks.append(raw_tracks) + finally: if tracks: if self.is_ipc_connected: - await self.send_ws({"op": "addTrack", "tracks": [track.track_id for track in tracks]}, tracks[0].requester) - return len(tracks) if isList else position - + await self.send_ws({"op": "addTrack", "tracks": [track.track_id for track in tracks], "position": -1 if is_list else position}, tracks[0].requester) + + self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) has been added {len(tracks)} tracks into the queue.") + 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]: + 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 + }, 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: raise TrackInvalidPosition("Seek position must be between 0 and the track length") - await self._node.send(method=0, guild_id=self._guild.id, 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) + + self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) has been seeking to {position}.") return self._position async def set_pause(self, pause: bool, requester: Member = None) -> bool: """Sets the pause state of the currently playing track.""" - await self._node.send(method=0, guild_id=self._guild.id, data={"paused": pause}) + + self._paused = pause + self.pause_votes.clear() if pause else self.resume_votes.clear() + await self.send(method=RequestMethod.patch, data={"paused": pause}) + if self.is_ipc_connected: await self.send_ws({"op": "updatePause", "pause": pause}, requester) - self._paused = pause + + self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) has been {'paused' if pause else 'resumed'}.") return self._paused 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._node.send(method=0, guild_id=self._guild.id, data={"volume": volume}) + await self.send(method=RequestMethod.patch, data={"volume": volume}) + self._volume = volume + if self.is_ipc_connected: await self.send_ws({"op": "updateVolume", "volume": volume}, requester) - self._volume = volume + + self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) has been update the volume to {volume}.") return self._volume async def shuffle(self, queue_type: str, requester: Member = None) -> None: + """Shuffles the tracks in the specified queue or history.""" replacement = self.queue.tracks() if queue_type == "queue" else self.queue.history() if len(replacement) < 3: raise VoicelinkException(self.get_msg('shuffleError')) @@ -590,14 +644,31 @@ class Player(VoiceProtocol): if self.is_ipc_connected: await self.send_ws({ "op": "shuffleTrack", - "tracks": [track.track_id for track in self.queue._queue], - "verified": { - "index": self.queue._position if queue_type == "queue" else 0, - "track_id": replacement[0].track_id, - } + "tracks": [{"track_id": track.track_id, "requester_id": str(track.requester.id)} for track in replacement], + "queue_type": queue_type }, requester) + + self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) has been shuffled the queue.") - async def set_repeat(self, mode: str = None) -> str: + async def swap_track(self, index1: int, index2: int, requester: Member = None) -> Tuple[Track, Track]: + track1, track2 = self.queue.swap(index1, index2) + if self.is_ipc_connected: + await self.send_ws({ + "op": "swapTrack", + "index1": {"index": index1, "trackId": track1.track_id}, + "index2": {"index": index2, "trackId": track2.track_id} + }, requester) + return track1, track2 + + async def move_track(self, index: int, new_index: int, requester: Member = None) -> Optional[Track]: + moved_track = self.queue.move(index, new_index) + + if self.is_ipc_connected: + await self.send_ws({"op": "moveTrack", "movedTrack": {"index": index, "trackId": moved_track.track_id}, "newIndex": new_index}, requester) + + return moved_track + + async def set_repeat(self, mode: str = None, requester: Member = None) -> str: if not mode: mode = self.queue._repeat.next().name @@ -612,37 +683,78 @@ class Player(VoiceProtocol): raise VoicelinkException("Invalid repeat mode.") if self.is_ipc_connected: - await self.send_ws({"op": "repeatTrack", "repeatMode": mode}) + await self.send_ws({"op": "repeatTrack", "repeatMode": mode}, requester) + self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) has been update the repeat mode to {mode}.") return mode - async def add_filter(self, filter: Filter, fast_apply=False) -> Filters: + async def add_filter(self, filter: Filter, requester: Member = None, fast_apply: bool = False) -> Filters: try: self._filters.add_filter(filter=filter) except FilterTagAlreadyInUse: raise FilterTagAlreadyInUse(self.get_msg("FilterTagAlreadyInUse")) + payload = self._filters.get_all_payloads() - await self._node.send(method=0, guild_id=self._guild.id, data={"filters": payload}) - if fast_apply: - await self.seek(self.position) - return self._filters - - async def remove_filter(self, filter_tag: str, fast_apply=False) -> Filters: - self._filters.remove_filter(filter_tag=filter_tag) - payload = self._filters.get_all_payloads() - await self._node.send(method=0, guild_id=self._guild.id, data={"filters": payload}) + await self.send(method=RequestMethod.patch, data={"filters": payload}) if fast_apply: await self.seek(self.position) + if self.is_ipc_connected: + await self.send_ws({ + "op": "updateFilter", + "filter": {"tag": filter.tag, "scope": filter.scope, "payload": filter.payload}, + "type": "add" + }, requester) + + self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) has been applied a {filter.tag} filter.") return self._filters - - async def reset_filter(self, *, fast_apply=False) -> None: - if not self._filters: - raise FilterInvalidArgument("You must have filters applied first in order to use this method.") - self._filters.reset_filters() - await self._node.send(method=0, guild_id=self._guild.id, data={"filters": {}}) + + async def clear_queue(self, queue_type: str, requester: Member = None) -> None: + queue_type = queue_type.lower() + if queue_type == 'history': + self.queue.history_clear(self.is_playing) + elif queue_type == "queue": + self.queue.clear() + + if self.is_ipc_connected: + await self.send_ws({ + "op": "clearQueue", + "queue_type": 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}) if fast_apply: await self.seek(self.position) + + if self.is_ipc_connected: + await self.send_ws({ + "op": "updateFilter", + "filter": {"tag": filter_tag}, + "type": "remove" + }, requester) + + self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) has been removed a {filter_tag} filter.") + return self._filters + + async def reset_filter(self, *, requester: Member = None, fast_apply=False) -> None: + if not self._filters: + raise FilterInvalidArgument("You must have filters applied first in order to use this method.") + + self._filters.reset_filters() + await self.send(method=RequestMethod.patch, data={"filters": {}}) + if fast_apply: + await self.seek(self.position) + + if self.is_ipc_connected: + await self.send_ws({ + "op": "updateFilter", + "type": "reset" + }, requester) + + 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.""" @@ -667,43 +779,43 @@ class Player(VoiceProtocol): if self.volume != 100: await self.set_volume(self.volume) - async def get_recommendations(self, *, track: Track = None) -> bool: + async def get_recommendations(self, *, track: Optional[Track] = None) -> bool: """Get recommendations from Youtube or Spotify.""" if not track: - track = choice(self.queue.history(incTrack=True)[-5:]) - - if track.spotify: - spotify_tracks = await self._node._spotify_client.similar_track(seed_tracks=track.identifier) - - tracks = [ Track( - track_id=None, - search_type=SearchType.ytsearch, - spotify_track=track, - info=track.to_dict(), - requester=self.client.user - ) - for track in spotify_tracks ] - - else: - if track.source != 'youtube': + try: + track = choice(self.queue.history(incTrack=True)[-5:]) + except IndexError: return False - - tracks = await self.get_tracks( - f"https://www.youtube.com/watch?v={track.identifier}&list=RD{track.identifier}", - requester=self.client.user - ) - + + tracks = await self._node.get_recommendations(track) if tracks: - if isinstance(tracks, Playlist): - await self.add_track(tracks.tracks, duplicate=False) - else: - await self.add_track(tracks, duplicate=False) + await self.add_track(tracks, duplicate=False) + + self._logger.debug(f"Player in {self.guild.name}({self.guild.id}) has been requested recommendations.") return True - return False + async def update_voice_status(self, remove_status: bool = False) -> None: + template = self.settings.get("stage_announce_template", func.settings.voice_status_template) + if not template or not self.channel: + return + + try: + 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) + + except Exception as e: + self._logger.error( + f"Failed to update voice status in channel '{self.channel.name}' " + f"({self.channel.id}) for guild '{self.channel.guild}' " + f"({self.channel.guild.id})", + exc_info=e + ) + async def send_ws(self, payload, requester: Member = None): - payload['guild_id'] = self.guild.id + payload['guild_id'] = str(self.guild.id) if requester: - payload['requester_id'] = requester.id - await self.bot.ipc.send(payload) + payload['requester_id'] = 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 f63fe23..a79da7f 100644 --- a/voicelink/pool.py +++ b/voicelink/pool.py @@ -27,6 +27,7 @@ import asyncio import os import re import aiohttp +import logging from discord import Client, Member from discord.ext.commands import Bot @@ -49,7 +50,8 @@ from .exceptions import ( TrackLoadError ) from .objects import Playlist, Track -from .utils import ExponentialBackoff, NodeStats, Ping +from .utils import ExponentialBackoff, NodeStats, NodeInfo, Ping +from .enums import RequestMethod if TYPE_CHECKING: from .player import Player @@ -68,7 +70,6 @@ URL_REGEX = re.compile( ) NODE_VERSION = "v4" -CALL_METHOD = ["PATCH", "DELETE"] class Node: """The base class for a node. @@ -90,8 +91,8 @@ class Node: session: Optional[aiohttp.ClientSession] = None, spotify_client_id: Optional[str] = None, spotify_client_secret: Optional[str] = None, - resume_key: Optional[str] = None - + resume_key: Optional[str] = None, + logger: Optional[logging.Logger] = None ): self._bot: Bot = bot self._host: str = host @@ -101,6 +102,7 @@ class Node: self._identifier: str = identifier 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}" @@ -121,7 +123,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 @@ -134,6 +137,10 @@ class Node: f"player_count={len(self._players)}>" ) + def get_player(self, guild_id: int) -> Optional[Player]: + """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: @@ -183,7 +190,7 @@ class Node: return Ping(self._host, port=self._port).get_ping() async def _update_handler(self, data: dict) -> None: - #await self._bot.wait_until_ready() + await self._bot.wait_until_ready() if not data: return @@ -219,7 +226,7 @@ class Node: self._available = False retry = backoff.delay() - print(f"Trying to reconnect {self._identifier} with {round(retry)}s") + self._logger.info(f"Trying to reconnect node [{self._identifier}] with {round(retry)}s") await asyncio.sleep(retry) if not self.is_connected: try: @@ -250,22 +257,13 @@ class Node: elif op == "playerUpdate": await player._update_state(data) - async def send( - self, method: int, - guild_id: Union[str, int] = None, - query: str = None, - data: Union[dict, str] = {} - ) -> dict: + async def send(self, method: RequestMethod, query: str, data: Union[dict, str] = {}) -> dict: if not self._available: raise NodeNotAvailable(f"The node '{self._identifier}' is unavailable.") - uri: str = f"{self._rest_uri}/{NODE_VERSION}" \ - f"/sessions/{self._session_id}/players" \ - f"/{guild_id}" if guild_id else "" \ - f"?{query}" if query else "" - + uri: str = f"{self._rest_uri}/{NODE_VERSION}/{query}" async with self._session.request( - method=CALL_METHOD[method], + method=method.value, url=uri, headers={"Authorization": self._password}, json=data @@ -273,15 +271,11 @@ class Node: if resp.status >= 300: raise NodeException(f"Getting errors from Lavalink REST api") - if method == CALL_METHOD[1]: + if method == RequestMethod.delete: return await resp.json(content_type=None) return await resp.json() - def get_player(self, guild_id: int) -> Optional[Player]: - """Takes a guild ID as a parameter. Returns a voicelink Player object.""" - return self._players.get(guild_id, None) - async def connect(self) -> Node: """Initiates a connection with a Lavalink node and adds it to the node pool.""" @@ -292,8 +286,9 @@ class Node: self._task = self._bot.loop.create_task(self._listen()) self._available = True - - print(f"{self._identifier} is connected!") + self._info = NodeInfo(await self.send(RequestMethod.get, query="info")) + + self._logger.info(f"Node [{self._identifier}] is connected!") except aiohttp.ClientConnectorError: raise NodeConnectionFailure( @@ -313,20 +308,20 @@ class Node: return self - async def disconnect(self) -> None: + async def disconnect(self, remove_from_pool: bool = False) -> None: """Disconnects a connected Lavalink node and removes it from the node pool. This also destroys any players connected to the node. """ for player in self.players.copy().values(): await player.teardown() - if self.spotify_client: - await self.spotify_client.close() - await self._websocket.close() - del self._pool._nodes[self._identifier] + if remove_from_pool: + del self._pool._nodes[self._identifier] self._available = False self._task.cancel() + + self._logger.info(f"Node [{self._identifier}] is disconnected!") async def reconnect(self) -> None: await asyncio.sleep(10) @@ -506,7 +501,7 @@ class Node: "please obtain Spotify API credentials here: https://developer.spotify.com/" ) - tracks = await self._spotify_client.trackSearch(query=query) + 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?") @@ -520,6 +515,38 @@ class Node: ) for track in tracks ] + async def get_recommendations(self, track: Track, limit: int = None) -> 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 + ] + + 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 + ) + + if isinstance(tracks, Playlist): + tracks = tracks.tracks + + return tracks[:limit] if limit else tracks + class NodePool: """The base class for the node pool. This holds all the nodes that are to be used by the bot. @@ -538,7 +565,7 @@ class NodePool: @property def node_count(self) -> Optional[Node]: return len(self._nodes.values()) - + @classmethod def get_best_node(cls, *, algorithm: NodeAlgorithm) -> Node: """Fetches the best node based on an NodeAlgorithm. @@ -601,18 +628,22 @@ class NodePool: spotify_client_secret: Optional[str] = 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.") - + + if not logger: + logger = logging.getLogger("voicelink") + 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 + resume_key=resume_key, logger=logger ) await node.connect() diff --git a/voicelink/queue.py b/voicelink/queue.py index 1f1a017..79bd41a 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 +from typing import Optional, Tuple, List, Callable, Dict from itertools import cycle from discord import Member @@ -124,11 +124,11 @@ class Queue: elif queue_type == "history": self._queue[:self._position] = replacement - def swap(self, num1: int, num2: int) -> Tuple[Track, Track]: + def swap(self, track_index1: int, track_index2: int) -> Tuple[Track, Track]: try: - pos = self._position - 1 - self._queue[pos + num1], self._queue[pos + num2] = self._queue[pos + num2], self._queue[pos + num1] - return self._queue[pos + num1], self._queue[pos + num2] + adjusted_position = self._position - 1 + self._queue[adjusted_position + track_index1], self._queue[adjusted_position + track_index2] = self._queue[adjusted_position + track_index2], self._queue[adjusted_position + track_index1] + return self._queue[adjusted_position + track_index1], self._queue[adjusted_position + track_index2] except IndexError: raise OutofList(self.get_msg("voicelinkOutofList")) @@ -137,14 +137,14 @@ class Queue: raise OutofList(self.get_msg("voicelinkOutofList")) try: - moveItem = self._queue[self._position + target - 1] - self._queue.remove(moveItem) - self.put_at_index(to, moveItem) - return moveItem + item = self._queue[self._position + target - 1] + self._queue.remove(item) + self.put_at_index(to, item) + return item except: raise OutofList(self.get_msg("voicelinkOutofList")) - def remove(self, index: int, index2: int = None, member: Member = None) -> Optional[List[Track]]: + def remove(self, index: int, index2: int = None, member: Member = None) -> Dict[int, Track]: pos = self._position - 1 if index2 is None: @@ -154,16 +154,15 @@ class Queue: index, index2 = index2, index try: - count = [] + removed_tracks: Dict[str, Track] = {} for i, track in enumerate(self._queue[pos + index: pos + index2 + 1]): - if member: - if track.requester != member: - continue + if member and track.requester != member: + continue self._queue.remove(track) - count.append({"position": pos + index + i, "track": track}) + removed_tracks[pos + index + i] = track - return count + return removed_tracks except: raise OutofList(self.get_msg("voicelinkOutofList")) diff --git a/voicelink/spotify/client.py b/voicelink/spotify/client.py index 58c5205..1ea310a 100644 --- a/voicelink/spotify/client.py +++ b/voicelink/spotify/client.py @@ -26,14 +26,15 @@ import time import aiohttp from base64 import b64encode -from typing import List, Union -from .objects import Track, Album, Artist, Playlist +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 = "https://api.spotify.com/v1/{type}s/{id}" -SEARCH_URL = "https://api.spotify.com/v1/search?q={query}&type={type}&limit={limit}" -SUGGESTION_URL = "https://api.spotify.com/v1/recommendations?limit={limit}&seed_tracks={seed_tracks}" +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]+)" ) @@ -45,16 +46,18 @@ class Client: """ def __init__(self, client_id: str, client_secret: str) -> None: - self._client_id = client_id - self._client_secret = client_secret + self._client_id: str = client_id + self._client_secret: str = client_secret - self.session = aiohttp.ClientSession() + self.session: aiohttp.ClientSession = aiohttp.ClientSession() self._bearer_token: str = None - self._expiry = 0 - self._auth_token = b64encode(f"{self._client_id}:{self._client_secret}".encode()) - self._grant_headers = {"Authorization": f"Basic {self._auth_token.decode()}"} - self._bearer_headers = None + 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"} @@ -65,48 +68,35 @@ class Client: f"Error fetching bearer token: {resp.status} {resp.reason}" ) - data: dict = await resp.json() + data: Dict = await resp.json() self._bearer_token = data["access_token"] self._expiry = time.time() + (int(data["expires_in"]) - 10) self._bearer_headers = {"Authorization": f"Bearer {self._bearer_token}"} - async def trackSearch(self, query: str, track: str = "track", limit: int = 10) -> List[Track]: + async def get_request(self, url: str) -> Dict: if not self._bearer_token or time.time() >= self._expiry: await self._fetch_bearer_token() - request_url = SEARCH_URL.format(query=query, type=track, limit=limit) - - async with self.session.get(request_url, headers=self._bearer_headers) as resp: + 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}" ) - data: dict = await resp.json() + 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 = 5) -> List[Track]: - if not self._bearer_token or time.time() >= self._expiry: - await self._fetch_bearer_token() - request_url = SUGGESTION_URL.format(limit=limit, seed_tracks=seed_tracks) - - async with self.session.get(request_url, headers=self._bearer_headers) as resp: - if resp.status != 200: - raise SpotifyRequestException( - f"Error while fetching results: {resp.status} {resp.reason}" - ) - - data: dict = await resp.json() - - return [ Track(track) for track in data['tracks'] ] + 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]: - if not self._bearer_token or time.time() >= self._expiry: - await self._fetch_bearer_token() - result = SPOTIFY_URL_REGEX.match(query) spotify_type = result.group("type") spotify_id = result.group("id") @@ -118,13 +108,7 @@ class Client: if isArtist := (spotify_type == "artist"): request_url += "/top-tracks?market=US" - async with self.session.get(request_url, headers=self._bearer_headers) as resp: - if resp.status != 200: - raise SpotifyRequestException( - f"Error while fetching results: {resp.status} {resp.reason}" - ) - - data: dict = await resp.json() + data = await self.get_request(request_url) if spotify_type == "track": return Track(data) @@ -150,7 +134,7 @@ class Client: f"Error while fetching results: {resp.status} {resp.reason}" ) - next_data: dict = await resp.json() + next_data: Dict = await resp.json() tracks += [ Track(track["track"]) @@ -160,5 +144,13 @@ class Client: 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() + await self.session.close() \ No newline at end of file diff --git a/voicelink/spotify/objects.py b/voicelink/spotify/objects.py index 2d55782..9a26062 100644 --- a/voicelink/spotify/objects.py +++ b/voicelink/spotify/objects.py @@ -13,8 +13,8 @@ class Track: def __init__(self, data: dict, image = None) -> None: self.name: str = data.get('name', 'Unknown') - self.artists: str = ", ".join(artist["name"] for artist in data.get('artists')) - self.artist_id: list[str] = [artist['id'] for artist in data.get('artists')] + 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 @@ -55,7 +55,7 @@ class Album: def __init__(self, data: dict) -> None: self.name: str = data.get('name', 'Unknown') - self.artists: str = ", ".join(artist["name"] for artist in data.get('artists')) + 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"] @@ -122,4 +122,14 @@ class Playlist: return ( f"" - ) \ No newline at end of file + ) + +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" None: + def __init__(self, data: Dict) -> None: - memory: dict = data.get("memory") - self.used = memory.get("used") - self.free = memory.get("free") - self.reservable = memory.get("reservable") - self.allocated = memory.get("allocated") + memory: Dict = data.get("memory") + self.used: int = memory.get("used") + self.free: int = memory.get("free") + self.reservable: int = memory.get("reservable") + self.allocated: int = memory.get("allocated") - cpu: dict = data.get("cpu") - self.cpu_cores = cpu.get("cores") - self.cpu_system_load = cpu.get("systemLoad") - self.cpu_process_load = cpu.get("lavalinkLoad") + cpu: Dict = data.get("cpu") + self.cpu_cores: int = cpu.get("cores") + self.cpu_system_load: float = cpu.get("systemLoad") + self.cpu_process_load: float = cpu.get("lavalinkLoad") - self.players_active = data.get("playingPlayers") - self.players_total = data.get("players") - self.uptime = data.get("uptime") + self.players_active: int = data.get("playingPlayers") + self.players_total: int = data.get("players") + self.uptime: int = data.get("uptime") def __repr__(self) -> str: return f"" +class NodeInfoVersion: + """The base class for the node info object. + Gives version information on the node. + """ + def __init__(self, data: Dict) -> None: + self.semver: str = data.get("semver") + self.major: int = data.get("major") + self.minor: int = data.get("minor") + self.patch: int = data.get("patch") + self.pre_release: Optional[str] = data.get("preRelease") + self.build: Optional[str] = data.get("build") + +class NodeInfo: + """The base class for the node info object. + Gives basic information on the node. + """ + def __init__(self, data: Dict) -> None: + self.version: NodeInfoVersion = NodeInfoVersion(data.get("version")) + self.build_time: int = data.get("buildTime") + self.jvm: str = data.get("jvm") + self.lavaplayer: str = data.get("lavaplayer") + self.plugins: Optional[Dict[str, Plugin]] = [Plugin(plugin_data) for plugin_data in data.get("plugins")] + +class Plugin: + """The base class for the plugin object. + Gives basic information on the plugin. + """ + def __init__(self, data: Dict) -> None: + self.name: str = data.get("name") + self.version: str = data.get("version") class Ping: # Thanks to https://github.com/zhengxiaowai/tcping for the nice ping impl diff --git a/web/README.md b/web/README.md deleted file mode 100644 index 3f28090..0000000 --- a/web/README.md +++ /dev/null @@ -1,27 +0,0 @@ -## Dashboard Tutorial -Click on the image below to watch the tutorial on Youtube. - -[![Dashboard](https://img.youtube.com/vi/5Y5GPO95uxE/maxresdefault.jpg)](https://youtu.be/5Y5GPO95uxE) - -## Configuration -1. **Fill in `CLIENT_SECRET_ID` and `SECRET_KEY` values in `.env`** - -| Values | Description | -| --- | --- | -| CLIENT_SECRET_ID | Your Discord bot client secret id [(Discord Portal)](https://discord.com/developers/applications) ***(optional)*** | -| SECRET_KEY | Secret key for dashboard ***(optional)*** | - -2. **Add `ipc_server` configuration in `settings.json`** -```json -"ipc_server": { - "host": "127.0.0.1", - "port": 8000, - "enable": false -}, -``` - -## Quick Start -```sh -cd web #Go to the web directory -python webapp.py #Start the web server -``` \ No newline at end of file diff --git a/web/__init__.py b/web/__init__.py deleted file mode 100644 index 4bafd1f..0000000 --- a/web/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .ipc import * -from .objects import * \ No newline at end of file diff --git a/web/ipc/__init__.py b/web/ipc/__init__.py deleted file mode 100644 index 8d6d350..0000000 --- a/web/ipc/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .server import IPCServer -from .client import IPCClient \ No newline at end of file diff --git a/web/ipc/client.py b/web/ipc/client.py deleted file mode 100644 index 3162925..0000000 --- a/web/ipc/client.py +++ /dev/null @@ -1,58 +0,0 @@ -import websockets, json, asyncio -from uuid import uuid4 - -class IPCClient: - def __init__( - self, - host = "127.0.0.1", - port = 8000, - secret_key = None, - callback = None - ): - - self.host = host - self.port = port - self.websocket = None - self.secret_key = secret_key - self.callback = callback - self.id = uuid4() - self.is_connected = False - - async def connect_and_reconnect(self): - while True: - if not self.is_connected: - await self.connect() - print("Reconnect in next 10s!") - await asyncio.sleep(10) - - async def connect(self): - try: - self.websocket = await websockets.connect(f"ws://{self.host}:{self.port}", extra_headers={"Client-Id": str(self.id)}) - await self.start_receiver() - except: - self.is_connected = False - pass - - async def send(self, message, user): - if not self.is_connected: - await self.connect_and_reconnect() - - data = json.loads(message) - - payload = { - "secret": self.secret_key, - "data": data | {"user_id": user.id, "guild_id": user.guild_id} - } - - await self.websocket.send(json.dumps(payload)) - - async def receive(self): - print("Connected ipc server!") - self.is_connected = True - async for message in self.websocket: - if self.callback: - data = json.loads(message) - self.callback(data) - - async def start_receiver(self): - await self.receive() \ No newline at end of file diff --git a/web/ipc/methods.py b/web/ipc/methods.py deleted file mode 100644 index 573c163..0000000 --- a/web/ipc/methods.py +++ /dev/null @@ -1,417 +0,0 @@ -import json, function as func - -from discord import Member, VoiceChannel -from discord.ext import commands -from voicelink import Player, Track, Playlist, NodePool, connect_channel, decode, LoopType - -class TempCtx(): - def __init__(self, author: Member, channel: VoiceChannel) -> None: - self.author = author - self.channel = channel - self.guild = channel.guild - -def missingPermission(user_id:int): - payload = {"op": "errorMsg", "level": "info", "msg": "Only the DJ or admins may use this funciton!"} - payload["user_id"] = user_id - return payload - -def error_msg(msg: str, *, user_id: int = None, guild_id: int = None, level: str = "info"): - payload = {"op": "errorMsg", "level": level, "msg": msg} - if user_id: - payload["user_id"] = user_id - if guild_id: - payload["guild_id"] = guild_id - - return payload - -async def connect_channel(member: Member, bot: commands.Bot): - if not member.voice: - return - - 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", "members_id": [member.id for member in channel.members]}) - return player - except: - return - -async def initPlayer(player: Player, member: Member, data: dict): - player._ipc_connection = True - return { - "op": "initPlayer", - "guild_id": player.guild.id, - "user_id": data.get("user_id"), - "users": [{ - "user_id": member.id, - "avatar_url": member.display_avatar.url, - "name": member.name - } for member in player.channel.members ], - "tracks": [ track.track_id for track in player.queue._queue ], - "repeat_mode": player.queue.repeat.lower(), - "channel_name": player.channel.name, - "current_queue_position": player.queue._position if player._current else player.queue._position + 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), - "autoplay": player.settings.get("autoplay", False) - } - -async def skipTo(player: Player, member: Member, data: dict): - if not player.is_privileged(member): - if player.current and member == player.current.requester: - pass - - elif member in player.skip_votes: - return error_msg(player.get_msg('voted'), user_id=member.id) - else: - player.skip_votes.add(member) - if len(player.skip_votes) >= (required := player.required()): - pass - else: - return error_msg(player.get_msg('skipVote').format(member, len(player.skip_votes), required), guild_id=player.guild.id) - - index = data.get("index", 1) - if index > 1: - player.queue.skipto(index) - - if player.queue._repeat.mode == LoopType.track: - await player.set_repeat(LoopType.off.name) - await player.stop() - -async def backTo(player: Player, member: Member, data: dict): - if not player.is_privileged(member): - if player.current and member == player.current.requester: - pass - - elif member in player.skip_votes: - return error_msg(player.get_msg('voted'), user_id=member.id) - else: - player.skip_votes.add(member) - if len(player.skip_votes) >= (required := player.required()): - pass - else: - return error_msg(player.get_msg('backVote').format(member, len(player.skip_votes), required), guild_id=player.guild.id) - - index = data.get("index", 1) - if not player.is_playing: - player.queue.backto(index) - await player.do_next() - else: - player.queue.backto(index + 1) - await player.stop() - -async def moveTrack(player: Player, member: Member, data: dict): - if not player.is_privileged(member): - return missingPermission(member.id) - - c = player.queue._position - 1 - position = data.get("position") - new_position = data.get("newPosition") - - moveItem = player.queue._queue[position] - player.queue._queue.remove(moveItem) - player.queue._queue.insert(new_position, moveItem) - - if position > c and new_position <= c: - player.queue._position += 1 - - elif position < c and new_position >= c: - player.queue._position -= 1 - - elif position == c: - player.queue._position = new_position + 1 - - return { - "op": "moveTrack", - "position": { - "index": position - c, - "track_id": moveItem.track_id - }, - "newPosition": { - "index": new_position - c - }, - "guild_id": player.guild.id, - "requester_id": member.id, - "skip_users": [member.id] - } - -async def addTracks(player: Player, member: Member, data: dict): - raw_tracks = data.get("tracks", []) - tracks = [Track( - track_id=track_id, - info=decode(track_id), - requester=member - ) for track_id in raw_tracks] - - await player.add_track(tracks) - - if not player.is_playing: - await player.do_next() - -async def getTracks(player: Player, member: Member, data: dict): - query = data.get("query", None) - - if query: - payload = {"op": "getTracks", "user_id": member.id} - tracks = await player.get_tracks(query, requester=member) - if not tracks: - return payload - - if isinstance(tracks, Playlist): - tracks = [ track for track in tracks.tracks[:50] ] - - payload["tracks"] = [ track.track_id for track in tracks ] - return payload - -async def shuffleTrack(player: Player, member: Member, data: dict): - if not player.is_privileged(member): - - if member in player.shuffle_votes: - return error_msg(player.get_msg('voted'), user_id=member.id) - else: - player.shuffle_votes.add(member) - if len(player.shuffle_votes) >= (required := player.required()): - pass - else: - return error_msg(player.get_msg('shuffleVote').format(member, len(player.skip_votes), required), guild_id=player.guild.id) - - await player.shuffle(data.get("type", "queue"), member) - -async def repeatTrack(player: Player, member: Member, data: dict): - if not player.is_privileged(member): - return missingPermission(member.id) - - await player.set_repeat() - -async def removeTrack(player: Player, member: Member, data: dict): - if not player.is_privileged(member): - return missingPermission(member.id) - - position = data.get("position") - verify_id = data.get("track_id") - - track = player.queue._queue[position] - if track.track_id == verify_id: - player.queue._queue.remove(track) - - if position < player.queue._position: - player.queue._position -= 1 - - return { - "op": "removeTrack", - "positions": [position], - "track_ids": [track.track_id], - "current_queue_position": player.queue._position, - "requester_id": member.id, - "guild_id": player.guild.id - } - -async def updatePause(player: Player, member: Member, data: dict): - pause = data.get("pause", True) - if not player.is_privileged(member): - if pause: - if member in player.pause_votes: - return error_msg(player.get_msg('voted'), user_id=member.id) - else: - player.pause_votes.add(member) - if len(player.pause_votes) >= (required := player.required()): - pass - else: - return error_msg(player.get_msg('pauseVote').format(member, len(player.pause_votes), required), guild_id=player.guild.id) - else: - if member in player.resume_votes: - return error_msg(player.get_msg('voted'), user_id=member.id) - else: - player.resume_votes.add(member) - if len(player.resume_votes) >= (required := player.required()): - pass - else: - return error_msg(player.get_msg('resumeVote').format(member, len(player.resume_votes), required), guild_id=player.guild.id) - - if pause: - player.pause_votes.clear() - else: - player.resume_votes.clear() - - await player.set_pause(pause, member) - -async def updatePosition(player: Player, member: Member, data: dict): - if not player.is_privileged(member): - return missingPermission(member.id) - - position = data.get("position"); - await player.seek(position, member); - -async def toggleAutoplay(player: Player, member: Member, data: dict): - if not player.is_privileged(member): - return error_msg(player.get_msg('missingPerms_autoplay')) - - check = data.get("status", False) - player.settings['autoplay'] = check - - if not player.is_playing: - await player.do_next() - - return { - "op": "toggleAutoplay", - "status": check, - "requester_id": member.id - } - -async def closeConnection(player: Player, member: Member, data: dict): - player._ipc_connection = False - -async def getPlaylists(member: Member, data: dict): - playlists: dict = await func.get_user(member.id, "playlist") - if not playlists: - return - - for pId, pList in playlists.copy().items(): - if "type" in pList: - if pList["type"] == "link": - tracks: Playlist = await NodePool.get_node().get_tracks(pList["uri"], requester=member) - if tracks: - playlists[pId]["tracks"] = [ track.track_id for track in tracks.tracks ] - - elif pList["type"] == "share": - playlist = await func.get_user(pList["user"], "playlist") - playlist = playlist.get(pList["referId"]) - if playlist: - if member.id not in playlist["perms"]["read"]: - await func.update_user(member.id, {"$unset": {f"playlist.{pId}": 1}}) - del playlists[pId] - continue - - if playlist['type'] == 'link': - tracks: Playlist = await NodePool.get_node().get_tracks(playlist["uri"], requester=member) - playlists[pId]["tracks"] = [ track.track_id for track in tracks.tracks ] - else: - playlists[pId]["tracks"] = playlist["tracks"] - - return { - "op": "getPlaylists", - "playlists": playlists, - "user_id": member.id - } - -async def removePlaylist(member: Member, data:dict): - pId = data.get("pId") - isShare = data.get("isShare", False) - - if pId == 200: - return - - if isShare: - refer_user = data.get("refer_user") - await func.update_user(refer_user, {"$pull": {f"playlist.{pId}.perms.read": member.id}}) - - await func.update_user(member.id, {"$unset": {f'playlist.{pId}': 1}}) - -async def addPlaylistTrack(member: Member, data: dict): - track_id = data.get("track_id") - pId = data.get("pId") - if not track_id or not pId: - return - - playlist: dict = await func.get_user(member.id, 'playlist') - playlist = playlist.get(pId) - if not playlist: - return - - if playlist["type"] != "playlist": - return error_msg(func.get_lang(member.guild.id, 'playlistNotAllow'), user_id=member.id) - - rank, max_p, max_t = func.check_roles() - if len(playlist["tracks"]) >= max_t: - return error_msg(func.get_lang(member.guild.id, "playlistlimited").format(max_t), user_id=member.id) - - if track_id in playlist['tracks']: - return error_msg(func.get_lang(member.guild.id, "playlistrepeated"), user_id=member.id) - - await func.update_user(member.id, {"$push": {f'playlist.{pId}.tracks': track_id}}) - -async def removePlaylistTrack(member: Member, data: dict): - track_id = data.get("track_id") - pId = data.get("pId") - if not track_id or not pId: - return - - await func.update_user(member.id, {"$pull": {f'playlist.{pId}.tracks': track_id }}) - -methods = { - "initPlayer": [initPlayer, False], - "skipTo": [skipTo, False], - "backTo": [backTo, False], - "moveTrack": [moveTrack, False], - "addTracks": [addTracks, True], - "getTracks": [getTracks, True], - "shuffleTrack": [shuffleTrack, False], - "repeatTrack": [repeatTrack, False], - "removeTrack": [removeTrack, False], - "updatePause": [updatePause, False], - "updatePosition": [updatePosition, False], - "toggleAutoplay": [toggleAutoplay, False], - "getPlaylists": [getPlaylists, False], - "removePlaylist": [removePlaylist, False], - "addPlaylistTrack": [addPlaylistTrack, False], - "removePlaylistTrack": [removePlaylistTrack, False] -} - -async def process_methods(websocket, bot: commands.Bot, data: dict) -> None: - method = methods.get(data.get("op", ""), None) - if not method: - return - - guild, member = None, None - guild_id = data.get("guild_id", None) - user_id = data.get("user_id", None) - if not user_id: - return - - if guild_id is None: - user = bot.get_user(user_id) - if not user: - return - - for g in user.mutual_guilds: - m = g.get_member(user.id) - if m.voice and m.voice.channel: - guild = g - member = m - break - else: - guild = bot.get_guild(guild_id) - member = guild.get_member(user_id) - - if not member: - return - - try: - if 'player' in method[0].__code__.co_varnames: - if not guild: - return - - player: Player = guild.voice_client - if not player: - if not method[1]: - return - player: Player = await connect_channel(member, bot) - - resp: dict = await method[0](player, member, data) - else: - resp: dict = await method[0](member, data) - - if resp: - await websocket.send(json.dumps(resp)) - - except Exception as e: - payload = { - "op": "errorMsg", - "level": "error", - "msg": str(e), - "user_id": member.id - } - await websocket.send(json.dumps(payload)) \ No newline at end of file diff --git a/web/ipc/server.py b/web/ipc/server.py deleted file mode 100644 index ecaf140..0000000 --- a/web/ipc/server.py +++ /dev/null @@ -1,67 +0,0 @@ -import json - -from websockets.server import serve, WebSocketServerProtocol -from websockets.exceptions import ConnectionClosed -from discord.ext import commands - -from typing import ( - Optional, - Dict, - Any -) - -class IPCServer: - def __init__( - self, - bot: commands.Bot, - host: str = "127.0.0.1", - port: int = 8000, - secret_key: Optional[str] = None - ): - self.bot = bot - self.host = host - self.port = port - self.secret_key = secret_key - - self.user = {} - self.connections = set() - - def is_secure(self, data: dict) -> bool: - if (key := data.get("secret")): - return str(key) == str(self.secret_key) - return bool(self.secret_key is None) - - async def start(self): - try: - print("Starting IPC") - await serve(self.handle_ipc_connection, self.host, self.port) - except Exception as e: - print(e) - - async def handle_ipc_connection(self, websocket: WebSocketServerProtocol): - from .methods import process_methods - - client_id = websocket.request_headers.get("Client-ID", None) - if not client_id: - return - - self.connections.add(websocket) - try: - async for message in websocket: - data: Dict[str, Any] = json.loads(message) - if self.is_secure(data): - action = data.get("data") - await process_methods(websocket, self.bot, action) - - except ConnectionClosed: - pass - - self.connections.remove(websocket) - - async def send(self, payload): - for conn in self.connections: - try: - await conn.send(json.dumps(payload)) - except: - await conn.close() - self.connections.remove(conn) \ No newline at end of file diff --git a/web/objects.py b/web/objects.py deleted file mode 100644 index 72121f6..0000000 --- a/web/objects.py +++ /dev/null @@ -1,25 +0,0 @@ -class Asset: - def __init__(self, userId: str, key: str): - self.key = key - self.url = f"https://cdn.discordapp.com/avatars/{userId}/{key}.png" - -class User: - def __init__(self, data: dict): - self.id = int(data.get("id")) - self.username = data.get("username") - self.display_name = data.get("display_name") - self.avatar = Asset(self.id, data.get("avatar")) - self.avatar_decoration = data.get("avatar_decoration") - self.discriminator = data.get("discriminator") - self.public_flag = data.get("public_flag") - self.flags = data.get("flags") - self.banner = data.get("banner") - self.banner_color = data.get("banner_color") - self.locale = data.get("locale") - self.mfa_enabled = data.get("mfa_enabled") - self.premium_type = data.get("premium_type") - self.access_token = data.get("access_token") - self.refresh_token = data.get("refresh_token") - - self.sid = None - self.guild_id = None \ No newline at end of file diff --git a/web/static/css/main.css b/web/static/css/main.css deleted file mode 100644 index 4f623cd..0000000 --- a/web/static/css/main.css +++ /dev/null @@ -1,920 +0,0 @@ -@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;500;600;700&display=swap'); - -* { - padding: 0; - margin: 0; - box-sizing: border-box; - color: #ffffff; - font-family: 'Poppins', sans-serif; -} - -html, -body { - background-color: #020202; - -webkit-user-select: none; - -ms-user-select: none; - user-select: none; - white-space: nowrap; - max-height: 100%; - overflow-x: hidden; -} - -a { - text-decoration: none; -} - -:root { - --grey: rgba(142, 142, 142, 20%); - --grey-hover: rgba(142, 142, 142, 30%); -} - -/* width */ -::-webkit-scrollbar { - width: 10px; -} - -/* Handle */ -::-webkit-scrollbar-thumb { - background: #888; -} - -/* Handle on hover */ -::-webkit-scrollbar-thumb:hover { - background: #555; -} - -.container { - display: flex; - flex-direction: column; - justify-content: space-between; - min-height: 100%; -} - -.box { - position: absolute; - opacity: 20%; - border-radius: 40%; - filter: blur(60px); - transition: all 2s ease-in-out; -} - -.dropdown-container { - position: relative; - display: inline-block; - padding: 10px; - border-radius: 10px 10px 0 0; - cursor: pointer; -} - -.dropdown-container:hover { - background-color: var(--grey); -} - -.dropdown-container:hover .dropdown-item { - display: block; - background-color: var(--grey); -} - -.dropdown-content { - display: flex; - align-items: center; -} - -.dropdown-content img { - height: 40px; - border-radius: 50%; - margin-right: .6rem; -} - -.dropdown-item { - display: none; - position: absolute; - left: 0; - margin-top: 10px; - padding: 5px; - border-radius: 0 0 10px 10px; - width: 100%; - font-size: 15px; -} - -.dropdown-item a { - display: block; - padding: .5rem 1rem; - border-radius: 8px; -} - -.dropdown-item i { - margin-right: .6rem; -} - -.dropdown-item a:hover { - background-color: var(--grey-hover); -} - -.header { - display: flex; - padding: 1rem; - justify-content: space-between; - align-items: center; - position: sticky; - top: 0; - z-index: 2; - background-color: transparent; - transition: background-color 0.5s ease; -} - -.header.active { - background-color: #020202; -} - -.header .left { - display: flex; -} - -.header .left p { - font-size: 20px; - padding: 12px; - border-radius: 10px; - margin: 0rem 0.4rem; - transition: all 0.2s; - cursor: pointer; -} - -.header .left p:hover { - background-color: var(--grey); -} - -.header .center .search-container { - display: flex; - justify-content: center; - position: relative; -} - -.header .center .search-container .search-bar { - display: flex; - justify-content: space-between; - align-items: center; - border-radius: 8px; - padding: .6rem 1rem; - width: 500px; - overflow: hidden; - background-color: rgba(33, 33, 33, 255); -} - -.header .center .search-loader { - display: none; - border: 3px solid transparent; - border-radius: 50%; - border-top: 3px solid #c9cbcc; - width: 20px; - height: 20px; - animation: spin 1s ease-in-out infinite; -} - -@keyframes spin { - 0% { - transform: rotate(0deg); - } - - 100% { - transform: rotate(360deg); - } -} - -.header .center input { - margin: 0 1rem; - background-color: transparent; - width: 100%; - height: 100%; - border: none; - outline: none; - font-size: 15px; -} - -.header .center .search-result-list { - position: absolute; - display: none; - overflow-x: hidden; - overflow-y: auto; - top: 50px; - width: 100%; - height: 400px; - border-radius: 8px; - padding: 1rem .5rem; - background-color: rgba(33, 33, 33, 255); -} - -.header .search-result { - display: flex; - justify-content: space-between; - align-items: center; - border-radius: 8px; - padding: .5rem 1rem; - cursor: pointer; - margin-bottom: .5rem; -} - -.header .search-result:hover { - background-color: var(--grey); -} - -.header .search-result-left { - display: flex; - align-items: center; - overflow: hidden; - margin-right: .6rem; - max-width: 85%; -} - -.header .search-result-info p.info { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; -} - -.header .search-result-info p.desc { - font-size: 14px; - color: gray; -} - -.header .search-result img { - width: auto; - height: 50px; - border-radius: 4px; - margin-right: .5rem; -} - -.main { - display: flex; - flex-grow: 1; - padding: 0rem 4rem; - justify-content: space-around; -} - -.toastContrainer { - position: absolute; - left: 2%; - z-index: 2; -} - -.toastContrainer .toast { - display: flex; - position: relative; - padding: .8rem; - margin-top: 1rem; - background-color: var(--grey-hover); - border-radius: 10px; - overflow: hidden; - transform: translateX(-100%); - animation: moveRight .5s ease-in-out forwards; -} - -@keyframes moveRight { - 100% { - transform: translateX(0); - } -} - -.toastContrainer .toast::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 3px; - background-color: azure; - animation: anim 6s ease-in-out forwards; -} - -@keyframes anim { - 100% { - width: 0; - } -} - -.toastContrainer .toast img { - border-radius: 10px; - width: 40px; - height: 40px; - margin-right: .7rem; -} - -.toastContrainer .toast .content p.username { - font-size: 15px; -} - -.toastContrainer .toast .content p.message { - font-size: 10px; - width: 300px; - white-space: pre-wrap; -} - -.main .thumbnail { - display: flex; - position: relative; - justify-content: center; - align-items: center; - flex: 1; -} - -.main .thumbnail img { - padding: 1rem; - width: 75%; - height: auto; - z-index: 1; -} - -.main .thumbnail-background { - position: absolute; - padding: 1rem; - filter: blur(50px); - transform: translate3d(0px, 0px, 0) scale(1); - opacity: var(0.6); - border-radius: inherit; - transition: all 1s ease-in-out; -} - -.player-controller { - position: sticky; - bottom: 0; - z-index: 2; -} - -.player-controller:hover .seek-bar { - height: 9px; -} - -.progress-bar-container { - position: relative; - width: 100%; -} - -.position-info { - position: absolute; - top: -30px; - left: 0; - display: none; -} - -.progress-bar-container:hover .position-info { - display: block; -} - -.seek-bar { - position: absolute; - appearance: none; - width: 100%; - height: 4px; - cursor: pointer; - overflow: hidden; - background-color: rgb(73, 73, 73); -} - -.seek-bar::-webkit-slider-thumb { - appearance: none; - width: 1px; - height: 20px; - background: rgb(171, 168, 168); - box-shadow: -1000px 0 0 1000px rgb(171, 168, 168); -} - -.control-container { - display: flex; - padding: 1rem; - align-items: center; - background-color: #212121; - justify-content: space-between; -} - -.player-controller .left { - display: flex; - flex: 1; -} - -.player-controller .left .control { - display: flex; - align-items: center; -} - -.player-controller .left .control i { - cursor: pointer; - padding: 5px; - margin-left: .8rem; - font-size: 20px; -} - -.player-controller .left .control #play-pause-btn { - font-size: 30px; -} - -.player-controller .left .position { - display: flex; - padding: 10px; - margin-left: 1rem; -} - -.player-controller .center { - display: flex; - padding: 0rem 1rem; - justify-content: center; - align-items: center; - flex-grow: 1; - flex: 2; - max-width: 40%; -} - -.player-controller .center img { - border-radius: .2rem; - height: 40; -} - -.player-controller .center .data { - overflow: hidden; - margin: 0 1rem; -} - -.player-controller .center .data p { - text-overflow: ellipsis; - overflow: hidden; -} - -.player-controller .center .desc { - color: grey; - font-size: 13px; -} - -.player-controller .center i { - cursor: pointer; - padding: 5px; - font-size: 20px; - color: gray; - transition: color .2s ease-in-out; -} - -.player-controller .center i:hover { - color: #fff; -} - -.player-controller .right { - flex: 1; - display: flex; - justify-content: right; - align-items: center; - padding-right: 5px; -} - -.player-controller .right i { - cursor: pointer; - padding: 5px; - margin-left: .8rem; - font-size: 20px; - color: gray; - transition: color .2s ease-in-out; -} - -.player-controller .right i:hover { - color: #fff; -} - -.main .list { - flex: 1; - z-index: 1; - display: flex; - flex-direction: column; - justify-items: center; - overflow: hidden; -} - -.main .auto-play { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 1rem; - padding: 0 1rem; -} - -.main .queue-list { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - display: flex; - justify-content: center; -} - -.main .queue-list ul { - list-style: none; - width: 95%; - max-height: 300px; -} - -.track { - display: flex; - justify-content: space-between; - max-width: 100%; - align-items: center; - cursor: pointer; - border-radius: .5rem; - padding: 5px 15px; - overflow: hidden; - margin-bottom: .5rem; - transition: background-color .2s, transform .2s; -} - -.track:hover { - background-color: var(--grey); -} - -.track:hover .time { - display: none; -} - -.track:hover i.action { - display: block; -} - -.track.active { - background-color: var(--grey-hover); -} - -.track .left { - display: flex; - justify-content: left; - align-items: center; - margin-right: .5rem; - max-width: 90%; - overflow: hidden; -} - -.track i { - cursor: move; - margin-right: 1rem; - font-size: 20px; -} - -.track i.action { - display: none; - padding: .5rem 1rem; - border-radius: 50%; - margin-right: 0; - cursor: pointer; - transition: background-color .2s; -} - -.track i.action:hover { - background-color: rgba(157, 157, 157, 50%); -} - -.track i:active { - transform: scale(0.85); - box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); -} - -.track .left img { - padding: 5px; - height: 50; - border-radius: .5rem; -} - -.track .left .info { - margin-left: .5rem; - overflow: hidden; -} - -.track .left .info p { - text-overflow: ellipsis; - overflow: hidden; -} - -.track .left .info p.desc { - color: grey; - font-size: 13px; -} - -.users-bar { - position: absolute; - right: 0; - padding: 1.5rem 0; - background-color: rgb(37, 37, 37); - width: 0px; - height: 100%; - overflow: hidden; - transition: all .5s ease-in-out; - z-index: 2; -} - -.users-bar.active { - width: 350px; - padding: 1.5rem 1rem; -} - -.users-bar .info { - padding: 0 .5rem; - margin-bottom: 1rem; -} - -.users-bar .info h5 { - color: rgba(142, 142, 142); -} - -.users-container { - width: 100%; - overflow-y: auto; - overflow-x: hidden; -} - -.user { - padding: .5rem; - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: .5rem; -} - -.user .left { - display: flex; - align-items: center; -} - -.user img { - width: 35px; - height: auto; - border-radius: 8px; - margin-right: .5rem; -} - -.context-menu { - display: none; - position: absolute; - z-index: 2; -} - -.context-menu ul { - list-style: none; - min-width: 150px; - padding: .5rem 0; - border-radius: .3rem; - background-color: rgb(37, 37, 37); -} - -.context-menu li { - padding: .5rem 1rem; - cursor: pointer; - transition: background-color .2s; - font-size: 14px; -} - -.context-menu li:hover { - background-color: rgb(98, 98, 98); -} - -.context-menu i { - margin-right: .8rem; -} - -.switch { - position: relative; - display: inline-block; - width: 52px; - height: 26px; -} - -.switch input { - opacity: 0; - width: 0; - height: 0; -} - -.slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--grey-hover); - -webkit-transition: .4s; - transition: .4s; -} - -.slider:before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 4px; - bottom: 4px; - background-color: white; - -webkit-transition: .4s; - transition: .4s; -} - -input:checked+.slider { - background-color: #2196F3; -} - -input:focus+.slider { - box-shadow: 0 0 1px #2196F3; -} - -input:checked+.slider:before { - -webkit-transform: translateX(26px); - -ms-transform: translateX(26px); - transform: translateX(26px); -} - -/* Rounded sliders */ -.slider.round { - border-radius: 34px; -} - -.slider.round:before { - border-radius: 50%; -} - -.playlists { - flex-grow: 1; - padding: 0rem 4rem; - transition: all 0.2s ease-in-out; -} - -.playlists-grid { - display: grid; - grid-gap: 1.5rem; - padding: 2rem; - grid-template-rows: repeat(auto-fit, 250px); - grid-template-columns: repeat(auto-fit, minmax(200px, 200px)); - grid-auto-rows: 250px; -} - -.images { - display: flex; - flex-wrap: wrap; - justify-content: center; - align-items: center; - height: 200px; - width: 200px; - border-radius: .5rem; - overflow: hidden; - position: relative; - transition: all 0.2s ease-in-out; - opacity: 0; - animation: created 1s ease-in-out forwards; -} - -.images img { - width: 50%; - height: 50%; - object-fit: cover; -} - -.images i.action { - position: absolute; - top: 0; - right: 0; - display: none; - color: white; - cursor: pointer; - margin: .5rem; - padding: .5rem .9rem; - background-color: transparent; - transition: all 0.2s ease-in-out; -} - -.images i.play { - position: absolute; - bottom: 0; - right: 0; - margin: 1rem; - padding: .7rem .84rem; - display: none; - color: white; - cursor: pointer; - font-size: 16px; - background-color: rgba(0, 0, 0, 0.6); - border-radius: 50%; - transition: all 0.2s ease-in-out; -} - -.images::before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - box-shadow: inset 0px 10px 40px rgba(0, 0, 0, 0.5); - opacity: 0; - transition: all 0.2s ease-in-out; - cursor: pointer; -} - -.images:hover::before { - opacity: 1; -} - -.images:hover i { - display: block; - text-align: center; - border-radius: 3rem; -} - -.images i.action:hover { - background-color: rgba(255, 255, 255, 0.3); - border-radius: 50%; -} - -.images i.action:active { - transform: scale(0.85); - box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); -} - -.images i.play:hover { - transform: scale(1.2); -} - -.images i.play:active { - transform: scale(0.85); - box-shadow: 0 0 10px rgba(230, 230, 230, 0.3); -} - -.playlist .info { - margin-top: .5rem; -} -.playlist .info p { - text-overflow: ellipsis; - overflow: hidden; -} - -.playlist .info p.desc { - font-size: 14px; - color: gray; -} - -@keyframes created { - 100% { - opacity: 1; - } -} - -@media (max-width: 935px) { - .main { - flex-direction: column; - justify-content: center; - padding: 0; - } - - .toastContrainer { - display: none; - } - - .header .center { - display: none; - } - - .player-controller .left .position { - display: none; - } - - .player-controller .center img { - display: none; - } - - ul { - width: 100%; - } - - .playlists { - padding: 0 1rem; - } - - .playlists-grid { - grid-gap: 1rem; - padding: 1rem; - grid-template-rows: repeat(auto-fit, 200px); - grid-template-columns: repeat(auto-fit, minmax(150px, 150px)); - grid-auto-rows: 200px; - justify-content: center; - align-items: center; - } - - .images { - height: 150px; - width: 150px; - } -} - -@media (max-width: 640px) { - .main { - padding: 0; - } - - .main .queue-list { - overflow-y: auto; - overflow-x: hidden; - flex: 1; - display: flex; - justify-content: center; - min-width: 100%; - } - -} \ No newline at end of file diff --git a/web/static/img/error.png b/web/static/img/error.png deleted file mode 100644 index 1cb4e9e..0000000 Binary files a/web/static/img/error.png and /dev/null differ diff --git a/web/static/img/info.png b/web/static/img/info.png deleted file mode 100644 index a914ef4..0000000 Binary files a/web/static/img/info.png and /dev/null differ diff --git a/web/static/js/action.js b/web/static/js/action.js deleted file mode 100644 index 361fd2e..0000000 --- a/web/static/js/action.js +++ /dev/null @@ -1,370 +0,0 @@ -$(document).ready(function () { - const player = new Player(userId); - const largeImage = document.getElementById("largeImage"); - const img = document.getElementById("image"); - const canvas = document.createElement('canvas'); - const $positionInfo = $('.position-info'); - - var startPos = null; - var selectedTrack = null; - var selectedPlaylistId = null; - var selectedPlaylistTrack = null; - - var typingTimer; - var doneTypingInterval = 2000; - - largeImage.crossOrigin = "Anonymous"; - largeImage.onload = function () { - try { - const context = canvas.getContext('2d', { willReadFrequently: true }); - const width = canvas.width = largeImage.width; - const height = canvas.height = Math.round(largeImage.height * 0.7); - const startY = Math.round((largeImage.height - height) / 2); - context.drawImage(largeImage, 0, startY, width, height, 0, 0, width, height); - getMainColorsFromImage(4) - .then(colors => { - let bg = $(".thumbnail-background"); - bg.css({ - "background": `linear-gradient(-132deg, ${colors[0]}, ${colors[1]}, ${colors[2]}, ${colors[3]})`, - "width": "70%", - "padding-bottom": `${largeImage.naturalHeight / largeImage.naturalWidth * 70}%`, - }); - bg.fadeIn(200); - $("#largeImage").fadeIn(200); - }) - .catch(error => { return }); - - } catch (e) { } - } - - img.onload = function () { - try { - $("#image").fadeIn(200); - } catch (e) { } - } - - function getMainColorsFromImage(numColors) { - return new Promise((resolve, reject) => { - const imageData = canvas.getContext('2d', { willReadFrequently: true }).getImageData(0, 0, canvas.width, canvas.height).data; - const colorCount = {}; - const threshold = 20; // adjust this value to change the threshold for black/white similarity - const colorGap = 30; // adjust this value to change the minimum color difference between each main color - for (let i = 0; i < imageData.length; i += 4) { - const r = imageData[i]; - const g = imageData[i + 1]; - const b = imageData[i + 2]; - const color = `rgb(${r}, ${g}, ${b})`; - if (!colorCount[color]) { - // Check if the color is too close to black or white - const isBlackOrWhite = r < threshold || r > 255 - threshold || g < threshold || g > 255 - threshold || b < threshold || b > 255 - threshold; - if (isBlackOrWhite) continue; - // Check if the color is too similar to previous colors - const prevColors = Object.keys(colorCount); - const isTooSimilar = prevColors.some(prevColor => { - const [pr, pg, pb] = prevColor.match(/\d+/g).map(Number); - const diff = Math.sqrt(Math.pow(r - pr, 2) + Math.pow(g - pg, 2) + Math.pow(b - pb, 2)); - return diff < colorGap; - }); - if (isTooSimilar) continue; - colorCount[color] = 0; - } - colorCount[color]++; - } - const colors = Object.keys(colorCount).sort((color1, color2) => colorCount[color2] - colorCount[color1]).slice(0, numColors); - resolve(colors); - }); - } - - $("#seek-bar").on('mousemove', function (e) { - let position = e.pageX - $(this).offset().left; - let duration = $(this).width(); - let percentage = (position / duration) * 100; - - let time = player.msToReadableTime(percentage * player.currentTrack?.length / 100); - $positionInfo.text(time).css({ - left: e.pageX - $positionInfo.outerWidth() / 2, - }); - }); - - $('body').click(function (event) { - let $target = $(event.target); - - if (!$target.closest(".search-container").length && !$target.is("#search-input") && $('#search-result-list').css("display") != 'none') { - $("#search-result-list").fadeOut(200); - } - - else if (!$target.closest(".users-bar").is('.users-bar') && - !$target.closest("#users-btn").is('#users-btn') && - $(".users-bar").hasClass("active")) { - $(".users-bar").removeClass("active"); - $("#users-btn").css({ "color": "" }); - - } else if ($target.closest(".action").is(".action") && - $target.closest(".playlists-tracks").is(".playlists-tracks")) { - - selectedPlaylistTrack = $target.closest(".track").data("value"); - const $contextMenu = $("#context-menu"); - const menuHeight = $contextMenu.outerHeight(); - const windowHeight = $(window).height(); - const topPosition = event.pageY + 30; - if (topPosition + menuHeight > windowHeight) { - // If the menu would go out of the page, position it above the btn instead - $contextMenu.css({ "left": `${event.pageX - 130}px`, "top": `${event.pageY - menuHeight - 30}px` }).fadeIn(200); - } else { - $contextMenu.css({ "left": `${event.pageX - 130}px`, "top": `${topPosition}px` }).fadeIn(200); - } - - } else if ($target.closest(".playlists-tracks").is(".playlists-tracks") && - $target.closest(".track").is(".track")) { - - const track_id = $target.closest(".track").data("value"); - player.send({ "op": "addTracks", "tracks": [track_id] }) - - } else if ($target.closest(".playlist").is(".playlist")) { - selectedPlaylistId = $target.closest(".playlist").data("value"); - - if ($target.closest(".action").is(".action")) { - const $contextMenu = $("#playlist-context-menu") - - const menuHeight = $contextMenu.outerHeight(); - const windowHeight = $(window).height(); - const topPosition = event.pageY + 30; - if (topPosition + menuHeight > windowHeight) { - // If the menu would go out of the page, position it above the btn instead - $contextMenu.css({ "left": `${event.pageX - 130}px`, "top": `${event.pageY - menuHeight - 30}px` }).fadeIn(200); - } else { - $contextMenu.css({ "left": `${event.pageX - 130}px`, "top": `${topPosition}px` }).fadeIn(200); - } - } else if ($target.closest(".play").is(".play")) { - if (selectedPlaylistId in player.playlists) { - if ("tracks" in player.playlists[selectedPlaylistId]) { - if (player.playlists[selectedPlaylistId]['tracks'].length != 0) { - player.send({ "op": "addTracks", "tracks": player.playlists[selectedPlaylistId]["tracks"] }); - } - } - } - } else { - const $pTracks = $("#playlists-tracks"); - if (selectedPlaylistId in player.playlists) { - $pTracks.empty(); - for (const track_id of player.playlists[selectedPlaylistId]["tracks"]) { - const track = decode(track_id); - - let $trackDiv = $(`
${(this.isDJ) ? '' : ''}

${track.title}

${track.author}

${player.msToReadableTime(track.length)}

`); - $pTracks.append($trackDiv); - } - } - } - } - - if (!$target.closest(".action").is(".action") && - !$target.closest("#context-menu li").is("#context-menu li") && - $("#context-menu").css("display") != "none") { - $("#context-menu").fadeOut(200); - } - - if (!$target.closest(".action").is(".action") || - !$target.closest(".images").is(".images") && - $("#playlist-context-menu").css("display") != "none") { - $("#playlist-context-menu").fadeOut(200); - } - }); - - $(function () { - $("#sortable").sortable({ - handle: ".handle", - scroll: true, - axis: "y", - start: function (event, ui) { - startPos = ui.item.index(); - }, - stop: function (event, ui) { - if (startPos != null) { - let newPos = ui.item.index(); - if (startPos != newPos) { - player.moveTrack(startPos, newPos) - } - } - } - }); - }); - - $('#sortable').on('click', 'li', function (event) { - let index = $(this).index(); - let position = player.current_queue_position; - - if ($(event.target).hasClass('action')) { - selectedTrack = { position: index, track: player.queue[index] }; - const menuHeight = $("#context-menu").outerHeight(); - const windowHeight = $(window).height(); - const topPosition = event.pageY + 30; - if (topPosition + menuHeight > windowHeight) { - // If the menu would go out of the page, position it above the btn instead - $("#context-menu").css({ "left": `${event.pageX - 130}px`, "top": `${event.pageY - menuHeight - 30}px` }).fadeIn(200); - } else { - $("#context-menu").css({ "left": `${event.pageX - 130}px`, "top": `${topPosition}px` }).fadeIn(200); - } - return; - } - - if (index < position) { - player.backTo(position - index); - } else if (index > position) { - player.skipTo(index - position) - } else { - player.togglePause(); - } - }) - - $('#search-result-list').on('click', 'li', function () { - let index = $(this).index(); - let track = player.searchList[index]; - if (track != undefined) { - player.send({ "op": "addTracks", "tracks": [track] }); - } - $("#search-result-list").fadeOut(200); - }) - - $('#search-input').on('input', function () { - clearTimeout(typingTimer); - $("#search-loader").fadeIn(200); - typingTimer = setTimeout(function () { - let input = $('#search-input').val(); - if (input.replace(/\s+/g, '') != "") { - player.send({ "op": "getTracks", "query": input }) - } else { - $("#search-loader").fadeOut(200); - $("#search-result-list").fadeOut(200); - } - }, doneTypingInterval); - }); - - $('#search-input').focus(function () { - if ($(this).val() != "") { - $("#search-result-list").fadeIn(200); - } - }) - - $('#play-pause-btn').on('click', function () { - player.togglePause(); - }); - - $('#skip-btn').on('click', function () { - player.skipTo(); - }); - - $('#back-btn').on('click', function () { - player.backTo(); - }); - - $('#seek-bar').change(function () { - player.seekTo($(this).val()); - }) - - $("#repeat-btn").on('click', function () { - player.repeatMode(); - }) - - $("#shuffle-btn").on('click', function () { - player.shuffle(); - }) - - $("#users-btn").on('click', function () { - const userBar = $(".users-bar") - userBar.toggleClass("active"); - if (userBar.hasClass("active")) { - $(this).css({ "color": "#fff" }); - } else { - $(this).css({ "color": "" }); - } - - }) - - $("#auto-play").on("click", function () { - let checkbox = $(this).is(':checked'); - player.send({ "op": "toggleAutoplay", "status": checkbox }) - player.autoplay = checkbox; - }); - - $("#remove-track-btn").on('click', function () { - if ($("#main").css("display") == "none") { - if (selectedPlaylistId != null && selectedPlaylistId != null && player.playlists != null) { - const playlist = player.playlists[selectedPlaylistId]; - if (playlist["type"] == "playlist") { - player.send({ "op": "removePlaylistTrack", "pId": selectedPlaylistId, "track_id": selectedPlaylistTrack, "pType": playlist["type"] }) - let index = playlist["tracks"].indexOf(selectedPlaylistTrack); - if (index !== -1) { - playlist["tracks"].splice(index, 1); - } - $(`div [data-value=${selectedPlaylistTrack}]`).remove(); - } - } - } else { - player.removeTrack(selectedTrack?.position, selectedTrack?.track) - } - $("#context-menu").fadeOut(200); - }) - - $("#copy-track-btn").on('click', function () { - if ($("#main").css("display") == "none") { - if (selectedPlaylistTrack != null) { - const track = decode(selectedPlaylistTrack); - navigator.clipboard.writeText(track.uri); - } - } else { - navigator.clipboard.writeText(selectedTrack?.track.uri); - } - $("#context-menu").fadeOut(200); - }) - - $("#remove-playlist-btn").on('click', function () { - if (player.playlists != null && selectedPlaylistId in player.playlists) { - if (selectedPlaylistId != 200) { - playlist = player.playlists[selectedPlaylistId]; - console.log(player.playlists); - let payload = { "op": "removePlaylist", "pId": selectedPlaylistId } - if (playlist['type'] == "share") { - payload["refer_user"] = playlist["user"]; - payload["isShare"] = true; - } - player.send(payload); - delete player.playlists[selectedPlaylistId]; - player.updatePlaylists(); - } - } - }) - - $("#homeBtn").on('click', function () { - $("#playlists").fadeOut(200, function () { - $("#main").fadeIn(200); - $(".header").removeClass("active"); - }); - - }) - - $("#playlistBtn").on('click', function () { - $("#main").fadeOut(200, function () { - if (player.playlists == null) { - player.send({ "op": "getPlaylists" }); - } - $("#playlists").fadeIn(200); - $(".header").addClass("active"); - }); - }) - - $("#like-btn").on('click', function () { - let currentTrack = player.currentTrack; - if (currentTrack != undefined) { - if (currentTrack.isStream) { - return player.showToast("error", "You are not allowed to add streaming videos to your playlist!"); - } - if ($(this).hasClass("fa-regular")) { - $(this).removeClass("fa-regular").addClass("fa-solid"); - player.send({ "op": "addPlaylistTrack", "track_id": currentTrack.track_id, "pId": "200" }) - } else { - $(this).removeClass("fa-solid").addClass("fa-regular"); - player.send({ "op": "removePlaylistTrack", "track_id": currentTrack.track_id, "pId": "200" }) - } - } - }) -}); \ No newline at end of file diff --git a/web/static/js/objects.js b/web/static/js/objects.js deleted file mode 100644 index 2ff9229..0000000 --- a/web/static/js/objects.js +++ /dev/null @@ -1,673 +0,0 @@ -class Timer { - constructor(callback, interval) { - this.callback = callback; - this.interval = interval; - this.timerId = null; - this.isRunning = false; - } - - start() { - if (!this.isRunning) { - this.isRunning = true; - this.timerId = setInterval(() => { - this.callback(); - }, this.interval); - } - } - - stop() { - if (this.isRunning) { - clearInterval(this.timerId); - this.timerId = null; - this.isRunning = false; - } - } - - getIsRunning() { - return this.isRunning; - } -} - -class DataInput { - #pos = 0; - #buf = Uint8Array; - #view = DataView; - - constructor(bytes) { - if (typeof bytes === "string") { - const decodedString = atob(bytes); - const byteArr = new Uint8Array(decodedString.length); - for (let i = 0; i < decodedString.length; i++) { - byteArr[i] = decodedString.charCodeAt(i); - } - bytes = byteArr; - } - this.#buf = bytes; - this.#view = new DataView(bytes.buffer); - } - - #_advance(bytes) { - if (this.#pos + bytes > this.#buf.length) { - throw new Error("Buffer overflow: cannot advance further."); - } - const p = this.#pos; - this.#pos += bytes; - return p; - } - - readByte() { - return this.#buf[this.#_advance(1)]; - } - - readBoolean() { - return this.readByte() !== 0; - } - - readUnsignedShort() { - return this.#view.getUint16(this.#_advance(2), false); - } - - readInt() { - return this.#view.getInt32(this.#_advance(4), false); - } - - readLong() { - const msb = this.#view.getInt32(this.#_advance(4), false); - const lsb = this.#view.getUint32(this.#_advance(4), false); - return BigInt(msb) << 32n | BigInt(lsb); - } - - readUTF() { - const len = this.readUnsignedShort(); - const start = this.#_advance(len); - return new window.TextDecoder().decode(this.#buf.slice(start, start + len)); - } -} - -class Track { - constructor(object) { - this.title = object.title; - this.author = object.author; - this.source = object.source; - this.identifier = object.identifier; - if (object.thumbnail == undefined) { - if (this.source == "youtube") { - this.imageUrl = `https://img.youtube.com/vi/${this.identifier}/hqdefault.jpg` - } else { - this.imageUrl = "https://cdn.discordapp.com/attachments/674788144931012638/823086668445384704/eq-dribbble.gif"; - } - } else { - this.imageUrl = object.thumbnail; - } - this.isStream = object.isStream; - this.length = Number(object.length); - this.track_id = object.track_id; - this.uri = object.uri; - } -} - -const decoders = [ - (input, track_id) => { - const title = input.readUTF(); - const author = input.readUTF(); - const length = input.readLong(); - const identifier = input.readUTF(); - const isStream = input.readBoolean(); - const uri = input.readBoolean() ? input.readUTF() : null; - const thumbnail = input.readBoolean() ? input.readUTF() : null; - const source = input.readUTF(); - - return { track_id, title, author, length, identifier, isStream, uri, thumbnail, source, position: 0n }; - }, - undefined, - (input, track_id) => { - const title = input.readUTF(); - const author = input.readUTF(); - const length = input.readLong(); - const identifier = input.readUTF(); - const isStream = input.readBoolean(); - const uri = input.readBoolean() ? input.readUTF() : null; - const source = input.readUTF(); - - return { track_id, title, author, length, identifier, isStream, uri, thumbnail: null, source, position: 0n }; - }, - (input, track_id) => { - const title = input.readUTF(); - const author = input.readUTF(); - const length = input.readLong(); - const identifier = input.readUTF(); - const isStream = input.readBoolean(); - const uri = input.readBoolean() ? input.readUTF() : null; - const thumbnail = input.readBoolean() ? input.readUTF() : null; - const isrc = input.readBoolean() ? input.readUTF() : null; - const source = input.readUTF(); - - return { track_id, title, author, length, identifier, isStream, uri, thumbnail, source, position: 0n }; - } -] -function decode(track_id) { - const input = new DataInput(track_id); - const flags = input.readInt(); - const version = input.readByte(); - - const decoder = decoders[version]; - return new Track(decoder(input, track_id)); -} - -const actions = { - initPlayer: function (player, data) { - player.init(); - player.isDJ = data['is_dj']; - player.addTrack(data["tracks"]); - player.updateCurrentQueuePos(data['current_queue_position']); - player.is_paused = data['is_paused']; - player.current_position = data['current_position']; - player.repeat = data['repeat_mode']; - player.channelName = data["channel_name"]; - player.autoplay = data["autoplay"]; - data["users"].forEach(user => { - player.addUser(user); - }) - - $("#sortable").sortable("option", "disabled", !player.isDJ); - }, - - playerUpdate: function (player, data) { - player.last_update = data["last_update"]; - player.is_connected = data["is_connected"]; - player.current_position = data["last_position"]; - }, - - trackUpdate: function (player, data) { - let track = player.updateCurrentQueuePos(data['current_queue_position']); - player.is_paused = data['is_paused']; - if (track?.track_id != data["track_id"]) { - player.send({ "op": "initPlayer" }) - } - }, - - addTrack: function (player, data) { - player.addTrack(data["tracks"]); - var tracks = data["tracks"]; - if (tracks.length == 1) { - var msg = `Added ${decode(tracks[0]).title} songs into the queue.` - } else { - var msg = `Added ${tracks.length} into the queue.` - } - player.showToast(data["requester_id"], msg) - }, - - getTracks: function (player, data) { - let tracks = data["tracks"]; - if (tracks != undefined) { - const resultList = $("#search-result-list"); - resultList.empty(); - player.searchList = tracks; - for (let i in tracks) { - let track = decode(tracks[i]) - resultList.append(`
  • ${track.title}

    ${track.author}

    ${player.msToReadableTime(track.length)}

  • `) - } - } - $("#search-result-list").fadeIn(200); - $("#search-loader").fadeOut(200); - }, - - playerClose: function (player, data) { - player.init(); - }, - - updateGuild: function (player, data) { - const user = data["user"]; - player.channelName = data["channel_name"]; - - if (user["user_id"] == player.userId) { - if (data['is_joined']) { - player.send({ "op": "initPlayer" }); - } else { - player.init(); - } - } - if (data['is_joined']) { - player.addUser(user); - if (user["user_id"] != player.userId) { - player.showToast(user["user_id"], "Joined your channel!"); - } - } else { - if (player.users.hasOwnProperty(user["user_id"])) { - player.showToast(user["user_id"], "Left your channel!"); - delete player.users[user['user_id']]; - player.updateUser(); - } - } - }, - - updatePause: function (player, data) { - player.is_paused = data['pause']; - if (data["pause"]) { - var msg = "Paused the player." - } else { - var msg = "Resumed the player." - } - player.showToast(data["requester_id"], msg) - }, - - updatePosition: function (player, data) { - player.current_position = data["position"]; - }, - - swapTrack: function (player, data) { - let index1 = player.current_queue_position + data['position2']["index"]; - let index2 = player.current_queue_position + data['position1']["index"]; - let track1 = player.queue[index1]; - let track2 = player.queue[index2]; - - if (track1?.track_id != data['position1']["track_id"] || track2?.track_id != data['position2']["track_id"]) { - return player.send({ "op": "initPlayer" }); - } - - player.queue[index1] = player.queue.splice(index2, 1, player.queue[index1])[0]; - player.initSortable(); - player.showToast(data["requester_id"], `${track1.title} and ${track2.title} are swapped`) - }, - - moveTrack: function (player, data) { - let position = player.current_queue_position + data["position"]["index"]; - let newPosition = player.current_queue_position + data["newPosition"]["index"]; - let element = player.queue.splice(position, 1)[0]; - if (element?.track_id != data["position"]["track_id"]) { - return player.send({ "op": "initPlayer" }); - } - player.queue.splice(newPosition, 0, element); - - const $ul = $('#sortable'); - const $li = $ul.children().eq(position); - $li.detach(); - $ul.children().eq(newPosition).before($li); - player.showToast(data["requester_id"], `Moved ${element.title} to ${newPosition}`) - }, - - shuffleTrack: function (player, data) { - let tracks = data["tracks"]; - if (tracks != undefined) { - player.queue = []; - tracks.forEach(rawTrack => { - let track = new Track(rawTrack); - player.queue.push(track); - }); - player.initSortable(); - player.showToast(data["requester_id"], "The queue is shuffled.") - } - }, - - repeatTrack: function (player, data) { - player.repeat = data["repeatMode"]; - }, - - removeTrack: function (player, data) { - const positions = data["positions"]; - const trackIds = data["track_ids"]; - var removeTracks = [] - var newQueue = []; - - player.queue.forEach((track, index) => { - if (positions.includes(index)) { - if (trackIds.includes(track.track_id)) { - if (index < player.current_queue_position) { - player.current_queue_position -= 1; - } - removeTracks.push(track); - } else { - return player.send({ "op": "initPlayer" }) - } - } else { - newQueue.push(track); - } - }) - - player.queue = newQueue; - player.initSortable(); - - if (removeTracks.length == 1) { - var msg = `Removed ${removeTracks[0].title} from the queue.`; - } else { - var msg = `Removed ${removeTracks.length} tracks from the queue.`; - } - - player.showToast(data["requester_id"], msg); - }, - - toggleAutoplay: function (player, data) { - var status = data["status"]; - player.autoplay = status; - }, - - errorMsg: function (player, data) { - player.showToast(data["level"], data["msg"]); - }, - - getPlaylists: function (player, data) { - const playlists = data["playlists"] - player.playlists = playlists; - player.updatePlaylists(); - } -} - -class Player { - constructor(userId) { - this.socket = new Socket(`${window.location.protocol}//${window.location.hostname}:${window.location.port}`); - this.socket.connect(this); - this.socket.addMessageListener((msg) => this.handleMessage(msg)); - this.timer = new Timer(() => this.updateTime(), 1000); - this.userId = parseInt(userId); - this.isDJ = false; - this.date = new Date(); - this.queue = []; - this.users = {}; - this.searchList = []; - this.repeat = "off"; - this.currentTrack = null; - this.current_queue_position = 0; - this.current_position = 0; - this.is_paused = false; - this.volume = 100; - this.last_update = 0; - this.is_connected = true; - this.autoplay = false; - - this.channelName = ""; - this.playlists = null; - } - - handleMessage(data) { - const op = data["op"]; - const validActions = Object.keys(actions); - - if (validActions.includes(op)) { - actions[op](this, data); - } else { - console.log(`Invalid action: ${op}`) - } - - return this.updateInfo() - } - - init() { - this.queue = [] - this.currentTrack = null; - this.users = []; - $('#sortable').empty(); - this.updateInfo(); - } - - initSortable() { - $('#sortable').empty(); - for (var i in this.queue) { - var track = this.queue[i]; - $("#sortable").append(`
  • ${(this.isDJ) ? '' : ''}

    ${track.title}

    ${track.author}

    ${this.msToReadableTime(track.length)}

  • `) - } - this.updateCurrentQueuePos(); - } - - updateCurrentQueuePos(pos) { - if (pos != undefined) { - this.current_queue_position = pos - 1; - } - this.currentTrack = this.queue[this.current_queue_position]; - - $('#sortable li div').removeClass('active'); - const li = $(`#sortable li:eq(${this.current_queue_position})`); - li.find('div').addClass('active'); - - const queue = $('.queue-list') - if (queue.prop('scrollHeight') > queue.prop('clientHeight')) { - queue.animate({ scrollTop: li.position().top - queue.position().top }, 'slow'); - } - return this.currentTrack; - } - - addUser(user) { - this.users[user['user_id']] = { avatar_url: user["avatar_url"], name: user["name"] }; - this.updateUser(); - } - - updateUser() { - $("#users-container").empty(); - for (const [id, user] of Object.entries(this.users)) { - $("#users-container").append(`
  • ${user.name}

  • `) - }; - } - addTrack(tracks) { - for (let i in tracks) { - let track = decode(tracks[i]) - this.queue.push(track); - $("#sortable").append(`
  • ${(this.isDJ) ? '' : ''}

    ${track.title}

    ${track.author}

    ${this.msToReadableTime(track.length)}

  • `) - } - } - - moveTrack(target, to) { - const c = this.current_queue_position; - let element = this.queue.splice(target, 1)[0]; - this.queue.splice(to, 0, element); - - const $ul = $('#sortable'); - const $li = $ul.children().eq(position); - $li.detach(); - $ul.children().eq(to).before($li); - - if (target > c && to <= c) { - this.current_queue_position += 1; - } else if (target < c && to >= c) { - this.current_queue_position -= 1; - } else if (target == c) { - this.current_queue_position = to; - } - - this.send({ "op": "moveTrack", "position": target, "newPosition": to }) - } - - removeTrack(position, track) { - let rawTrack = this.queue[position]; - if (position == this.current_queue_position) { - return this.showToast("error", "You are not allow to remove playing track!"); - } - if (rawTrack.track_id == track.track_id) { - this.send({ "op": "removeTrack", "position": position, "track_id": track.track_id }); - } else { - this.showToast("error", "Track not found!"); - } - } - - togglePause() { - this.send({ "op": "updatePause", "pause": !this.is_paused }); - } - - skipTo(index = 1) { - this.send({ "op": "skipTo", "index": index }); - } - - backTo(index = 1) { - this.send({ "op": "backTo", "index": index }); - } - - seekTo(tempPosition) { - if (this.currentTrack == undefined) { - return; - } - let position = tempPosition / 500 * this.currentTrack.length; - this.send({ "op": "updatePosition", "position": Math.trunc(position) }); - } - - shuffle() { - if ((this.queue.length - this.current_queue_position) > 3) { - this.send({ "op": "shuffleTrack" }); - } else { - this.showToast("info", "Add more songs to the queue before shuffling."); - } - } - - repeatMode() { - this.send({ "op": "repeatTrack" }) - } - - send(payload) { - let json = JSON.stringify(payload) - this.socket.send(json); - } - - isPlaying() { - return (this.currentTrack != undefined && this.is_connected); - } - - msToReadableTime(ms) { - let totalSeconds = Math.floor(ms / 1000); - - let hours = Math.floor(totalSeconds / 3600); - let minutes = Math.floor((totalSeconds % 3600) / 60); - let seconds = totalSeconds % 60; - - minutes = (minutes < 10) ? "0" + minutes : minutes; - seconds = (seconds < 10) ? "0" + seconds : seconds; - - let timeString = ""; - if (hours > 0) { - timeString += hours + ":" + minutes + ":" + seconds; - } else { - timeString += minutes + ":" + seconds; - } - - return timeString; - } - - showToast(userId, msg) { - if (userId == "info") { - var user = { avatar_url: "/static/img/info.png", name: "Info" } - } else if (userId == "error") { - var user = { avatar_url: "/static/img/error.png", name: "Error" } - } else { - var user = this.users[userId]; - } - if (user != null) { - let $element = $(`
    user-icon

    ${user['name']}

    ${msg}

    `) - $(".toastContrainer").append($element) - - setTimeout(function () { - $element.fadeOut(500, function () { - $(this).remove(); - }); - }, 6000); - } - } - - updateTime() { - if (this.currentTrack == undefined) { - return this.timer.stop(); - } - - if (this.current_position >= this.currentTrack?.length) { - return this.timer.stop(); - } - this.current_position += 1000; - $("#position").text(this.msToReadableTime(this.current_position)); - - let time = (this.current_position / this.currentTrack?.length) * 500; - $("#seek-bar").val(time); - } - - updateInfo() { - let currentTrack = this.currentTrack; - if (currentTrack == undefined) { - $("#title").text(""); - $("#author").text(""); - $("#position").text("00:00"); - $("#length").text("00:00"); - $("#image").fadeOut(100, function () { $(this).removeAttr("src"); }); - $("#largeImage").fadeOut(100, function () { $(this).removeAttr("src"); }); - $(".thumbnail-background").fadeOut(200); - - } else { - $("#title").text(currentTrack.title); - $("#author").text(currentTrack.author); - $("#length").text(this.msToReadableTime(currentTrack.length)); - $("#auto-play").prop('checked', this.autoplay); - - let image = ""; - if (currentTrack.source == "youtube") { - image += `${window.location.protocol}//${window.location.hostname}:${window.location.port}/`; - } - image += currentTrack.imageUrl; - - if ($("#largeImage").attr("src") != image) { - $(".thumbnail-background").fadeOut(100); - $("#largeImage").fadeOut(function () { - $(this).attr("src", image); - - }); - $("#image").fadeOut(function () { - $(this).attr("src", currentTrack.imageUrl); - }); - - if (this.playlists != null) { - if (this.playlists["200"]["tracks"].includes(currentTrack.track_id)) { - $("#like-btn").removeClass("fa-regular").addClass("fa-solid"); - } else { - $("#like-btn").removeClass("fa-solid").addClass("fa-regular"); - } - } - } - } - - $("#channel-name").text((this.channelName == "") ? "Not Found" : this.channelName); - let play_pause_btn = $("#play-pause-btn"); - let repeat_btn = $("#repeat-btn"); - if (this.is_paused || currentTrack == undefined) { - play_pause_btn.removeClass('fa-pause').addClass('fa-play'); - if (this.timer.getIsRunning()) { - this.timer.stop(); - } - } else { - play_pause_btn.removeClass('fa-play').addClass('fa-pause'); - this.timer.start(); - } - - repeat_btn.removeClass("fa-repeat-1").addClass("fa-repeat") - if (this.repeat == "off") { - repeat_btn.css('color', ''); - } else if (this.repeat == "track") { - repeat_btn.css('color', '#fff'); - } else { - repeat_btn.removeClass("fa-repeat").addClass("fa-repeat-1"); - } - } - - updatePlaylists() { - $("#playlists-grid").empty(); - for (let key in this.playlists) { - const pList = this.playlists[key]; - let pDiv = $(`
    `); - let iDiv = $("
    "); - let infoDiv = $("
    "); - - if (pList["tracks"] === 0) { - continue; - } else { - for (let i = 0; i < 4; i++) { - if (pList["tracks"][i] == undefined) { - continue; - } - let track = decode(pList["tracks"][i]) - let img = $("").attr("src", track.imageUrl); - iDiv.append(img); - - } - } - iDiv.append(``); - iDiv.append(``); - pDiv.append(iDiv); - infoDiv.append(`

    ${pList["name"]}

    `); - infoDiv.append(`

    ${pList["type"]} • ${pList["tracks"].length} Tracks

    `); - pDiv.append(infoDiv); - $("#playlists-grid").append(pDiv); - $("#playlists-tracks").empty() - } - } -} \ No newline at end of file diff --git a/web/static/js/socket.js b/web/static/js/socket.js deleted file mode 100644 index 2ed2aa5..0000000 --- a/web/static/js/socket.js +++ /dev/null @@ -1,47 +0,0 @@ -class Socket { - constructor(url) { - this.url = url; - this.socket = null; - } - - connect(player) { - this.socket = io.connect(this.url); - - this.socket.on('connect', () => { - console.log("Connected to server!"); - }); - - this.socket.on('disconnect', () => { - player.init(); - console.log("Disconnected from server!"); - }); - - this.socket.on('error', (e) => { - console.log(e); - }) - } - - disconnect() { - if (this.socket) { - this.socket.disconnect(); - } - } - - send(msg) { - if (this.socket) { - this.socket.emit('message', msg); - } - } - - addMessageListener(callback) { - if (this.socket) { - this.socket.on('message', callback); - } - } - - removeMessageListener(callback) { - if (this.socket) { - this.socket.off('message', callback); - } - } -} \ No newline at end of file diff --git a/web/templates/index.html b/web/templates/index.html deleted file mode 100644 index b1ec475..0000000 --- a/web/templates/index.html +++ /dev/null @@ -1,150 +0,0 @@ - - - - - Vocard Dashboard - - - - - - - - - - - - - - - - -
    -
    -
    -

    Home

    -

    Playlist

    -
    -
    -
    - -
      -
    -
    -
    -
    - -
    -
    - -
    -
    -
    - -
    -
    -
    -
    -
    -

    Autoplay

    -

    Similar track will be to the queue

    -
    - -
    -
    -
      -
    -
    -
    -
    - - - -
    -
    -

    -
    You are listen in
    -
    -
      -
    -
    - -
    -
    - -
    -
    -
    -
    -
    - - - -
    - -
    -

    00:00

    /

    00:00

    -
    - -
    - -
    - -
    -

    -

    -
    - -
    - -
    - - - -
    -
    -
    -
    - -
    -
      -
    • Remove
    • -
    • Copy Song Link
    • -
    -
    - -
    -
      -
    • Remove
    • -
    -
    - - - \ No newline at end of file diff --git a/web/webapp.py b/web/webapp.py deleted file mode 100644 index 24d31bc..0000000 --- a/web/webapp.py +++ /dev/null @@ -1,215 +0,0 @@ -from flask import Flask, redirect, url_for, session, request, render_template, Response, stream_with_context -from flask_socketio import SocketIO, emit, join_room, leave_room, rooms, disconnect -from ipc import IPCClient -from objects import User -from dotenv import load_dotenv -from datetime import timedelta - -import requests -import json -import os -import asyncio -import functools - -load_dotenv() - -app = Flask(__name__) -app.secret_key = os.getenv("DASHBOARD_SECRET_KEY") -socketio = SocketIO(app) - -# Discord OAuth2 credentials -CLIENT_ID = os.getenv("CLIENT_ID") -CLIENT_SECRET = os.getenv("CLIENT_SECRET_ID") -HOST = os.getenv("DASHBOARD_HOST") -PORT = os.getenv("DASHBOARD_PORT") -IS_SECURE = os.getenv("DASHBOARD_SECURE", "False").lower() == "true" - -REDIRECT_URI = f'http{'s' if IS_SECURE else ''}://{HOST}:{PORT}/callback' -DISCORD_API_BASE_URL = 'https://discord.com/api' - -USERS = {} - -def get_user(user_id: int): - for user in USERS.values(): - if user.id == user_id: - return user - return None - -def user_join_room(guild_id: int, user: User) -> None: - if not user.sid: - return - join_room(guild_id, sid=user.sid) - user.guild_id = guild_id - -def user_leave_room(guild_id: int, user: User) -> None: - if not user.sid: - return - leave_room(guild_id, sid=user.sid) - user.guild_id = None - -def message_handler(data: dict): - op = data.get("op") - - user_id = data.get("user_id", None) - if user_id: - user: User = get_user(user_id) - else: - user = None - - guild_id = data.get("guild_id", None) - - if op == "updateGuild": - user_id = data.get("user", {}).get("user_id", None) - is_joined = data.get("is_joined") - - user: User = get_user(user_id) - if user and guild_id: - user_join_room(guild_id, user) if is_joined else user_leave_room(guild_id, user) - - elif op == "createPlayer": - members_id = data.get("members_id", []) - for member_id in members_id: - user: User = get_user(member_id) - if user: - user_join_room(guild_id, user) - emit('message', { - "op": "updateGuild", - "user": { - "user_id": user.id, - "avatar_url": user.avatar.url, - "name": user.username - }, - "is_joined": True - }, to=user.sid) - return - - if user: - if guild_id and user.sid: - join_room(guild_id, sid=user.sid) - elif guild_id in rooms(user.sid): - pass - else: - emit('message', data, to=user.sid) - - if op == "initPlayer": - return emit('message', data, to=user.sid) - - if guild_id: - skip_sids = [] - if skip_users := data.get("skip_users"): - for user_id in skip_users: - if user := get_user(user_id): - skip_sids.append(user.sid) - - emit('message', data, room=guild_id, skip_sid=skip_sids) - -ipc_client = IPCClient(secret_key=app.secret_key, callback=message_handler) - -def login_required(func): - - @functools.wraps(func) - def wrapper(*args, **kwargs): - token = session.get("discord_token", None) - if not token: - return redirect(url_for('login')) - - if token not in USERS: - resp = requests_api(f'{DISCORD_API_BASE_URL}/users/@me', headers={'Authorization': f'Bearer {token}'}) - if resp: - user = USERS[token] = User(resp) - else: - return redirect(url_for('login')) - else: - user = USERS[token] - - return func(user, *args, **kwargs) - return wrapper - - -def requests_api(url: str, headers=None): - resp = requests.get(url=url, headers=headers) - if not resp: - return False - - return resp.json() - -@app.route('/', methods=["GET"]) -@login_required -def proxy(user: User, url): - request = requests.get(url, stream=True) - response = Response( - stream_with_context(request.iter_content()), - content_type=request.headers['content-type'], - status=request.status_code - ) - response.headers['Access-Control-Allow-Origin'] = "*" - return response - -# home page -@app.route('/') -@login_required -def home(user: User): - return render_template("index.html", user=user) - -# login page -@app.route('/login') -def login(): - # redirect to Discord OAuth2 login page - params = { - 'client_id': CLIENT_ID, - 'response_type': 'code', - 'redirect_uri': REDIRECT_URI, - 'scope': 'identify' - } - return redirect(f'{DISCORD_API_BASE_URL}/oauth2/authorize?{"&".join([f"{k}={v}" for k, v in params.items()])}') - -# Logout page -@app.route('/logout') -@login_required -def logout(user: User): - session.pop("discord_token", None) - - return redirect(url_for("home")) - -# callback page -@app.route('/callback') -def callback(): - # fetch user token from Discord OAuth2 - code = request.args.get('code') - data = { - 'client_id': CLIENT_ID, - 'client_secret': CLIENT_SECRET, - 'grant_type': 'authorization_code', - 'code': code, - 'redirect_uri': REDIRECT_URI, - 'scope': 'identify' - } - response = requests.post(f'{DISCORD_API_BASE_URL}/oauth2/token', data=data) - token_data = json.loads(response.content.decode('utf-8')) - session.permanent = True - app.permanent_session_lifetime = timedelta(days=30) - session['discord_token'] = token_data.get("access_token") - - return redirect(url_for("home")) - - -@socketio.on("connect") -@login_required -def handle_connect(user: User): - if user.sid: - disconnect(sid=user.sid) - user.sid = request.sid - asyncio.run(ipc_client.send('{"op": "initPlayer"}', user)) - -@socketio.on("disconnect") -@login_required -def handle_disconnect(user: User): - user.sid = None - -@socketio.on("message") -@login_required -def handle_message(user: User, msg): - asyncio.run(ipc_client.send(msg, user)) - -if __name__ == '__main__': - socketio.run(app, host="0.0.0.0", port=PORT, allow_unsafe_werkzeug=True)