diff --git a/v6d3music/run-bot.py b/v6d3music/run-bot.py index b2539f4..a2a48e6 100644 --- a/v6d3music/run-bot.py +++ b/v6d3music/run-bot.py @@ -9,7 +9,7 @@ import subprocess import time from collections import deque from io import StringIO -from typing import Optional, AsyncIterable, Any +from typing import Optional, AsyncIterable, Any, Iterable # noinspection PyPackageRequirements import discord @@ -17,7 +17,7 @@ import nacl.hash import youtube_dl from ptvp35 import Db, KVJson from v6d1tokens.client import request_token -from v6d2ctx.context import Context, at, escape, monitor, Benchmark, Explicit +from v6d2ctx.context import Context, at, escape, monitor, Benchmark, Explicit, Implicit from v6d2ctx.handle_content import handle_content from v6d2ctx.lock_for import lock_for from v6d2ctx.serve import serve @@ -75,7 +75,7 @@ async def restore_vcs(): @client.event async def on_ready(): - print("ready") + print('ready') await client.change_presence(activity=discord.Game( name='феноменально', )) @@ -92,6 +92,31 @@ 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.FFmpegPCMAudio @@ -127,30 +152,11 @@ class YTAudio(discord.AudioSource): self.already_read = already_read self.set_source() - def _speed_quotient(self) -> float: - options = self.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 - return quotient - - def speed_quotient(self) -> float: - return max(0.5, min(2.0, self._speed_quotient())) + def set_seconds(self, seconds: float): + self.set_already_read(round(seconds / sparq(self.options))) def source_seconds(self) -> float: - return self.already_read * self.speed_quotient() * discord.opus.Encoder.FRAME_LENGTH / 1000 + return self.already_read * sparq(self.options) def source_timecode(self) -> str: seconds = round(self.source_seconds()) @@ -266,6 +272,12 @@ class YTAudio(discord.AudioSource): 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() @@ -328,31 +340,22 @@ class QueueAudio(discord.AudioSource): return False def clear(self, member: discord.Member) -> None: - permissions: discord.Permissions = member.guild_permissions - if permissions.administrator: - self.cleanup() - else: - raise Explicit('not an administrator') + assert_admin(member) + self.cleanup() def swap(self, member: discord.Member, a: int, b: int) -> None: - permissions: discord.Permissions = member.guild_permissions - if permissions.administrator: - if max(a, b) >= len(self.queue): - return - self.queue[a], self.queue[b] = self.queue[b], self.queue[a] - else: - raise Explicit('not an administrator') + assert_admin(member) + if max(a, b) >= len(self.queue): + return + self.queue[a], self.queue[b] = self.queue[b], self.queue[a] def move(self, member: discord.Member, a: int, b: int) -> None: - permissions: discord.Permissions = member.guild_permissions - if permissions.administrator: - if max(a, b) >= len(self.queue): - return - audio = self.queue[a] - self.queue.remove(audio) - self.queue.insert(b, audio) - else: - raise Explicit('not an administrator') + assert_admin(member) + if max(a, b) >= len(self.queue): + return + audio = self.queue[a] + self.queue.remove(audio) + self.queue.insert(b, audio) async def format(self) -> str: stream = StringIO() @@ -375,13 +378,11 @@ class MainAudio(discord.PCMVolumeTransformer): super().__init__(self.queue, volume=volume) async def set(self, volume: float, member: discord.Member): + assert_admin(member) if volume < 0.01: raise Explicit('volume too small') if volume > 1: raise Explicit('volume too big') - permissions: discord.Permissions = member.guild_permissions - if not permissions.administrator: - raise Explicit('not an administrator') self.volume = volume await volume_db.set(member.guild.id, volume) @@ -469,14 +470,17 @@ async def real_url(url: str, override: bool) -> str: 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) -> YTAudio: if effects: - permissions: discord.Permissions = ctx.member.guild_permissions - if not permissions.administrator: - raise Explicit("not an administrator") + if effects not in allowed_effects: + assert_admin(ctx.member) if not set(effects) <= set(string.ascii_letters + string.digits + '*,=+-/()|.^:_'): raise Explicit('malformed effects') - options = f'-af {shlex.quote(effects)}' + options = options_for_effects(effects) else: options = None return YTAudio( @@ -494,8 +498,8 @@ async def entries_for_url(url: str) -> AsyncIterable[ ]: info = await aextract( { - "playlistend": 128, - "logtostderr": True + 'playlistend': 128, + 'logtostderr': True }, url, download=False, @@ -530,6 +534,8 @@ presets: dict[str, str] = { 'difference': 'aeval=val(0)-val(1)|val(1)-val(0)', 'mono': 'aeval=.5*val(0)+.5*val(1)|.5*val(1)+.5*val(0)', } +allowed_presets = ['bassboost', 'bassbooboost', 'nightcore', 'mono'] +allowed_effects = {'', *(presets[key] for key in allowed_presets)} async def entries_effects_for_args(args: list[str]) -> AsyncIterable[tuple[dict[str, Any], str, int]]: @@ -543,17 +549,17 @@ async def entries_effects_for_args(args: list[str]) -> AsyncIterable[tuple[dict[ effects = None case _: raise RuntimeError - timestamp = 0 + seconds = 0 match args: case [h, m, s, *args] if h.isdecimal() and m.isdecimal() and s.isdecimal(): - timestamp = 3600 * int(h) + 60 * int(m) + int(s) + seconds = 3600 * int(h) + 60 * int(m) + int(s) case [m, s, *args] if m.isdecimal() and s.isdecimal(): - timestamp = 60 * int(m) + int(s) + seconds = 60 * int(m) + int(s) case [s, *args] if s.isdecimal(): - timestamp = int(s) + seconds = int(s) case [*args]: pass - already_read = timestamp * 1000 / discord.opus.Encoder.FRAME_LENGTH + already_read = round(seconds / sparq(options_for_effects(effects))) async for info in entries_for_url(url): yield info, effects, already_read @@ -573,39 +579,49 @@ async def yt_audios(ctx: Context, args: list[str]) -> AsyncIterable[YTAudio]: mainasrcs: dict[discord.Guild, MainAudio] = {} +async def catch(ctx: Context, args: list[str], reply: str, *catched: (Iterable[str] | str)): + catched = {(case,) if isinstance(case, str) else tuple(case) for case in catched} + if tuple(args) in catched: + await ctx.reply(reply.strip()) + raise Implicit + + @at('commands', '/') @at('commands', 'play') async def play(ctx: Context, args: list[str]) -> None: - match args: - case [] | ['help']: - await ctx.reply(''' + await catch( + ctx, args, + f''' `play ...args` `play url [- effects] ...args` -'''.strip()) - case _: - async with lock_for(ctx.guild, 'not in a guild'): - queue = await queue_for(ctx, create=True) - async for audio in yt_audios(ctx, args): - queue.append(audio) - await ctx.reply('done') +`play url [+ preset] ...args` +presets: {shlex.join(allowed_presets)} +''', + (), 'help' + ) + async with lock_for(ctx.guild, 'not in a guild'): + queue = await queue_for(ctx, create=True) + async for audio in yt_audios(ctx, args): + queue.append(audio) + await ctx.reply('done') async def raw_vc_for(ctx: Context) -> discord.VoiceClient: if ctx.guild is None: - raise Explicit("not in a guild") + raise Explicit('not in a guild') vc: discord.VoiceProtocol = ctx.guild.voice_client if vc is None or isinstance(vc, discord.VoiceClient) and not vc.is_connected(): vs: discord.VoiceState = ctx.member.voice if vs is None: - raise Explicit("not connected") + raise Explicit('not connected') vch: discord.VoiceChannel = vs.channel if vch is None: - raise Explicit("not connected") + raise Explicit('not connected') try: vc: discord.VoiceProtocol = await vch.connect() except discord.ClientException: await ctx.guild.fetch_channels() - raise Explicit("try again later") + raise Explicit('try again later') assert isinstance(vc, discord.VoiceClient) return vc @@ -620,7 +636,7 @@ async def main_for_raw_vc(vc: discord.VoiceClient, *, create: bool) -> MainAudio MainAudio(await QueueAudio.create(vc.guild), volume=volume_db.get(vc.guild.id, 0.2)) ) else: - raise Explicit("not playing") + raise Explicit('not playing') if vc.source != source or create and not vc.is_playing(): vc.play(source) return source @@ -647,12 +663,10 @@ async def queue_for(ctx: Context, *, create: bool) -> QueueAudio: @at('commands', 'skip') async def skip(ctx: Context, args: list[str]) -> None: - match args: - case ['help']: - await ctx.reply(''' + await catch(ctx, args, ''' `skip [first] [last]` -'''.strip()) - return +''', 'help') + match args: case []: queue = await queue_for(ctx, create=False) queue.skip_at(0, ctx.member) @@ -667,34 +681,58 @@ async def skip(ctx: Context, args: list[str]) -> None: if not queue.skip_at(pos0, ctx.member): pos0 += 1 case _: - raise Explicit("misformatted") + raise Explicit('misformatted') await ctx.reply('done') @at('commands', 'to') async def skip_to(ctx: Context, args: list[str]) -> None: + await catch(ctx, args, ''' +`to [[h]] [m] s` +''', 'help') match args: - case ['help']: - await ctx.reply('`to [[h]] [m] s`') - return case [h, m, s] if h.isdecimal() and m.isdecimal() and s.isdecimal(): - timestamp = 3600 * int(h) + 60 * int(m) + int(s) + seconds = 3600 * int(h) + 60 * int(m) + int(s) case [m, s] if m.isdecimal() and s.isdecimal(): - timestamp = 60 * int(m) + int(s) + seconds = 60 * int(m) + int(s) case [s] if s.isdecimal(): - timestamp = int(s) + seconds = int(s) case _: - raise Explicit("misformatted") - already_read = timestamp * 1000 / discord.opus.Encoder.FRAME_LENGTH + raise Explicit('misformatted') queue = await queue_for(ctx, create=False) - queue.queue[0].set_already_read(already_read) + queue.queue[0].set_seconds(seconds) + + +@at('commands', 'effects') +async def effects_(ctx: Context, args: list[str]) -> None: + await catch(ctx, args, ''' +`effects - effects` +`effects + preset` +''', 'help') + match args: + case ['-', effects]: + pass + case ['+', preset]: + effects = presets[preset] + case _: + raise Explicit('misformatted') + assert_admin(ctx.member) + queue = await queue_for(ctx, create=False) + yta = queue.queue[0] + seconds = yta.source_seconds() + yta.options = options_for_effects(effects) + yta.set_seconds(seconds) @at('commands', 'queue') async def queue_(ctx: Context, args: list[str]) -> None: + await catch(ctx, args, ''' +`queue` +`queue clear` +`queue resume` +`queue pause` +''', 'help') match args: - case ['help']: - await ctx.reply('current queue') case []: await ctx.long((await (await queue_for(ctx, create=False)).format()).strip() or 'no queue') case ['clear']: @@ -710,43 +748,46 @@ async def queue_(ctx: Context, args: list[str]) -> None: vc.pause() await ctx.reply('done') case _: - raise Explicit("misformatted") + raise Explicit('misformatted') @at('commands', 'swap') async def swap(ctx: Context, args: list[str]) -> None: + await catch(ctx, args, ''' +`swap a b` +''', 'help') match args: - case ['help']: - await ctx.reply('`swap a b`') case [a, b] if a.isdecimal() and b.isdecimal(): a, b = int(a), int(b) (await queue_for(ctx, create=False)).swap(ctx.member, a, b) case _: - raise Explicit("misformatted") + raise Explicit('misformatted') @at('commands', 'move') async def move(ctx: Context, args: list[str]) -> None: + await catch(ctx, args, ''' +`move a b` +''', 'help') match args: - case ['help']: - await ctx.reply('`move a b`') case [a, b] if a.isdecimal() and b.isdecimal(): a, b = int(a), int(b) (await queue_for(ctx, create=False)).move(ctx.member, a, b) case _: - raise Explicit("misformatted") + raise Explicit('misformatted') @at('commands', 'volume') async def volume_(ctx: Context, args: list[str]) -> None: + await catch(ctx, args, ''' +`volume volume` +''', 'help') match args: - case ['help']: - await ctx.reply('`volume 0.2`') case [volume]: volume = float(volume) await (await main_for(ctx, create=False)).set(volume, ctx.member) case _: - raise Explicit("misformatted") + raise Explicit('misformatted') @at('commands', 'pause')