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