diff --git a/Docs.Dockerfile b/Docs.Dockerfile new file mode 100644 index 0000000..a08ec12 --- /dev/null +++ b/Docs.Dockerfile @@ -0,0 +1,20 @@ +# syntax=docker/dockerfile:1 +FROM python:3.10 +RUN apt-get update +RUN apt-get install -y python3-sphinx node.js npm +RUN npm install -g http-server +RUN pip install pydata-sphinx-theme +WORKDIR /app/ +ENV v6root=/app/data/ +RUN mkdir ${v6root} +COPY base.requirements.txt base.requirements.txt +RUN pip install -r base.requirements.txt +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt +COPY docs/Makefile docs/Makefile +COPY v6d3music v6d3music +COPY docs/source docs/source +WORKDIR /app/docs/ +RUN make html +WORKDIR /app/docs/build/html/ +CMD [ "http-server", "-p", "80" ] diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/source/administration.rst b/docs/source/administration.rst new file mode 100644 index 0000000..d856935 --- /dev/null +++ b/docs/source/administration.rst @@ -0,0 +1,2 @@ +For Administrators +================== diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..d92cc47 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,33 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +import os.path +import sys +project = 'parrrate-music' +copyright = '2022, PARRRATE TNV' +author = 'PARRRATE TNV' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', +] + +templates_path = ['_templates'] +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'pydata_sphinx_theme' +html_static_path = ['_static'] + + +sys.path.insert(0, os.path.abspath('../..')) diff --git a/docs/source/development.rst b/docs/source/development.rst new file mode 100644 index 0000000..4c1a6f6 --- /dev/null +++ b/docs/source/development.rst @@ -0,0 +1,2 @@ +For Developers +============== diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..f0ae4f9 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,21 @@ +Welcome to parrrate-music's documentation! +========================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + usage + administration + operation + development + modules + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..7e95561 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,7 @@ +v6d3music +========= + +.. toctree:: + :maxdepth: 4 + + v6d3music diff --git a/docs/source/operation.rst b/docs/source/operation.rst new file mode 100644 index 0000000..0355e93 --- /dev/null +++ b/docs/source/operation.rst @@ -0,0 +1,2 @@ +For Operators +============= diff --git a/docs/source/usage.rst b/docs/source/usage.rst new file mode 100644 index 0000000..248e30f --- /dev/null +++ b/docs/source/usage.rst @@ -0,0 +1,2 @@ +For Users +========= diff --git a/docs/source/v6d3music.rst b/docs/source/v6d3music.rst new file mode 100644 index 0000000..2346530 --- /dev/null +++ b/docs/source/v6d3music.rst @@ -0,0 +1,102 @@ +v6d3music module +================ + +Root modules +------------ + +.. automodule:: v6d3music.api + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: v6d3music.app + :members: + :undoc-members: + :show-inheritance: + +Core modules +------------ + +.. automodule:: v6d3music.core.caching + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: v6d3music.core.create_audio + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: v6d3music.core.default_effects + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: v6d3music.core.ffmpegnormalaudio + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: v6d3music.core.mainaudio + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: v6d3music.core.mainservice + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: v6d3music.core.queueaudio + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: v6d3music.core.real_url + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: v6d3music.core.ytaservicing + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: v6d3music.core.ytaudio + :members: + :undoc-members: + :show-inheritance: + +v6d2ctx module +============== + +Library/Framework modules +------------------------- + +.. automodule:: v6d2ctx.at_of + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: v6d2ctx.context + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: v6d2ctx.handle_content + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: v6d2ctx.serve + :members: + :undoc-members: + :show-inheritance: + +Instrumentation +--------------- + +.. automodule:: v6d2ctx.pain + :members: + :undoc-members: + :show-inheritance: diff --git a/setup.py b/setup.py deleted file mode 100644 index c28d08b..0000000 --- a/setup.py +++ /dev/null @@ -1,12 +0,0 @@ -from setuptools import setup - -setup( - name='v6d3music', - version='', - packages=['v6d3music'], - url='', - license='', - author='PARRRATE T&V', - author_email='', - description='' -) diff --git a/v6d3music/__init__.py b/v6d3music/__init__.py index e69de29..48ba684 100644 --- a/v6d3music/__init__.py +++ b/v6d3music/__init__.py @@ -0,0 +1,3 @@ +__all__ = ('api', 'app', 'commands', 'config') + +from . import api, app, commands, config diff --git a/v6d3music/api.py b/v6d3music/api.py index 4aa866e..8a5a0cf 100644 --- a/v6d3music/api.py +++ b/v6d3music/api.py @@ -115,7 +115,7 @@ class UserApi: try: return await self._api() except Explicit as e: - raise Api.ExplicitFailure(e) + raise Api.ExplicitFailure(e) from e except Api.MisusedApi as e: catches = self.request.get('catches', {}) if len(e.args) and (key := e.args[0]) in catches: diff --git a/v6d3music/commands.py b/v6d3music/commands.py index 0d9de1b..ed86ab2 100644 --- a/v6d3music/commands.py +++ b/v6d3music/commands.py @@ -1,6 +1,8 @@ import shlex from typing import Callable +from v6d2ctx.at_of import * +from v6d2ctx.context import * from v6d3music.core.default_effects import * from v6d3music.core.mainservice import * from v6d3music.utils.assert_admin import * @@ -9,10 +11,6 @@ from v6d3music.utils.effects_for_preset import * from v6d3music.utils.options_for_effects import * from v6d3music.utils.presets import * -from v6d2ctx.at_of import * -from v6d2ctx.context import * -from v6d2ctx.lock_for import * - __all__ = ('get_of',) @@ -42,7 +40,7 @@ def get_of(mainservice: MainService) -> Callable[[str], command_type]: ''', (), 'help' ) - async with lock_for(ctx.guild, 'not in a guild'): + async with mainservice.lock_for(ctx.guild): queue = await mainservice.context(ctx, create=True, force_play=False).queue() if ctx.message.attachments: if len(ctx.message.attachments) > 1: @@ -148,15 +146,36 @@ def get_of(mainservice: MainService) -> Callable[[str], command_type]: @at('repeat') async def repeat(ctx: Context, args: list[str]): match args: - case []: - n = 1 - case [n_] if n_.isdecimal(): + case [n_, *args] if n_.isdecimal(): n = int(n_) + case [*args]: + n = 1 case _: - raise Explicit('misformatted') + raise RuntimeError + match args: + case ['at', p_, *args] if p_.isdecimal(): + p = int(p_) + case [*args]: + p = 0 + case _: + raise RuntimeError + match args: + case ['to', t_, *args] if t_.isdecimal(): + t = int(t_) + case ['to', 'end']: + t = None + case [*args]: + t = p + 1 + case _: + raise RuntimeError + match args: + case []: + pass + case _: + raise Explicit('misformatted (extra arguments)') assert_admin(ctx.member) queue = await mainservice.context(ctx, create=False, force_play=False).queue() - queue.repeat(n) + queue.repeat(n, p, t) @at('shuffle') async def shuffle(ctx: Context, args: list[str]): @@ -202,24 +221,30 @@ def get_of(mainservice: MainService) -> Callable[[str], command_type]: ''', 'help' ) assert ctx.member is not None + limit = 100 + match args: + case [lstr, *args] if lstr.isdecimal(): + limit = int(lstr) + case [*args]: + pass match args: case []: await ctx.long( ( await ( await mainservice.context(ctx, create=True, force_play=False).queue() - ).format() + ).format(limit) ).strip() or 'no queue' ) case ['clear']: (await mainservice.context(ctx, create=False, force_play=False).queue()).clear(ctx.member) await ctx.reply('done') case ['resume']: - async with lock_for(ctx.guild, 'not in a guild'): + async with mainservice.lock_for(ctx.guild): await mainservice.context(ctx, create=True, force_play=True).vc() await ctx.reply('done') case ['pause']: - async with lock_for(ctx.guild, 'not in a guild'): + async with mainservice.lock_for(ctx.guild): vc = await mainservice.context(ctx, create=True, force_play=False).vc() vc.pause() await ctx.reply('done') diff --git a/v6d3music/core/caching.py b/v6d3music/core/caching.py index 0a2dd23..164c645 100644 --- a/v6d3music/core/caching.py +++ b/v6d3music/core/caching.py @@ -50,7 +50,7 @@ class Caching: await self.__db.set(f'cachable:{hurl}', True) async def cache_url(self, hurl: str, rurl: str, override: bool, tor: bool) -> None: - async with lock_for(('cache', hurl), 'cache failed'): + async with self.__locks.lock_for(('cache', hurl), 'cache failed'): await self._cache_url(hurl, rurl, override, tor) def get(self, hurl: str) -> str | None: @@ -59,6 +59,7 @@ class Caching: async def __aenter__(self) -> 'Caching': es = AsyncExitStack() async with es: + self.__locks = Locks() self.__db = await es.enter_async_context(DbFactory(myroot / 'cache.db', kvfactory=KVJson())) self.__tasks = set() self.__es = es.pop_all() diff --git a/v6d3music/core/ffmpegnormalaudio.py b/v6d3music/core/ffmpegnormalaudio.py index 086a020..026ccab 100644 --- a/v6d3music/core/ffmpegnormalaudio.py +++ b/v6d3music/core/ffmpegnormalaudio.py @@ -17,6 +17,7 @@ class FFmpegNormalAudio(discord.FFmpegAudio): self, source, *, executable='ffmpeg', pipe=False, stderr=None, before_options=None, options=None, tor: bool ): + self.source = source args = [] if tor: _tor_prefix = tor_prefix() diff --git a/v6d3music/core/mainservice.py b/v6d3music/core/mainservice.py index 3c498fd..8031731 100644 --- a/v6d3music/core/mainservice.py +++ b/v6d3music/core/mainservice.py @@ -4,10 +4,6 @@ from contextlib import AsyncExitStack from typing import AsyncIterable, TypeVar import discord - -from ptvp35 import * -from v6d2ctx.context import * -from v6d2ctx.lock_for import * from v6d3music.config import myroot from v6d3music.core.caching import * from v6d3music.core.default_effects import * @@ -19,6 +15,10 @@ from v6d3music.core.ytaudio import * from v6d3music.processing.pool import * from v6d3music.utils.argctx import * +from ptvp35 import * +from v6d2ctx.context import * +from v6d2ctx.lock_for import * + __all__ = ('MainService', 'MainDescriptor', 'MainContext') @@ -44,12 +44,12 @@ class MainService: raise Explicit('not connected') try: vc = await vch.connect() - except discord.ClientException: + except discord.ClientException as e: vc = member.guild.voice_client assert vc is not None await member.guild.fetch_channels() await vc.disconnect(force=True) - raise Explicit('try again later') + raise Explicit('try again later') from e assert isinstance(vc, discord.VoiceClient) return vc @@ -69,6 +69,7 @@ class MainService: async def __aenter__(self) -> 'MainService': async with AsyncExitStack() as es: + self.__locks = Locks() self.__volumes = await es.enter_async_context(DbFactory(myroot / 'volume.db', kvfactory=KVJson())) self.__queues = await es.enter_async_context(DbFactory(myroot / 'queue.db', kvfactory=KVJson())) self.__caching = await es.enter_async_context(Caching()) @@ -156,11 +157,14 @@ class MainService: if vc_is_paused: vc.pause() + def lock_for(self, guild: discord.Guild | None) -> asyncio.Lock: + return self.__locks.lock_for(guild, 'not in a guild') + async def restore_vc(self, vcgid: int, vccid: int, vc_is_paused: bool) -> None: try: print(f'vc restoring {vcgid}') guild: discord.Guild = await self.client.fetch_guild(vcgid) - async with lock_for(guild, 'not in a guild'): + async with self.lock_for(guild): await self._restore_vc(guild, vccid, vc_is_paused) except Exception as e: print(f'vc {vcgid} {vccid} {vc_is_paused} failed', e) diff --git a/v6d3music/core/queueaudio.py b/v6d3music/core/queueaudio.py index ac61051..f1f16b3 100644 --- a/v6d3music/core/queueaudio.py +++ b/v6d3music/core/queueaudio.py @@ -129,11 +129,13 @@ class QueueAudio(discord.AudioSource): self.queue.insert(b, audio) self.update_sources() - async def format(self) -> str: + async def format(self, limit=100) -> str: + if limit > 100: + raise Explicit('queue limit is too large') stream = StringIO() for i, audio in enumerate(lst := list(self.queue)): - if i >= (n := 100): - stream.write(f'cutting queue at {n} results, {len(lst) - n} remaining.\n') + if i >= limit: + stream.write(f'cutting queue at {limit} results, {len(lst) - limit} remaining.\n') break stream.write(f'`[{i}]` `{audio.source_timecode()} / {audio.duration()}` {audio.description}\n') return stream.getvalue() @@ -150,14 +152,20 @@ class QueueAudio(discord.AudioSource): audios = list(self.queue) return [await audio.pubjson(member) for audio, _ in zip(audios, range(limit))] - def repeat(self, n: int) -> None: + def repeat(self, n: int, p: int, t: int | None) -> None: if not self.queue: raise Explicit('empty queue') if n > 99: raise Explicit('too long') - audio = self.queue[0] + try: + audio = self.queue[p] + except IndexError: + raise Explicit('no track at that index') for _ in range(n): - self.queue.insert(1, audio.copy()) + if t is None: + self.queue.append(audio.copy()) + else: + self.queue.insert(t, audio.copy()) self.update_sources() def shuffle(self) -> None: diff --git a/v6d3music/core/ystate.py b/v6d3music/core/ystate.py index cebaf73..c5b4773 100644 --- a/v6d3music/core/ystate.py +++ b/v6d3music/core/ystate.py @@ -3,16 +3,21 @@ from collections import deque from contextlib import AsyncExitStack from typing import AsyncIterable, Iterable -from v6d2ctx.context import * from v6d3music.core.create_ytaudio import * from v6d3music.core.ytaservicing import * from v6d3music.core.ytaudio import * from v6d3music.processing.pool import * from v6d3music.utils.argctx import * +from v6d2ctx.context import * + __all__ = ('YState',) +class _Stop: + pass + + class YState: def __init__(self, servicing: YTAServicing, pool: Pool, ctx: Context, sources: Iterable[UrlCtx]) -> None: self.servicing = servicing @@ -21,7 +26,7 @@ class YState: self.sources: deque[UrlCtx] = deque(sources) self.playlists: deque[asyncio.Future[list[InfoCtx]]] = deque() self.entries: deque[InfoCtx | BaseException] = deque() - self.results: asyncio.Queue[asyncio.Future[YTAudio | None] | None] = asyncio.Queue() + self.results: asyncio.Queue[asyncio.Future[YTAudio | None] | _Stop] = asyncio.Queue() self.es = AsyncExitStack() self.descheduled: int = 0 @@ -31,22 +36,38 @@ class YState: def empty_processing(self) -> bool: return not self.sources and not self.playlists and not self.entries + async def _start_workers(self) -> None: + for _ in range(self.pool.workers()): + await self.es.enter_async_context(YJD(self).at(self.pool)) + + async def _next_audio(self) -> YTAudio | None | _Stop: + future = await self.results.get() + if isinstance(future, _Stop): + return _Stop() + try: + return await future + except OSError as e: + raise Explicit('extraction error\nunknown ytdl error') from e + finally: + self.results.task_done() + + async def _iterate_with_workers(self) -> AsyncIterable[YTAudio]: + while not self.empty(): + audio = await self._next_audio() + if isinstance(audio, _Stop): + return + if audio is not None: + yield audio + + async def _iterate(self) -> AsyncIterable[YTAudio]: + await self._start_workers() + async for audio in self._iterate_with_workers(): + yield audio + async def iterate(self) -> AsyncIterable[YTAudio]: async with self.es: - for _ in range(self.pool.workers()): - await self.es.enter_async_context(YJD(self).at(self.pool)) - while not self.empty(): - future = await self.results.get() - if future is None: - return - try: - audio = await future - if audio is not None: - yield audio - except OSError: - raise Explicit('extraction error\nunknown ytdl error') - finally: - self.results.task_done() + async for audio in self._iterate(): + yield audio async def playlist(self, source: UrlCtx) -> list[InfoCtx]: return [info async for info in source.entries()] @@ -79,7 +100,7 @@ class YJD(JobDescriptor): async def run(self) -> JobDescriptor | None: if self.state.empty_processing(): if self.state.results.empty(): - self.state.results.put_nowait(None) + self.state.results.put_nowait(_Stop()) return None elif self.state.entries: entry = self.state.entries.popleft() diff --git a/v6d3music/core/ytaudio.py b/v6d3music/core/ytaudio.py index 8e1e401..9a08d01 100644 --- a/v6d3music/core/ytaudio.py +++ b/v6d3music/core/ytaudio.py @@ -117,11 +117,11 @@ class YTAudio(discord.AudioSource): self.schedule_duration_update() return duration or '?:??:??' - def before_options(self): + def before_options(self) -> str: before_options = '' if 'https' in self.url: before_options += ( - '-reconnect 1 -reconnect_at_eof 0 -reconnect_streamed 1 -reconnect_delay_max 60 -copy_unknown' + '-reconnect 1 -reconnect_at_eof 0 -reconnect_streamed 1 -reconnect_delay_max 60 -rw_timeout 5000000 -copy_unknown' ) if self.already_read: before_options += (