docs + raise from + new locks + extended repeat

+ _Stop + maybe better connection handling
This commit is contained in:
AF 2023-01-01 05:54:11 +00:00
parent efa124b134
commit 9ad6126838
20 changed files with 321 additions and 59 deletions

20
Docs.Dockerfile Normal file
View File

@ -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" ]

20
docs/Makefile Normal file
View File

@ -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)

View File

@ -0,0 +1,2 @@
For Administrators
==================

33
docs/source/conf.py Normal file
View File

@ -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('../..'))

View File

@ -0,0 +1,2 @@
For Developers
==============

21
docs/source/index.rst Normal file
View File

@ -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`

7
docs/source/modules.rst Normal file
View File

@ -0,0 +1,7 @@
v6d3music
=========
.. toctree::
:maxdepth: 4
v6d3music

View File

@ -0,0 +1,2 @@
For Operators
=============

2
docs/source/usage.rst Normal file
View File

@ -0,0 +1,2 @@
For Users
=========

102
docs/source/v6d3music.rst Normal file
View File

@ -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:

View File

@ -1,12 +0,0 @@
from setuptools import setup
setup(
name='v6d3music',
version='',
packages=['v6d3music'],
url='',
license='',
author='PARRRATE T&V',
author_email='',
description=''
)

View File

@ -0,0 +1,3 @@
__all__ = ('api', 'app', 'commands', 'config')
from . import api, app, commands, config

View File

@ -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:

View File

@ -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')

View File

@ -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()

View File

@ -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()

View File

@ -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)

View File

@ -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:

View File

@ -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()

View File

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