${user['name']}
-
+
diff --git a/.env Example b/.env Example index 5bd0292..19e70ce 100644 --- a/.env Example +++ b/.env Example @@ -1,5 +1,8 @@ TOKEN = YOUR_BOT_TOKEN CLIENT_ID = YOUR_BOT_CLIENT_ID +CLIENT_SECRET_ID = YOUR_BOT_CLIENT_SECRET_ID +SERCET_KEY = DASHBOARD_SERCET_KEY + BUG_REPORT_CHANNEL_ID = YOUR_DISCORD_CHANNEL_ID SPOTIFY_CLIENT_ID = YOUR_SPOTIFY_CLIENT_ID diff --git a/README.md b/README.md index 8baa726..63f2778 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,26 @@ # Vocard (Discord Music Bot) > Vocard is a simple custom Disocrd Music Bot built with Python & [discord.py](https://discordpy.readthedocs.io/en/stable/) +# Demo (Bot & Dashboard) +* [Discord Bot](https://discord.com/api/oauth2/authorize?client_id=890399639008866355&permissions=36708608&scope=bot%20applications.commands) +* [Dashboard](http://vocard.xyz) + ## Tutorial Click on the image below to watch the tutorial on Youtube. [](https://www.youtube.com/watch?v=f_Z0RLRZzWw) ## Screenshot -
-
-
-
-
+
+
+
+
+### Dashboard
+
+
+
## Run the Projects
[](https://replit.com/new/github/ChocoMeow/Vocard)
@@ -40,6 +48,9 @@ Start your bot with `python main.py`
```sh
TOKEN = XXXXXXXXXXXXXXXXXXXXXXXX.XXXXXX.XXXXXXXXXXXXXXXXXXXXXXXXXXX
CLIENT_ID = 123456789012345678
+CLIENT_SECRET_ID = YOUR_BOT_CLIENT_SECRET_ID
+SERCET_KEY = DASHBOARD_SERCET_KEY
+
BUG_REPORT_CHANNEL_ID = 123456789012345678
SPOTIFY_CLIENT_ID = 0XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
@@ -54,6 +65,8 @@ MONGODB_NAME = Vocard
| --- | --- |
| TOKEN | Your Discord bot token [(Discord Portal)](https://discord.com/developers/applications) |
| CLIENT_ID | Your Discord bot client id [(Discord Portal)](https://discord.com/developers/applications) |
+| CLIENT_SECRET_ID | Your Discord bot client secret id [(Discord Portal)](https://discord.com/developers/applications) ***(optional)*** |
+| SERCET_KEY | Secret key for dashboard ***(optional)*** |
| BUG_REPORT_CHANNEL_ID | All the error messages will send to this text channel ***(optional)*** |
| SPOTIFY_CLIENT_ID | Your Spoity client id [(Spotify Portal)](https://developer.spotify.com/dashboard/applications) ***(optional)*** |
| SPOTIFY_CLIENT_SECRET | Your Spoity client sercret id [(Spotify Portal)](https://developer.spotify.com/dashboard/applications) ***(optional)*** |
@@ -78,6 +91,11 @@ MONGODB_NAME = Vocard
"bot_access_user": [],
"color_code":"0xb3b3b3",
"default_max_queue": 1000,
+ "ipc_server": {
+ "host": "127.0.0.1",
+ "port": 8000,
+ "enable": false
+ },
"emoji_source_raw": {
"youtube": "<:youtube:826661982760992778>",
"youtube music": "<:youtube:826661982760992778>",
@@ -111,6 +129,7 @@ MONGODB_NAME = Vocard
* For `bot_access_user` you can pass the [discord user id](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-). Example: `[123456789012345678]`
* For `color_code` you must pass a [Hexadecimal color code](https://htmlcolorcodes.com/) and add `0x` before the color code. Example: `"0xb3b3b3"`
* For `default_max_queue` you can set a default maximum number of tracks that can be added to the queue.
+* For `ipc_server` you can set the host, password and enable of the ipc server.
* For `emoji_source_raw` you can change the source emoji of the track with discord emoji like `<:EMOJI_NAME:EMOJI_ID>`
* For `cooldowns` you can set a custom cooldown in the command. Example: `"command_name": [The total number of tokens available, The length of the cooldown period in seconds]`
* For `aliases` you can set custom aliases in the command. Example: `"command_name": [alias1, alias2, ...]`
diff --git a/addons/__init__.py b/addons/__init__.py
index 088e3ad..aa4e903 100644
--- a/addons/__init__.py
+++ b/addons/__init__.py
@@ -1 +1,2 @@
-from .lyrics import getLyrics
\ No newline at end of file
+from .lyrics import getLyrics
+from .settings import Settings
\ No newline at end of file
diff --git a/addons/settings.py b/addons/settings.py
new file mode 100644
index 0000000..91ad991
--- /dev/null
+++ b/addons/settings.py
@@ -0,0 +1,18 @@
+class Settings:
+ def __init__(self, settings: dict) -> None:
+ self.invite_link = "https://discord.gg/wRCgB7vBQv"
+ self.nodes = settings.get("nodes", {})
+ self.max_queue = settings.get("default_max_queue", 1000)
+ self.bot_prefix = settings.get("prefix", "")
+ self.embed_color = int(settings.get("embed_color", "0xb3b3b3"), 16)
+ self.bot_access_user = settings.get("bot_access_user", [])
+ self.emoji_source_raw = settings.get("emoji_source_raw", {})
+ self.cooldowns_settings = settings.get("cooldowns", {})
+ self.aliases_settings = settings.get("aliases", {})
+ self.controller_settings = settings.get("controller", [["back", "resume", "skip", {"stop": "red"}, "add"], ["tracks"]])
+ self.ipc_server = settings.get("ipc_server", {
+ "host": "127.0.0.1",
+ "port": 8000,
+ "enable": False
+ }
+ )
\ No newline at end of file
diff --git a/cogs/admin.py b/cogs/admin.py
index 0bee764..a040e34 100644
--- a/cogs/admin.py
+++ b/cogs/admin.py
@@ -13,7 +13,7 @@ from function import (
update_settings,
get_settings,
get_lang,
- embed_color,
+ settings as sett,
time as ctime,
get_aliases,
cooldown_check
@@ -138,13 +138,13 @@ class Admin(commands.Cog, name="settings"):
async def view(self, ctx: commands.Context):
"Show all the bot settings in your server."
player, settings = self.get_settings(ctx)
- embed = discord.Embed(color=embed_color)
+ embed = discord.Embed(color=sett.embed_color)
embed.set_author(name=get_lang(ctx.guild.id, 'settingsMenu').format(ctx.guild.name), icon_url=self.bot.user.display_avatar.url)
if ctx.guild.icon:
embed.set_thumbnail(url=ctx.guild.icon.url)
embed.add_field(name=get_lang(ctx.guild.id, 'settingsTitle'), value=get_lang(ctx.guild.id, 'settingsValue').format(
- settings.get('prefix', func.bot_prefix),
+ settings.get('prefix', func.settings.bot_prefix),
settings.get('lang', 'EN'),
settings.get('controller', True),
f"<@&{settings['dj']}>" if 'dj' in settings else '`None`',
@@ -156,7 +156,7 @@ class Admin(commands.Cog, name="settings"):
)
embed.add_field(name=get_lang(ctx.guild.id, 'settingsTitle2'), value=get_lang(ctx.guild.id, 'settingsValue2').format(
settings.get("queueType", "Queue"),
- func.max_queue,
+ func.settings.max_queue,
settings.get("duplicateTrack", True)
)
)
@@ -179,7 +179,7 @@ class Admin(commands.Cog, name="settings"):
player: voicelink.Player = ctx.guild.voice_client
if player:
player.settings['volume'] = value
- await player.set_volume(value)
+ await player.set_volume(value, ctx.author)
update_settings(ctx.guild.id, {'volume': value})
await ctx.send(get_lang(ctx.guild.id, 'setVolume').format(value))
@@ -219,7 +219,7 @@ class Admin(commands.Cog, name="settings"):
@app_commands.command(name="debug")
async def debug(self, interaction: discord.Interaction):
- if interaction.user.id not in func.bot_access_user:
+ 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!")
def clear_code(content):
diff --git a/cogs/basic.py b/cogs/basic.py
index 1c67752..7d5e827 100644
--- a/cogs/basic.py
+++ b/cogs/basic.py
@@ -11,7 +11,7 @@ from function import (
youtube_api_key,
requests_api,
get_lang,
- embed_color,
+ settings,
cooldown_check,
get_aliases,
connect_channel
@@ -36,7 +36,7 @@ async def nowplay(ctx: commands.Context, player: voicelink.Player):
upnext = "\n".join(f"`{index}.` `[{track.formatLength}]` [{track.title[:30]}]({track.uri})" for index, track in enumerate(
player.queue.tracks()[:2], start=2))
embed = discord.Embed(description=player.get_msg(
- 'nowplayingDesc').format(track.title), color=embed_color)
+ 'nowplayingDesc').format(track.title), color=settings.embed_color)
embed.set_author(name=track.requester if track.requester else ctx.bot,
icon_url=track.requester.display_avatar.url if track.requester else ctx.me.display_avatar.url)
@@ -187,7 +187,7 @@ class Basic(commands.Cog):
query_track = "\n".join(
f"`{index}.` `[{track.formatLength}]` **{track.title[:35]}**" for index, track in enumerate(tracks[0:10], start=1))
embed = discord.Embed(title=player.get_msg('searchTitle').format(query), description=player.get_msg(
- 'searchDesc').format(emoji_source(platform), platform, len(tracks[0:10]), query_track), color=embed_color)
+ 'searchDesc').format(emoji_source(platform), platform, len(tracks[0:10]), query_track), color=settings.embed_color)
view = SearchView(tracks=tracks[0:10], lang=player.lang)
message = await ctx.send(embed=embed, view=view, ephemeral=True)
view.response = message
@@ -256,7 +256,7 @@ class Basic(commands.Cog):
else:
return await ctx.send(player.get_msg('pauseVote').format(ctx.author, len(player.pause_votes), required))
- await player.set_pause(True)
+ await player.set_pause(True, ctx.author)
player.pause_votes.clear()
await ctx.send(player.get_msg('paused').format(ctx.author))
@@ -281,7 +281,7 @@ class Basic(commands.Cog):
else:
return await ctx.send(player.get_msg('resumeVote').format(ctx.author, len(player.resume_votes), required))
- await player.set_pause(False)
+ await player.set_pause(False, ctx.author)
player.resume_votes.clear()
await ctx.send(player.get_msg('resumed').format(ctx.author))
@@ -318,7 +318,7 @@ class Basic(commands.Cog):
await ctx.send(player.get_msg('skipped').format(ctx.author))
if player.queue._repeat == 1:
- player.queue.set_repeat("off")
+ await player.queue.set_repeat("off")
await player.stop()
@commands.hybrid_command(name="back", aliases=get_aliases("back"))
@@ -353,7 +353,7 @@ class Basic(commands.Cog):
await ctx.send(player.get_msg('backed').format(ctx.author))
if player.queue._repeat == 1:
- player.queue.set_repeat("off")
+ await player.queue.set_repeat("off")
@commands.hybrid_command(name="seek", aliases=get_aliases("seek"))
@app_commands.describe(position="Input position. Exmaple: 1:20.")
@@ -374,7 +374,7 @@ class Basic(commands.Cog):
if num is None:
return await ctx.send(player.get_msg('timeFormatError'), ephemeral=True)
- await player.seek(num)
+ await player.seek(num, ctx.author)
await ctx.send(player.get_msg('seek').format(position))
@commands.hybrid_command(name="queue", aliases=get_aliases("queue"))
@@ -463,9 +463,7 @@ class Basic(commands.Cog):
if not player.is_privileged(ctx.author):
return await ctx.send(player.get_msg('missingPerms_mode'), ephemeral=True)
- if mode.lower() not in ['off', 'track', 'queue']:
- mode = "off"
- player.queue.set_repeat(mode)
+ await player.set_repeat(mode)
await ctx.send(player.get_msg('repeat').format(mode.capitalize()))
@commands.hybrid_command(name="clear", aliases=get_aliases("clear"))
@@ -509,8 +507,17 @@ class Basic(commands.Cog):
if not player.is_privileged(ctx.author):
return await ctx.send(player.get_msg('missingPerms_queue'), ephemeral=True)
- removedTrack = player.queue.remove(position1, position2, member)
- await ctx.send(player.get_msg('removed').format(removedTrack))
+ 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 ctx.send(player.get_msg('removed').format(len(removedTrack)))
@commands.hybrid_command(name="forward", aliases=get_aliases("forward"))
@app_commands.describe(position="Input a amount that you to forward to. Exmaple: 1:20")
@@ -591,7 +598,7 @@ class Basic(commands.Cog):
else:
return await ctx.send(player.get_msg('shuffleVote').format(ctx.author, len(player.skip_votes), required))
- await player.shuffle("queue")
+ await player.shuffle("queue", ctx.author)
await ctx.send(player.get_msg('shuffled'))
@commands.hybrid_command(name="swap", aliases=get_aliases("swap"))
@@ -610,6 +617,11 @@ class Basic(commands.Cog):
return await ctx.send(player.get_msg('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)
await ctx.send(player.get_msg('swapped').format(track1.title, track2.title))
@commands.hybrid_command(name="move", aliases=get_aliases("move"))
@@ -628,6 +640,11 @@ class Basic(commands.Cog):
return await ctx.send(player.get_msg('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)
await ctx.send(player.get_msg('moved').format(moved_track.title, to))
@commands.hybrid_command(name="lyrics", aliases=get_aliases("lyrics"))
@@ -749,7 +766,7 @@ class Basic(commands.Cog):
async def ping(self, ctx: commands.Context):
"Test if the bot is alive, and see the delay between your commands and my response."
player: voicelink.Player = ctx.guild.voice_client
- embed = discord.Embed(color=embed_color)
+ embed = discord.Embed(color=settings.embed_color)
embed.add_field(name=get_lang(ctx.guild.id, 'pingTitle1'), value=get_lang(ctx.guild.id, 'pingfield1').format(
"0", "0", self.bot.latency, 'π' if self.bot.latency > 5 else ('π¨' if self.bot.latency > 1 else 'π'), "St Louis, MO, United States"))
if player:
diff --git a/cogs/listener.py b/cogs/listeners.py
similarity index 56%
rename from cogs/listener.py
rename to cogs/listeners.py
index d33a04a..c4a9f8b 100644
--- a/cogs/listener.py
+++ b/cogs/listeners.py
@@ -19,7 +19,7 @@ class Nodes(commands.Cog):
async def start_nodes(self) -> None:
"""Connect and intiate nodes."""
await self.bot.wait_until_ready()
- for n in func.nodes.values():
+ for n in func.settings.nodes.values():
try:
await self.voicelink.create_node(bot=self.bot,
spotify_client_id=getenv('SPOTIFY_CLIENT_ID'),
@@ -46,5 +46,43 @@ class Nodes(commands.Cog):
await asyncio.sleep(5)
await player.do_next()
+ @commands.Cog.listener()
+ async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
+ if member.bot:
+ return
+
+ if before.channel == after.channel:
+ return
+
+ 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:
+ if after.channel.id != player.channel.id:
+ return
+
+ elif before.channel and not after.channel:
+ is_joined = False
+
+ elif before.channel and after.channel:
+ if after.channel.id != player.channel.id:
+ is_joined = False
+
+ await self.bot.ipc.send({
+ "op": "updateGuild",
+ "user": {
+ "user_id": member.id,
+ "avatar_url": member.avatar.url,
+ "name": member.name,
+ },
+ "channel_name": member.voice.channel.name if is_joined else "",
+ "guild_id": guild,
+ "is_joined": is_joined
+ })
+
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Nodes(bot))
diff --git a/cogs/playlist.py b/cogs/playlist.py
index d027c72..e10571b 100644
--- a/cogs/playlist.py
+++ b/cogs/playlist.py
@@ -13,7 +13,7 @@ from function import (
update_inbox,
get_lang,
playlist_name,
- embed_color,
+ settings,
get_aliases,
cooldown_check,
connect_channel
@@ -179,7 +179,7 @@ class Playlists(commands.Cog, name="playlist"):
embed = discord.Embed(title=get_lang(ctx.guild.id, 'playlistViewTitle').format(ctx.author.name),
description='```%0s %4s %10s %10s %10s\n' % tuple(get_lang(ctx.guild.id, 'playlistViewHeaders')) + '\n'.join(
'%0s %3s. %10s %10s %10s' % (info['emoji'], info['id'], f"[{info['time']}]", info['name'], len(info['tracks'])) for info in results) + '```',
- color=embed_color)
+ color=settings.embed_color)
embed.add_field(name=get_lang(ctx.guild.id, 'playlistMaxP'),
value=f"β₯ {len(user)}/{max_p}", inline=True)
embed.add_field(name=get_lang(ctx.guild.id,
diff --git a/function.py b/function.py
index 2e88a2c..665085c 100644
--- a/function.py
+++ b/function.py
@@ -3,6 +3,7 @@ import json
import aiohttp
import os
+from importlib import import_module
from discord.ext import commands
from dotenv import load_dotenv
from random import choice
@@ -11,6 +12,7 @@ from time import strptime
from io import BytesIO
from pymongo import MongoClient
from typing import Optional
+from addons import Settings
root_dir = os.path.dirname(os.path.abspath(__file__))
@@ -36,29 +38,18 @@ Playlist = mongodb[MONGODB_NAME]['Playlist']
youtube_api_key = os.getenv('YOUTUBE_API_KEY')
#--------------- Cache Var ---------------
-invite_link = "https://discord.gg/wRCgB7vBQv" #Template of invite link
-embed_color = None
-
try:
report_channel_id = int(os.getenv("BUG_REPORT_CHANNEL_ID"))
except:
report_channel_id = 0
-emoji_source_raw = {} #Stores all source emoji for track
+settings: Settings
error_log = {} #Stores error that not a Voicelink Exception
-bot_access_user = [] #Stores bot access user id
langs = {} #Stores all the languages in ./langs
guild_settings = {} #Cache guild language
local_langs = {} #Stores all the localization languages in ./local_langs
playlist_name = {} #Cache the user's playlist name
-bot_prefix = "?" #The default bot prefix
-max_queue = 1000 #The default maximum number of tracks in the queue
-cooldowns_settings = {} #Stores command cooldown settings
-aliases_settings = {} #Stores command aliases settings
-controller_settings = {} #Stores the layout of music controller
-#----------------- Nodes -----------------
-nodes = {}
#-------------- Vocard Functions --------------
def get_settings(guild_id:int):
@@ -85,37 +76,12 @@ def update_settings(guild_id:int, data: dict, mode="Set"):
collection.update_one({"_id":guild_id}, {"$unset":data})
return
-def langs_setup():
- for language in os.listdir(os.path.join(root_dir, "langs")):
- if language.endswith('.json'):
- with open(os.path.join(root_dir, "langs", language), encoding="utf8") as json_file:
- lang = json.load(json_file)
-
- langs[language[:-5]] = lang
-
- for language in os.listdir(os.path.join(root_dir, "local_langs")):
- if language.endswith('.json'):
- with open(os.path.join(root_dir, "local_langs", language), encoding="utf8") as json_file:
- lang = json.load(json_file)
-
- local_langs[language[:-5]] = lang
- return
-
-def settings_setup():
- with open(os.path.join(root_dir, "settings.json"), encoding="utf8") as json_file:
- rawSettings = json.load(json_file)
-
- global nodes, max_queue, bot_prefix, embed_color, bot_access_user, emoji_source_raw, cooldowns_settings, aliases_settings, controller_settings
- nodes = rawSettings.get("nodes", {})
- if (new_max_queue := rawSettings.get("default_max_queue", max_queue)):
- max_queue = new_max_queue
- bot_prefix = rawSettings.get("prefix", "")
- embed_color = int(rawSettings.get("embed_color", "0xb3b3b3"), 16)
- bot_access_user = rawSettings.get("bot_access_user", [])
- emoji_source_raw = rawSettings.get("emoji_source_raw", {})
- cooldowns_settings = rawSettings.get("cooldowns", {})
- aliases_settings = rawSettings.get("aliases", {})
- controller_settings = rawSettings.get("controller", [["back", "resume", "skip", {"stop": "red"}, "add"], ["tracks"]])
+def open_json(path: str):
+ try:
+ with open(path, encoding="utf8") as json_file:
+ return json.load(json_file)
+ except:
+ return {}
def get_lang(guild_id:int, key:str):
lang = get_settings(guild_id).get("lang", "EN")
@@ -129,12 +95,30 @@ async def requests_api(url: str):
return await resp.json(encoding="utf-8")
+def init():
+ global settings
+
+ json = open_json(os.path.join(root_dir, "settings.json"))
+ if json:
+ settings = Settings(json)
+
+def langs_setup():
+ for language in os.listdir(os.path.join(root_dir, "langs")):
+ if language.endswith('.json'):
+ langs[language[:-5]] = open_json(os.path.join(root_dir, "langs", language))
+
+ for language in os.listdir(os.path.join(root_dir, "local_langs")):
+ if language.endswith('.json'):
+ local_langs[language[:-5]] = open_json(os.path.join(root_dir, "local_langs", language))
+
+ return
+
async def create_account(ctx: commands.Context):
if not ctx.author:
return
from views import CreateView
view = CreateView()
- embed=discord.Embed(title="Do you want to create an account on Vocard?", color=embed_color)
+ embed=discord.Embed(title="Do you want to create an account on Vocard?", color=settings.embed_color)
embed.description = f"> Plan: `Default` | `5` Playlist | `500` tracks in each playlist."
embed.add_field(name="Terms of Service:", value="β β₯ We assure you that all your data on Vocard will not be disclosed to any third party\n"
"β β₯ We will not perform any data analysis on your data\n"
@@ -221,7 +205,7 @@ async def similar_track(player) -> bool:
return False
async def connect_channel(ctx: commands.Context, channel: discord.VoiceChannel = None):
- import voicelink
+ voicelink = import_module("voicelink");
try:
channel = channel or ctx.author.voice.channel
except:
@@ -233,6 +217,7 @@ async def connect_channel(ctx: commands.Context, channel: discord.VoiceChannel =
get_lang(ctx.guild.id, 'noPermission'))
player: voicelink.Player = await channel.connect(cls=voicelink.Player(ctx.bot, channel, ctx))
+ await player.send_ws({"op": "createPlayer", "members_id": [member.id for member in channel.members]})
return player
def time(millis:int) -> str:
@@ -259,7 +244,7 @@ def formatTime(number:str) -> Optional[int]:
return (int(num.tm_hour) * 3600 + int(num.tm_min) * 60 + int(num.tm_sec)) * 1000
def emoji_source(emoji:str):
- return emoji_source_raw.get(emoji.lower(), "π")
+ return settings.emoji_source_raw.get(emoji.lower(), "π")
def gen_report() -> Optional[discord.File]:
if error_log:
@@ -275,12 +260,12 @@ def gen_report() -> Optional[discord.File]:
return None
def cooldown_check(ctx: commands.Context) -> Optional[commands.Cooldown]:
- if ctx.author.id in bot_access_user:
+ if ctx.author.id in settings.bot_access_user:
return None
- cooldown = cooldowns_settings.get(f"{ctx.command.parent.qualified_name} {ctx.command.name}" if ctx.command.parent else ctx.command.name)
+ cooldown = settings.cooldowns_settings.get(f"{ctx.command.parent.qualified_name} {ctx.command.name}" if ctx.command.parent else ctx.command.name)
if not cooldown:
return None
return commands.Cooldown(cooldown[0], cooldown[1])
def get_aliases(name: str) -> list:
- return aliases_settings.get(name, [])
+ return settings.aliases_settings.get(name, [])
diff --git a/main.py b/main.py
index 9868d78..c153baf 100644
--- a/main.py
+++ b/main.py
@@ -1,39 +1,52 @@
import discord
-import sys, os, traceback, aiohttp
+import sys
+import os
+import traceback
+import aiohttp
import update
import function as func
from discord.ext import commands
+from web import IPCServer
from dotenv import load_dotenv
from datetime import datetime
from voicelink import VoicelinkException
load_dotenv()
-func.settings_setup()
+func.init()
class Translator(discord.app_commands.Translator):
async def load(self):
print("Loaded Translator")
-
+
async def unload(self):
print("Unload Translator")
-
+
async def translate(self, string: discord.app_commands.locale_str, locale: discord.Locale, context: discord.app_commands.TranslationContext):
if str(locale) in func.local_langs:
return func.local_langs[str(locale)].get(string.message, None)
return None
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"],
+ sercet_key=os.getenv("SERCET_KEY")
+ )
async def on_message(self, message: discord.Message, /) -> None:
if message.author.bot or not message.guild:
return False
-
+
if self.user.mentioned_in(message) and not message.mention_everyone:
await message.channel.send(f"My prefix is `{await self.command_prefix(self, message)}`")
await self.process_commands(message)
-
+
async def setup_hook(self):
func.langs_setup()
for module in os.listdir(func.root_dir + '/cogs'):
@@ -44,6 +57,9 @@ class Vocard(commands.Bot):
except Exception as e:
print(traceback.format_exc())
+ if func.settings.ipc_server["enable"]:
+ await self.ipc.start()
+
await bot.tree.set_translator(Translator())
await bot.tree.sync()
@@ -58,9 +74,11 @@ class Vocard(commands.Bot):
async def on_command_error(self, ctx: commands.Context, exception, /) -> None:
error = getattr(exception, 'original', exception)
+ if ctx.interaction:
+ error = getattr(error, 'original', error)
if isinstance(error, (commands.CommandNotFound, aiohttp.client_exceptions.ClientOSError)):
return
-
+
elif isinstance(error, (commands.CommandOnCooldown, commands.MissingPermissions, commands.RangeError, commands.BadArgument)):
pass
@@ -68,34 +86,34 @@ class Vocard(commands.Bot):
command = f" Correct Usage: {ctx.prefix}" + (f"{ctx.command.parent.qualified_name} " if ctx.command.parent else "") + f"{ctx.command.name} {ctx.command.signature}"
position = command.find(f"<{ctx.current_parameter.name}>") + 1
error = f"```css\n[You are missing argument!]\n{command}\n" + " " * position + "^" * len(ctx.current_parameter.name) + "```"
-
+
elif not issubclass(error.__class__, VoicelinkException):
- error = function.get_lang(ctx.guild.id, "unknownException") + function.invite_link
- if (guildId := ctx.guild.id) not in function.error_log:
- function.error_log[guildId] = {}
- function.error_log[guildId][round(datetime.timestamp(datetime.now()))] = str(traceback.format_exc())
-
+ error = func.get_lang(ctx.guild.id, "unknownException") + func.settings.invite_link
+ if (guildId := ctx.guild.id) not in func.error_log:
+ func.error_log[guildId] = {}
+ func.error_log[guildId][round(datetime.timestamp(datetime.now()))] = "".join(traceback.format_exception(type(exception), exception, exception.__traceback__))
+
try:
return await ctx.reply(error, ephemeral=True)
except:
pass
class CommandCheck(discord.app_commands.CommandTree):
-
+
async def interaction_check(self, interaction: discord.Interaction, /) -> bool:
if not interaction.guild:
await interaction.response.send_message("This command can only be used in guilds!")
return False
-
+
return await super().interaction_check(interaction)
async def get_prefix(bot, message: discord.Message):
settings = func.get_settings(message.guild.id)
- return settings.get("prefix", func.bot_prefix)
+ return settings.get("prefix", func.settings.bot_prefix)
intents = discord.Intents.default()
intents.members = True
-intents.message_content = True if func.bot_prefix else False
+intents.message_content = True if func.settings.bot_prefix else False
member_cache = discord.MemberCacheFlags(
voice=True,
joined=False
@@ -110,26 +128,6 @@ bot = Vocard(command_prefix=get_prefix,
case_insensitive=True,
intents=intents)
-@bot.tree.error
-async def app_command_error(interaction: discord.Interaction, error):
- error = getattr(error, 'original', error)
- if isinstance(error, (discord.errors.NotFound, aiohttp.client_exceptions.ClientOSError)):
- return
- elif isinstance(error, (discord.app_commands.CommandOnCooldown, discord.app_commands.errors.MissingPermissions)):
- pass
- elif not issubclass(error.__class__, VoicelinkException):
- error = function.get_lang(interaction.guild_id, "unknownException") + function.invite_link
- if (guildId := interaction.guild_id) not in function.error_log:
- function.error_log[guildId] = {}
- function.error_log[guildId][round(datetime.timestamp(datetime.now()))] = str(traceback.format_exc())
- try:
- if interaction.response.is_done():
- return await interaction.followup.send(error, ephemeral=True)
-
- return await interaction.response.send_message(error, ephemeral=True)
- except:
- pass
-
if __name__ == "__main__":
update.checkVersion(withMsg=True)
bot.run(os.getenv("TOKEN"), log_handler=None)
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 04ddf2b..30a25d3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
-discord.py==2.1.0
+discord.py==2.2.2
pymongo==4.1.1
dnspython==2.2.1
tldextract==3.2.1
@@ -6,3 +6,6 @@ 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
diff --git a/settings Example.json b/settings Example.json
index 0847813..bbc1608 100644
--- a/settings Example.json
+++ b/settings Example.json
@@ -12,6 +12,11 @@
"bot_access_user": [],
"color_code":"0xb3b3b3",
"default_max_queue": 1000,
+ "ipc_server": {
+ "host": "127.0.0.1",
+ "port": 8000,
+ "enable": false
+ },
"emoji_source_raw": {
"youtube": "<:youtube:826661982760992778>",
"youtube music": "<:youtube:826661982760992778>",
diff --git a/update.py b/update.py
index 997aad2..552517f 100644
--- a/update.py
+++ b/update.py
@@ -3,7 +3,7 @@ from io import BytesIO
root_dir = os.path.dirname(os.path.abspath(__file__))
install_pack_dir = os.path.join(root_dir, "Vocard.zip")
-__version__ = "v2.5.7"
+__version__ = "v2.6.0"
def checkVersion(withMsg = False):
resp = requests.get("https://api.github.com/repos/ChocoMeow/Vocard/releases/latest")
diff --git a/views/chapter.py b/views/chapter.py
index 93b2caa..325baa6 100644
--- a/views/chapter.py
+++ b/views/chapter.py
@@ -41,7 +41,7 @@ class Dropdown(discord.ui.Select):
super().__init__(
placeholder=self.player.get_msg('chaptersDropdown'),
min_values=1, max_values=1,
- options=options,
+ options=options[:25],
)
async def callback(self, interaction: discord.Interaction):
@@ -52,6 +52,7 @@ class Dropdown(discord.ui.Select):
return await self.view.stop_view()
await self.player.seek(position)
+ await interaction.response.send_message(self.player.get_msg('seek').format(formatTime(position)))
class ChapterView(discord.ui.View):
def __init__(self, player, chapters, author):
@@ -61,7 +62,7 @@ class ChapterView(discord.ui.View):
self.chapters = chapters
self.author = author
self.response = None
- self.add_item(self.Dropdown(player, chapters))
+ self.add_item(Dropdown(player, chapters))
async def on_error(self, error: Exception, item, interaction) -> None:
return
diff --git a/views/controller.py b/views/controller.py
index 6c28b19..6a3d080 100644
--- a/views/controller.py
+++ b/views/controller.py
@@ -63,7 +63,7 @@ class Back(discord.ui.Button):
await interaction.response.send_message(self.player.get_msg("backed").format(interaction.user))
if self.player.queue._repeat == 1:
- self.player.queue.set_repeat("off")
+ await self.player.set_repeat("off")
class Resume(discord.ui.Button):
def __init__(self, player, style, row):
@@ -85,7 +85,7 @@ class Resume(discord.ui.Button):
self.player.resume_votes.clear()
self.emoji = "βΈοΈ"
self.label = self.player.get_msg("buttonPause")
- await self.player.set_pause(False)
+ await self.player.set_pause(False, interaction.user)
else:
if not self.player.is_privileged(interaction.user):
@@ -101,7 +101,7 @@ class Resume(discord.ui.Button):
self.player.pause_votes.clear()
self.emoji = "βΆοΈ"
self.label = self.player.get_msg("buttonResume")
- await self.player.set_pause(True)
+ await self.player.set_pause(True, interaction.user)
await interaction.response.edit_message(view=self.view)
class Skip(discord.ui.Button):
@@ -127,7 +127,7 @@ class Skip(discord.ui.Button):
await interaction.response.send_message(self.player.get_msg("skipped").format(interaction.user))
if self.player.queue._repeat == 1:
- self.player.queue.set_repeat("off")
+ await self.player.set_repeat("off")
await self.player.stop()
class Stop(discord.ui.Button):
@@ -182,20 +182,13 @@ class Add(discord.ui.Button):
class Loop(discord.ui.Button):
def __init__(self, player, style, row):
self.player = player
- self.loopType = {
- 0: "off",
- 1: "track",
- 2: "queue",
- }
super().__init__(emoji="π", label=player.get_msg('buttonLoop'), style=style, row=row)
async def callback(self, interaction: discord.Interaction):
if not self.player.is_privileged(interaction.user):
return await interaction.response.send_message(self.player.get_msg('missingPerms_mode'), ephemeral=True)
- current_repeat = self.player.queue._repeat
- mode = self.loopType.get((current_repeat + 1)%len(self.loopType), 'off')
- self.player.queue.set_repeat(mode)
+ mode = await self.player.set_repeat()
await interaction.response.send_message(self.player.get_msg('repeat').format(mode.capitalize()))
class VolumeUp(discord.ui.Button):
@@ -208,7 +201,7 @@ class VolumeUp(discord.ui.Button):
return interaction.response.send_message(self.player.get_msg("missingPerms_function"))
value = value if (value := self.player.volume + 20) <= 150 else 150
- await self.player.set_volume(value)
+ await self.player.set_volume(value, interaction.user)
await interaction.response.send_message(self.player.get_msg('setVolume').format(value), ephemeral=True)
@@ -222,7 +215,7 @@ class VolumeDown(discord.ui.Button):
return interaction.response.send_message(self.player.get_msg("missingPerms_function"))
value = value if (value := self.player.volume - 20) >= 0 else 150
- await self.player.set_volume(value)
+ await self.player.set_volume(value, interaction.user)
await interaction.response.send_message(self.player.get_msg('setVolume').format(value), ephemeral=True)
@@ -246,7 +239,7 @@ class VolumeMute(discord.ui.Button):
self.emoji = "π"
self.label = self.player.get_msg("buttonVolumeMute")
- await self.player.set_volume(value)
+ await self.player.set_volume(value, interaction.user)
await interaction.response.edit_message(view=self.view)
@@ -301,7 +294,7 @@ class InteractiveController(discord.ui.View):
super().__init__(timeout=None)
self.player = player
- for row, btnRow in enumerate(func.controller_settings):
+ for row, btnRow in enumerate(func.settings.controller_settings):
for btn in btnRow:
color = ""
if isinstance(btn, Dict):
@@ -320,7 +313,7 @@ class InteractiveController(discord.ui.View):
await interaction.response.send_message(self.player.get_msg("nodeReconnect"), ephemeral=True)
return False
- if interaction.user.id in func.bot_access_user:
+ if interaction.user.id in func.settings.bot_access_user:
return True
if self.player.channel and self.player.is_user_join(interaction.user):
diff --git a/views/help.py b/views/help.py
index 0a94774..506fde1 100644
--- a/views/help.py
+++ b/views/help.py
@@ -58,7 +58,7 @@ class HelpView(discord.ui.View):
self.response = None
self.categorys = [ 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.invite_link))
+ 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='https://discord.com/oauth2/authorize?client_id={}&permissions=2184260928&scope=bot%20applications.commands'.format(getenv('CLIENT_ID'))))
self.add_item(discord.ui.Button(label='Github', url='https://github.com/ChocoMeow/Vocard'))
self.add_item(discord.ui.Button(label='Donate', emoji=':patreon:913397909024800878', url='https://www.patreon.com/Vocard'))
@@ -84,7 +84,7 @@ class HelpView(discord.ui.View):
def build_embed(self, category: str):
category = category.lower()
if category == "news":
- embed = discord.Embed(title="Vocard Help Menu", url="https://discord.com/channels/811542332678996008/811909963718459392/1069971173116481636", color=func.embed_color)
+ embed = discord.Embed(title="Vocard Help Menu", url="https://discord.com/channels/811542332678996008/811909963718459392/1069971173116481636", color=func.settings.embed_color)
embed.add_field(name=f"Available Categories: [{2 + len(self.categorys)}]",
value="```py\nπ News\n2. Tutorial\n{}```".format("".join(f"{i}. {c}\n" for i, c in enumerate(self.categorys, start=3))),
@@ -101,7 +101,7 @@ class HelpView(discord.ui.View):
return embed
- embed = discord.Embed(title=f"Category: {category.capitalize()}", color=func.embed_color)
+ embed = discord.Embed(title=f"Category: {category.capitalize()}", color=func.settings.embed_color)
embed.add_field(name=f"Categories: [{2 + len(self.categorys)}]", value="```py\n" + "\n".join(("π " if c == category.capitalize() else f"{i}. ") + c for i, c in enumerate(['News', 'Tutorial'] + self.categorys, start=1)) + "```", inline=True)
if category == 'tutorial':
diff --git a/views/inbox.py b/views/inbox.py
index e169e9f..68597ff 100644
--- a/views/inbox.py
+++ b/views/inbox.py
@@ -51,7 +51,7 @@ class InboxView(discord.ui.View):
def build_embed(self):
embed=discord.Embed(title=f"π All {self.author}'s Inbox",
description=f'Max Messages: {len(self.inbox)}/10' + '```%0s %2s %20s\n' % (" ", "ID:", "Title:") + '\n'.join('%0s %2s. %35s'% ('βοΈ' if mail['type'] == 'invite' else 'π’', index, mail['title'][:35] + "...") for index, mail in enumerate(self.inbox, start=1)) + '```',
- color=func.embed_color)
+ color=func.settings.embed_color)
if self.current:
embed.add_field(name="Message Info:", value=f"```{self.current['description']}\nSender ID: {self.current['sender']}\nPlaylist ID: {self.current['referId']}\nInvite Time: {self.current['time'].strftime('%d-%m %H:%M:%S')}```")
return embed
diff --git a/views/list.py b/views/list.py
index 5a4d623..cbc582a 100644
--- a/views/list.py
+++ b/views/list.py
@@ -64,7 +64,7 @@ class ListView(discord.ui.View):
offset = self.current_page * 7
tracks = self.tracks[(offset-7):offset]
- embed = discord.Embed(title=self.player.get_msg('viewTitle'), color=func.embed_color)
+ embed = discord.Embed(title=self.player.get_msg('viewTitle'), color=func.settings.embed_color)
embed.description=self.player.get_msg('viewDesc').format(self.player.current.uri, f"```{self.player.current.title}```") if self.player.current else self.player.get_msg('nowplayingDesc').format("None")
queueText = ""
for index, track in enumerate(tracks, start=offset - 6):
diff --git a/views/lyrics.py b/views/lyrics.py
index c374be9..26fbe3c 100644
--- a/views/lyrics.py
+++ b/views/lyrics.py
@@ -72,7 +72,7 @@ class LyricsView(discord.ui.View):
def build_embed(self):
chunk = self.source.get(self.lang)[self.current_page - 1]
- embed=discord.Embed(description=chunk, color=func.embed_color)
+ embed=discord.Embed(description=chunk, color=func.settings.embed_color)
embed.set_author(name=f"Searching Query: {self.name}", icon_url=self.author.display_avatar.url)
embed.set_footer(text=f"Page: {self.current_page}/{self.pages}")
return embed
diff --git a/views/playlist.py b/views/playlist.py
index f749891..e4ab7df 100644
--- a/views/playlist.py
+++ b/views/playlist.py
@@ -88,7 +88,7 @@ class PlaylistView(discord.ui.View):
offset = self.current_page * 7
tracks = self.current['tracks'][(offset-7):offset]
- embed = discord.Embed(title=func.get_lang(self.guildID, 'playlistView'), color=func.embed_color)
+ embed = discord.Embed(title=func.get_lang(self.guildID, 'playlistView'), color=func.settings.embed_color)
embed.description= func.get_lang(self.guildID, 'playlistViewDesc').format(self.current['name'], self.current['id'], len(self.current['tracks']), self.current.get('owner', "None"), self.current['type'])
@@ -163,7 +163,7 @@ class CreateView(discord.ui.View):
self.value = None
self.response = None
self.add_item(agree())
- self.add_item(discord.ui.Button(label='Support', emoji=':support:915152950471581696', url=func.invite_link))
+ self.add_item(discord.ui.Button(label='Support', emoji=':support:915152950471581696', url=func.settings.invite_link))
async def on_timeout(self):
for child in self.children:
diff --git a/voicelink/objects.py b/voicelink/objects.py
index 12aada4..100a741 100644
--- a/voicelink/objects.py
+++ b/voicelink/objects.py
@@ -83,6 +83,13 @@ class Track:
self.is_seekable = info.get("isSeekable", True)
self.position = info.get("position", 0)
+ def toDict(self):
+ return {
+ "track_id": self.track_id,
+ "info": self.info,
+ "thumbnail": self.thumbnail
+ }
+
def __eq__(self, other):
if not isinstance(other, Track):
return False
diff --git a/voicelink/player.py b/voicelink/player.py
index 5aaebd9..1986495 100644
--- a/voicelink/player.py
+++ b/voicelink/player.py
@@ -43,7 +43,8 @@ from discord import (
StageChannel,
Member,
Embed,
- ui
+ ui,
+ Message
)
from discord.ext import commands
@@ -83,29 +84,30 @@ class Player(VoiceProtocol):
self.dj: Member = ctx.author
self.channel: VoiceChannel = channel
self._guild = channel.guild if channel else None
+ self._ipc_connection: bool = False
self.settings: dict = func.get_settings(ctx.guild.id)
- self.joinTime = round(time.time())
- self._volume = self.settings.get('volume', 100)
- self.lang = self.settings.get('lang', 'EN') if self.settings.get('lang', 'EN') in func.langs else "EN"
- self.queue = eval(self.settings.get("queueType", "Queue"))(self.settings.get("maxQueue", func.max_queue), self.settings.get("duplicateTrack", True), self.get_msg)
+ self.joinTime: float = round(time.time())
+ self._volume: int = self.settings.get('volume', 100)
+ self.lang: dict = self.settings.get('lang', 'EN') if self.settings.get('lang', 'EN') in func.langs else "EN"
+ self.queue: Queue = eval(self.settings.get("queueType", "Queue"))(self.settings.get("maxQueue", func.settings.max_queue), self.settings.get("duplicateTrack", True), self.get_msg)
self._node = NodePool.get_node()
self._current: Track = None
self._filters: Filters = Filters()
- self._paused = False
- self._is_connected = False
- self._ping = 0.0
+ self._paused: bool = False
+ self._is_connected: bool = False
+ self._ping: float = 0.0
- self._position = 0
- self._last_position = 0
- self._last_update = 0
+ self._position: int = 0
+ self._last_position: int = 0
+ self._last_update: int = 0
self._ending_track: Optional[Track] = None
- self._voice_state = {}
+ self._voice_state: dict = {}
- self.controller = None
- self.updating = False
+ self.controller: Message = None
+ self.updating: bool = False
self.pause_votes = set()
self.resume_votes = set()
@@ -195,8 +197,8 @@ class Player(VoiceProtocol):
def ping(self) -> float:
return round(self._ping / 1000, 2)
- def get_msg(self, mKey: str) -> str:
- return func.langs.get(self.lang, func.langs["EN"])[mKey]
+ def get_msg(self, mKey: str, lang: str = None) -> str:
+ return func.langs.get(lang if lang else self.lang, func.langs["EN"])[mKey]
def required(self, leave=False):
if self.settings.get('votedisable'):
@@ -209,14 +211,18 @@ class Player(VoiceProtocol):
return required
+ @property
+ def is_ipc_connected(self) -> bool:
+ return bool(self._ipc_connection and len(self.bot.ipc.connections))
+
def is_user_join(self, user: Member):
if user not in self.channel.members:
if not user.guild_permissions.manage_guild:
return False
return True
- def is_privileged(self, user: Member, check_user_join: bool = True):
- if user.id in func.bot_access_user:
+ def is_privileged(self, user: Member, check_user_join: bool = True) -> bool:
+ if user.id in func.settings.bot_access_user:
return True
manage_perm = user.guild_permissions.manage_guild
@@ -227,12 +233,19 @@ 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 _update_state(self, data: dict):
+ 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")
+ if self.is_ipc_connected:
+ await self.send_ws({
+ "op": "playerUpdate",
+ "last_update": self._last_update,
+ "is_connected": self._is_connected,
+ "last_position": self._last_position
+ })
async def _dispatch_voice_update(self, voice_data: Dict[str, Any]):
if {"sessionId", "event"} != self._voice_state.keys():
@@ -310,7 +323,15 @@ class Player(VoiceProtocol):
if self.settings.get('controller', True):
await self.invoke_controller()
-
+
+ if self.is_ipc_connected:
+ await self.send_ws({
+ "op": "trackUpdate",
+ "current_queue_position": self.queue._position if track else self.queue._position + 1,
+ "track_id": track.track_id if track else None,
+ "is_paused": self._paused
+ })
+
async def invoke_controller(self):
if self.updating or not self.channel:
return
@@ -346,18 +367,18 @@ class Player(VoiceProtocol):
track = self.current
if not track:
- embed=Embed(title=self.get_msg("noTrackPlaying"), description=f"[Vote](https://top.gg/bot/605618911471468554/vote/) | [Support]({func.invite_link}) | [Invite](https://discord.com/oauth2/authorize?client_id=605618911471468554&permissions=2184260928&scope=bot%20applications.commands) | [Questionnaire](https://forms.gle/UqeeEv4GEdCq9hi3A)", color=func.embed_color)
+ embed=Embed(title=self.get_msg("noTrackPlaying"), description=f"[Vote](https://top.gg/bot/605618911471468554/vote/) | [Support]({func.settings.invite_link}) | [Invite](https://discord.com/oauth2/authorize?client_id=605618911471468554&permissions=2184260928&scope=bot%20applications.commands) | [Questionnaire](https://forms.gle/UqeeEv4GEdCq9hi3A)", color=func.settings.embed_color)
embed.set_image(url='https://i.imgur.com/dIFBwU7.png')
else:
try:
- embed = Embed(color=func.embed_color)
+ embed = Embed(color=func.settings.embed_color)
embed.set_author(name=self.get_msg("playerAuthor").format(self.channel.name), icon_url=self.client.user.avatar.url)
embed.description = self.get_msg("playerDesc").format(track.title, track.uri, (track.requester.mention if track.requester else "<@605618911471468554>"), (f"<@&{self.settings['dj']}>" if self.settings.get('dj') else f"{self.dj.mention}"))
embed.set_image(url=track.thumbnail if track.thumbnail else "https://cdn.discordapp.com/attachments/674788144931012638/823086668445384704/eq-dribbble.gif")
embed.set_footer(text=self.get_msg("playerFooter").format(self.queue.count, (self.get_msg("live") if track.is_stream else func.time(track.length)), self.volume, self.get_msg("playerFooter2").format(self.queue.repeat.capitalize()) if self.queue._repeat else ""))
except:
- embed = Embed(description=self.get_msg("missingTrackInfo"), color=func.embed_color)
+ embed = Embed(description=self.get_msg("missingTrackInfo"), color=func.settings.embed_color)
return embed
async def is_position_fresh(self):
@@ -373,7 +394,9 @@ class Player(VoiceProtocol):
async def teardown(self):
timeNow = round(time.time())
func.update_settings(self.guild.id, {"lastActice": timeNow, "playTime": round(self.settings.get("playTime", 0) + ((timeNow - self.joinTime) / 60), 2)})
-
+ if self.is_ipc_connected:
+ await self.send_ws({"op": "playerClose"})
+
try:
await self.controller.delete()
except:
@@ -543,27 +566,34 @@ class Player(VoiceProtocol):
position = self.queue.put_at_front(raw_tracks) if at_font else self.queue.put(raw_tracks)
tracks.append(raw_tracks)
finally:
- return len(tracks) if isList else position
+ if tracks:
+ if self.is_ipc_connected:
+ await self.send_ws({"op": "addTrack", "tracks": [track.toDict() for track in tracks]}, tracks[0].requester)
+ return len(tracks) if isList else position
- async def seek(self, position: float) -> float:
+ 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"
- )
+ 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})
+ if self.is_ipc_connected:
+ await self.send_ws({"op": "updatePosition", "position": position}, requester)
return self._position
- async def set_pause(self, pause: bool) -> bool:
+ 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})
+ if self.is_ipc_connected:
+ await self.send_ws({"op": "updatePause", "pause": pause}, requester)
self._paused = pause
return self._paused
- async def set_volume(self, volume: int) -> int:
+ 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})
+ if self.is_ipc_connected:
+ await self.send_ws({"op": "updateVolume", "volume": volume}, requester)
self._volume = volume
return self._volume
@@ -575,6 +605,37 @@ class Player(VoiceProtocol):
shuffle(replacement)
self.queue.replace(queue_type, replacement)
self.shuffle_votes.clear()
+ if self.is_ipc_connected:
+ await self.send_ws({
+ "op": "shuffleTrack",
+ "tracks": [track.toDict() for track in self.queue._queue],
+ "verified": {
+ "index": self.queue._position if queue_type == "queue" else 0,
+ "track_id": replacement[0].track_id,
+ }
+ }, requester)
+
+ async def set_repeat(self, mode:str = None):
+ if not mode:
+ mode = self.queue._repeat_mode.get((self.queue._repeat + 1)%len(self.queue._repeat_mode), 'off')
+
+ is_found = False
+ for i, m in self.queue._repeat_mode.items():
+ if m == mode.lower():
+ is_found = True
+ self.queue._repeat = i
+ if i == 2:
+ self._repeat_position = self._position - 1
+ break
+
+ if not is_found:
+ raise VoicelinkException("Invalid repeat mode.")
+
+ if self.is_ipc_connected:
+ await self.send_ws({"op": "repeatTrack", "repeatMode": mode})
+
+ return mode
+
async def add_filter(self, filter: Filter, fast_apply=False) -> Filters:
try:
self._filters.add_filter(filter=filter)
@@ -625,4 +686,10 @@ class Player(VoiceProtocol):
await self.set_pause(True)
if self.volume != 100:
- await self.set_volume(self.volume)
\ No newline at end of file
+ await self.set_volume(self.volume)
+
+ async def send_ws(self, payload, requester: Member = None):
+ payload['guild_id'] = self.guild.id
+ if requester:
+ payload['requester_id'] = requester.id
+ await self.bot.ipc.send(payload)
diff --git a/voicelink/queue.py b/voicelink/queue.py
index 20ff437..716fa86 100644
--- a/voicelink/queue.py
+++ b/voicelink/queue.py
@@ -26,19 +26,27 @@ from .objects import Track
from discord import Member
class Queue:
- def __init__(self, size:int, duplicateTrack: bool, get_msg):
+ def __init__(self, size: int, duplicate_track: bool, get_msg):
self._queue = []
self._position = 0
self._size = size
self._repeat = 0
self._repeat_position = 0
- self._duplicateTrack = duplicateTrack
+ self._duplicate_track = duplicate_track
+
+ self._repeat_mode = {
+ 0: "off",
+ 1: "track",
+ 2: "queue",
+ }
+
self.get_msg = get_msg
-
+
def get(self):
track = None
try:
- track = self._queue[self._position - 1 if self._repeat == 1 else self._position]
+ track = self._queue[self._position -
+ 1 if self._repeat == 1 else self._position]
if self._repeat != 1:
self._position += 1
except:
@@ -54,30 +62,30 @@ class Queue:
def put(self, item: Track) -> int:
if self.count >= self._size:
raise QueueFull(self.get_msg("voicelinkQueueFull").format(self._size))
-
- if not self._duplicateTrack:
+
+ if not self._duplicate_track:
if item.uri in [track.uri for track in self._queue]:
raise DuplicateTrack(self.get_msg("voicelinkDuplicateTrack"))
self._queue.append(item)
return self.count
-
+
def put_at_front(self, item: Track):
if self.count >= self._size:
raise QueueFull(self.get_msg("voicelinkQueueFull").format(self._size))
-
- if not self._duplicateTrack:
+
+ if not self._duplicate_track:
if item.uri in [track.uri for track in self._queue]:
raise DuplicateTrack(self.get_msg("voicelinkDuplicateTrack"))
self._queue.insert(self._position, item)
return 1
-
+
def put_at_index(self, index: int, item: Track):
if self.count >= self._size:
raise QueueFull(self.get_msg("voicelinkQueueFull").format(self._size))
-
- if not self._duplicateTrack:
+
+ if not self._duplicate_track:
if item.uri in [track.uri for track in self._queue]:
raise DuplicateTrack(self.get_msg("voicelinkDuplicateTrack"))
@@ -88,21 +96,12 @@ class Queue:
raise OutofList(self.get_msg("voicelinkOutofList"))
else:
self._position += index - 1
-
+
def backto(self, index: int):
if not self._position - index >= 0:
raise OutofList(self.get_msg("voicelinkOutofList"))
else:
self._position -= index
-
- def set_repeat(self, mode:str):
- if mode == 'track':
- self._repeat = 1
- elif mode == 'queue':
- self._repeat = 2
- self._repeat_position = self._position - 1
- else:
- self._repeat = 0
def history_clear(self, is_playing: bool):
self._queue[:self._position - 1 if is_playing else self._position] = []
@@ -111,14 +110,14 @@ class Queue:
def clear(self):
del self._queue[self._position:]
- def replace(self, queue_type:str, replacement:list):
+ def replace(self, queue_type: str, replacement: list):
if queue_type == "queue":
self.clear()
self._queue += replacement
elif queue_type == "history":
self._queue[:self._position] = replacement
-
- def swap(self, num1:int, num2:int):
+
+ def swap(self, num1: int, num2: int):
try:
pos = self._position - 1
self._queue[pos + num1], self._queue[pos + num2] = self._queue[pos + num2], self._queue[pos + num1]
@@ -126,10 +125,10 @@ class Queue:
except IndexError:
raise OutofList(self.get_msg("voicelinkOutofList"))
- def move(self, target:int, to:int):
+ def move(self, target: int, to: int):
if not 0 < target <= self.count or not 0 < to:
raise OutofList(self.get_msg("voicelinkOutofList"))
-
+
try:
moveItem = self._queue[self._position + target - 1]
self._queue.remove(moveItem)
@@ -138,25 +137,31 @@ class Queue:
except:
raise OutofList(self.get_msg("voicelinkOutofList"))
- def remove(self, index:int, index2:int, member: Member = None):
+ def remove(self, index: int, index2: int = None, member: Member = None):
pos = self._position - 1
- if not index2:
+
+ if index2 is None:
index2 = index
- count = 0
+
+ if index2 < index:
+ temp = index
+ index = index2
+ index2 = temp
+
try:
- for track in self._queue[pos + index: pos + index2 + 1]:
+ count = []
+ for i, track in enumerate(self._queue[pos + index: pos + index2 + 1], start=0):
if member:
- if track.requester == member:
- self._queue.remove(track)
- count += 1
- continue
- else:
- self._queue.remove(track)
- count += 1
+ if track.requester != member:
+ continue
+
+ self._queue.remove(track)
+ count.append({"position": pos + index + i, "track": track})
+
return count
except:
raise OutofList(self.get_msg("voicelinkOutofList"))
-
+
def history(self, incTrack: bool = False) -> list:
if incTrack:
return self._queue[:self._position]
@@ -170,10 +175,10 @@ class Queue:
@property
def count(self):
return len(self._queue[self._position:])
-
+
@property
def repeat(self):
- return "Off" if self._repeat == 0 else ("Track" if self._repeat == 1 else "Queue")
+ return self._repeat_mode.get(self._repeat, "Off").capitalize()
@property
def is_empty(self):
@@ -183,16 +188,17 @@ class Queue:
return True
return False
+
class FairQueue(Queue):
- def __init__(self, size:int, duplicateTrack: bool, get_msg):
- super().__init__(size, duplicateTrack, get_msg)
+ def __init__(self, size: int, duplicate_track: bool, get_msg):
+ super().__init__(size, duplicate_track, get_msg)
self._set = set()
async def put(self, item: Track) -> int:
if len(self._queue) >= self._size:
raise QueueFull(self.get_msg("voicelinkQueueFull").format(self._size))
- if not self._duplicateTrack:
+ if not self._duplicate_track:
if item.uri in [track.uri for track in self._queue]:
raise DuplicateTrack(self.get_msg("voicelinkDuplicateTrack"))
@@ -205,8 +211,8 @@ class FairQueue(Queue):
self._set.clear()
for track in tracks[lastIndex:]:
if track.requester in self._set:
- break;
+ break
lastIndex += 1
self._set.add(track.requester)
await self.put_at_index(lastIndex, item)
- return lastIndex
\ No newline at end of file
+ return lastIndex
diff --git a/web/__init__.py b/web/__init__.py
new file mode 100644
index 0000000..4bafd1f
--- /dev/null
+++ b/web/__init__.py
@@ -0,0 +1,2 @@
+from .ipc import *
+from .objects import *
\ No newline at end of file
diff --git a/web/ipc/__init__.py b/web/ipc/__init__.py
new file mode 100644
index 0000000..8d6d350
--- /dev/null
+++ b/web/ipc/__init__.py
@@ -0,0 +1,2 @@
+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
new file mode 100644
index 0000000..4ce5ff9
--- /dev/null
+++ b/web/ipc/client.py
@@ -0,0 +1,57 @@
+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:
+ 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 = {
+ "sercet": 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
new file mode 100644
index 0000000..a540e3b
--- /dev/null
+++ b/web/ipc/methods.py
@@ -0,0 +1,281 @@
+import json
+
+from discord import Member
+from discord.ext import commands
+from voicelink import Player, Track, Playlist
+
+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}
+ if user_id:
+ payload["user_id"]: user_id
+ if guild_id:
+ payload["guild_id"]: guild_id
+
+ return payload
+
+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.avatar.url,
+ "name": member.name
+ } for member in player.channel.members ],
+ "tracks": [ track.toDict() 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.is_playing 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)
+ }
+
+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', 'EN'), 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', 'EN').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 == 1:
+ await player.set_repeat("off")
+ 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', 'EN'), 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', 'EN').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["track_id"],
+ info=track["info"],
+ requester=member
+ ) for track 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[:20] ]
+
+ payload["tracks"] = [ track.toDict() 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);
+
+methods = {
+ "initPlayer": initPlayer,
+ "skipTo": skipTo,
+ "backTo": backTo,
+ "moveTrack": moveTrack,
+ "addTracks": addTracks,
+ "getTracks": getTracks,
+ "shuffleTrack": shuffleTrack,
+ "repeatTrack": repeatTrack,
+ "removeTrack": removeTrack,
+ "updatePause": updatePause,
+ "updatePosition": updatePosition,
+}
+
+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 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
+
+ else:
+ guild = bot.get_guild(guild_id)
+ member = guild.get_member(user_id)
+
+ if not guild:
+ return
+
+ player: Player = guild.voice_client
+ if not player or not Member:
+ return
+ try:
+ resp: dict = await method(player, 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
new file mode 100644
index 0000000..df0894b
--- /dev/null
+++ b/web/ipc/server.py
@@ -0,0 +1,67 @@
+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,
+ sercet_key: Optional[str] = None
+ ):
+ self.bot = bot
+ self.host = host
+ self.port = port
+ self.sercet_key = sercet_key
+
+ self.user = {}
+ self.connections = set()
+
+ def is_secure(self, data: dict) -> bool:
+ if (key := data.get("sercet")):
+ return str(key) == str(self.sercet_key)
+ return bool(self.sercet_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
new file mode 100644
index 0000000..72121f6
--- /dev/null
+++ b/web/objects.py
@@ -0,0 +1,25 @@
+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
new file mode 100644
index 0000000..fc0d3a8
--- /dev/null
+++ b/web/static/css/main.css
@@ -0,0 +1,659 @@
+@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;
+}
+
+.header .left {
+ display: flex;
+}
+
+.header .left p {
+ font-size: 20;
+ 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;
+}
+
+.main .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(80px);
+ 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;
+}
+
+.progress-bar-container {
+ position: relative;
+ width: 100%;
+}
+
+.seek-bar {
+ position: absolute;
+ appearance: none;
+ width: 100%;
+ height: 4px;
+ cursor: pointer;
+ overflow: hidden;
+ background-color: rgb(73, 73, 73);
+}
+
+.seek-bar:hover {
+ height: 8px;
+}
+
+.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: 20;
+}
+
+.player-controller .left .control #play-pause-button {
+ font-size: 30;
+}
+
+.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 {
+ margin-left: 1rem;
+ overflow: hidden;
+}
+
+.player-controller .center .desc {
+ color: grey;
+ font-size: 13;
+}
+
+.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: 20;
+ color: gray;
+ transition: color .2s ease-in-out;
+}
+
+.player-controller .right i:hover {
+ color: #fff;
+}
+
+.main .queue-list {
+ overflow-y: auto;
+ overflow-x: hidden;
+ flex: 1;
+ display: flex;
+ justify-content: center;
+}
+
+.track {
+ display: flex;
+ justify-content: space-between;
+ 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 .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: 13;
+}
+
+.main .queue-list ul {
+ list-style: none;
+ width: 90%;
+ padding: 1rem;
+ padding-bottom: 5rem;
+ max-height: 300px;
+}
+
+.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;
+}
+
+.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;
+}
+
+.context-menu ul {
+ list-style: none;
+ padding: .5rem;
+ border-radius: .5rem;
+ background-color: rgb(37, 37, 37);
+}
+
+.context-menu li {
+ padding: .5rem;
+ border-radius: .3rem;
+ cursor: pointer;
+ transition: background-color .2s;
+}
+
+.context-menu li:hover {
+ padding: .5rem;
+ background-color: rgb(98, 98, 98);
+}
+
+.context-menu i {
+ margin-right: .8rem;
+}
+
+@media (max-width: 935px) {
+ .main {
+ flex-direction: column;
+ justify-content: center;
+ padding: 0;
+ }
+
+ .main .toastContrainer {
+ display: none;
+ }
+
+ .header .center {
+ display: none;
+ }
+
+ .player-controller .left .position {
+ display: none;
+ }
+
+ .player-controller .center img {
+ display: none;
+ }
+
+ ul {
+ width: 100%;
+ }
+}
+
+@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
new file mode 100644
index 0000000..1cb4e9e
Binary files /dev/null and b/web/static/img/error.png differ
diff --git a/web/static/img/info.png b/web/static/img/info.png
new file mode 100644
index 0000000..a914ef4
Binary files /dev/null and b/web/static/img/info.png differ
diff --git a/web/static/js/Socket.js b/web/static/js/Socket.js
new file mode 100644
index 0000000..74ac9f2
--- /dev/null
+++ b/web/static/js/Socket.js
@@ -0,0 +1,47 @@
+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/static/js/action.js b/web/static/js/action.js
new file mode 100644
index 0000000..e0ca080
--- /dev/null
+++ b/web/static/js/action.js
@@ -0,0 +1,200 @@
+$(document).ready(function () {
+ const player = new Player(userId);
+ const img = document.getElementById("largeImage");
+ const canvas = document.createElement('canvas');
+
+ var startPos = null;
+ var selectedTrack = null;
+
+ var typingTimer;
+ var doneTypingInterval = 2000;
+
+ img.crossOrigin = "Anonymous";
+ img.onload = function () {
+ try {
+ const context = canvas.getContext('2d', {willReadFrequently: true});
+ const width = canvas.width = img.width;
+ const height = canvas.height = Math.round(img.height * 0.7); // set canvas height to 80% of image height
+ const startY = Math.round((img.height - height) / 2); // calculate starting Y coordinate to center the canvas
+ context.drawImage(img, 0, startY, width, height, 0, 0, width, height);
+ getMainColorsFromImage(4)
+ .then(colors => {
+ $(".thumbnail-background").css({
+ "background": `linear-gradient(-132deg, ${colors[0]}, ${colors[1]}, ${colors[2]}, ${colors[3]})`,
+ "width": "70%",
+ "padding-bottom": "70%"
+ })
+ })
+ .catch(error => { return });
+
+ } 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);
+ });
+ }
+
+ $('body').click(function (event) {
+ var $target = $(event.target);
+
+ if (!$target.closest(".search-contrainer").is('.search-contrainer') && $('#search-result-list').css("display") != 'none') {
+ $("#search-result-list").fadeOut(200);
+ }
+
+ if (!$target.closest(".users-bar").is('.users-bar') &&
+ !$target.closest("#users-button").is('#users-button') &&
+ $(".users-bar").hasClass("active")) {
+ $(".users-bar").removeClass("active");
+ $("#users-button").css({ "color": "" });
+ }
+
+ if (!$target.closest(".action").is(".action") &&
+ !$target.closest("#context-menu li").is("#context-menu li") &&
+ $("#context-menu").css("display") != "none") {
+ $("#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) {
+ var newPos = ui.item.index();
+ if (startPos != newPos) {
+ player.moveTrack(startPos, newPos)
+ }
+ }
+ }
+ });
+ });
+
+ $('#sortable').on('click', 'li', function (event) {
+ var index = $(this).index();
+ var position = player.current_queue_position;
+
+ if ($(event.target).hasClass('action')) {
+ selectedTrack = { position: index, track: player.queue[index] };
+ $("#context-menu").css({ "left": `${event.pageX - 150}px`, "top": `${event.pageY + 30}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 () {
+ var index = $(this).index();
+ var 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 () {
+ var 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-button').on('click', function () {
+ player.togglePause();
+ });
+
+ $('#skip-button').on('click', function () {
+ player.skipTo();
+ });
+
+ $('#back-button').on('click', function () {
+ player.backTo();
+ });
+
+ $('#seek-bar').change(function () {
+ player.seekTo($(this).val());
+ })
+
+ $("#repeat-button").on('click', function () {
+ player.repeatMode();
+ })
+
+ $("#shuffle-button").on('click', function () {
+ player.shuffle();
+ })
+
+ $("#users-button").on('click', function () {
+ const userBar = $(".users-bar")
+ userBar.toggleClass("active");
+ if (userBar.hasClass("active")) {
+ $(this).css({ "color": "#fff" });
+ } else {
+ $(this).css({ "color": "" });
+ }
+
+ })
+
+ $("#remove-track-button").on('click', function () {
+ player.removeTrack(selectedTrack?.position, selectedTrack?.track)
+ $("#context-menu").fadeOut(200);
+ })
+
+ $("#copy-track-button").on('click', function () {
+ navigator.clipboard.writeText(selectedTrack?.track.uri);
+ $("#context-menu").fadeOut(200);
+ })
+});
diff --git a/web/static/js/objects.js b/web/static/js/objects.js
new file mode 100644
index 0000000..2b3d4f6
--- /dev/null
+++ b/web/static/js/objects.js
@@ -0,0 +1,490 @@
+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;
+ }
+}
+
+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"];
+ 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) {
+ var 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 ${tracks[0]['info']['title']} songs into the queue.`
+ } else {
+ var msg = `Added ${tracks.length} into the queue.`
+ }
+ player.showToast(data["requester_id"], msg)
+ },
+
+ getTracks: function (player, data) {
+ var tracks = data["tracks"];
+ if (tracks != undefined) {
+ const resultList = $("#search-result-list");
+ resultList.empty();
+ player.searchList = tracks;
+ for (var i in tracks) {
+ var track = new Track(tracks[i]);
+ resultList.append(`${track.title}
${track.author}
${player.msToReadableTime(track.length)}
${track.title}
${track.author}
${this.msToReadableTime(track.length)}
${user.name}
${track.title}
${track.author}
${this.msToReadableTime(track.length)}
${user['name']}
00:00
/00:00
+