app + refactor

This commit is contained in:
AF 2022-06-19 02:57:30 +03:00
parent 1f3ec55a48
commit aad21d7135
25 changed files with 828 additions and 318 deletions

View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

View 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>

View 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=&quot;3&quot; --memory=&quot;4000mb&quot; --network=&quot;v6d&quot;" />
<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>

View 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>

View File

@ -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" />

View File

@ -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
View 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
View 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)

View File

@ -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):
try:
extracted = youtube_dl.YoutubeDL(params=params).extract_info(url, **kwargs) extracted = youtube_dl.YoutubeDL(params=params).extract_info(url, **kwargs)
if 'entries' in extracted: if 'entries' in extracted:
extracted['entries'] = list(extracted['entries']) extracted['entries'] = list(extracted['entries'])
return extracted 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}

View File

@ -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
View 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
View 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
View 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
View File

@ -0,0 +1,4 @@
html, body {
color: white;
background: black;
}

58
v6d3music/html/main.js Normal file
View 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
View 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

View File

@ -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(
activity=discord.Game(
name='феноменально', 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 main(): async def start_app():
async with volume_db, queue_db, cache_db: await MusicAppFactory.start(session_db, client)
await client.login(token)
async def setup_tasks():
loop.create_task(save_job()) loop.create_task(save_job())
loop.create_task(start_app())
async def main():
async with volume_db, queue_db, cache_db, session_db:
await client.login(token)
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')

View File

View 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')

View 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
View File

@ -0,0 +1,3 @@
import discord
FILL = b'\x00' * discord.opus.Encoder.FRAME_SIZE

View 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
View 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

View 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
View 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