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">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/v6d3music/html" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||
</content>
|
||||
<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 apt-get install -y tor obfs4proxy
|
||||
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"]
|
||||
|
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
|
||||
|
||||
eerror = namedtuple('eerror', ['content'])
|
||||
|
||||
|
||||
def extract(params: dict, url: str, kwargs: dict):
|
||||
extracted = youtube_dl.YoutubeDL(params=params).extract_info(url, **kwargs)
|
||||
if 'entries' in extracted:
|
||||
extracted['entries'] = list(extracted['entries'])
|
||||
return extracted
|
||||
try:
|
||||
extracted = youtube_dl.YoutubeDL(params=params).extract_info(url, **kwargs)
|
||||
if 'entries' in 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 subprocess
|
||||
import time
|
||||
from threading import Thread
|
||||
from typing import Optional
|
||||
|
||||
import discord
|
||||
|
||||
from v6d3music.utils.fill import FILL
|
||||
|
||||
|
||||
class FFmpegNormalAudio(discord.FFmpegAudio):
|
||||
def __init__(
|
||||
@ -31,11 +34,45 @@ class FFmpegNormalAudio(discord.FFmpegAudio):
|
||||
|
||||
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):
|
||||
ret = self._stdout.read(discord.opus.Encoder.FRAME_SIZE)
|
||||
ret = self._raw_read()
|
||||
if len(ret) != discord.opus.Encoder.FRAME_SIZE:
|
||||
if self._process.poll() is None:
|
||||
time.sleep(.5)
|
||||
print('poll')
|
||||
return FILL
|
||||
return b''
|
||||
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 json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shlex
|
||||
import string
|
||||
import subprocess
|
||||
import time
|
||||
from collections import deque
|
||||
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 nacl.hash
|
||||
from ptvp35 import Db, KVJson
|
||||
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.lock_for import lock_for
|
||||
from v6d2ctx.serve import serve
|
||||
|
||||
import v6d3music.extract
|
||||
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()
|
||||
asyncio.set_event_loop(loop)
|
||||
@ -43,9 +47,7 @@ client = discord.Client(
|
||||
)
|
||||
volume_db = Db(myroot / 'volume.db', kvrequest_type=KVJson)
|
||||
queue_db = Db(myroot / 'queue.db', kvrequest_type=KVJson)
|
||||
cache_db = Db(myroot / 'cache.db', kvrequest_type=KVJson)
|
||||
cache_root = myroot / 'cache'
|
||||
cache_root.mkdir(exist_ok=True)
|
||||
session_db = Db(myroot / 'session.db', kvrequest_type=KVJson)
|
||||
|
||||
vcs_restored = False
|
||||
|
||||
@ -78,9 +80,11 @@ async def restore_vcs():
|
||||
@client.event
|
||||
async def on_ready():
|
||||
print('ready')
|
||||
await client.change_presence(activity=discord.Game(
|
||||
name='феноменально',
|
||||
))
|
||||
await client.change_presence(
|
||||
activity=discord.Game(
|
||||
name='феноменально',
|
||||
)
|
||||
)
|
||||
if not vcs_restored:
|
||||
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`')
|
||||
|
||||
|
||||
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):
|
||||
def __init__(self, guild: discord.Guild, respawned: list[YTAudio]):
|
||||
self.queue: deque[YTAudio] = deque()
|
||||
@ -400,21 +207,6 @@ class MainAudio(discord.PCMVolumeTransformer):
|
||||
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):
|
||||
with Benchmark('AEX'):
|
||||
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())
|
||||
|
||||
|
||||
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(
|
||||
ctx: Context, info: dict[str, Any], effects: Optional[str], already_read: int, tor: bool
|
||||
) -> YTAudio:
|
||||
@ -550,6 +275,8 @@ async def entries_for_url(url: str, tor: bool) -> AsyncIterable[
|
||||
download=False,
|
||||
process=False
|
||||
)
|
||||
if '__error__' in info:
|
||||
raise Explicit('extraction error\n' + info.get('__error_str__'))
|
||||
if 'entries' in info:
|
||||
for entry in info['entries']:
|
||||
yield entry
|
||||
@ -587,13 +314,20 @@ allowed_presets = ['bassboost', 'bassbooboost', 'nightcore', 'daycore', 'mono']
|
||||
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]:
|
||||
while args:
|
||||
match args:
|
||||
case [url, '-', effects, *args]:
|
||||
pass
|
||||
case [url, '+', preset, *args]:
|
||||
effects = presets[preset]
|
||||
effects = effects_for_preset(preset)
|
||||
case [url, *args]:
|
||||
effects = None
|
||||
case _:
|
||||
@ -718,9 +452,11 @@ async def queue_for(ctx: Context, *, create: bool) -> QueueAudio:
|
||||
|
||||
@at('commands', 'skip')
|
||||
async def skip(ctx: Context, args: list[str]) -> None:
|
||||
await catch(ctx, args, '''
|
||||
await catch(
|
||||
ctx, args, '''
|
||||
`skip [first] [last]`
|
||||
''', 'help')
|
||||
''', 'help'
|
||||
)
|
||||
match args:
|
||||
case []:
|
||||
queue = await queue_for(ctx, create=False)
|
||||
@ -742,9 +478,11 @@ async def skip(ctx: Context, args: list[str]) -> None:
|
||||
|
||||
@at('commands', 'to')
|
||||
async def skip_to(ctx: Context, args: list[str]) -> None:
|
||||
await catch(ctx, args, '''
|
||||
await catch(
|
||||
ctx, args, '''
|
||||
`to [[h]] [m] s`
|
||||
''', 'help')
|
||||
''', 'help'
|
||||
)
|
||||
match args:
|
||||
case [h, m, s] if h.isdecimal() and m.isdecimal() and s.isdecimal():
|
||||
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')
|
||||
async def effects_(ctx: Context, args: list[str]) -> None:
|
||||
await catch(ctx, args, '''
|
||||
await catch(
|
||||
ctx, args, '''
|
||||
`effects - effects`
|
||||
`effects + preset`
|
||||
''', 'help')
|
||||
''', 'help'
|
||||
)
|
||||
match args:
|
||||
case ['-', effects]:
|
||||
pass
|
||||
case ['+', preset]:
|
||||
effects = presets[preset]
|
||||
effects = effects_for_preset(preset)
|
||||
case _:
|
||||
raise Explicit('misformatted')
|
||||
assert_admin(ctx.member)
|
||||
@ -781,12 +521,14 @@ async def effects_(ctx: Context, args: list[str]) -> None:
|
||||
|
||||
@at('commands', 'queue')
|
||||
async def queue_(ctx: Context, args: list[str]) -> None:
|
||||
await catch(ctx, args, '''
|
||||
await catch(
|
||||
ctx, args, '''
|
||||
`queue`
|
||||
`queue clear`
|
||||
`queue resume`
|
||||
`queue pause`
|
||||
''', 'help')
|
||||
''', 'help'
|
||||
)
|
||||
match args:
|
||||
case []:
|
||||
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')
|
||||
async def swap(ctx: Context, args: list[str]) -> None:
|
||||
await catch(ctx, args, '''
|
||||
await catch(
|
||||
ctx, args, '''
|
||||
`swap a b`
|
||||
''', 'help')
|
||||
''', 'help'
|
||||
)
|
||||
match args:
|
||||
case [a, b] if a.isdecimal() and b.isdecimal():
|
||||
a, b = int(a), int(b)
|
||||
@ -821,9 +565,11 @@ async def swap(ctx: Context, args: list[str]) -> None:
|
||||
|
||||
@at('commands', 'move')
|
||||
async def move(ctx: Context, args: list[str]) -> None:
|
||||
await catch(ctx, args, '''
|
||||
await catch(
|
||||
ctx, args, '''
|
||||
`move a b`
|
||||
''', 'help')
|
||||
''', 'help'
|
||||
)
|
||||
match args:
|
||||
case [a, b] if a.isdecimal() and b.isdecimal():
|
||||
a, b = int(a), int(b)
|
||||
@ -834,9 +580,11 @@ async def move(ctx: Context, args: list[str]) -> None:
|
||||
|
||||
@at('commands', 'volume')
|
||||
async def volume_(ctx: Context, args: list[str]) -> None:
|
||||
await catch(ctx, args, '''
|
||||
await catch(
|
||||
ctx, args, '''
|
||||
`volume volume`
|
||||
''', 'help')
|
||||
''', 'help'
|
||||
)
|
||||
match args:
|
||||
case [volume]:
|
||||
volume = float(volume)
|
||||
@ -895,10 +643,19 @@ async def save_job():
|
||||
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 with volume_db, queue_db, cache_db:
|
||||
async with volume_db, queue_db, cache_db, session_db:
|
||||
await client.login(token)
|
||||
loop.create_task(save_job())
|
||||
loop.create_task(setup_tasks())
|
||||
if os.getenv('v6monitor'):
|
||||
loop.create_task(monitor())
|
||||
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