diff --git a/v6d3music/run-bot.py b/v6d3music/run-bot.py index 0e71f72..b2539f4 100644 --- a/v6d3music/run-bot.py +++ b/v6d3music/run-bot.py @@ -2,6 +2,7 @@ import asyncio import concurrent.futures import os import random +import re import shlex import string import subprocess @@ -112,14 +113,78 @@ class YTAudio(discord.AudioSource): self.loaded = False self.regenerating = False self.set_source() + self._durations: dict[str, str] = {} def set_source(self): + self.schedule_duration_update() self.source = discord.FFmpegPCMAudio( self.url, options=self.options, before_options=self.before_options() ) + def set_already_read(self, already_read: int): + 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 source_seconds(self) -> float: + return self.already_read * self.speed_quotient() * discord.opus.Encoder.FRAME_LENGTH / 1000 + + 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, '') + p = subprocess.Popen( + f'ffprobe -i {shlex.quote(url)}' + ' -show_entries format=duration -v quiet -of csv="p=0" -sexagesimal', + 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: @@ -127,7 +192,9 @@ class YTAudio(discord.AudioSource): '-reconnect 1 -reconnect_at_eof 0 -reconnect_streamed 1 -reconnect_delay_max 10 -copy_unknown' ) if self.already_read: - before_options += f' -ss {self.already_read * discord.opus.Encoder.FRAME_LENGTH / 1000}' + before_options += ( + f' -ss {self.source_seconds()}' + ) return before_options def read(self) -> bytes: @@ -287,10 +354,10 @@ class QueueAudio(discord.AudioSource): else: raise Explicit('not an administrator') - def format(self) -> str: + async def format(self) -> str: stream = StringIO() for i, audio in enumerate(list(self.queue)): - stream.write(f'`[{i}]` {audio.description}\n') + stream.write(f'`[{i}]` `{audio.source_timecode()} / {audio.duration()}` {audio.description}\n') return stream.getvalue() def cleanup(self): @@ -415,7 +482,7 @@ async def create_ytaudio(ctx: Context, info: dict[str, Any], effects: Optional[s return YTAudio( await real_url(info['url'], False), info['url'], - f'{escape(info.get("title"))} `Rby` {ctx.member}', + f'{escape(info.get("title", "unknown"))} `Rby` {ctx.member}', options, ctx.member, already_read @@ -506,6 +573,7 @@ async def yt_audios(ctx: Context, args: list[str]) -> AsyncIterable[YTAudio]: mainasrcs: dict[discord.Guild, MainAudio] = {} +@at('commands', '/') @at('commands', 'play') async def play(ctx: Context, args: list[str]) -> None: match args: @@ -553,7 +621,7 @@ async def main_for_raw_vc(vc: discord.VoiceClient, *, create: bool) -> MainAudio ) else: raise Explicit("not playing") - if vc.source != source or not vc.is_playing(): + if vc.source != source or create and not vc.is_playing(): vc.play(source) return source @@ -603,19 +671,44 @@ async def skip(ctx: Context, args: list[str]) -> None: await ctx.reply('done') +@at('commands', 'to') +async def skip_to(ctx: Context, args: list[str]) -> None: + 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) + case [m, s] if m.isdecimal() and s.isdecimal(): + timestamp = 60 * int(m) + int(s) + case [s] if s.isdecimal(): + timestamp = int(s) + case _: + raise Explicit("misformatted") + already_read = timestamp * 1000 / discord.opus.Encoder.FRAME_LENGTH + queue = await queue_for(ctx, create=False) + queue.queue[0].set_already_read(already_read) + + @at('commands', 'queue') async def queue_(ctx: Context, args: list[str]) -> None: match args: case ['help']: await ctx.reply('current queue') case []: - await ctx.long((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') case ['clear']: (await queue_for(ctx, create=False)).clear(ctx.member) await ctx.reply('done') case ['resume']: async with lock_for(ctx.guild, 'not in a guild'): await queue_for(ctx, create=True) + await ctx.reply('done') + case ['pause']: + async with lock_for(ctx.guild, 'not in a guild'): + vc = await vc_for(ctx, create=True) + vc.pause() + await ctx.reply('done') case _: raise Explicit("misformatted") @@ -686,7 +779,8 @@ async def save_vcs(): for vc in list(client.voice_clients): await asyncio.sleep(0.01) if vc.is_playing(): - vcs.append((vc.guild.id, vc.channel.id, vc.is_paused())) + if vc.guild is not None and vc.channel is not None: + vcs.append((vc.guild.id, vc.channel.id, vc.is_paused())) queue_db.set_nowait('vcs', vcs)