This commit is contained in:
AF 2022-11-30 01:52:15 +00:00
parent 38c86f22c0
commit 5d54f08c2b
7 changed files with 419 additions and 86 deletions

199
v6d3music/api.py Normal file
View File

@ -0,0 +1,199 @@
import asyncio
import time
from typing import TypeAlias
import discord
from v6d3music.core.mainasrc import main_for_raw_vc, raw_vc_for_member
from v6d3music.core.mainaudio import MainAudio
from v6d2ctx.context import Explicit
ResponseType: TypeAlias = list | dict | float | str | None
class Api:
class MisusedApi(KeyError):
def json(self) -> ResponseType:
return {'error': list(map(str, self.args)), 'errormessage': str(self)}
class UnknownApi(MisusedApi):
pass
class ExplicitFailure(MisusedApi):
def __init__(self, explicit: Explicit) -> None:
super().__init__(*explicit.args)
def __init__(self, client: discord.Client) -> None:
self.client = client
async def api(self, request: dict, user_id: int) -> ResponseType:
return await UserApi(self.client, request, user_id).api()
class UserApi(Api):
class UnknownMember(Api.MisusedApi):
pass
def __init__(self, client: discord.Client, request: dict, user_id: int) -> None:
super().__init__(client)
self.request = request
self.user_id = user_id
async def _guild_api(self, guild_id: int) -> 'GuildApi':
guild = self.client.get_guild(guild_id) or await self.client.fetch_guild(guild_id)
if guild is None:
raise UserApi.UnknownMember('unknown guild')
member = guild.get_member(self.user_id) or await guild.fetch_member(self.user_id)
if member is None:
raise UserApi.UnknownMember('unknown member of a guild')
return GuildApi(self.client, self.request, member)
def sub(self, request: dict) -> 'UserApi':
return UserApi(self.client, request, self.user_id)
async def subs(self, requests: list[dict] | dict[str, dict]) -> ResponseType:
match requests:
case list():
return list(
await asyncio.gather(
*(self.sub(request).api() for request in requests)
)
)
case dict():
items = list(requests.items())
responses = await asyncio.gather(
*(self.sub(request if 'type' in request else request | {'type': key}).api() for key, request in items)
)
return dict((key, response) for (key, _), response in zip(items, responses))
case _:
raise Api.MisusedApi('that should not happen')
async def _api(self) -> ResponseType:
match self.request:
case {'guild': str() as guild_id_str} if guild_id_str.isdecimal() and len(guild_id_str) < 100:
self.request.pop('guild')
return await (await self._guild_api(int(guild_id_str))).api()
case {'type': 'ping', 't': (float() | int()) as t}:
return time.time() - t
case {'type': 'guilds'}:
guilds = []
for guild in self.client.guilds:
if guild.get_member(self.user_id) is not None:
guilds.append(str(guild.id))
return guilds
case {'type': '?'}:
return 'this is user api'
case {'type': '*', 'requests': list() | dict() as requests}:
return await self.subs(requests)
case _:
raise Api.UnknownApi('unknown user api')
async def api(self):
try:
try:
return await self._api()
except Explicit as e:
raise Api.ExplicitFailure(e)
except Api.MisusedApi as e:
catches = self.request.get('catches', {})
if len(e.args) and (key := e.args[0]) in catches:
return catches[key]
if '*' in catches:
return e.json()
raise
class GuildApi(UserApi):
class VoiceNotConnected(Api.MisusedApi):
pass
def __init__(self, client: discord.Client, request: dict, member: discord.Member) -> None:
super().__init__(client, request, member.id)
self.member = member
self.guild = member.guild
async def voice_api(self) -> 'VoiceApi':
voice = self.member.voice
if voice is None:
raise GuildApi.VoiceNotConnected('you are not connected to voice')
channel = voice.channel
if channel is None:
raise GuildApi.VoiceNotConnected('you are not connected to a voice channel')
if self.client.user is None:
raise GuildApi.VoiceNotConnected('bot client user not initialised')
if self.client.user.id not in channel.voice_states:
raise GuildApi.VoiceNotConnected('bot not connected')
return VoiceApi(self.client, self.request, self.member, channel)
def sub(self, request: dict) -> 'GuildApi':
return GuildApi(self.client, request, self.member)
async def _api(self) -> ResponseType:
match self.request:
case {'voice': _}:
self.request.pop('voice')
return await (await self.voice_api()).api()
case {'type': '?'}:
return 'this is guild api'
case {'type': '*', 'requests': list() | dict() as requests}:
return await self.subs(requests)
case _:
raise Api.UnknownApi('unknown guild api')
class VoiceApi(GuildApi):
def __init__(
self, client: discord.Client, request: dict, member: discord.Member, channel: discord.VoiceChannel | discord.StageChannel
) -> None:
super().__init__(client, request, member)
self.channel = channel
async def _main_api(self) -> 'MainApi':
vc = await raw_vc_for_member(self.member)
main = await main_for_raw_vc(vc, create=False, force_play=False)
return MainApi(self.client, self.request, self.member, self.channel, vc, main)
def sub(self, request: dict) -> 'VoiceApi':
return VoiceApi(self.client, request, self.member, self.channel)
async def _api(self) -> ResponseType:
match self.request:
case {'main': _}:
self.request.pop('main')
return await (await self._main_api()).api()
case {'type': '?'}:
return 'this is voice api'
case {'type': '*', 'requests': list() | dict() as requests}:
return await self.subs(requests)
case _:
raise Api.UnknownApi('unknown voice api')
class MainApi(VoiceApi):
def __init__(
self, client: discord.Client, request: dict, member: discord.Member, channel: discord.VoiceChannel | discord.StageChannel,
vc: discord.VoiceClient, main: MainAudio
) -> None:
super().__init__(client, request, member, channel)
self.vc = vc
self.main = main
def sub(self, request: dict) -> 'MainApi':
return MainApi(self.client, request, self.member, self.channel, self.vc, self.main)
async def _api(self) -> ResponseType:
match self.request:
case {'type': 'volume'}:
return self.main.volume
case {'type': 'playing'}:
return self.vc.is_playing()
case {'type': 'queueformat'}:
return await self.main.queue.format()
case {'type': 'queuejson'}:
return await self.main.queue.pubjson(self.member)
case {'type': '?'}:
return 'this is main api'
case {'type': '*', 'requests': list() | dict() as requests}:
return await self.subs(requests)
case _:
raise Api.UnknownApi('unknown main api')

View File

@ -13,6 +13,7 @@ from v6d1tokens.client import request_token
from v6d3music.config import auth_redirect, myroot
from v6d3music.utils.bytes_hash import bytes_hash
from v6d3music.api import Api
session_db = Db(myroot / 'session.db', kvfactory=KVJson())
@ -23,14 +24,16 @@ class MusicAppFactory(AppFactory):
def __init__(
self,
secret: str,
client: discord.Client
client: discord.Client,
api: Api
):
self.secret = secret
self.redirect = auth_redirect
self.loop = asyncio.get_running_loop()
self.client = client
self._api = api
def auth_link(self):
def auth_link(self) -> str:
if self.client.user is None:
return ''
else:
@ -156,10 +159,12 @@ class MusicAppFactory(AppFactory):
return cid
@classmethod
def session_data(cls, session: str) -> dict:
def session_data(cls, session: str | None) -> dict:
if session is None:
return {}
data = session_db.get(session, {})
if not isinstance(data, dict):
data = {}
return {}
return data
def define_routes(self, routes: web.RouteTableDef) -> None:
@ -174,14 +179,11 @@ class MusicAppFactory(AppFactory):
@routes.get('/auth/')
async def auth(request: web.Request) -> web.Response:
if 'session' in request.query:
print(request.query.get('code'))
response = web.HTTPFound('/')
session = str(request.query.get('session'))
s_state = str(request.query.get('state'))
code = str(request.query.get('code'))
if bytes_hash(session.encode()) != s_state:
print(session)
print(bytes_hash(session.encode()), s_state)
raise web.HTTPBadRequest
data = self.session_data(session)
data['code'] = code
@ -224,10 +226,31 @@ class MusicAppFactory(AppFactory):
text=await self.file('main.css')
)
@routes.post('/api/')
async def api(request: web.Request) -> web.Response:
session = request.query.get('session')
data = self.session_data(session)
sclient = await self.session_client(data)
if sclient is None:
raise web.HTTPUnauthorized
user = self.client_user(sclient)
if user is None:
raise web.HTTPUnauthorized
user_id = self.user_id(user)
if user_id is None:
raise web.HTTPUnauthorized
user_id = int(user_id)
jsr = await request.json()
assert isinstance(jsr, dict)
try:
return web.json_response(await self._api.api(jsr, user_id))
except Api.MisusedApi as e:
return web.json_response(e.json(), status=404)
@classmethod
async def start(cls, client: discord.Client):
try:
factory = cls(await request_token('music-client', 'token'), client)
factory = cls(await request_token('music-client', 'token'), client, Api(client))
except aiohttp.ClientConnectorError:
print('no web app (likely due to no token)')
else:

View File

@ -7,13 +7,10 @@ from v6d3music.core.queueaudio import QueueAudio
mainasrcs: dict[discord.Guild, MainAudio] = {}
async def raw_vc_for(ctx: Context) -> discord.VoiceClient:
if ctx.guild is None:
raise Explicit('not in a guild')
assert ctx.member is not None
vc: discord.VoiceProtocol | None = ctx.guild.voice_client
async def raw_vc_for_member(member: discord.Member) -> discord.VoiceClient:
vc: discord.VoiceProtocol | None = member.guild.voice_client
if vc is None or isinstance(vc, discord.VoiceClient) and not vc.is_connected():
vs: discord.VoiceState | None = ctx.member.voice
vs: discord.VoiceState | None = member.voice
if vs is None:
raise Explicit('not connected')
vch: discord.VoiceChannel | None = vs.channel # type: ignore
@ -22,15 +19,21 @@ async def raw_vc_for(ctx: Context) -> discord.VoiceClient:
try:
vc = await vch.connect()
except discord.ClientException:
vc = ctx.guild.voice_client
vc = member.guild.voice_client
assert vc is not None
await ctx.guild.fetch_channels()
await member.guild.fetch_channels()
await vc.disconnect(force=True)
raise Explicit('try again later')
assert isinstance(vc, discord.VoiceClient)
return vc
async def raw_vc_for(ctx: Context) -> discord.VoiceClient:
if ctx.member is None:
raise Explicit('not in a guild')
return await raw_vc_for_member(ctx.member)
async def main_for_raw_vc(vc: discord.VoiceClient, *, create: bool, force_play: bool) -> MainAudio:
if vc.guild in mainasrcs:
source = mainasrcs[vc.guild]

View File

@ -135,3 +135,7 @@ class QueueAudio(discord.AudioSource):
audio.cleanup()
except ValueError:
pass
async def pubjson(self, member: discord.Member) -> list:
audios = list(self.queue)
return [await audio.pubjson(member) for audio in audios]

View File

@ -180,3 +180,12 @@ class YTAudio(discord.AudioSource):
print(f'regenerated {self.origin}')
finally:
self.regenerating = False
async def pubjson(self, member: discord.Member) -> dict:
return {
'seconds': self.source_seconds(),
'timecode': self.source_timecode(),
'duration': self.duration(),
'description': self.description,
'canbeskipped': self.can_be_skipped_by(member),
}

View File

@ -1,4 +1,4 @@
<link rel="stylesheet" href="/main.css">
<link rel="stylesheet" href="/main.css" />
<div id="root"></div>
<script src="/main.js"></script>
<script>

View File

@ -1,69 +1,66 @@
const genRanHex = size => [...Array(size)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
const genRanHex = (size) =>
[...Array(size)]
.map(() => Math.floor(Math.random() * 16).toString(16))
.join("");
const sessionStr = () => {
if (!localStorage.getItem('session'))
localStorage.setItem('session', genRanHex(64));
return localStorage.getItem('session');
if (!localStorage.getItem("session"))
localStorage.setItem("session", genRanHex(64));
return localStorage.getItem("session");
};
const sessionState = async () => {
const response = await fetch(
`/state/?session=${sessionStr()}`
);
const response = await fetch(`/state/?session=${sessionStr()}`);
return await response.json();
};
const sessionStatus = (
() => {
const sessionStatus = (() => {
let task;
return (async () => {
return async () => {
if (task === undefined) {
task = (async () => {
const response = await fetch(
`/status/?session=${sessionStr()}`
);
const response = await fetch(`/status/?session=${sessionStr()}`);
return await response.json();
})();
}
return await task;
})
}
)();
const root = document.querySelector('#root');
};
})();
const root = document.querySelector("#root");
const logEl = (msg) => {
const el = document.createElement('pre');
const el = document.createElement("pre");
el.innerText = msg;
root.append(el);
};
const sessionClient = async () => {
const session = await sessionStatus();
return session && session['client'];
return session && session["client"];
};
const sessionUser = async () => {
const client = await sessionClient();
return client && client['user'];
return client && client["user"];
};
const userAvatarUrl = async () => {
const user = await sessionUser();
return user && user['avatar'];
return user && user["avatar"];
};
const userUsername = async () => {
const user = await sessionUser();
return user && user['username'];
return user && user["username"];
};
const userAvatarImg = async () => {
const avatar = await userAvatarUrl();
if (avatar) {
const img = document.createElement('img');
const img = document.createElement("img");
img.src = avatar;
img.width = 64;
img.height = 64;
img.alt = await userUsername();
return img;
} else {
return baseEl('span');
return baseEl("span");
}
};
const userId = async () => {
const user = await sessionUser();
return user && user['id'];
return user && user["id"];
};
const baseEl = (tag, ...appended) => {
const element = document.createElement(tag);
@ -71,24 +68,122 @@ const baseEl = (tag, ...appended) => {
return element;
};
const aLogin = () => {
const a = document.createElement('a');
a.href = '/login/';
a.innerText = 'login';
const a = document.createElement("a");
a.href = "/login/";
a.innerText = "login";
return a;
};
const pageHome = async () => {
return baseEl(
'div',
baseEl('div', aLogin()),
baseEl('div', await userAvatarImg()),
baseEl('div', await userId()),
baseEl('div', await userUsername()),
)
"div",
baseEl("div", aLogin()),
baseEl("div", await userAvatarImg()),
baseEl("div", await userId()),
baseEl("div", await userUsername()),
baseEl("div", await aQueueWidget())
);
};
let authbase;
const aAuth = async () => {
const a = document.createElement('a');
a.href = authbase + '&state=' + await sessionState();
a.innerText = 'auth';
const a = document.createElement("a");
a.href = authbase + "&state=" + (await sessionState());
a.innerText = "auth";
return a;
};
const aApi = async (request) => {
const response = await fetch(`/api/?session=${sessionStr()}`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
return await response.json();
};
const aGuilds = async () => {
return await aApi({ type: "guilds" });
};
const aQueue = async () => {
const requests = {};
for (const guild of await aGuilds()) {
requests[guild] = {
type: "*",
guild,
voice: null,
main: null,
catches: { "you are not connected to voice": null, "*": null },
requests: { volume: {}, playing: {}, queueformat: {}, queuejson: {} },
};
}
const responses = await aApi({ type: "*", requests });
for (const [guild, response] of Object.entries(responses)) {
if (response !== null && response.error === undefined) {
response.guild = guild;
response.time = Date.now() / 1000;
response.delta = () => Date.now() / 1000 - response.time;
let index = 0;
for (const audio of response.queuejson) {
audio.playing = response.playing && index === 0;
audio.delta = () => (audio.playing ? response.delta() : 0);
audio.now = () => audio.seconds + audio.delta();
audio.ts = () => {
const seconds_total = Math.round(audio.now());
const seconds = seconds_total % 60;
const minutes_total = (seconds_total - seconds) / 60;
const minutes = minutes_total % 60;
const hours = (minutes_total - minutes) / 60;
return `${hours}:${("00" + minutes).slice(-2)}:${(
"00" + seconds
).slice(-2)}`;
};
index += 1;
}
return response;
}
}
return null;
};
const sleep = (s) => {
return new Promise((resolve) => setTimeout(resolve, 1000 * s));
};
const aUpdateAudioSchedule = async (timecode, audio, i, current_i) => {
while (i == current_i()) {
timecode.innerText = audio.ts();
await sleep(0.5);
}
};
const audioWidget = (audio, i, current_i) => {
const description = baseEl("span", audio.description);
const timecode = baseEl("span", audio.timecode);
const duration = baseEl("span", audio.duration);
aUpdateAudioSchedule(timecode, audio, i, current_i);
return baseEl("div", "audio", " ", timecode, "/", duration, " ", description);
};
const aUpdateQueueOnce = async (queue, el, i, current_i) => {
console.log(queue);
console.log(JSON.stringify(queue));
el.innerHTML = "";
if (queue !== null) {
for (const audio of queue.queuejson) {
el.append(audioWidget(audio, i, current_i));
}
}
};
const aUpdateQueueSetup = async (el) => {
let i = 0;
await aUpdateQueueOnce(await aQueue(), el, i, () => i);
(async () => {
while (true) {
const queue = await aQueue();
i += 1;
await aUpdateQueueOnce(queue, el, i, () => i);
await sleep(2);
}
})();
};
const aQueueWidget = async () => {
const el = baseEl("div");
if (await sessionUser()) await aUpdateQueueSetup(el);
return el;
};