web api
This commit is contained in:
parent
38c86f22c0
commit
5d54f08c2b
199
v6d3music/api.py
Normal file
199
v6d3music/api.py
Normal 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')
|
@ -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:
|
||||
|
@ -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]
|
||||
|
@ -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]
|
||||
|
@ -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),
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user