app + refactor
This commit is contained in:
parent
1f3ec55a48
commit
aad21d7135
5
.idea/codeStyles/codeStyleConfig.xml
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||||
|
</state>
|
||||||
|
</component>
|
7
.idea/inspectionProfiles/profiles_settings.xml
Normal file
7
.idea/inspectionProfiles/profiles_settings.xml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="PROJECT_PROFILE" value="Default" />
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
46
.idea/runConfigurations/Docker_Image.xml
Normal file
46
.idea/runConfigurations/Docker_Image.xml
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Docker Image" type="docker-deploy" factoryName="docker-image" server-name="Docker">
|
||||||
|
<deployment type="docker-image">
|
||||||
|
<settings>
|
||||||
|
<option name="imageTag" value="v6d3music" />
|
||||||
|
<option name="containerName" value="v6d3music" />
|
||||||
|
<option name="envVars">
|
||||||
|
<list>
|
||||||
|
<DockerEnvVarImpl>
|
||||||
|
<option name="name" value="v6ca" />
|
||||||
|
<option name="value" value="da5261eb5232b4b08452f25099b53b59d2e308b86aaf9c4204f0aa92569044d7" />
|
||||||
|
</DockerEnvVarImpl>
|
||||||
|
<DockerEnvVarImpl>
|
||||||
|
<option name="name" value="v6caurl" />
|
||||||
|
<option name="value" value="http://172.18.0.2:5900" />
|
||||||
|
</DockerEnvVarImpl>
|
||||||
|
<DockerEnvVarImpl>
|
||||||
|
<option name="name" value="v6taurl" />
|
||||||
|
<option name="value" value="http://172.18.0.3:5910" />
|
||||||
|
</DockerEnvVarImpl>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="portBindings">
|
||||||
|
<list>
|
||||||
|
<DockerPortBindingImpl>
|
||||||
|
<option name="containerPort" value="5930" />
|
||||||
|
<option name="hostIp" value="127.0.0.1" />
|
||||||
|
<option name="hostPort" value="5930" />
|
||||||
|
</DockerPortBindingImpl>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="commandLineOptions" value="--cpus="3" --memory="4000mb" --network="v6d"" />
|
||||||
|
<option name="showCommandPreview" value="true" />
|
||||||
|
<option name="volumeBindings">
|
||||||
|
<list>
|
||||||
|
<DockerVolumeBindingImpl>
|
||||||
|
<option name="containerPath" value="/v6data" />
|
||||||
|
<option name="hostPath" value="v6d3music" />
|
||||||
|
</DockerVolumeBindingImpl>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</settings>
|
||||||
|
</deployment>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
13
.idea/runConfigurations/Dockerfile.xml
Normal file
13
.idea/runConfigurations/Dockerfile.xml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Dockerfile" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
|
||||||
|
<deployment type="dockerfile">
|
||||||
|
<settings>
|
||||||
|
<option name="imageTag" value="v6d3music" />
|
||||||
|
<option name="buildOnly" value="true" />
|
||||||
|
<option name="showCommandPreview" value="true" />
|
||||||
|
<option name="sourceFilePath" value="Dockerfile" />
|
||||||
|
</settings>
|
||||||
|
</deployment>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
@ -2,6 +2,7 @@
|
|||||||
<module type="PYTHON_MODULE" version="4">
|
<module type="PYTHON_MODULE" version="4">
|
||||||
<component name="NewModuleRootManager">
|
<component name="NewModuleRootManager">
|
||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/v6d3music/html" isTestSource="false" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="jdk" jdkName="Python 3.10 (v6d3music)" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="Python 3.10 (v6d3music)" jdkType="Python SDK" />
|
||||||
|
@ -8,5 +8,8 @@ COPY requirements.txt requirements.txt
|
|||||||
RUN pip install -r requirements.txt
|
RUN pip install -r requirements.txt
|
||||||
RUN apt-get install -y tor obfs4proxy
|
RUN apt-get install -y tor obfs4proxy
|
||||||
COPY v6d3music v6d3music
|
COPY v6d3music v6d3music
|
||||||
RUN printf "\nClientTransportPlugin obfs4 exec /usr/bin/obfs4proxy\nBridge obfs4 65.108.56.114:55487 621BA99387F65441630DFBC8A403D11D126EBC72 cert=5HzsLradvYOipNky+aHrgo31KRtxq5Cb6tz3y5Ds7PbBeB0r+C4r15IPYppupCJgzuXgWw iat-mode=0\nUseBridges 1\n" >> "/etc/tor/torrc"
|
RUN printf "\nClientTransportPlugin obfs4 exec /usr/bin/obfs4proxy\nBridge obfs4 185.177.207.210:11210 044DEFCA9726828CAE0F880DFEDB6D957006087A cert=mLCpY31wGw9Vs1tQdCXGIyZaAQ6RCdWvw50klpDAk/4mZvA+wekmLZQRqatcbuMp2y36TQ iat-mode=1\nUseBridges 1\n" >> "/etc/tor/torrc"
|
||||||
|
ENV v6host=0.0.0.0
|
||||||
|
EXPOSE 5930
|
||||||
|
ENV v6port=5930
|
||||||
CMD ["python3", "-m", "v6d3music.run-bot"]
|
CMD ["python3", "-m", "v6d3music.run-bot"]
|
||||||
|
214
v6d3music/app.py
Normal file
214
v6d3music/app.py
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import asyncio
|
||||||
|
import urllib.parse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import discord
|
||||||
|
from aiohttp import web
|
||||||
|
from ptvp35 import Db
|
||||||
|
from v6d0auth.appfactory import AppFactory
|
||||||
|
from v6d0auth.run_app import start_app
|
||||||
|
from v6d1tokens.client import request_token
|
||||||
|
|
||||||
|
from v6d3music.utils.bytes_hash import bytes_hash
|
||||||
|
|
||||||
|
|
||||||
|
class MusicAppFactory(AppFactory):
|
||||||
|
htmlroot = Path(__file__).parent / 'html'
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
secret: str,
|
||||||
|
db: Db,
|
||||||
|
client: discord.Client
|
||||||
|
):
|
||||||
|
self.secret = secret
|
||||||
|
self.redirect = 'https://music.parrrate.ru/auth/'
|
||||||
|
self.discord_auth = 'https://discord.com/api/oauth2/authorize?client_id=914432576926646322' \
|
||||||
|
f'&redirect_uri={urllib.parse.quote(self.redirect)}&response_type=code&scope=identify'
|
||||||
|
self.loop = asyncio.get_running_loop()
|
||||||
|
self.db = db
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
def _file(self, file: str):
|
||||||
|
with open(self.htmlroot / file) as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
async def file(self, file: str):
|
||||||
|
return await self.loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
self._file,
|
||||||
|
file
|
||||||
|
)
|
||||||
|
|
||||||
|
async def html_resp(self, file: str):
|
||||||
|
text = await self.file(f'{file}.html')
|
||||||
|
text = text.replace(
|
||||||
|
'$$DISCORD_AUTH$$',
|
||||||
|
self.discord_auth
|
||||||
|
)
|
||||||
|
return web.Response(
|
||||||
|
text=text,
|
||||||
|
content_type='text/html'
|
||||||
|
)
|
||||||
|
|
||||||
|
async def code_token(self, code: str):
|
||||||
|
data = {
|
||||||
|
'client_id': '914432576926646322',
|
||||||
|
'client_secret': self.secret,
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'code': code,
|
||||||
|
'redirect_uri': self.redirect
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post('https://discord.com/api/oauth2/token', data=data, headers=headers) as response:
|
||||||
|
return await response.json()
|
||||||
|
|
||||||
|
async def session_client(self, session: str):
|
||||||
|
data = self.session_data(session)
|
||||||
|
client_token = data.get('token')
|
||||||
|
if client_token is None:
|
||||||
|
return None
|
||||||
|
access_token = client_token.get('access_token')
|
||||||
|
if access_token is None:
|
||||||
|
return None
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {access_token}'
|
||||||
|
}
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get('https://discord.com/api/oauth2/@me', headers=headers) as response:
|
||||||
|
return await response.json()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def client_status(cls, sclient: dict):
|
||||||
|
user = cls.client_user(sclient)
|
||||||
|
return {
|
||||||
|
'expires': sclient.get('expires'),
|
||||||
|
'user': (None if user is None else cls.user_status(user)),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def user_status(cls, user: dict):
|
||||||
|
return {
|
||||||
|
'avatar': cls.user_avatar_url(user),
|
||||||
|
'id': cls.user_id(user),
|
||||||
|
'username': cls.user_username_full(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def user_username_full(cls, user: dict):
|
||||||
|
username = cls.user_username(user)
|
||||||
|
if username is None:
|
||||||
|
return None
|
||||||
|
discriminator = cls.user_discriminator(user)
|
||||||
|
if discriminator is None:
|
||||||
|
return None
|
||||||
|
return username + discriminator
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def user_username(cls, user: dict):
|
||||||
|
return user.get('username')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def user_discriminator(cls, user: dict):
|
||||||
|
return user.get('discriminator')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def client_user(cls, sclient: dict):
|
||||||
|
return sclient.get('user')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def user_id(cls, user: dict):
|
||||||
|
return user.get('id')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def user_avatar(cls, user: dict):
|
||||||
|
return user.get('avatar')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def user_avatar_url(cls, user: dict):
|
||||||
|
cid = cls.user_id(user)
|
||||||
|
if cid is None:
|
||||||
|
return None
|
||||||
|
avatar = cls.user_avatar(user)
|
||||||
|
if avatar is None:
|
||||||
|
return None
|
||||||
|
return f'https://cdn.discordapp.com/avatars/{cid}/{avatar}.png'
|
||||||
|
|
||||||
|
async def session_status(self, session: str):
|
||||||
|
data = self.session_data(session)
|
||||||
|
sclient = await self.session_client(session)
|
||||||
|
return {
|
||||||
|
'code_set': data.get('code') is not None,
|
||||||
|
'token_set': data.get('token') is not None,
|
||||||
|
'client': (None if sclient is None else self.client_status(sclient))
|
||||||
|
}
|
||||||
|
|
||||||
|
def session_data(self, session: str) -> dict:
|
||||||
|
data = self.db.get(session, {})
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
return data
|
||||||
|
|
||||||
|
def define_routes(self, routes: web.RouteTableDef) -> None:
|
||||||
|
@routes.get('/')
|
||||||
|
async def home(_request: web.Request) -> web.Response:
|
||||||
|
return await self.html_resp('home')
|
||||||
|
|
||||||
|
@routes.get('/login/')
|
||||||
|
async def login(_request: web.Request) -> web.Response:
|
||||||
|
return await self.html_resp('login')
|
||||||
|
|
||||||
|
@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
|
||||||
|
data['token'] = await self.code_token(code)
|
||||||
|
await self.db.set(session, data)
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
return await self.html_resp('auth')
|
||||||
|
|
||||||
|
@routes.get('/state/')
|
||||||
|
async def state(request: web.Request) -> web.Response:
|
||||||
|
session = str(request.query.get('session'))
|
||||||
|
return web.json_response(
|
||||||
|
data=f"{bytes_hash(session.encode())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@routes.get('/status/')
|
||||||
|
async def status(request: web.Request) -> web.Response:
|
||||||
|
session = str(request.query.get('session'))
|
||||||
|
return web.json_response(
|
||||||
|
data=await self.session_status(session)
|
||||||
|
)
|
||||||
|
|
||||||
|
@routes.get('/main.js')
|
||||||
|
async def state(_request: web.Request) -> web.Response:
|
||||||
|
return web.Response(
|
||||||
|
text=await self.file('main.js')
|
||||||
|
)
|
||||||
|
|
||||||
|
@routes.get('/main.css')
|
||||||
|
async def state(_request: web.Request) -> web.Response:
|
||||||
|
return web.Response(
|
||||||
|
text=await self.file('main.css')
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def start(cls, db: Db, client: discord.Client):
|
||||||
|
factory = cls(await request_token('music-client', 'token'), db, client)
|
||||||
|
await start_app(factory.app())
|
48
v6d3music/cache_url.py
Normal file
48
v6d3music/cache_url.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from ptvp35 import Db, KVJson
|
||||||
|
from v6d2ctx.context import Benchmark
|
||||||
|
from v6d2ctx.lock_for import lock_for
|
||||||
|
|
||||||
|
from v6d3music.config import myroot
|
||||||
|
|
||||||
|
cache_root = myroot / 'cache'
|
||||||
|
cache_root.mkdir(exist_ok=True)
|
||||||
|
cache_db = Db(myroot / 'cache.db', kvrequest_type=KVJson)
|
||||||
|
|
||||||
|
|
||||||
|
async def cache_url(hurl: str, rurl: str, override: bool, tor: bool) -> None:
|
||||||
|
async with lock_for(('cache', hurl), 'cache failed'):
|
||||||
|
if not override and cache_db.get(f'url:{hurl}', None) is not None:
|
||||||
|
return
|
||||||
|
cachable: bool = cache_db.get(f'cachable:{hurl}', False)
|
||||||
|
if cachable:
|
||||||
|
print('caching', hurl)
|
||||||
|
path = cache_root / f'{hurl}.opus'
|
||||||
|
tmp_path = cache_root / f'{hurl}.tmp.opus'
|
||||||
|
args = []
|
||||||
|
if tor:
|
||||||
|
args.append('torify')
|
||||||
|
args.extend(
|
||||||
|
[
|
||||||
|
'ffmpeg', '-hide_banner', '-loglevel', 'warning',
|
||||||
|
'-reconnect', '1', '-reconnect_at_eof', '0',
|
||||||
|
'-reconnect_streamed', '1', '-reconnect_delay_max', '10', '-copy_unknown',
|
||||||
|
'-y', '-i', rurl, '-b:a', '128k', str(tmp_path)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
p = subprocess.Popen(
|
||||||
|
args,
|
||||||
|
)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
with Benchmark('CCH'):
|
||||||
|
code = await loop.run_in_executor(None, p.wait)
|
||||||
|
if code:
|
||||||
|
raise RuntimeError(code)
|
||||||
|
await loop.run_in_executor(None, tmp_path.rename, path)
|
||||||
|
await cache_db.set(f'url:{hurl}', str(path))
|
||||||
|
print('cached', hurl)
|
||||||
|
# await cache_db.set(f'cachable:{hurl}', False)
|
||||||
|
else:
|
||||||
|
await cache_db.set(f'cachable:{hurl}', True)
|
@ -1,8 +1,22 @@
|
|||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
import discord.utils
|
||||||
import youtube_dl
|
import youtube_dl
|
||||||
|
|
||||||
|
eerror = namedtuple('eerror', ['content'])
|
||||||
|
|
||||||
|
|
||||||
def extract(params: dict, url: str, kwargs: dict):
|
def extract(params: dict, url: str, kwargs: dict):
|
||||||
extracted = youtube_dl.YoutubeDL(params=params).extract_info(url, **kwargs)
|
try:
|
||||||
if 'entries' in extracted:
|
extracted = youtube_dl.YoutubeDL(params=params).extract_info(url, **kwargs)
|
||||||
extracted['entries'] = list(extracted['entries'])
|
if 'entries' in extracted:
|
||||||
return extracted
|
extracted['entries'] = list(extracted['entries'])
|
||||||
|
return extracted
|
||||||
|
except (youtube_dl.utils.ExtractorError, youtube_dl.utils.DownloadError) as e:
|
||||||
|
msg = str(e)
|
||||||
|
msg = discord.utils.escape_markdown(msg)
|
||||||
|
msg = msg.replace('\x1b[0;31m', '__')
|
||||||
|
msg = msg.replace('\x1b[0m', '__')
|
||||||
|
print(msg)
|
||||||
|
msg = 'unknown ytdl error'
|
||||||
|
return {'__error__': True, '__error_str__': msg}
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
from threading import Thread
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
|
from v6d3music.utils.fill import FILL
|
||||||
|
|
||||||
|
|
||||||
class FFmpegNormalAudio(discord.FFmpegAudio):
|
class FFmpegNormalAudio(discord.FFmpegAudio):
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -31,11 +34,45 @@ class FFmpegNormalAudio(discord.FFmpegAudio):
|
|||||||
|
|
||||||
super().__init__(source, executable=executable, args=args, **subprocess_kwargs)
|
super().__init__(source, executable=executable, args=args, **subprocess_kwargs)
|
||||||
|
|
||||||
|
self._chunk: Optional[bytes] = None
|
||||||
|
self._generating = False
|
||||||
|
self._started = False
|
||||||
|
|
||||||
|
def _raw_read(self):
|
||||||
|
return self._stdout.read(discord.opus.Encoder.FRAME_SIZE)
|
||||||
|
|
||||||
|
def _set_chunk(self):
|
||||||
|
self._chunk = self._raw_read()
|
||||||
|
|
||||||
|
def _thread_step(self):
|
||||||
|
if self._chunk is None:
|
||||||
|
self._set_chunk()
|
||||||
|
self._generating = False
|
||||||
|
|
||||||
|
def _generate(self):
|
||||||
|
if not self._generating:
|
||||||
|
self._generating = True
|
||||||
|
Thread(target=self._thread_step).start()
|
||||||
|
|
||||||
|
def _read(self):
|
||||||
|
if not self._started:
|
||||||
|
self._set_chunk()
|
||||||
|
self._started = True
|
||||||
|
if self._chunk is None:
|
||||||
|
self._generate()
|
||||||
|
return FILL
|
||||||
|
else:
|
||||||
|
chunk = self._chunk
|
||||||
|
self._chunk = None
|
||||||
|
self._generate()
|
||||||
|
return chunk
|
||||||
|
|
||||||
def read(self):
|
def read(self):
|
||||||
ret = self._stdout.read(discord.opus.Encoder.FRAME_SIZE)
|
ret = self._raw_read()
|
||||||
if len(ret) != discord.opus.Encoder.FRAME_SIZE:
|
if len(ret) != discord.opus.Encoder.FRAME_SIZE:
|
||||||
if self._process.poll() is None:
|
if self._process.poll() is None:
|
||||||
time.sleep(.5)
|
print('poll')
|
||||||
|
return FILL
|
||||||
return b''
|
return b''
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
8
v6d3music/html/auth.html
Normal file
8
v6d3music/html/auth.html
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<link rel="stylesheet" href="/main.css">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="/main.js"></script>
|
||||||
|
<script>
|
||||||
|
(async () => {
|
||||||
|
window.location = window.location + `&session=${sessionStr()}`;
|
||||||
|
})();
|
||||||
|
</script>
|
13
v6d3music/html/home.html
Normal file
13
v6d3music/html/home.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<link rel="stylesheet" href="/main.css">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="/main.js"></script>
|
||||||
|
<script>
|
||||||
|
(async () => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = '/login/';
|
||||||
|
a.innerText = 'login';
|
||||||
|
root.append(a);
|
||||||
|
logEl(JSON.stringify(await sessionStatus(), undefined, 2));
|
||||||
|
root.append(await userAvatarImg());
|
||||||
|
})();
|
||||||
|
</script>
|
13
v6d3music/html/login.html
Normal file
13
v6d3music/html/login.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<link rel="stylesheet" href="/main.css">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="/main.js"></script>
|
||||||
|
<script>
|
||||||
|
(async () => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = "$$DISCORD_AUTH$$&state=" + await sessionState();
|
||||||
|
a.innerText = 'auth';
|
||||||
|
root.append(a);
|
||||||
|
logEl(sessionStr());
|
||||||
|
logEl(await sessionState());
|
||||||
|
})();
|
||||||
|
</script>
|
4
v6d3music/html/main.css
Normal file
4
v6d3music/html/main.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
html, body {
|
||||||
|
color: white;
|
||||||
|
background: black;
|
||||||
|
}
|
58
v6d3music/html/main.js
Normal file
58
v6d3music/html/main.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
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 img = document.createElement('img');
|
||||||
|
img.src = await userAvatarUrl();
|
||||||
|
img.width = 128;
|
||||||
|
img.height = 128;
|
||||||
|
img.alt = await userUsername();
|
||||||
|
return img;
|
||||||
|
};
|
37
v6d3music/real_url.py
Normal file
37
v6d3music/real_url.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from v6d2ctx.context import Benchmark
|
||||||
|
|
||||||
|
from v6d3music.utils.bytes_hash import bytes_hash
|
||||||
|
from v6d3music.cache_url import cache_db, cache_url
|
||||||
|
|
||||||
|
|
||||||
|
async def real_url(url: str, override: bool, tor: bool) -> str:
|
||||||
|
hurl: str = bytes_hash(url.encode())
|
||||||
|
if not override:
|
||||||
|
curl: Optional[str] = cache_db.get(f'url:{hurl}', None)
|
||||||
|
if curl is not None:
|
||||||
|
print('using cached', hurl)
|
||||||
|
return curl
|
||||||
|
args = []
|
||||||
|
if tor:
|
||||||
|
args.append('torify')
|
||||||
|
args.extend(
|
||||||
|
[
|
||||||
|
'youtube-dl', '--no-playlist', '-f', 'bestaudio', '-g', '--', url
|
||||||
|
]
|
||||||
|
)
|
||||||
|
p = subprocess.Popen(
|
||||||
|
args,
|
||||||
|
stdout=subprocess.PIPE
|
||||||
|
)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
with Benchmark('URL'):
|
||||||
|
code = await loop.run_in_executor(None, p.wait)
|
||||||
|
if code:
|
||||||
|
raise RuntimeError(code)
|
||||||
|
rurl: str = p.stdout.readline().decode()[:-1]
|
||||||
|
loop.create_task(cache_url(hurl, rurl, override, tor))
|
||||||
|
return rurl
|
@ -2,29 +2,33 @@ import asyncio
|
|||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import shlex
|
import shlex
|
||||||
import string
|
import string
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from typing import Optional, AsyncIterable, Any, Iterable, TypeAlias
|
from typing import Any, AsyncIterable, Iterable, Optional, TypeAlias
|
||||||
|
|
||||||
# noinspection PyPackageRequirements
|
|
||||||
import discord
|
import discord
|
||||||
import nacl.hash
|
|
||||||
from ptvp35 import Db, KVJson
|
from ptvp35 import Db, KVJson
|
||||||
from v6d1tokens.client import request_token
|
from v6d1tokens.client import request_token
|
||||||
from v6d2ctx.context import Context, at, escape, monitor, Benchmark, Explicit, Implicit
|
from v6d2ctx.context import Benchmark, Context, Explicit, Implicit, at, escape, monitor
|
||||||
from v6d2ctx.handle_content import handle_content
|
from v6d2ctx.handle_content import handle_content
|
||||||
from v6d2ctx.lock_for import lock_for
|
from v6d2ctx.lock_for import lock_for
|
||||||
from v6d2ctx.serve import serve
|
from v6d2ctx.serve import serve
|
||||||
|
|
||||||
import v6d3music.extract
|
import v6d3music.extract
|
||||||
import v6d3music.ffmpegnormalaudio
|
import v6d3music.ffmpegnormalaudio
|
||||||
from v6d3music.config import prefix, myroot
|
from v6d3music.app import MusicAppFactory
|
||||||
|
from v6d3music.cache_url import cache_db
|
||||||
|
from v6d3music.config import myroot, prefix
|
||||||
|
from v6d3music.real_url import real_url
|
||||||
|
from v6d3music.utils.assert_admin import assert_admin
|
||||||
|
from v6d3music.utils.fill import FILL
|
||||||
|
from v6d3music.utils.options_for_effects import options_for_effects
|
||||||
|
from v6d3music.utils.sparq import sparq
|
||||||
|
from v6d3music.ytaudio import YTAudio
|
||||||
|
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
@ -43,9 +47,7 @@ client = discord.Client(
|
|||||||
)
|
)
|
||||||
volume_db = Db(myroot / 'volume.db', kvrequest_type=KVJson)
|
volume_db = Db(myroot / 'volume.db', kvrequest_type=KVJson)
|
||||||
queue_db = Db(myroot / 'queue.db', kvrequest_type=KVJson)
|
queue_db = Db(myroot / 'queue.db', kvrequest_type=KVJson)
|
||||||
cache_db = Db(myroot / 'cache.db', kvrequest_type=KVJson)
|
session_db = Db(myroot / 'session.db', kvrequest_type=KVJson)
|
||||||
cache_root = myroot / 'cache'
|
|
||||||
cache_root.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
vcs_restored = False
|
vcs_restored = False
|
||||||
|
|
||||||
@ -78,9 +80,11 @@ async def restore_vcs():
|
|||||||
@client.event
|
@client.event
|
||||||
async def on_ready():
|
async def on_ready():
|
||||||
print('ready')
|
print('ready')
|
||||||
await client.change_presence(activity=discord.Game(
|
await client.change_presence(
|
||||||
name='феноменально',
|
activity=discord.Game(
|
||||||
))
|
name='феноменально',
|
||||||
|
)
|
||||||
|
)
|
||||||
if not vcs_restored:
|
if not vcs_restored:
|
||||||
await restore_vcs()
|
await restore_vcs()
|
||||||
|
|
||||||
@ -94,203 +98,6 @@ async def help_(ctx: Context, args: list[str]) -> None:
|
|||||||
await ctx.reply(f'help for {name}: `{name} help`')
|
await ctx.reply(f'help for {name}: `{name} help`')
|
||||||
|
|
||||||
|
|
||||||
def speed_quotient(options: str) -> float:
|
|
||||||
options = options or ''
|
|
||||||
options = ''.join(c for c in options if not c.isspace())
|
|
||||||
options += ','
|
|
||||||
quotient: float = 1.0
|
|
||||||
asetrate: str
|
|
||||||
for asetrate in re.findall(r'asetrate=([0-9.]+?),', options):
|
|
||||||
try:
|
|
||||||
quotient *= float(asetrate) / discord.opus.Encoder.SAMPLING_RATE
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
atempo: str
|
|
||||||
for atempo in re.findall(r'atempo=([0-9.]+?),', options):
|
|
||||||
try:
|
|
||||||
quotient *= float(atempo)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
quotient = max(0.1, min(10.0, quotient))
|
|
||||||
return quotient
|
|
||||||
|
|
||||||
|
|
||||||
def sparq(options: str) -> float:
|
|
||||||
return speed_quotient(options) * discord.opus.Encoder.FRAME_LENGTH / 1000
|
|
||||||
|
|
||||||
|
|
||||||
class YTAudio(discord.AudioSource):
|
|
||||||
source: discord.FFmpegAudio
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
url: str,
|
|
||||||
origin: str,
|
|
||||||
description: str,
|
|
||||||
options: Optional[str],
|
|
||||||
rby: discord.Member,
|
|
||||||
already_read: int,
|
|
||||||
tor: bool
|
|
||||||
):
|
|
||||||
self.url = url
|
|
||||||
self.origin = origin
|
|
||||||
self.description = description
|
|
||||||
self.options = options
|
|
||||||
self.rby = rby
|
|
||||||
self.already_read = already_read
|
|
||||||
self.tor = tor
|
|
||||||
self.loaded = False
|
|
||||||
self.regenerating = False
|
|
||||||
self.set_source()
|
|
||||||
self._durations: dict[str, str] = {}
|
|
||||||
|
|
||||||
def set_source(self):
|
|
||||||
self.schedule_duration_update()
|
|
||||||
self.source = v6d3music.ffmpegnormalaudio.FFmpegNormalAudio(
|
|
||||||
self.url,
|
|
||||||
options=self.options,
|
|
||||||
before_options=self.before_options(),
|
|
||||||
tor=self.tor
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_already_read(self, already_read: int):
|
|
||||||
self.already_read = already_read
|
|
||||||
self.set_source()
|
|
||||||
|
|
||||||
def set_seconds(self, seconds: float):
|
|
||||||
self.set_already_read(round(seconds / sparq(self.options)))
|
|
||||||
|
|
||||||
def source_seconds(self) -> float:
|
|
||||||
return self.already_read * sparq(self.options)
|
|
||||||
|
|
||||||
def source_timecode(self) -> str:
|
|
||||||
seconds = round(self.source_seconds())
|
|
||||||
minutes, seconds = divmod(seconds, 60)
|
|
||||||
hours, minutes = divmod(minutes, 60)
|
|
||||||
return f'{hours}:{minutes:02d}:{seconds:02d}'
|
|
||||||
|
|
||||||
def schedule_duration_update(self):
|
|
||||||
asyncio.get_running_loop().create_task(self.update_duration())
|
|
||||||
|
|
||||||
async def update_duration(self):
|
|
||||||
url: str = self.url
|
|
||||||
if url in self._durations:
|
|
||||||
return
|
|
||||||
self._durations.setdefault(url, '')
|
|
||||||
prompt = ''
|
|
||||||
if self.tor:
|
|
||||||
prompt = 'torify '
|
|
||||||
prompt += (
|
|
||||||
f'ffprobe -i {shlex.quote(url)}'
|
|
||||||
' -show_entries format=duration -v quiet -of csv="p=0" -sexagesimal'
|
|
||||||
)
|
|
||||||
p = subprocess.Popen(
|
|
||||||
prompt,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
shell=True
|
|
||||||
)
|
|
||||||
with Benchmark('FFP'):
|
|
||||||
code = await loop.run_in_executor(None, p.wait)
|
|
||||||
if code:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
self._durations[url] = p.stdout.read().decode().strip().split('.')[0]
|
|
||||||
|
|
||||||
def duration(self) -> str:
|
|
||||||
duration = self._durations.get(self.url)
|
|
||||||
if duration is None:
|
|
||||||
self.schedule_duration_update()
|
|
||||||
return duration or '?:??:??'
|
|
||||||
|
|
||||||
def before_options(self):
|
|
||||||
before_options = ''
|
|
||||||
if 'https' in self.url:
|
|
||||||
before_options += (
|
|
||||||
'-reconnect 1 -reconnect_at_eof 0 -reconnect_streamed 1 -reconnect_delay_max 10 -copy_unknown'
|
|
||||||
)
|
|
||||||
if self.already_read:
|
|
||||||
before_options += (
|
|
||||||
f' -ss {self.source_seconds()}'
|
|
||||||
)
|
|
||||||
return before_options
|
|
||||||
|
|
||||||
def read(self) -> bytes:
|
|
||||||
if self.regenerating:
|
|
||||||
return FILL
|
|
||||||
self.already_read += 1
|
|
||||||
ret: bytes = self.source.read()
|
|
||||||
if ret:
|
|
||||||
self.loaded = True
|
|
||||||
elif not self.loaded:
|
|
||||||
if random.random() > .1:
|
|
||||||
self.regenerating = True
|
|
||||||
loop.create_task(self.regenerate())
|
|
||||||
return FILL
|
|
||||||
else:
|
|
||||||
print(f'dropped {self.origin}')
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
self.source.cleanup()
|
|
||||||
|
|
||||||
def can_be_skipped_by(self, member: discord.Member) -> bool:
|
|
||||||
permissions: discord.Permissions = member.guild_permissions
|
|
||||||
if permissions.administrator:
|
|
||||||
return True
|
|
||||||
elif permissions.manage_permissions:
|
|
||||||
return True
|
|
||||||
elif permissions.manage_guild:
|
|
||||||
return True
|
|
||||||
elif permissions.manage_channels:
|
|
||||||
return True
|
|
||||||
elif permissions.manage_messages:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return self.rby == member
|
|
||||||
|
|
||||||
def hybernate(self):
|
|
||||||
return {
|
|
||||||
'url': self.url,
|
|
||||||
'origin': self.origin,
|
|
||||||
'description': self.description,
|
|
||||||
'options': self.options,
|
|
||||||
'rby': self.rby.id,
|
|
||||||
'already_read': self.already_read,
|
|
||||||
'tor': self.tor,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def respawn(cls, guild: discord.Guild, respawn) -> 'YTAudio':
|
|
||||||
return YTAudio(
|
|
||||||
respawn['url'],
|
|
||||||
respawn['origin'],
|
|
||||||
respawn['description'],
|
|
||||||
respawn['options'],
|
|
||||||
guild.get_member(respawn['rby']) or await guild.fetch_member(respawn['rby']),
|
|
||||||
respawn['already_read'],
|
|
||||||
respawn.get('tor', False),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def regenerate(self):
|
|
||||||
try:
|
|
||||||
print(f'regenerating {self.origin}')
|
|
||||||
self.url = await real_url(self.origin, True, self.tor)
|
|
||||||
self.source.cleanup()
|
|
||||||
self.set_source()
|
|
||||||
print(f'regenerated {self.origin}')
|
|
||||||
finally:
|
|
||||||
self.regenerating = False
|
|
||||||
|
|
||||||
|
|
||||||
FILL = b'\x00' * discord.opus.Encoder.FRAME_SIZE
|
|
||||||
|
|
||||||
|
|
||||||
def assert_admin(member: discord.Member):
|
|
||||||
permissions: discord.Permissions = member.guild_permissions
|
|
||||||
if not permissions.administrator:
|
|
||||||
raise Explicit('not an administrator')
|
|
||||||
|
|
||||||
|
|
||||||
class QueueAudio(discord.AudioSource):
|
class QueueAudio(discord.AudioSource):
|
||||||
def __init__(self, guild: discord.Guild, respawned: list[YTAudio]):
|
def __init__(self, guild: discord.Guild, respawned: list[YTAudio]):
|
||||||
self.queue: deque[YTAudio] = deque()
|
self.queue: deque[YTAudio] = deque()
|
||||||
@ -400,21 +207,6 @@ class MainAudio(discord.PCMVolumeTransformer):
|
|||||||
await volume_db.set(member.guild.id, volume)
|
await volume_db.set(member.guild.id, volume)
|
||||||
|
|
||||||
|
|
||||||
def bytes_hash(b: bytes) -> str:
|
|
||||||
return nacl.hash.sha256(b).decode()
|
|
||||||
|
|
||||||
|
|
||||||
def recursive_hash(obj) -> str:
|
|
||||||
if isinstance(obj, str):
|
|
||||||
return bytes_hash(obj.encode())
|
|
||||||
elif isinstance(obj, tuple) or isinstance(obj, list):
|
|
||||||
return recursive_hash(';'.join(map(recursive_hash, obj)))
|
|
||||||
elif isinstance(obj, dict):
|
|
||||||
return recursive_hash([*obj.items()])
|
|
||||||
else:
|
|
||||||
raise TypeError
|
|
||||||
|
|
||||||
|
|
||||||
async def aextract(params: dict, url: str, **kwargs):
|
async def aextract(params: dict, url: str, **kwargs):
|
||||||
with Benchmark('AEX'):
|
with Benchmark('AEX'):
|
||||||
with concurrent.futures.ProcessPoolExecutor() as pool:
|
with concurrent.futures.ProcessPoolExecutor() as pool:
|
||||||
@ -446,73 +238,6 @@ async def tor_extract(params: dict, url: str, **kwargs):
|
|||||||
return json.loads(p.stdout.read())
|
return json.loads(p.stdout.read())
|
||||||
|
|
||||||
|
|
||||||
async def cache_url(hurl: str, rurl: str, override: bool, tor: bool) -> None:
|
|
||||||
async with lock_for(('cache', hurl), 'cache failed'):
|
|
||||||
if not override and cache_db.get(f'url:{hurl}', None) is not None:
|
|
||||||
return
|
|
||||||
cachable: bool = cache_db.get(f'cachable:{hurl}', False)
|
|
||||||
if cachable:
|
|
||||||
print('caching', hurl)
|
|
||||||
path = cache_root / f'{hurl}.opus'
|
|
||||||
tmp_path = cache_root / f'{hurl}.tmp.opus'
|
|
||||||
args = []
|
|
||||||
if tor:
|
|
||||||
args.append('torify')
|
|
||||||
args.extend(
|
|
||||||
[
|
|
||||||
'ffmpeg', '-hide_banner', '-loglevel', 'warning',
|
|
||||||
'-reconnect', '1', '-reconnect_at_eof', '0',
|
|
||||||
'-reconnect_streamed', '1', '-reconnect_delay_max', '10', '-copy_unknown',
|
|
||||||
'-y', '-i', rurl, '-b:a', '128k', str(tmp_path)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
p = subprocess.Popen(
|
|
||||||
args,
|
|
||||||
)
|
|
||||||
with Benchmark('CCH'):
|
|
||||||
code = await loop.run_in_executor(None, p.wait)
|
|
||||||
if code:
|
|
||||||
raise RuntimeError(code)
|
|
||||||
await loop.run_in_executor(None, tmp_path.rename, path)
|
|
||||||
await cache_db.set(f'url:{hurl}', str(path))
|
|
||||||
print('cached', hurl)
|
|
||||||
# await cache_db.set(f'cachable:{hurl}', False)
|
|
||||||
else:
|
|
||||||
await cache_db.set(f'cachable:{hurl}', True)
|
|
||||||
|
|
||||||
|
|
||||||
async def real_url(url: str, override: bool, tor: bool) -> str:
|
|
||||||
hurl: str = bytes_hash(url.encode())
|
|
||||||
if not override:
|
|
||||||
curl: Optional[str] = cache_db.get(f'url:{hurl}', None)
|
|
||||||
if curl is not None:
|
|
||||||
print('using cached', hurl)
|
|
||||||
return curl
|
|
||||||
args = []
|
|
||||||
if tor:
|
|
||||||
args.append('torify')
|
|
||||||
args.extend(
|
|
||||||
[
|
|
||||||
'youtube-dl', '--no-playlist', '-f', 'bestaudio', '-g', '--', url
|
|
||||||
]
|
|
||||||
)
|
|
||||||
p = subprocess.Popen(
|
|
||||||
args,
|
|
||||||
stdout=subprocess.PIPE
|
|
||||||
)
|
|
||||||
with Benchmark('URL'):
|
|
||||||
code = await loop.run_in_executor(None, p.wait)
|
|
||||||
if code:
|
|
||||||
raise RuntimeError(code)
|
|
||||||
rurl: str = p.stdout.readline().decode()[:-1]
|
|
||||||
loop.create_task(cache_url(hurl, rurl, override, tor))
|
|
||||||
return rurl
|
|
||||||
|
|
||||||
|
|
||||||
def options_for_effects(effects: str) -> Optional[str]:
|
|
||||||
return f'-af {shlex.quote(effects)}' if effects else None
|
|
||||||
|
|
||||||
|
|
||||||
async def create_ytaudio(
|
async def create_ytaudio(
|
||||||
ctx: Context, info: dict[str, Any], effects: Optional[str], already_read: int, tor: bool
|
ctx: Context, info: dict[str, Any], effects: Optional[str], already_read: int, tor: bool
|
||||||
) -> YTAudio:
|
) -> YTAudio:
|
||||||
@ -550,6 +275,8 @@ async def entries_for_url(url: str, tor: bool) -> AsyncIterable[
|
|||||||
download=False,
|
download=False,
|
||||||
process=False
|
process=False
|
||||||
)
|
)
|
||||||
|
if '__error__' in info:
|
||||||
|
raise Explicit('extraction error\n' + info.get('__error_str__'))
|
||||||
if 'entries' in info:
|
if 'entries' in info:
|
||||||
for entry in info['entries']:
|
for entry in info['entries']:
|
||||||
yield entry
|
yield entry
|
||||||
@ -587,13 +314,20 @@ allowed_presets = ['bassboost', 'bassbooboost', 'nightcore', 'daycore', 'mono']
|
|||||||
allowed_effects = {'', *(presets[key] for key in allowed_presets)}
|
allowed_effects = {'', *(presets[key] for key in allowed_presets)}
|
||||||
|
|
||||||
|
|
||||||
|
def effects_for_preset(preset: str) -> str:
|
||||||
|
if preset in presets:
|
||||||
|
return presets[preset]
|
||||||
|
else:
|
||||||
|
raise Explicit('unknown preset')
|
||||||
|
|
||||||
|
|
||||||
async def entries_effects_for_args(args: list[str]) -> AsyncIterable[info_tuple]:
|
async def entries_effects_for_args(args: list[str]) -> AsyncIterable[info_tuple]:
|
||||||
while args:
|
while args:
|
||||||
match args:
|
match args:
|
||||||
case [url, '-', effects, *args]:
|
case [url, '-', effects, *args]:
|
||||||
pass
|
pass
|
||||||
case [url, '+', preset, *args]:
|
case [url, '+', preset, *args]:
|
||||||
effects = presets[preset]
|
effects = effects_for_preset(preset)
|
||||||
case [url, *args]:
|
case [url, *args]:
|
||||||
effects = None
|
effects = None
|
||||||
case _:
|
case _:
|
||||||
@ -718,9 +452,11 @@ async def queue_for(ctx: Context, *, create: bool) -> QueueAudio:
|
|||||||
|
|
||||||
@at('commands', 'skip')
|
@at('commands', 'skip')
|
||||||
async def skip(ctx: Context, args: list[str]) -> None:
|
async def skip(ctx: Context, args: list[str]) -> None:
|
||||||
await catch(ctx, args, '''
|
await catch(
|
||||||
|
ctx, args, '''
|
||||||
`skip [first] [last]`
|
`skip [first] [last]`
|
||||||
''', 'help')
|
''', 'help'
|
||||||
|
)
|
||||||
match args:
|
match args:
|
||||||
case []:
|
case []:
|
||||||
queue = await queue_for(ctx, create=False)
|
queue = await queue_for(ctx, create=False)
|
||||||
@ -742,9 +478,11 @@ async def skip(ctx: Context, args: list[str]) -> None:
|
|||||||
|
|
||||||
@at('commands', 'to')
|
@at('commands', 'to')
|
||||||
async def skip_to(ctx: Context, args: list[str]) -> None:
|
async def skip_to(ctx: Context, args: list[str]) -> None:
|
||||||
await catch(ctx, args, '''
|
await catch(
|
||||||
|
ctx, args, '''
|
||||||
`to [[h]] [m] s`
|
`to [[h]] [m] s`
|
||||||
''', 'help')
|
''', 'help'
|
||||||
|
)
|
||||||
match args:
|
match args:
|
||||||
case [h, m, s] if h.isdecimal() and m.isdecimal() and s.isdecimal():
|
case [h, m, s] if h.isdecimal() and m.isdecimal() and s.isdecimal():
|
||||||
seconds = 3600 * int(h) + 60 * int(m) + int(s)
|
seconds = 3600 * int(h) + 60 * int(m) + int(s)
|
||||||
@ -760,15 +498,17 @@ async def skip_to(ctx: Context, args: list[str]) -> None:
|
|||||||
|
|
||||||
@at('commands', 'effects')
|
@at('commands', 'effects')
|
||||||
async def effects_(ctx: Context, args: list[str]) -> None:
|
async def effects_(ctx: Context, args: list[str]) -> None:
|
||||||
await catch(ctx, args, '''
|
await catch(
|
||||||
|
ctx, args, '''
|
||||||
`effects - effects`
|
`effects - effects`
|
||||||
`effects + preset`
|
`effects + preset`
|
||||||
''', 'help')
|
''', 'help'
|
||||||
|
)
|
||||||
match args:
|
match args:
|
||||||
case ['-', effects]:
|
case ['-', effects]:
|
||||||
pass
|
pass
|
||||||
case ['+', preset]:
|
case ['+', preset]:
|
||||||
effects = presets[preset]
|
effects = effects_for_preset(preset)
|
||||||
case _:
|
case _:
|
||||||
raise Explicit('misformatted')
|
raise Explicit('misformatted')
|
||||||
assert_admin(ctx.member)
|
assert_admin(ctx.member)
|
||||||
@ -781,12 +521,14 @@ async def effects_(ctx: Context, args: list[str]) -> None:
|
|||||||
|
|
||||||
@at('commands', 'queue')
|
@at('commands', 'queue')
|
||||||
async def queue_(ctx: Context, args: list[str]) -> None:
|
async def queue_(ctx: Context, args: list[str]) -> None:
|
||||||
await catch(ctx, args, '''
|
await catch(
|
||||||
|
ctx, args, '''
|
||||||
`queue`
|
`queue`
|
||||||
`queue clear`
|
`queue clear`
|
||||||
`queue resume`
|
`queue resume`
|
||||||
`queue pause`
|
`queue pause`
|
||||||
''', 'help')
|
''', 'help'
|
||||||
|
)
|
||||||
match args:
|
match args:
|
||||||
case []:
|
case []:
|
||||||
await ctx.long((await (await queue_for(ctx, create=False)).format()).strip() or 'no queue')
|
await ctx.long((await (await queue_for(ctx, create=False)).format()).strip() or 'no queue')
|
||||||
@ -808,9 +550,11 @@ async def queue_(ctx: Context, args: list[str]) -> None:
|
|||||||
|
|
||||||
@at('commands', 'swap')
|
@at('commands', 'swap')
|
||||||
async def swap(ctx: Context, args: list[str]) -> None:
|
async def swap(ctx: Context, args: list[str]) -> None:
|
||||||
await catch(ctx, args, '''
|
await catch(
|
||||||
|
ctx, args, '''
|
||||||
`swap a b`
|
`swap a b`
|
||||||
''', 'help')
|
''', 'help'
|
||||||
|
)
|
||||||
match args:
|
match args:
|
||||||
case [a, b] if a.isdecimal() and b.isdecimal():
|
case [a, b] if a.isdecimal() and b.isdecimal():
|
||||||
a, b = int(a), int(b)
|
a, b = int(a), int(b)
|
||||||
@ -821,9 +565,11 @@ async def swap(ctx: Context, args: list[str]) -> None:
|
|||||||
|
|
||||||
@at('commands', 'move')
|
@at('commands', 'move')
|
||||||
async def move(ctx: Context, args: list[str]) -> None:
|
async def move(ctx: Context, args: list[str]) -> None:
|
||||||
await catch(ctx, args, '''
|
await catch(
|
||||||
|
ctx, args, '''
|
||||||
`move a b`
|
`move a b`
|
||||||
''', 'help')
|
''', 'help'
|
||||||
|
)
|
||||||
match args:
|
match args:
|
||||||
case [a, b] if a.isdecimal() and b.isdecimal():
|
case [a, b] if a.isdecimal() and b.isdecimal():
|
||||||
a, b = int(a), int(b)
|
a, b = int(a), int(b)
|
||||||
@ -834,9 +580,11 @@ async def move(ctx: Context, args: list[str]) -> None:
|
|||||||
|
|
||||||
@at('commands', 'volume')
|
@at('commands', 'volume')
|
||||||
async def volume_(ctx: Context, args: list[str]) -> None:
|
async def volume_(ctx: Context, args: list[str]) -> None:
|
||||||
await catch(ctx, args, '''
|
await catch(
|
||||||
|
ctx, args, '''
|
||||||
`volume volume`
|
`volume volume`
|
||||||
''', 'help')
|
''', 'help'
|
||||||
|
)
|
||||||
match args:
|
match args:
|
||||||
case [volume]:
|
case [volume]:
|
||||||
volume = float(volume)
|
volume = float(volume)
|
||||||
@ -895,10 +643,19 @@ async def save_job():
|
|||||||
await save_commit()
|
await save_commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def start_app():
|
||||||
|
await MusicAppFactory.start(session_db, client)
|
||||||
|
|
||||||
|
|
||||||
|
async def setup_tasks():
|
||||||
|
loop.create_task(save_job())
|
||||||
|
loop.create_task(start_app())
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
async with volume_db, queue_db, cache_db:
|
async with volume_db, queue_db, cache_db, session_db:
|
||||||
await client.login(token)
|
await client.login(token)
|
||||||
loop.create_task(save_job())
|
loop.create_task(setup_tasks())
|
||||||
if os.getenv('v6monitor'):
|
if os.getenv('v6monitor'):
|
||||||
loop.create_task(monitor())
|
loop.create_task(monitor())
|
||||||
subprocess.Popen('tor')
|
subprocess.Popen('tor')
|
||||||
|
0
v6d3music/utils/__init__.py
Normal file
0
v6d3music/utils/__init__.py
Normal file
8
v6d3music/utils/assert_admin.py
Normal file
8
v6d3music/utils/assert_admin.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import discord
|
||||||
|
from v6d2ctx.context import Explicit
|
||||||
|
|
||||||
|
|
||||||
|
def assert_admin(member: discord.Member):
|
||||||
|
permissions: discord.Permissions = member.guild_permissions
|
||||||
|
if not permissions.administrator:
|
||||||
|
raise Explicit('not an administrator')
|
5
v6d3music/utils/bytes_hash.py
Normal file
5
v6d3music/utils/bytes_hash.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import nacl.hash
|
||||||
|
|
||||||
|
|
||||||
|
def bytes_hash(b: bytes) -> str:
|
||||||
|
return nacl.hash.sha256(b).decode()
|
3
v6d3music/utils/fill.py
Normal file
3
v6d3music/utils/fill.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import discord
|
||||||
|
|
||||||
|
FILL = b'\x00' * discord.opus.Encoder.FRAME_SIZE
|
6
v6d3music/utils/options_for_effects.py
Normal file
6
v6d3music/utils/options_for_effects.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import shlex
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def options_for_effects(effects: str) -> Optional[str]:
|
||||||
|
return f'-af {shlex.quote(effects)}' if effects else None
|
7
v6d3music/utils/sparq.py
Normal file
7
v6d3music/utils/sparq.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import discord
|
||||||
|
|
||||||
|
from v6d3music.utils.speed_quotient import speed_quotient
|
||||||
|
|
||||||
|
|
||||||
|
def sparq(options: str) -> float:
|
||||||
|
return speed_quotient(options) * discord.opus.Encoder.FRAME_LENGTH / 1000
|
26
v6d3music/utils/speed_quotient.py
Normal file
26
v6d3music/utils/speed_quotient.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
|
|
||||||
|
def speed_quotient(options: str) -> float:
|
||||||
|
options = options or ''
|
||||||
|
options = ''.join(c for c in options if not c.isspace())
|
||||||
|
options += ','
|
||||||
|
quotient: float = 1.0
|
||||||
|
asetrate: str
|
||||||
|
# noinspection RegExpSimplifiable
|
||||||
|
for asetrate in re.findall(r'asetrate=([0-9.]+?),', options):
|
||||||
|
try:
|
||||||
|
quotient *= float(asetrate) / discord.opus.Encoder.SAMPLING_RATE
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
atempo: str
|
||||||
|
# noinspection RegExpSimplifiable
|
||||||
|
for atempo in re.findall(r'atempo=([0-9.]+?),', options):
|
||||||
|
try:
|
||||||
|
quotient *= float(atempo)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
quotient = max(0.1, min(10.0, quotient))
|
||||||
|
return quotient
|
177
v6d3music/ytaudio.py
Normal file
177
v6d3music/ytaudio.py
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from v6d2ctx.context import Benchmark
|
||||||
|
|
||||||
|
from v6d3music.ffmpegnormalaudio import FFmpegNormalAudio
|
||||||
|
from v6d3music.utils.fill import FILL
|
||||||
|
from v6d3music.real_url import real_url
|
||||||
|
from v6d3music.utils.sparq import sparq
|
||||||
|
|
||||||
|
|
||||||
|
class YTAudio(discord.AudioSource):
|
||||||
|
source: discord.FFmpegAudio
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
origin: str,
|
||||||
|
description: str,
|
||||||
|
options: Optional[str],
|
||||||
|
rby: discord.Member,
|
||||||
|
already_read: int,
|
||||||
|
tor: bool
|
||||||
|
):
|
||||||
|
self.url = url
|
||||||
|
self.origin = origin
|
||||||
|
self.description = description
|
||||||
|
self.options = options
|
||||||
|
self.rby = rby
|
||||||
|
self.already_read = already_read
|
||||||
|
self.tor = tor
|
||||||
|
self.loaded = False
|
||||||
|
self.regenerating = False
|
||||||
|
self.set_source()
|
||||||
|
self._durations: dict[str, str] = {}
|
||||||
|
self.loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
def set_source(self):
|
||||||
|
self.schedule_duration_update()
|
||||||
|
self.source = FFmpegNormalAudio(
|
||||||
|
self.url,
|
||||||
|
options=self.options,
|
||||||
|
before_options=self.before_options(),
|
||||||
|
tor=self.tor
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_already_read(self, already_read: int):
|
||||||
|
self.already_read = already_read
|
||||||
|
self.set_source()
|
||||||
|
|
||||||
|
def set_seconds(self, seconds: float):
|
||||||
|
self.set_already_read(round(seconds / sparq(self.options)))
|
||||||
|
|
||||||
|
def source_seconds(self) -> float:
|
||||||
|
return self.already_read * sparq(self.options)
|
||||||
|
|
||||||
|
def source_timecode(self) -> str:
|
||||||
|
seconds = round(self.source_seconds())
|
||||||
|
minutes, seconds = divmod(seconds, 60)
|
||||||
|
hours, minutes = divmod(minutes, 60)
|
||||||
|
return f'{hours}:{minutes:02d}:{seconds:02d}'
|
||||||
|
|
||||||
|
def schedule_duration_update(self):
|
||||||
|
asyncio.get_running_loop().create_task(self.update_duration())
|
||||||
|
|
||||||
|
async def update_duration(self):
|
||||||
|
url: str = self.url
|
||||||
|
if url in self._durations:
|
||||||
|
return
|
||||||
|
self._durations.setdefault(url, '')
|
||||||
|
prompt = ''
|
||||||
|
if self.tor:
|
||||||
|
prompt = 'torify '
|
||||||
|
prompt += (
|
||||||
|
f'ffprobe -i {shlex.quote(url)}'
|
||||||
|
' -show_entries format=duration -v quiet -of csv="p=0" -sexagesimal'
|
||||||
|
)
|
||||||
|
p = subprocess.Popen(
|
||||||
|
prompt,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
shell=True
|
||||||
|
)
|
||||||
|
with Benchmark('FFP'):
|
||||||
|
code = await self.loop.run_in_executor(None, p.wait)
|
||||||
|
if code:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
self._durations[url] = p.stdout.read().decode().strip().split('.')[0]
|
||||||
|
|
||||||
|
def duration(self) -> str:
|
||||||
|
duration = self._durations.get(self.url)
|
||||||
|
if duration is None:
|
||||||
|
self.schedule_duration_update()
|
||||||
|
return duration or '?:??:??'
|
||||||
|
|
||||||
|
def before_options(self):
|
||||||
|
before_options = ''
|
||||||
|
if 'https' in self.url:
|
||||||
|
before_options += (
|
||||||
|
'-reconnect 1 -reconnect_at_eof 0 -reconnect_streamed 1 -reconnect_delay_max 10 -copy_unknown'
|
||||||
|
)
|
||||||
|
if self.already_read:
|
||||||
|
before_options += (
|
||||||
|
f' -ss {self.source_seconds()}'
|
||||||
|
)
|
||||||
|
return before_options
|
||||||
|
|
||||||
|
def read(self) -> bytes:
|
||||||
|
if self.regenerating:
|
||||||
|
return FILL
|
||||||
|
self.already_read += 1
|
||||||
|
ret: bytes = self.source.read()
|
||||||
|
if ret:
|
||||||
|
self.loaded = True
|
||||||
|
elif not self.loaded:
|
||||||
|
if random.random() > .1:
|
||||||
|
self.regenerating = True
|
||||||
|
self.loop.create_task(self.regenerate())
|
||||||
|
return FILL
|
||||||
|
else:
|
||||||
|
print(f'dropped {self.origin}')
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
self.source.cleanup()
|
||||||
|
|
||||||
|
def can_be_skipped_by(self, member: discord.Member) -> bool:
|
||||||
|
permissions: discord.Permissions = member.guild_permissions
|
||||||
|
if permissions.administrator:
|
||||||
|
return True
|
||||||
|
elif permissions.manage_permissions:
|
||||||
|
return True
|
||||||
|
elif permissions.manage_guild:
|
||||||
|
return True
|
||||||
|
elif permissions.manage_channels:
|
||||||
|
return True
|
||||||
|
elif permissions.manage_messages:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return self.rby == member
|
||||||
|
|
||||||
|
def hybernate(self):
|
||||||
|
return {
|
||||||
|
'url': self.url,
|
||||||
|
'origin': self.origin,
|
||||||
|
'description': self.description,
|
||||||
|
'options': self.options,
|
||||||
|
'rby': self.rby.id,
|
||||||
|
'already_read': self.already_read,
|
||||||
|
'tor': self.tor,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def respawn(cls, guild: discord.Guild, respawn) -> 'YTAudio':
|
||||||
|
return YTAudio(
|
||||||
|
respawn['url'],
|
||||||
|
respawn['origin'],
|
||||||
|
respawn['description'],
|
||||||
|
respawn['options'],
|
||||||
|
guild.get_member(respawn['rby']) or await guild.fetch_member(respawn['rby']),
|
||||||
|
respawn['already_read'],
|
||||||
|
respawn.get('tor', False)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def regenerate(self):
|
||||||
|
try:
|
||||||
|
print(f'regenerating {self.origin}')
|
||||||
|
self.url = await real_url(self.origin, True, self.tor)
|
||||||
|
self.source.cleanup()
|
||||||
|
self.set_source()
|
||||||
|
print(f'regenerated {self.origin}')
|
||||||
|
finally:
|
||||||
|
self.regenerating = False
|
Loading…
Reference in New Issue
Block a user