From 5d54f08c2b49195c1ae03979031a295b4745bd1a Mon Sep 17 00:00:00 2001 From: timofey Date: Wed, 30 Nov 2022 01:52:15 +0000 Subject: [PATCH] web api --- v6d3music/api.py | 199 ++++++++++++++++++++++++++++++ v6d3music/app.py | 39 ++++-- v6d3music/core/mainasrc.py | 19 +-- v6d3music/core/queueaudio.py | 4 + v6d3music/core/ytaudio.py | 9 ++ v6d3music/html/home.html | 8 +- v6d3music/html/main.js | 227 +++++++++++++++++++++++++---------- 7 files changed, 419 insertions(+), 86 deletions(-) create mode 100644 v6d3music/api.py diff --git a/v6d3music/api.py b/v6d3music/api.py new file mode 100644 index 0000000..67d4bf2 --- /dev/null +++ b/v6d3music/api.py @@ -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') diff --git a/v6d3music/app.py b/v6d3music/app.py index 5f68c62..13af0d7 100644 --- a/v6d3music/app.py +++ b/v6d3music/app.py @@ -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: diff --git a/v6d3music/core/mainasrc.py b/v6d3music/core/mainasrc.py index 8125e76..69a8586 100644 --- a/v6d3music/core/mainasrc.py +++ b/v6d3music/core/mainasrc.py @@ -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] diff --git a/v6d3music/core/queueaudio.py b/v6d3music/core/queueaudio.py index 066ccd3..cd4113a 100644 --- a/v6d3music/core/queueaudio.py +++ b/v6d3music/core/queueaudio.py @@ -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] diff --git a/v6d3music/core/ytaudio.py b/v6d3music/core/ytaudio.py index 4643e49..c86173b 100644 --- a/v6d3music/core/ytaudio.py +++ b/v6d3music/core/ytaudio.py @@ -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), + } diff --git a/v6d3music/html/home.html b/v6d3music/html/home.html index 51ab999..5b19734 100644 --- a/v6d3music/html/home.html +++ b/v6d3music/html/home.html @@ -1,8 +1,8 @@ - +
diff --git a/v6d3music/html/main.js b/v6d3music/html/main.js index 609568e..88d8e7c 100644 --- a/v6d3music/html/main.js +++ b/v6d3music/html/main.js @@ -1,94 +1,189 @@ -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()}` - ); - return await response.json(); + const response = await fetch(`/state/?session=${sessionStr()}`); + return await response.json(); }; -const sessionStatus = ( - () => { - let task; - return (async () => { - if (task === undefined) { - task = (async () => { - const response = await fetch( - `/status/?session=${sessionStr()}` - ); - return await response.json(); - })(); - } - return await task; - }) +const sessionStatus = (() => { + let task; + return async () => { + if (task === undefined) { + task = (async () => { + const response = await fetch(`/status/?session=${sessionStr()}`); + return await response.json(); + })(); } -)(); -const root = document.querySelector('#root'); + return await task; + }; +})(); +const root = document.querySelector("#root"); const logEl = (msg) => { - const el = document.createElement('pre'); - el.innerText = msg; - root.append(el); + const el = document.createElement("pre"); + el.innerText = msg; + root.append(el); }; const sessionClient = async () => { - const session = await sessionStatus(); - return session && session['client']; + const session = await sessionStatus(); + return session && session["client"]; }; const sessionUser = async () => { - const client = await sessionClient(); - return client && client['user']; + const client = await sessionClient(); + return client && client["user"]; }; const userAvatarUrl = async () => { - const user = await sessionUser(); - return user && user['avatar']; + const user = await sessionUser(); + return user && user["avatar"]; }; const userUsername = async () => { - const user = await sessionUser(); - return user && user['username']; + const user = await sessionUser(); + return user && user["username"]; }; const userAvatarImg = async () => { - const avatar = await userAvatarUrl(); - if (avatar) { - const img = document.createElement('img'); - img.src = avatar; - img.width = 64; - img.height = 64; - img.alt = await userUsername(); - return img; - } else { - return baseEl('span'); - } + const avatar = await userAvatarUrl(); + if (avatar) { + const img = document.createElement("img"); + img.src = avatar; + img.width = 64; + img.height = 64; + img.alt = await userUsername(); + return img; + } else { + return baseEl("span"); + } }; const userId = async () => { - const user = await sessionUser(); - return user && user['id']; + const user = await sessionUser(); + return user && user["id"]; }; const baseEl = (tag, ...appended) => { - const element = document.createElement(tag); - element.append(...appended); - return element; + const element = document.createElement(tag); + element.append(...appended); + return element; }; const aLogin = () => { - const a = document.createElement('a'); - a.href = '/login/'; - a.innerText = 'login'; - return a; + 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()), - ) + return baseEl( + "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'; - return a; + 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; };