diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 0000000..a55e7a1
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..dd4c951
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Docker_Image.xml b/.idea/runConfigurations/Docker_Image.xml
new file mode 100644
index 0000000..7d4cb6d
--- /dev/null
+++ b/.idea/runConfigurations/Docker_Image.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Dockerfile.xml b/.idea/runConfigurations/Dockerfile.xml
new file mode 100644
index 0000000..d00dc3c
--- /dev/null
+++ b/.idea/runConfigurations/Dockerfile.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/v6d3music.iml b/.idea/v6d3music.iml
index b720d8c..8a67e66 100644
--- a/.idea/v6d3music.iml
+++ b/.idea/v6d3music.iml
@@ -2,6 +2,7 @@
+
diff --git a/Dockerfile b/Dockerfile
index 3f1c5c7..4ef7acf 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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"]
diff --git a/v6d3music/app.py b/v6d3music/app.py
new file mode 100644
index 0000000..74f1322
--- /dev/null
+++ b/v6d3music/app.py
@@ -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())
diff --git a/v6d3music/cache_url.py b/v6d3music/cache_url.py
new file mode 100644
index 0000000..078185e
--- /dev/null
+++ b/v6d3music/cache_url.py
@@ -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)
diff --git a/v6d3music/extract.py b/v6d3music/extract.py
index 65a11cb..0c0a4ef 100644
--- a/v6d3music/extract.py
+++ b/v6d3music/extract.py
@@ -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}
diff --git a/v6d3music/ffmpegnormalaudio.py b/v6d3music/ffmpegnormalaudio.py
index 7beefb5..223f2e5 100644
--- a/v6d3music/ffmpegnormalaudio.py
+++ b/v6d3music/ffmpegnormalaudio.py
@@ -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
diff --git a/v6d3music/html/auth.html b/v6d3music/html/auth.html
new file mode 100644
index 0000000..e9ee04f
--- /dev/null
+++ b/v6d3music/html/auth.html
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/v6d3music/html/home.html b/v6d3music/html/home.html
new file mode 100644
index 0000000..64b048c
--- /dev/null
+++ b/v6d3music/html/home.html
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/v6d3music/html/login.html b/v6d3music/html/login.html
new file mode 100644
index 0000000..877a2cd
--- /dev/null
+++ b/v6d3music/html/login.html
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/v6d3music/html/main.css b/v6d3music/html/main.css
new file mode 100644
index 0000000..8411934
--- /dev/null
+++ b/v6d3music/html/main.css
@@ -0,0 +1,4 @@
+html, body {
+ color: white;
+ background: black;
+}
diff --git a/v6d3music/html/main.js b/v6d3music/html/main.js
new file mode 100644
index 0000000..eb2c44a
--- /dev/null
+++ b/v6d3music/html/main.js
@@ -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;
+};
diff --git a/v6d3music/real_url.py b/v6d3music/real_url.py
new file mode 100644
index 0000000..bc91c7e
--- /dev/null
+++ b/v6d3music/real_url.py
@@ -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
diff --git a/v6d3music/run-bot.py b/v6d3music/run-bot.py
index 2c230d9..3e8e893 100644
--- a/v6d3music/run-bot.py
+++ b/v6d3music/run-bot.py
@@ -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')
diff --git a/v6d3music/utils/__init__.py b/v6d3music/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/v6d3music/utils/assert_admin.py b/v6d3music/utils/assert_admin.py
new file mode 100644
index 0000000..011d34f
--- /dev/null
+++ b/v6d3music/utils/assert_admin.py
@@ -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')
diff --git a/v6d3music/utils/bytes_hash.py b/v6d3music/utils/bytes_hash.py
new file mode 100644
index 0000000..9174d88
--- /dev/null
+++ b/v6d3music/utils/bytes_hash.py
@@ -0,0 +1,5 @@
+import nacl.hash
+
+
+def bytes_hash(b: bytes) -> str:
+ return nacl.hash.sha256(b).decode()
diff --git a/v6d3music/utils/fill.py b/v6d3music/utils/fill.py
new file mode 100644
index 0000000..2b7e9e4
--- /dev/null
+++ b/v6d3music/utils/fill.py
@@ -0,0 +1,3 @@
+import discord
+
+FILL = b'\x00' * discord.opus.Encoder.FRAME_SIZE
diff --git a/v6d3music/utils/options_for_effects.py b/v6d3music/utils/options_for_effects.py
new file mode 100644
index 0000000..ee00c09
--- /dev/null
+++ b/v6d3music/utils/options_for_effects.py
@@ -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
diff --git a/v6d3music/utils/sparq.py b/v6d3music/utils/sparq.py
new file mode 100644
index 0000000..e48525e
--- /dev/null
+++ b/v6d3music/utils/sparq.py
@@ -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
diff --git a/v6d3music/utils/speed_quotient.py b/v6d3music/utils/speed_quotient.py
new file mode 100644
index 0000000..b541b88
--- /dev/null
+++ b/v6d3music/utils/speed_quotient.py
@@ -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
diff --git a/v6d3music/ytaudio.py b/v6d3music/ytaudio.py
new file mode 100644
index 0000000..5ef61fb
--- /dev/null
+++ b/v6d3music/ytaudio.py
@@ -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