From 9e688b76b46335402264038a90618d1073cf05a3 Mon Sep 17 00:00:00 2001 From: timofey Date: Mon, 27 Feb 2023 11:35:57 +0000 Subject: [PATCH] basic proxy implementation --- .dockerignore | 1 + .gitignore | 212 +++++++++++++++++++++++++++++++++++++++ Dockerfile | 13 +++ app/main.py | 126 +++++++++++++++++++++++ app/static/auth.html | 8 ++ app/static/home.html | 17 ++++ app/static/login.html | 12 +++ app/static/main.css | 50 +++++++++ app/static/main.js | 204 +++++++++++++++++++++++++++++++++++++ app/static/operator.css | 20 ++++ app/static/operator.html | 24 +++++ app/static/operator.js | 69 +++++++++++++ requirements.txt | 3 + 13 files changed, 759 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/main.py create mode 100644 app/static/auth.html create mode 100644 app/static/home.html create mode 100644 app/static/login.html create mode 100644 app/static/main.css create mode 100644 app/static/main.js create mode 100644 app/static/operator.css create mode 100644 app/static/operator.html create mode 100644 app/static/operator.js create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c1c9f4d --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.git* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38ccb19 --- /dev/null +++ b/.gitignore @@ -0,0 +1,212 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + + + +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0ebe7cd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11 + +WORKDIR /code/ + +COPY requirements.txt requirements.txt + +RUN pip install --no-cache-dir --upgrade -r requirements.txt + +COPY app app + +RUN python3 -m app.main + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..ab5dee7 --- /dev/null +++ b/app/main.py @@ -0,0 +1,126 @@ +import asyncio +from pathlib import Path +from typing import Any, Callable, Coroutine, TypeVar + +import aiohttp +from fastapi import FastAPI, HTTPException +from fastapi.responses import ( + FileResponse, + JSONResponse, + PlainTextResponse, + RedirectResponse, + Response, +) + +app = FastAPI() + +root = Path(__file__).parent / "static" + + +@app.get("/") +async def home(): + return FileResponse(root / "home.html") + + +@app.get("/operator/") +async def operatorhome(): + return FileResponse(root / "operator.html") + + +@app.get("/login/") +async def login(): + return FileResponse(root / "login.html") + + +base = "http://v6d3music/" + + +@app.get("/authlink/") +async def authlink(): + async with aiohttp.ClientSession() as s, s.get(f"{base}authlink/") as response: + return PlainTextResponse(await response.text()) + + +T = TypeVar("T") + + +async def url_get( + url: str, params: dict[str, str], handle: Callable[[aiohttp.ClientResponse], Coroutine[Any, Any, T]] +) -> T: + async with aiohttp.ClientSession() as s, s.get(url, params=params) as response: + return await handle(response) + + +async def url_post( + url: str, params: dict[str, str], json: dict, handle: Callable[[aiohttp.ClientResponse], Coroutine[Any, Any, T]] +) -> T: + async with aiohttp.ClientSession() as s, s.post(url, params=params, json=json) as response: + print(await response.read()) + return await handle(response) + + +async def repeat(repeated: Callable[[], Coroutine[Any, Any, T]]) -> T: + for _ in range(60): + try: + return await repeated() + except aiohttp.ClientConnectorError: + await asyncio.sleep(1) + raise HTTPException(504) + + +async def handle_auth(response: aiohttp.ClientResponse): + if 300 <= response.status <= 399: + return RedirectResponse("/") + else: + return Response(content=await response.read(), media_type=response.content_type, status_code=response.status) + + +@app.get("/auth/") +async def auth(session: str | None = None, state: str | None = None, code: str | None = None): + match session, state, code: + case str() as session, str() as state, str() as code: + params: dict[str, str] = {"session": session, "state": state, "code": code} + return await repeat(lambda: url_get(f"{base}auth/", params, handle_auth)) + case None, None, None: + return FileResponse(root / "auth.html") + case _: + raise HTTPException(400) + + +async def handle_json(response: aiohttp.ClientResponse): + return JSONResponse(await response.json()) + + +@app.get("/state/") +async def state(session: str): + return await repeat(lambda: url_get(f"{base}state/", {"session": session}, handle_json)) + + +@app.get("/status/") +async def status(session: str): + return await repeat(lambda: url_get(f"{base}status/", {"session": session}, handle_json)) + + +@app.get("/main.js") +async def mainjs(): + return FileResponse(root / "main.js") + + +@app.get("/operator.js") +async def operatorjs(): + return FileResponse(root / "operator.js") + + +@app.get("/main.css") +async def maincss(): + return FileResponse(root / "main.css") + + +@app.get("/operator.css") +async def operatorcss(): + return FileResponse(root / "operator.css") + + +@app.post("/api/") +async def api(json: dict, session: str): + return await repeat(lambda: url_post(f"{base}api/", {"session": session}, json, handle_json)) diff --git a/app/static/auth.html b/app/static/auth.html new file mode 100644 index 0000000..e9ee04f --- /dev/null +++ b/app/static/auth.html @@ -0,0 +1,8 @@ + +
+ + diff --git a/app/static/home.html b/app/static/home.html new file mode 100644 index 0000000..4fa9d5c --- /dev/null +++ b/app/static/home.html @@ -0,0 +1,17 @@ + + + + + +
+ +
+ +
+ + + diff --git a/app/static/login.html b/app/static/login.html new file mode 100644 index 0000000..43055a2 --- /dev/null +++ b/app/static/login.html @@ -0,0 +1,12 @@ + +
+ + diff --git a/app/static/main.css b/app/static/main.css new file mode 100644 index 0000000..1f4334f --- /dev/null +++ b/app/static/main.css @@ -0,0 +1,50 @@ +html, +body, +input { + color: white; + background: black; + margin: 0; +} + +::-webkit-scrollbar { + width: 1em; +} + +::-webkit-scrollbar-track { + background: #111; +} + +::-webkit-scrollbar-thumb { + background: #444; +} + +::-webkit-scrollbar-thumb:hover { + background: #555; +} + +html, +body, +#root-container { + height: 100%; +} + +#root-container { + display: flex; + height: 100%; +} + +#root { + width: 0%; + min-width: min(40em, 100%); + flex: auto; + overflow-y: scroll; +} + +.sidebars { + width: 100%; + background: #050505; +} + +#homeroot { + padding: 1em; +} diff --git a/app/static/main.js b/app/static/main.js new file mode 100644 index 0000000..6b1772b --- /dev/null +++ b/app/static/main.js @@ -0,0 +1,204 @@ +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"); +}; +const sessionState = async () => { + 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 root = document.querySelector("#root"); +const logEl = (msg) => { + const el = document.createElement("pre"); + el.innerText = msg; + root.append(el); +}; +const sessionClient = async () => { + const session = await sessionStatus(); + return session && session["client"]; +}; +const sessionUser = async () => { + const client = await sessionClient(); + return client && client["user"]; +}; +const userAvatarUrl = async () => { + const user = await sessionUser(); + return user && user["avatar"]; +}; +const userUsername = async () => { + 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 userId = async () => { + const user = await sessionUser(); + return user && user["id"]; +}; +const baseEl = (tag, ...appended) => { + 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 aAuthLink = async () => { + const response = await fetch("/authlink/"); + return await response.text(); +}; +const aAuth = async () => { + const a = document.createElement("a"); + const [authlink, sessionstate] = await Promise.all([ + aAuthLink(), + sessionState(), + ]); + a.href = authlink + "&state=" + 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 audioWidget = (audio) => { + const description = baseEl("span", audio.description); + const timecode = baseEl("span", audio.timecode); + const duration = baseEl("span", audio.duration); + audio.tce = timecode; + return baseEl("div", "audio", " ", timecode, "/", duration, " ", description); +}; +const aUpdateQueueOnce = async (queue, el) => { + el.innerHTML = ""; + if (queue !== null) { + for (const audio of queue.queuejson) { + el.append(audioWidget(audio)); + } + } +}; +const aUpdateQueueSetup = async (el) => { + let queue = await aQueue(); + await aUpdateQueueOnce(queue, el); + (async () => { + while (true) { + await sleep(2); + if (queue !== null && queue.queuejson.length > 100) { + await sleep((queue.queuejson.length - 100) / 200); + } + const newQueue = await aQueue(); + await aUpdateQueueOnce(newQueue, el); + queue = newQueue; + } + })(); + (async () => { + while (true) { + await sleep(0.25); + if (queue !== null) { + for (const audio of queue.queuejson) { + audio.tce.innerText = audio.ts(); + break; + } + } + } + })(); +}; +const aQueueWidget = async () => { + const el = baseEl("div"); + if (await sessionUser()) await aUpdateQueueSetup(el); + return el; +}; +const pageHome = async () => { + const el = document.createElement("div"); + el.append( + baseEl("div", aLogin()), + baseEl("div", await userAvatarImg()), + baseEl("div", await userId()), + baseEl("div", await userUsername()), + baseEl("div", await aQueueWidget()) + ); + el.id = "homeroot"; + return el; +}; diff --git a/app/static/operator.css b/app/static/operator.css new file mode 100644 index 0000000..021deaa --- /dev/null +++ b/app/static/operator.css @@ -0,0 +1,20 @@ +#operatorroot { + height: 10em; +} + +/* #operation { +} */ + +#workerpool { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 1em; + padding: 1em; + height: 5em; + overflow: hidden; +} + +.workerview { + background: #0f0f0f; + overflow: hidden; +} diff --git a/app/static/operator.html b/app/static/operator.html new file mode 100644 index 0000000..2ed4b38 --- /dev/null +++ b/app/static/operator.html @@ -0,0 +1,24 @@ + + + + + + +
+ +
+ +
+ + + + + diff --git a/app/static/operator.js b/app/static/operator.js new file mode 100644 index 0000000..8cd0193 --- /dev/null +++ b/app/static/operator.js @@ -0,0 +1,69 @@ +aApi({ + type: "guilds", + operator: null, + catches: { "not an operator": null, "*": null }, +}).then(console.log); +aApi({ + type: "sleep", + operator: null, + duration: 1, + echo: {}, + time: null, + catches: { "not an operator": null, "*": null }, +}).then(console.log); +aApi({ + type: "*", + idkey: "target", + idbase: { + type: "*", + requests: { + Count: {}, + Concurrency: {}, + }, + }, + operator: null, + requests: { + "v6d3music.api.Api().api": {}, + "v6d3music.processing.pool.UnitJob.run": {}, + }, + catches: { "not an operator": null, "*": null }, + time: null, +}).then((value) => console.log(JSON.stringify(value, undefined, 2))); +aApi({ + type: "pool", + operator: null, + catches: { "not an operator": null, "*": null }, +}).then((value) => console.log(JSON.stringify(value, undefined, 2))); +const elJob = (job) => { + const jobview = document.createElement("div"); + jobview.classList.add("jobview"); + jobview.innerText = JSON.stringify(job); + return jobview; +}; +const elWorker = (worker) => { + const workerview = document.createElement("div"); + workerview.classList.add("workerview"); + workerview.append(`qsize: ${worker.qsize}`); + workerview.append(elJob(worker.job)); + return workerview; +}; +const elPool = async () => { + const pool = document.createElement("div"); + pool.id = "workerpool"; + const workers = await aApi({ + type: "pool", + operator: null, + catches: { "not an operator": null, "*": null }, + }); + if (workers === null || workers.error !== undefined) return null; + for (const worker of workers) { + pool.append(elWorker(worker)); + } + return pool; +}; +const pageOperator = async () => { + const operation = document.createElement("div"); + operation.id = "operation"; + operation.append(await elPool()); + return operation; +}; diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..af33c76 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +aiohttp==3.8.4 +fastapi==0.92.0 +uvicorn[standard]==0.20.0