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