From 9ad61268385196edd8e5cfa2b81707b8db63246b Mon Sep 17 00:00:00 2001
From: timofey <tim@ongoteam.yaconnect.com>
Date: Sun, 1 Jan 2023 05:54:11 +0000
Subject: [PATCH] docs + raise from + new locks + extended repeat + _Stop +
 maybe better connection handling

---
 Docs.Dockerfile                     |  20 ++++++
 docs/Makefile                       |  20 ++++++
 docs/source/administration.rst      |   2 +
 docs/source/conf.py                 |  33 +++++++++
 docs/source/development.rst         |   2 +
 docs/source/index.rst               |  21 ++++++
 docs/source/modules.rst             |   7 ++
 docs/source/operation.rst           |   2 +
 docs/source/usage.rst               |   2 +
 docs/source/v6d3music.rst           | 102 ++++++++++++++++++++++++++++
 setup.py                            |  12 ----
 v6d3music/__init__.py               |   3 +
 v6d3music/api.py                    |   2 +-
 v6d3music/commands.py               |  51 ++++++++++----
 v6d3music/core/caching.py           |   3 +-
 v6d3music/core/ffmpegnormalaudio.py |   1 +
 v6d3music/core/mainservice.py       |  18 +++--
 v6d3music/core/queueaudio.py        |  20 ++++--
 v6d3music/core/ystate.py            |  55 ++++++++++-----
 v6d3music/core/ytaudio.py           |   4 +-
 20 files changed, 321 insertions(+), 59 deletions(-)
 create mode 100644 Docs.Dockerfile
 create mode 100644 docs/Makefile
 create mode 100644 docs/source/administration.rst
 create mode 100644 docs/source/conf.py
 create mode 100644 docs/source/development.rst
 create mode 100644 docs/source/index.rst
 create mode 100644 docs/source/modules.rst
 create mode 100644 docs/source/operation.rst
 create mode 100644 docs/source/usage.rst
 create mode 100644 docs/source/v6d3music.rst
 delete mode 100644 setup.py

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 += (