underrun detection + new architecture
This commit is contained in:
parent
b8b378a5bb
commit
4aa5a679c1
@ -9,8 +9,9 @@ COPY base.requirements.txt base.requirements.txt
|
||||
RUN pip install -r base.requirements.txt
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip install -r requirements.txt
|
||||
ENV v6port=80
|
||||
ENV v6host=0.0.0.0
|
||||
RUN mkdir ${v6root}
|
||||
COPY v6d3musicbase v6d3musicbase
|
||||
COPY v6d3music v6d3music
|
||||
RUN python3 -m v6d3music.main
|
||||
CMD ["python3", "-m", "v6d3music.run-bot"]
|
||||
|
@ -1,12 +1,18 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM python:3.10
|
||||
|
||||
FROM python:3.10 as compile-sphinx5.3.0-base
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y python3-sphinx node.js npm
|
||||
RUN npm install -g http-server
|
||||
RUN pip install pydata-sphinx-theme
|
||||
|
||||
RUN apt-get install -y python3-sphinx
|
||||
RUN pip install Sphinx==5.3.0 pydata-sphinx-theme==0.12.0
|
||||
WORKDIR /app/
|
||||
ENV v6root=/app/data/
|
||||
RUN mkdir ${v6root}
|
||||
|
||||
|
||||
FROM compile-sphinx5.3.0-base as compile-latest
|
||||
|
||||
COPY base.requirements.txt base.requirements.txt
|
||||
RUN pip install -r base.requirements.txt
|
||||
COPY requirements.txt requirements.txt
|
||||
@ -16,5 +22,11 @@ COPY v6d3music v6d3music
|
||||
COPY docs/source docs/source
|
||||
WORKDIR /app/docs/
|
||||
RUN make html
|
||||
|
||||
|
||||
FROM node:19
|
||||
|
||||
RUN npm install -g http-server
|
||||
WORKDIR /app/docs/build/html/
|
||||
COPY --from=compile-latest /app/docs/build/html/ /app/docs/build/html/
|
||||
CMD [ "http-server", "-p", "80" ]
|
||||
|
@ -1,4 +1,4 @@
|
||||
aiohttp>=3.7.4,<4
|
||||
discord.py[voice]~=2.1.0
|
||||
yt-dlp~=2022.11.11
|
||||
yt-dlp~=2023.2.17
|
||||
typing_extensions~=4.4.0
|
||||
|
@ -1,6 +1,6 @@
|
||||
ptvp35 @ git+https://gitea.parrrate.ru/PTV/ptvp35.git@e760fca39e2070b9959aeb95b53e59e895f1ad57
|
||||
v6d0auth @ git+https://gitea.parrrate.ru/PTV/v6d0auth.git@c718d4d1422945a756213d22d9e26aa24babe0f6
|
||||
v6d1tokens @ git+https://gitea.parrrate.ru/PTV/v6d1tokens.git@9ada50f111bd6e9a49c9c6683fa7504fee030056
|
||||
v6d2ctx @ git+https://gitea.parrrate.ru/PTV/v6d2ctx.git@18001ff3403646db46f36175a824e571c5734fd6
|
||||
v6d2ctx @ git+https://gitea.parrrate.ru/PTV/v6d2ctx.git@c9f3f5ac5c7feb2165fc4fae4eb998a0fe4f5f00
|
||||
rainbowadn @ git+https://gitea.parrrate.ru/PTV/rainbowadn.git@fc1d11f4b53ac4653ffac1bbcad130855e1b7f10
|
||||
adaas @ git+https://gitea.parrrate.ru/PTV/adaas.git@0c7f974ec4955204b35f463749df138663c98550
|
||||
adaas @ git+https://gitea.parrrate.ru/PTV/adaas.git@8093665489901098f92d5a4001f1782dab6ddcf9
|
||||
|
2
setup.py
2
setup.py
@ -3,7 +3,7 @@ from setuptools import setup
|
||||
setup(
|
||||
name='v6d3music',
|
||||
version='',
|
||||
packages=['v6d3music', 'v6d3musicbase'],
|
||||
packages=['v6d3music'],
|
||||
url='',
|
||||
license='',
|
||||
author='PARRRATE T&V',
|
||||
|
@ -3,8 +3,8 @@ import time
|
||||
|
||||
import discord
|
||||
from typing_extensions import Self
|
||||
from v6d3musicbase.responsetype import *
|
||||
from v6d3musicbase.targets import *
|
||||
from v6d2ctx.integration.responsetype import *
|
||||
from v6d2ctx.integration.targets import *
|
||||
|
||||
from rainbowadn.instrument import Instrumentation
|
||||
from v6d2ctx.context import *
|
||||
|
@ -56,8 +56,6 @@ class CachedDictionary(Generic[TKey, T]):
|
||||
|
||||
|
||||
class MusicAppFactory(AppFactory):
|
||||
htmlroot = Path(__file__).parent / 'html'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
secret: str,
|
||||
@ -81,13 +79,6 @@ class MusicAppFactory(AppFactory):
|
||||
return f'https://discord.com/api/oauth2/authorize?client_id={client_id}' \
|
||||
f'&redirect_uri={urllib.parse.quote(self.redirect)}&response_type=code&scope=identify'
|
||||
|
||||
def _path(self, file: str):
|
||||
return self.htmlroot / file
|
||||
|
||||
def _file(self, file: str):
|
||||
with open(self.htmlroot / file) as f:
|
||||
return f.read()
|
||||
|
||||
async def code_token(self, code: str) -> dict:
|
||||
client_id = self._api.user_id()
|
||||
assert client_id is not None
|
||||
@ -199,38 +190,26 @@ class MusicAppFactory(AppFactory):
|
||||
return data
|
||||
|
||||
def define_routes(self, routes: web.RouteTableDef) -> None:
|
||||
@routes.get('/')
|
||||
async def home(_request: web.Request) -> web.StreamResponse:
|
||||
return web.FileResponse(self._path('home.html'))
|
||||
|
||||
@routes.get('/operator/')
|
||||
async def operatorhome(_request: web.Request) -> web.StreamResponse:
|
||||
return web.FileResponse(self._path('operator.html'))
|
||||
|
||||
@routes.get('/login/')
|
||||
async def login(_request: web.Request) -> web.StreamResponse:
|
||||
return web.FileResponse(self._path('login.html'))
|
||||
|
||||
@routes.get('/authlink/')
|
||||
async def authlink(_request: web.Request) -> web.StreamResponse:
|
||||
return web.Response(text=self.auth_link())
|
||||
|
||||
@routes.get('/auth/')
|
||||
async def auth(request: web.Request) -> web.StreamResponse:
|
||||
if 'session' in request.query:
|
||||
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:
|
||||
session = request.query.get('session')
|
||||
state = request.query.get('state')
|
||||
code = request.query.get('code')
|
||||
match session, state, code:
|
||||
case str() as session, str() as state, str() as code:
|
||||
if bytes_hash(session.encode()) != state:
|
||||
raise web.HTTPBadRequest
|
||||
data = self.session_data(session)
|
||||
data['code'] = code
|
||||
data['token'] = await self.code_token(code)
|
||||
await self.db.set(session, data)
|
||||
return web.HTTPFound('/')
|
||||
case _:
|
||||
raise web.HTTPBadRequest
|
||||
data = self.session_data(session)
|
||||
data['code'] = code
|
||||
data['token'] = await self.code_token(code)
|
||||
await self.db.set(session, data)
|
||||
return response
|
||||
else:
|
||||
return web.FileResponse(self._path('auth.html'))
|
||||
|
||||
@routes.get('/state/')
|
||||
async def get_state(request: web.Request) -> web.Response:
|
||||
@ -246,29 +225,6 @@ class MusicAppFactory(AppFactory):
|
||||
data=await self.session_status(session)
|
||||
)
|
||||
|
||||
@routes.get('/queue/')
|
||||
async def api_queue(request: web.Request) -> web.Response:
|
||||
session = str(request.query.get('session'))
|
||||
return web.json_response(
|
||||
data=await self.session_queue(session)
|
||||
)
|
||||
|
||||
@routes.get('/main.js')
|
||||
async def mainjs(_request: web.Request) -> web.StreamResponse:
|
||||
return web.FileResponse(self._path('main.js'))
|
||||
|
||||
@routes.get('/operator.js')
|
||||
async def operatorjs(_request: web.Request) -> web.StreamResponse:
|
||||
return web.FileResponse(self._path('operator.js'))
|
||||
|
||||
@routes.get('/main.css')
|
||||
async def maincss(_request: web.Request) -> web.StreamResponse:
|
||||
return web.FileResponse(self._path('main.css'))
|
||||
|
||||
@routes.get('/operator.css')
|
||||
async def operatorcss(_request: web.Request) -> web.StreamResponse:
|
||||
return web.FileResponse(self._path('operator.css'))
|
||||
|
||||
@routes.post('/api/')
|
||||
async def api(request: web.Request) -> web.Response:
|
||||
session = request.query.get('session')
|
||||
|
@ -8,9 +8,10 @@ from v6d3music.core.mainservice import *
|
||||
from v6d3music.utils.assert_admin import *
|
||||
from v6d3music.utils.catch import *
|
||||
from v6d3music.utils.effects_for_preset import *
|
||||
from v6d3music.utils.options_for_effects import *
|
||||
from v6d3music.utils.presets import *
|
||||
|
||||
import discord
|
||||
|
||||
__all__ = ('get_of',)
|
||||
|
||||
|
||||
@ -40,12 +41,23 @@ presets: {shlex.join(allowed_presets)}
|
||||
''',
|
||||
(), 'help'
|
||||
)
|
||||
match args:
|
||||
case ['this', *args]:
|
||||
reference = ctx.message.reference
|
||||
if reference is None:
|
||||
raise Explicit('use reply')
|
||||
resolved = reference.resolved
|
||||
if not isinstance(resolved, discord.Message):
|
||||
raise Explicit('reference message is either deleted or cannot be found')
|
||||
attachments = resolved.attachments
|
||||
case [*args]:
|
||||
attachments = ctx.message.attachments
|
||||
case _:
|
||||
raise RuntimeError
|
||||
async with mainservice.lock_for(ctx.guild):
|
||||
queue = await mainservice.context(ctx, create=True, force_play=False).queue()
|
||||
if ctx.message.attachments:
|
||||
if len(ctx.message.attachments) > 1:
|
||||
raise Explicit('no more than one attachment')
|
||||
args = [ctx.message.attachments[0].url] + args
|
||||
if attachments:
|
||||
args = ['[[', *(attachment.url for attachment in attachments), ']]'] + args
|
||||
async for audio in mainservice.yt_audios(ctx, args):
|
||||
queue.append(audio)
|
||||
await ctx.reply('done')
|
||||
@ -69,9 +81,7 @@ presets: {shlex.join(allowed_presets)}
|
||||
case [pos0, pos1] if pos0.isdecimal() and pos1.isdecimal():
|
||||
pos0, pos1 = int(pos0), int(pos1)
|
||||
queue = await mainservice.context(ctx, create=False, force_play=False).queue()
|
||||
for _ in range(pos0, pos1 + 1):
|
||||
if not queue.skip_at(pos0, ctx.member):
|
||||
pos0 += 1
|
||||
queue.skip_between(pos0, pos1, ctx.member)
|
||||
case _:
|
||||
raise Explicit('misformatted')
|
||||
await ctx.reply('done')
|
||||
@ -120,10 +130,7 @@ presets: {shlex.join(allowed_presets)}
|
||||
raise Explicit('misformatted')
|
||||
assert_admin(ctx.member)
|
||||
queue = await mainservice.context(ctx, create=False, force_play=False).queue()
|
||||
yta = queue.queue[0]
|
||||
seconds = yta.source_seconds()
|
||||
yta.options = options_for_effects(effects)
|
||||
yta.set_seconds(seconds)
|
||||
queue.queue[0].set_effects(effects)
|
||||
|
||||
@at('default')
|
||||
async def default(ctx: Context, args: list[str]) -> None:
|
||||
@ -154,7 +161,7 @@ presets: {shlex.join(allowed_presets)}
|
||||
@at('repeat')
|
||||
async def repeat(ctx: Context, args: list[str]):
|
||||
match args:
|
||||
case [n_, *args] if n_.isdecimal():
|
||||
case ['x', n_, *args] if n_.isdecimal():
|
||||
n = int(n_)
|
||||
case [*args]:
|
||||
n = 1
|
||||
@ -206,16 +213,7 @@ presets: {shlex.join(allowed_presets)}
|
||||
raise Explicit('misformatted')
|
||||
assert_admin(ctx.member)
|
||||
queue = await mainservice.context(ctx, create=False, force_play=False).queue()
|
||||
if not queue.queue:
|
||||
raise Explicit('empty queue')
|
||||
audio = queue.queue[0].branch()
|
||||
if effects is not None:
|
||||
seconds = audio.source_seconds()
|
||||
audio.options = options_for_effects(effects or None)
|
||||
audio.set_seconds(seconds)
|
||||
else:
|
||||
audio.set_source()
|
||||
queue.queue.insert(1, audio)
|
||||
queue.branch(effects)
|
||||
|
||||
@at('//')
|
||||
@at('queue')
|
||||
@ -321,4 +319,13 @@ presets: {shlex.join(allowed_presets)}
|
||||
vc = await mainservice.context(ctx, create=False, force_play=True).vc()
|
||||
vc.resume()
|
||||
|
||||
@at('leave')
|
||||
async def leave(ctx: Context, _args: list[str]) -> None:
|
||||
async with mainservice.lock_for(ctx.guild):
|
||||
vc, main = await mainservice.context(ctx, create=False, force_play=False).vc_main()
|
||||
queue = main.queue
|
||||
if queue.queue:
|
||||
raise Explicit('queue not empty')
|
||||
await vc.disconnect()
|
||||
|
||||
return of
|
||||
|
@ -4,9 +4,9 @@ from contextlib import AsyncExitStack
|
||||
from typing import AsyncIterable, TypeVar
|
||||
|
||||
import discord
|
||||
from v6d3musicbase.event import *
|
||||
from v6d3musicbase.responsetype import *
|
||||
from v6d3musicbase.targets import *
|
||||
from v6d2ctx.integration.event import *
|
||||
from v6d2ctx.integration.responsetype import *
|
||||
from v6d2ctx.integration.targets import *
|
||||
|
||||
import v6d3music.processing.pool
|
||||
from ptvp35 import *
|
||||
@ -72,7 +72,7 @@ class MainService:
|
||||
@staticmethod
|
||||
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():
|
||||
if vc is None or vc.channel is None or isinstance(vc, discord.VoiceClient) and not vc.is_connected():
|
||||
vs: discord.VoiceState | None = member.voice
|
||||
if vs is None:
|
||||
raise Explicit('not connected')
|
||||
|
@ -94,6 +94,11 @@ class QueueAudio(discord.AudioSource):
|
||||
return True
|
||||
return False
|
||||
|
||||
def skip_between(self, pos0: int, pos1: int, member: discord.Member) -> None:
|
||||
for _ in range(pos0, min(pos1 + 1, len(self.queue))):
|
||||
if not self.skip_at(pos0, member):
|
||||
pos0 += 1
|
||||
|
||||
def skip_audio(self, audio: YTAudio, member: discord.Member) -> bool:
|
||||
if audio in self.queue:
|
||||
if audio.can_be_skipped_by(member):
|
||||
@ -188,6 +193,16 @@ class QueueAudio(discord.AudioSource):
|
||||
print_exc()
|
||||
self.update_sources()
|
||||
|
||||
def branch(self, effects: str | None) -> None:
|
||||
if not self.queue:
|
||||
raise Explicit('empty queue')
|
||||
audio = self.queue[0].branch()
|
||||
if effects is not None:
|
||||
audio.set_effects(effects or None)
|
||||
else:
|
||||
audio.set_source()
|
||||
self.queue.insert(1, audio)
|
||||
|
||||
|
||||
class ForwardView(MutableSequence[YTAudio]):
|
||||
def __init__(self, sequence: MutableSequence[YTAudio]) -> None:
|
||||
|
@ -3,7 +3,7 @@ from collections import deque
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import AsyncIterable, Iterable
|
||||
|
||||
from v6d3musicbase.responsetype import *
|
||||
from v6d2ctx.integration.responsetype import *
|
||||
|
||||
from v6d2ctx.context import *
|
||||
from v6d3music.core.create_ytaudio import *
|
||||
|
@ -1,5 +1,7 @@
|
||||
import asyncio
|
||||
import random
|
||||
import re
|
||||
import traceback
|
||||
from typing import Optional
|
||||
|
||||
import discord
|
||||
@ -10,6 +12,7 @@ from v6d3music.core.real_url import *
|
||||
from v6d3music.core.ytaservicing import *
|
||||
from v6d3music.processing.abstractrunner import *
|
||||
from v6d3music.utils.fill import *
|
||||
from v6d3music.utils.options_for_effects import *
|
||||
from v6d3music.utils.sparq import *
|
||||
from v6d3music.utils.tor_prefix import *
|
||||
|
||||
@ -47,6 +50,7 @@ class YTAudio(discord.AudioSource):
|
||||
self._duration_lock = asyncio.Lock()
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.stop_at: int | None = stop_at
|
||||
self.attempts = 0
|
||||
|
||||
def _reduced_durations(self) -> dict[str, str]:
|
||||
return {url: duration for url, duration in self._durations.items() if url == self.url}
|
||||
@ -71,6 +75,11 @@ class YTAudio(discord.AudioSource):
|
||||
def set_seconds(self, seconds: float):
|
||||
self.set_already_read(round(seconds / sparq(self.options)))
|
||||
|
||||
def set_effects(self, effects: str | None) -> None:
|
||||
seconds = self.source_seconds()
|
||||
self.options = options_for_effects(effects or None)
|
||||
self.set_seconds(seconds)
|
||||
|
||||
def source_seconds(self) -> float:
|
||||
return self.already_read * sparq(self.options)
|
||||
|
||||
@ -139,6 +148,24 @@ class YTAudio(discord.AudioSource):
|
||||
)
|
||||
return before_options
|
||||
|
||||
def estimated_seconds_duration(self) -> float:
|
||||
duration = self.duration()
|
||||
_m = re.match(r'(\d+):(\d+):(\d+)', duration)
|
||||
if _m is None:
|
||||
return 0.0
|
||||
else:
|
||||
try:
|
||||
hs, ms, ss = _m.groups()
|
||||
h, m, s = int(hs), int(ms), int(ss)
|
||||
return float(h * 3600 + m * 60 + s)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return 0.0
|
||||
|
||||
def underran(self) -> bool:
|
||||
to_end = self.estimated_seconds_duration() - self.source_seconds()
|
||||
return to_end > 1.0
|
||||
|
||||
def read(self) -> bytes:
|
||||
if self.regenerating:
|
||||
return FILL
|
||||
@ -146,10 +173,15 @@ class YTAudio(discord.AudioSource):
|
||||
return b''
|
||||
self.already_read += 1
|
||||
ret: bytes = self.source.read()
|
||||
if not ret and not self.source.droppable():
|
||||
if random.random() > .1:
|
||||
if not ret and (not (droppable := self.source.droppable()) or self.underran()):
|
||||
if self.attempts < 5 or random.random() > .1:
|
||||
self.attempts += 1
|
||||
self.regenerating = True
|
||||
self.loop.create_task(self.regenerate())
|
||||
self.loop.create_task(
|
||||
self.regenerate(
|
||||
'underran' if droppable else 'not droppable'
|
||||
)
|
||||
)
|
||||
return FILL
|
||||
else:
|
||||
print(f'dropped {self.origin}')
|
||||
@ -216,9 +248,9 @@ class YTAudio(discord.AudioSource):
|
||||
audio._durations |= respawn.get('durations', {})
|
||||
return audio
|
||||
|
||||
async def regenerate(self):
|
||||
async def regenerate(self, reason: str):
|
||||
try:
|
||||
print(f'regenerating {self.origin}')
|
||||
print(f'regenerating {self.origin} {reason=}')
|
||||
self.url = await real_url(self.servicing.caching, self.origin, True, self.tor)
|
||||
if hasattr(self, 'source'):
|
||||
self.source.cleanup()
|
||||
|
@ -1,8 +0,0 @@
|
||||
<link rel="stylesheet" href="/main.css">
|
||||
<div id="root"></div>
|
||||
<script src="/main.js"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
window.location = window.location + `&session=${sessionStr()}`;
|
||||
})();
|
||||
</script>
|
@ -1,17 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="/main.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root-container">
|
||||
<div class="sidebars"></div>
|
||||
<div id="root"></div>
|
||||
<div class="sidebars"></div>
|
||||
</div>
|
||||
<script src="/main.js"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
root.append(await pageHome());
|
||||
})();
|
||||
</script>
|
||||
</body>
|
@ -1,12 +0,0 @@
|
||||
<link rel="stylesheet" href="/main.css">
|
||||
<div id="root"></div>
|
||||
<script src="/main.js"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
const a = await aAuth();
|
||||
root.append(a);
|
||||
logEl(sessionStr());
|
||||
logEl(await sessionState());
|
||||
a.click();
|
||||
})();
|
||||
</script>
|
@ -1,50 +0,0 @@
|
||||
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: auto;
|
||||
min-width: min(40em, 100%);
|
||||
flex: auto;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.sidebars {
|
||||
width: 100%;
|
||||
background: #050505;
|
||||
}
|
||||
|
||||
#homeroot {
|
||||
padding: 1em;
|
||||
}
|
@ -1,204 +0,0 @@
|
||||
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;
|
||||
};
|
@ -1,22 +0,0 @@
|
||||
#operatorroot {
|
||||
height: 10em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#operation {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#workerpool {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 1em;
|
||||
padding: 1em;
|
||||
height: 5em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workerview {
|
||||
background: #0f0f0f;
|
||||
overflow: hidden;
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="/main.css" />
|
||||
<link rel="stylesheet" href="/operator.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root-container">
|
||||
<div class="sidebars"></div>
|
||||
<div id="root"><div id="operatorroot"></div></div>
|
||||
<div class="sidebars"></div>
|
||||
</div>
|
||||
<script src="/main.js"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
root.append(await pageHome());
|
||||
})();
|
||||
</script>
|
||||
<script src="/operator.js"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
operatorroot.append(await pageOperator());
|
||||
})();
|
||||
</script>
|
||||
</body>
|
@ -1,69 +0,0 @@
|
||||
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(elJob(worker.job));
|
||||
workerview.append(`qsize: ${worker.qsize}`);
|
||||
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;
|
||||
};
|
@ -6,8 +6,8 @@ import time
|
||||
from traceback import print_exc
|
||||
|
||||
import discord
|
||||
from v6d3musicbase.event import *
|
||||
from v6d3musicbase.targets import *
|
||||
from v6d2ctx.integration.event import *
|
||||
from v6d2ctx.integration.targets import *
|
||||
|
||||
from ptvp35 import *
|
||||
from rainbowadn.instrument import Instrumentation
|
||||
|
@ -3,8 +3,8 @@ __all__ = ('AbstractRunner', 'CoroEvent', 'CoroContext', 'CoroStatusChanged')
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Callable, Coroutine, TypeVar
|
||||
|
||||
from v6d3musicbase.event import *
|
||||
from v6d3musicbase.responsetype import *
|
||||
from v6d2ctx.integration.event import *
|
||||
from v6d2ctx.integration.responsetype import *
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
@ -3,8 +3,8 @@ __all__ = ('Job', 'Pool', 'JobUnit', 'JobContext', 'JobStatusChanged', 'PoolEven
|
||||
import asyncio
|
||||
from typing import Any, Callable, Coroutine, Generic, TypeVar, Union
|
||||
|
||||
from v6d3musicbase.event import *
|
||||
from v6d3musicbase.responsetype import *
|
||||
from v6d2ctx.integration.event import *
|
||||
from v6d2ctx.integration.responsetype import *
|
||||
|
||||
from .abstractrunner import *
|
||||
|
||||
|
@ -12,15 +12,26 @@ from v6d3music.utils.sparq import *
|
||||
__all__ = ('InfoCtx', 'BoundCtx', 'UrlCtx', 'ArgCtx',)
|
||||
|
||||
|
||||
class PostCtx:
|
||||
def __init__(
|
||||
self, effects: str | None
|
||||
) -> None:
|
||||
self.effects: str | None = effects
|
||||
self.already_read: int = 0
|
||||
self.tor: bool = False
|
||||
self.ignore: bool = False
|
||||
|
||||
|
||||
class InfoCtx:
|
||||
def __init__(
|
||||
self, info: dict[str, Any], effects: str | None, already_read: int, tor: bool, ignore: bool
|
||||
self, info: dict[str, Any], post: PostCtx
|
||||
) -> None:
|
||||
self.info = info
|
||||
self.effects = effects
|
||||
self.already_read = already_read
|
||||
self.tor = tor
|
||||
self.ignore = ignore
|
||||
self.post = post
|
||||
self.effects = post.effects
|
||||
self.already_read = post.already_read
|
||||
self.tor = post.tor
|
||||
self.ignore = post.ignore
|
||||
|
||||
def bind(self, ctx: Context) -> 'BoundCtx':
|
||||
return BoundCtx(self, ctx)
|
||||
@ -52,17 +63,18 @@ class BoundCtx:
|
||||
|
||||
|
||||
class UrlCtx:
|
||||
def __init__(self, url: str, effects: str | None) -> None:
|
||||
def __init__(self, url: str, post: PostCtx) -> None:
|
||||
self.url = url
|
||||
self.effects = effects
|
||||
self.already_read = 0
|
||||
self.tor = False
|
||||
self.ignore = False
|
||||
self.post = post
|
||||
self.effects: str | None = post.effects
|
||||
self.already_read = post.already_read
|
||||
self.tor = post.tor
|
||||
self.ignore = post.ignore
|
||||
|
||||
async def entries(self) -> AsyncIterable[InfoCtx]:
|
||||
try:
|
||||
async for info in entries_for_url(self.url, self.tor):
|
||||
yield InfoCtx(info, self.effects, self.already_read, self.tor, self.ignore)
|
||||
yield InfoCtx(info, self.post)
|
||||
except Exception:
|
||||
if not self.ignore:
|
||||
raise
|
||||
@ -73,22 +85,38 @@ class ArgCtx:
|
||||
self.sources: list[UrlCtx] = []
|
||||
while args:
|
||||
match args:
|
||||
case [url, '-', effects, *args]:
|
||||
case ['[[', *args]:
|
||||
try:
|
||||
close_ix = args.index(']]')
|
||||
except ValueError:
|
||||
raise Explicit('expected closing `]]`, not found')
|
||||
urls = args[:close_ix]
|
||||
args = args[close_ix + 1:]
|
||||
case [']]', *args]:
|
||||
raise Explicit('unexpected `]]`')
|
||||
case [_url, *args]:
|
||||
urls = [_url]
|
||||
case _:
|
||||
raise RuntimeError
|
||||
for url in urls:
|
||||
if url in presets:
|
||||
raise Explicit('expected url, got preset. maybe you are missing `+`?')
|
||||
if url in {'+', '-'}:
|
||||
raise Explicit('expected url, got `+` or `-`. maybe you tried to use multiple effects?')
|
||||
if url.startswith('+') or url.startswith('-"') or url.startswith('-\''):
|
||||
raise Explicit(
|
||||
'expected url, got `+` or `-"` or `-\'`. maybe you forgot to separate control symbol from the effects?'
|
||||
)
|
||||
match args:
|
||||
case ['-', effects, *args]:
|
||||
pass
|
||||
case [url, '+', preset, *args]:
|
||||
case ['+', preset, *args]:
|
||||
effects = effects_for_preset(preset)
|
||||
case [url, *args]:
|
||||
case [*args]:
|
||||
effects = default_effects
|
||||
case _:
|
||||
raise RuntimeError
|
||||
if url in presets:
|
||||
raise Explicit('expected url, got preset. maybe you are missing `+`?')
|
||||
if url in {'+', '-'}:
|
||||
raise Explicit('expected url, got `+` or `-`. maybe you tried to use multiple effects?')
|
||||
if url.startswith('+') or url.startswith('-"') or url.startswith('-\''):
|
||||
raise Explicit('expected url, got `+` or `-"` or `-\'`. maybe you forgot to separate control symbol from the effects?')
|
||||
ctx = UrlCtx(url, effects)
|
||||
seconds = 0
|
||||
post = PostCtx(effects)
|
||||
match args:
|
||||
case [h, m, s, *args] if h.isdecimal() and m.isdecimal() and s.isdecimal():
|
||||
seconds = 3600 * int(h) + 60 * int(m) + int(s)
|
||||
@ -97,18 +125,19 @@ class ArgCtx:
|
||||
case [s, *args] if s.isdecimal():
|
||||
seconds = int(s)
|
||||
case [*args]:
|
||||
pass
|
||||
ctx.already_read = round(seconds / sparq(options_for_effects(effects)))
|
||||
seconds = 0
|
||||
post.already_read = round(seconds / sparq(options_for_effects(effects)))
|
||||
while True:
|
||||
match args:
|
||||
case ['tor', *args]:
|
||||
if ctx.tor:
|
||||
if post.tor:
|
||||
raise Explicit('duplicate tor')
|
||||
ctx.tor = True
|
||||
post.tor = True
|
||||
case ['ignore', *args]:
|
||||
if ctx.ignore:
|
||||
if post.ignore:
|
||||
raise Explicit('duplicate ignore')
|
||||
ctx.ignore = True
|
||||
post.ignore = True
|
||||
case [*args]:
|
||||
break
|
||||
self.sources.append(ctx)
|
||||
for url in urls:
|
||||
self.sources.append(UrlCtx(url, post))
|
||||
|
@ -1,60 +0,0 @@
|
||||
__all__ = ('Event', 'SendableEvents', 'ReceivableEvents', 'Events', 'Receiver')
|
||||
|
||||
import asyncio
|
||||
from typing import Callable, Generic, TypeVar
|
||||
|
||||
from typing_extensions import Self
|
||||
|
||||
from .responsetype import ResponseType
|
||||
|
||||
|
||||
class Event:
|
||||
def json(self) -> ResponseType:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
T = TypeVar('T', bound=Event)
|
||||
T_co = TypeVar('T_co', bound=Event, covariant=True)
|
||||
T_contra = TypeVar('T_contra', bound=Event, contravariant=True)
|
||||
|
||||
|
||||
class Receiver(Generic[T_contra]):
|
||||
def __init__(self, receive: Callable[[T_contra], None], receivers: set[Self], /) -> None:
|
||||
self.__receive = receive
|
||||
self.__receivers = receivers
|
||||
self.__receiving = False
|
||||
|
||||
def __enter__(self) -> None:
|
||||
self.__receivers.add(self)
|
||||
self.__receiving = True
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.__receiving = False
|
||||
self.__receivers.remove(self)
|
||||
|
||||
def receive(self, event: T_contra, /) -> None:
|
||||
if self.__receiving:
|
||||
self.__receive(event)
|
||||
|
||||
|
||||
class SendableEvents(Generic[T_contra]):
|
||||
def send(self, event: T_contra, /) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ReceivableEvents(Generic[T_co]):
|
||||
def receive(self, receive: Callable[[T_co], None], /) -> Receiver[T_co]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Events(Generic[T], SendableEvents[T], ReceivableEvents[T]):
|
||||
def __init__(self) -> None:
|
||||
self.__receivers: set[Receiver[T]] = set()
|
||||
self.__loop = asyncio.get_running_loop()
|
||||
|
||||
def send(self, event: T, /) -> None:
|
||||
for receiver in self.__receivers:
|
||||
self.__loop.call_soon(receiver.receive, event)
|
||||
|
||||
def receive(self, receive: Callable[[T], None], /) -> Receiver[T]:
|
||||
return Receiver(receive, self.__receivers)
|
@ -1,17 +0,0 @@
|
||||
__all__ = ('ResponseType', 'cast_to_response')
|
||||
|
||||
from typing import Any, TypeAlias
|
||||
|
||||
ResponseType: TypeAlias = list['ResponseType'] | dict[str, 'ResponseType'] | float | int | bool | str | None
|
||||
|
||||
|
||||
def cast_to_response(target: Any) -> ResponseType:
|
||||
match target:
|
||||
case str() | int() | float() | bool() | None:
|
||||
return target
|
||||
case list() | tuple():
|
||||
return list(map(cast_to_response, target))
|
||||
case dict():
|
||||
return {str(key): cast_to_response(value) for key, value in target.items()}
|
||||
case _:
|
||||
return str(target)
|
@ -1,76 +0,0 @@
|
||||
__all__ = ('Targets', 'JsonLike', 'Async')
|
||||
|
||||
import abc
|
||||
from typing import Any, Callable, Generic, TypeVar
|
||||
|
||||
from rainbowadn.instrument import Instrumentation
|
||||
|
||||
from .responsetype import *
|
||||
|
||||
|
||||
def qualname(t: type) -> str:
|
||||
return f'{t.__module__}.{t.__qualname__}'
|
||||
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class Flagful(Generic[T]):
|
||||
def __init__(self, value: T, flags: set[object]) -> None:
|
||||
self.value = value
|
||||
self.flags = flags
|
||||
|
||||
|
||||
class Targets:
|
||||
def __init__(self) -> None:
|
||||
self.targets: dict[str, Flagful[tuple[Any, str]]] = {}
|
||||
self.instrumentations: dict[str, Flagful[Callable[[Any, str], Instrumentation]]] = {}
|
||||
self.factories: dict[tuple[str, str], Callable[[], Instrumentation]] = {}
|
||||
|
||||
def register_target(self, targetname: str, target: Any, methodname: str, /, *flags: object) -> None:
|
||||
self.targets[targetname] = Flagful((target, methodname), set(flags))
|
||||
print(f'registered target: {targetname}')
|
||||
|
||||
def register_type(self, target: type, methodname: str, /, *flags: object) -> None:
|
||||
self.register_target(f'{qualname(target)}.{methodname}', target, methodname, *flags)
|
||||
|
||||
def register_instance(self, target: object, methodname: str, /, *flags: object) -> None:
|
||||
self.register_target(f'{qualname(target.__class__)}().{methodname}', target, methodname, *flags)
|
||||
|
||||
def register_instrumentation(
|
||||
self,
|
||||
instrumentationname: str,
|
||||
instrumentation_factory: Callable[[Any, str], Instrumentation],
|
||||
/,
|
||||
*flags: object,
|
||||
) -> None:
|
||||
self.instrumentations[instrumentationname] = Flagful(instrumentation_factory, set(flags))
|
||||
print(f'registered instrumentation: {instrumentationname}')
|
||||
|
||||
def get_factory(
|
||||
self,
|
||||
targetname: str,
|
||||
target: Any,
|
||||
methodname: str,
|
||||
instrumentationname: str,
|
||||
instrumentation_factory: Callable[[Any, str], Instrumentation],
|
||||
/
|
||||
) -> Callable[[], Instrumentation]:
|
||||
if (targetname, instrumentationname) not in self.factories:
|
||||
flags_required = self.instrumentations[instrumentationname].flags
|
||||
flags_present = self.targets[targetname].flags
|
||||
if not flags_required.issubset(flags_present):
|
||||
raise KeyError('target lacks flags required by instrumentation')
|
||||
self.factories[targetname, instrumentationname] = (
|
||||
lambda: instrumentation_factory(target, methodname)
|
||||
)
|
||||
return self.factories[targetname, instrumentationname]
|
||||
|
||||
|
||||
class JsonLike(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def json(self) -> ResponseType:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
Async = object()
|
Loading…
Reference in New Issue
Block a user