Merge pull request #1 from ChocoMeow/beta

Update project to v2.6.0 with new features and fixes
This commit is contained in:
Choco
2023-03-31 20:39:15 +08:00
committed by GitHub
37 changed files with 2545 additions and 224 deletions

View File

@@ -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

View File

@@ -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.
[![Discord Music Bot](https://img.youtube.com/vi/f_Z0RLRZzWw/maxresdefault.jpg)](https://www.youtube.com/watch?v=f_Z0RLRZzWw)
## Screenshot
<div style="display: flex">
<img src="https://user-images.githubusercontent.com/94597336/218930155-630d8a0c-aa2d-4fcf-b6b4-957b8b2ba891.png" height=300>
<img src="https://user-images.githubusercontent.com/94597336/218930292-a2221d22-40bb-4040-9823-6527f2f5f833.png" height=300>
<img src="https://user-images.githubusercontent.com/94597336/218930558-f0b3bc78-cf0f-4b75-853e-eb15b13287c2.png" height=300>
<img src="https://user-images.githubusercontent.com/94597336/218930368-46fff9ef-628b-4daa-aee1-7c5e5692700c.png" height=300>
</div>
### Discord Bot
<img src="https://user-images.githubusercontent.com/94597336/227766331-dfa7d360-18d7-4014-ac6a-4fca55907d99.png">
<img src="https://user-images.githubusercontent.com/94597336/227766379-c824512e-a6f7-4ca5-9342-cc8e78d52491.png">
<img src="https://user-images.githubusercontent.com/94597336/227766408-37d733f7-c849-4cbd-9e17-0cd5800affb3.png">
<img src="https://user-images.githubusercontent.com/94597336/227766416-22ae3d91-40d9-44c0-bde1-9d40bd54c3af.png">
### Dashboard
<img src="https://user-images.githubusercontent.com/94597336/227766460-2c8e7718-c1c2-4c8a-8c0e-185d81db20cc.png">
<img src="https://user-images.githubusercontent.com/94597336/227766493-e7010813-077b-486a-b45c-4fbdc86f40bc.png">
## Run the Projects
[![Run on Repl.it](https://replit.com/badge/github/ChocoMeow/Vocard)](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, ...]`

View File

@@ -1 +1,2 @@
from .lyrics import getLyrics
from .lyrics import getLyrics
from .settings import Settings

18
addons/settings.py Normal file
View File

@@ -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
}
)

View File

@@ -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):

View File

@@ -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:

View File

@@ -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))

View File

@@ -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,

View File

@@ -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, [])

72
main.py
View File

@@ -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)

View File

@@ -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

View File

@@ -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>",

View File

@@ -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")

View File

@@ -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

View File

@@ -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):

View File

@@ -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':

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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)
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)

View File

@@ -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
return lastIndex

2
web/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .ipc import *
from .objects import *

2
web/ipc/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .server import IPCServer
from .client import IPCClient

57
web/ipc/client.py Normal file
View File

@@ -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()

281
web/ipc/methods.py Normal file
View File

@@ -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))

67
web/ipc/server.py Normal file
View File

@@ -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)

25
web/objects.py Normal file
View File

@@ -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

659
web/static/css/main.css Normal file
View File

@@ -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%;
}
}

BIN
web/static/img/error.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
web/static/img/info.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

47
web/static/js/Socket.js Normal file
View File

@@ -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);
}
}
}

200
web/static/js/action.js Normal file
View File

@@ -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);
})
});

490
web/static/js/objects.js Normal file
View File

@@ -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(`<li class="search-result"><div class="search-result-left"><img src=${track.imageUrl} /><div class="search-result-info"><p class="info">${track.title}</p><p class="desc">${track.author}</p></div></div><p>${player.msToReadableTime(track.length)}</p></li>`)
}
}
$("#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'];
var msg = "";
if (data["pause"]) {
msg = "Paused the player."
} else {
msg = "Resumed the player."
}
player.showToast(data["requester_id"], msg)
},
updatePosition: function (player, data) {
player.current_position = data["position"];
},
swapTrack: function (player, data) {
var index1 = player.current_queue_position + data['position2']["index"];
var index2 = player.current_queue_position + data['position1']["index"];
var track1 = player.queue[index1];
var 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) {
var tracks = data["tracks"];
if (tracks != undefined) {
player.queue = [];
tracks.forEach(rawTrack => {
var 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);
},
errorMsg: function (player, data) {
var level = data["level"];
player.showToast(level, data["msg"]);
}
}
class Track {
constructor(object) {
this.title = object["info"]["title"];
this.author = object["info"]["author"];
this.imageUrl = object["thumbnail"];
this.length = object["info"]["length"];
this.track_id = object["track_id"];
this.uri = object["info"]["uri"];
}
}
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.channelName = "";
}
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(`<li><div class="track"><div class="left">${(this.isDJ) ? '<i class="fa-solid fa-bars handle"></i>' : ''}<img src=${track.imageUrl} /><div class="info"><p>${track.title}</p><p class="desc">${track.author}</p></div></div><p class="time">${this.msToReadableTime(track.length)}</p><i class="fa-solid fa-ellipsis-vertical action"></i></div></li>`)
}
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(`<li class="user"><div class="left"><img src=${user.avatar_url} /><p>${user.name}</p></div><i class="fa-solid fa-microphone"></i></li>`)
};
}
addTrack(tracks) {
for (var i in tracks) {
var track = new Track(tracks[i]);
this.queue.push(track);
$("#sortable").append(`<li><div class="track"><div class="left">${(this.isDJ) ? '<i class="fa-solid fa-bars handle"></i>' : ''}<img src=${track.imageUrl} /><div class="info"><p>${track.title}</p><p class="desc">${track.author}</p></div></div><p class="time">${this.msToReadableTime(track.length)}</p><i class="fa-solid fa-ellipsis-vertical action"></i></div></li>`)
}
}
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) {
var 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;
}
var position = tempPosition / 500 * this.currentTrack.length;
this.send({ "op": "updatePosition", "position": 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) {
var 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) {
var $element = $(`<div class="toast"><img src=${user['avatar_url']} alt="user-icon"/><div class="content"><p class="username">${user['name']}</p><p class="message">${msg}</p></div></div>`)
$(".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));
var time = (this.current_position / this.currentTrack?.length) * 500;
$("#seek-bar").val(time);
}
updateInfo() {
var currentTrack = this.currentTrack;
if (currentTrack == undefined) {
$("#title").text("");
$("#author").text("");
$("#position").text("00:00");
$("#length").text("00:00");
$("#image").removeAttr('src');
$("#largeImage").removeAttr('src');
} else {
$("#title").text(currentTrack.title);
$("#author").text(currentTrack.author);
$("#length").text(this.msToReadableTime(currentTrack.length));
$("#image").attr("src", currentTrack.imageUrl);
$("#largeImage").attr("src", `${window.location.protocol}//${window.location.hostname}:${window.location.port}/` + currentTrack.imageUrl);
}
$("#channel-name").text((this.channelName == "") ? "Not Found" : this.channelName);
var play_pause_btn = $("#play-pause-button");
var repeat_btn = $("#repeat-button");
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");
}
}
}

122
web/templates/index.html Normal file
View File

@@ -0,0 +1,122 @@
<html>
<head>
<meta charset="utf-8">
<title>Vocard Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.3/jquery.min.js"
integrity="sha512-STof4xm1wgkfm7heWqFJVn58Hm3EtS31XFaagaa8VMReCXAkQnJZ+jEy8PCC/iT18dFy95WcExNHFTqLyp72eQ=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdn.socket.io/4.6.0/socket.io.min.js"
integrity="sha384-c79GN5VsunZvi+Q/WObgk2in0CbZsHnjEqvFxC5DxHn9lTfNce2WW6h2pH6u/kF+"
crossorigin="anonymous"></script>
<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.js"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/socket.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/action.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/objects.js') }}"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.3.0/css/all.min.css"
integrity="sha512-SzlrxWUlpfuzQ+pcUCosxcglQRNAq/DZjVsC0lE40xsADsfeQoEypE+enwcOiGjk/bSuGGKHEyjSoQ1zVisanQ=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
</head>
<body>
<script> const userId = "{{ user.id }}"</script>
<div class="container">
<div class="header">
<div class="left">
<p>Home</p>
<p>Playlist</p>
</div>
<div class="center">
<div class="search-container">
<div class="search-bar">
<i class="fa-solid fa-magnifying-glass"></i>
<input id="search-input" class="search-input"
placeholder="Search your input and add it to the queue." />
<div id="search-loader" class="search-loader"></div>
</div>
<ul id="search-result-list" class="search-result-list">
</ul>
</div>
</div>
<div class="right">
<div class="dropdown-container">
<div class="dropdown-content">
<img src="{{ user.avatar.url }}" />
<p>{{ user.username }}</p>
</div>
<div class="dropdown-item">
<a href="logout"><i class="fa-solid fa-right-from-bracket"></i>Log Out</a>
</div>
</div>
</div>
</div>
<div class="main">
<div class="toastContrainer"></div>
<div class="thumbnail">
<img id="largeImage" />
<div class="thumbnail-background"></div>
</div>
<div class="queue-list">
<ul id="sortable">
</ul>
</div>
</div>
<div class="users-bar">
<div class="info">
<h3 id="channel-name"> </h3>
<h5>You are listen in</h5>
</div>
<ul id="users-container" class="users-container">
</ul>
</div>
<div class="player-controller">
<div class="progress-bar-container">
<input type="range" value="0" min="0" max="500" class="seek-bar" id="seek-bar">
</div>
<div class="control-container">
<div class="left">
<div class="control">
<i id="back-button" class="fa-solid fa-backward-step"></i>
<i id="play-pause-button" class="fa-solid fa-play"></i>
<i id="skip-button" class="fa-solid fa-forward-step"></i>
</div>
<div class="position">
<p id="position">00:00 </p>/ <p id="length"> 00:00</p>
</div>
</div>
<div class="center">
<img class="image" id="image">
<div class="data">
<p id="title"></p>
<p class="desc" id="author"></p>
</div>
</div>
<div class="right">
<i id="repeat-button" class="fa-solid fa-repeat"></i>
<i id="shuffle-button" class="fa-solid fa-shuffle"></i>
<i id="users-button" class="fa-solid fa-user-group"></i>
</div>
</div>
</div>
</div>
<div id="context-menu" class="context-menu">
<ul>
<li id="remove-track-button"><i class="fa-solid fa-trash"></i>Remove</li>
<li id="copy-track-button"><i class="fa-solid fa-share"></i>Copy Song Link</li>
</ul>
</div>
</body>
</html>

208
web/webapp.py Normal file
View File

@@ -0,0 +1,208 @@
from flask import Flask, redirect, url_for, session, request, render_template, abort, 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
import requests
import json
import os
import asyncio
import functools
import threading
load_dotenv()
app = Flask(__name__)
app.secret_key = os.getenv("SERCET_KEY")
socketio = SocketIO(app)
# Discord OAuth2 credentials
CLIENT_ID = os.getenv("CLIENT_ID")
CLIENT_SECRET = os.getenv("CLIENT_SECRET_ID")
REDIRECT_URI = 'http://127.0.0.1:5000/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:
abort(401, description="Unauthorized")
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('/<path:url>', methods=["GET"])
def proxy(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['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="127.0.0.1", port=5000)