Docstrings and documentation

This commit is contained in:
Christoph Stahl 2022-12-01 20:41:58 +01:00
parent 37e09d10da
commit b6ec014914
10 changed files with 442 additions and 61 deletions

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)

35
docs/make.bat Normal file
View file

@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

6
docs/source/client.rst Normal file
View file

@ -0,0 +1,6 @@
Client
======
.. automodule:: syng.client
:members:

30
docs/source/conf.py Normal file
View file

@ -0,0 +1,30 @@
# 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
import sys
sys.path.insert(0, os.path.abspath(".."))
project = "syng"
copyright = "2022, Christoph Stahl"
author = "Christoph Stahl"
release = "2.0.0"
# -- 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 = "alabaster"
html_static_path = ["_static"]

5
docs/source/entry.rst Normal file
View file

@ -0,0 +1,5 @@
Entry
=====
.. automodule:: syng.entry
:members:

18
docs/source/index.rst Normal file
View file

@ -0,0 +1,18 @@
Welcome to syng's documentation!
================================
.. toctree::
:maxdepth: 2
:caption: Contents:
server
client
entry
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

6
docs/source/server.rst Normal file
View file

@ -0,0 +1,6 @@
Server
======
.. automodule:: syng.server
:members:

View file

@ -1,21 +1,24 @@
import asyncio
import string
import secrets
from traceback import print_exc
from json import load
import logging
from argparse import ArgumentParser
from dataclasses import dataclass, field
from typing import Optional, Any
import tempfile
import datetime
import logging
import secrets
import string
import tempfile
from argparse import ArgumentParser
from dataclasses import dataclass
from dataclasses import field
from json import load
from traceback import print_exc
from typing import Any
from typing import Optional
import socketio
import pyqrcode
import socketio
from PIL import Image
from .sources import Source, configure_sources
from .entry import Entry
from .sources import configure_sources
from .sources import Source
sio: socketio.AsyncClient = socketio.AsyncClient()
@ -28,6 +31,33 @@ currentLock: asyncio.Semaphore = asyncio.Semaphore(0)
@dataclass
class State:
"""This captures the current state of the playback client.
It doubles as a backup of the state of the :py:class:`syng.server` in case
the server needs to be restarted.
:param current_source: This holds a reference to the
:py:class:`syng.sources.source.Source` object, that is currently
playing. If no song is played, the value is `None`.
:type current_source: Optional[Source]
:param queue: A copy of the current playlist on the server.
:type queue: list[Entry]
:param recent: A copy of all played songs this session.
:type recent: list[Entry]
:param room: The room on the server this playback client is connected to.
:type room: str
:param secret: The passcode of the room. If a playback client reconnects to
a room, this must be identical. Also, if a webclient wants to have
admin privileges, this must be included.
:type secret: str
:param preview_duration: Amount of seconds the preview before a song be
displayed.
:type preview_duration: int
:param last_song: At what time should the server not accept any more songs.
`None` if no such limit should exist.
:type last_song: Optional[datetime.datetime]
"""
current_source: Optional[Source] = None
queue: list[Entry] = field(default_factory=list)
recent: list[Entry] = field(default_factory=list)
@ -38,6 +68,16 @@ class State:
last_song: Optional[datetime.datetime] = None
def get_config(self) -> dict[str, Any]:
"""
Return a subset of values to be send to the server.
Currently this is:
- :py:attr:`State.preview_duration`
- :py:attr:`State.last_song` (As a timestamp)
:return: A dict resulting from the above values
:rtype: dict[str, Any]
"""
return {
"preview_duration": self.preview_duration,
"last_song": self.last_song.timestamp() if self.last_song else None,
@ -49,6 +89,16 @@ state: State = State()
@sio.on("skip-current")
async def handle_skip_current(_: dict[str, Any] = {}) -> None:
"""
Handle the "skip-current" message.
Skips the song, that is currently played. If playback currently waits for
buffering, the buffering is also aborted.
:param _: Data part of the message, ignored
:type _: dict[str, Any]
:rtype: None
"""
logger.info("Skipping current")
if state.current_source is not None:
await state.current_source.skip_current(state.queue[0])
@ -56,6 +106,20 @@ async def handle_skip_current(_: dict[str, Any] = {}) -> None:
@sio.on("state")
async def handle_state(data: dict[str, Any]) -> None:
"""
Handle the "state" message.
The "state" message forwards the current queue and recent list from the
server. This function saves a copy of both in the global
:py:class:`State`:.
After recieving the new state, a buffering task for the first elements of
the queue is started.
:param data: A dictionary with the `queue` and `recent` list.
:type data: dict[str, Any]
:rtype: None
"""
state.queue = [Entry(**entry) for entry in data["queue"]]
state.recent = [Entry(**entry) for entry in data["recent"]]
@ -66,6 +130,25 @@ async def handle_state(data: dict[str, Any]) -> None:
@sio.on("connect")
async def handle_connect(_: dict[str, Any] = {}) -> None:
"""
Handle the "connect" message.
Called when the client successfully connects or reconnects to the server.
Sends a `register-client` message to the server with the initial state and
configuration of the client, consiting of the currently saved
:py:attr:`State.queue` and :py:attr:`State.recent` field of the global
:py:class:`State`, as well a room code the client wants to connect to, a
secret to secure the access to the room and a config dictionary.
If the room code is `None`, the server will issue a room code.
This message will be handled by the
:py:func:`syng.server.handle_register_client` function of the server.
:param _: Data part of the message, ignored
:type _: dict[str, Any]
:rtype: None
"""
logging.info("Connected to server")
await sio.emit(
"register-client",
@ -79,14 +162,38 @@ async def handle_connect(_: dict[str, Any] = {}) -> None:
)
@sio.on("buffer")
async def handle_buffer(data: dict[str, Any]) -> None:
@sio.on("get-meta-info")
async def handle_get_meta_info(data: dict[str, Any]) -> None:
"""
Handle a "get-meta-info" message.
Collects the metadata from a given :py:class:`Entry`, from its source, and
sends them back to the server in a "meta-info" message. On the server side
a :py:func:`syng.server.handle_meta_info` function is called.
:param data: A dictionary encoding the entry
:type data: dict[str, Any]
:rtype: None
"""
source: Source = sources[data["source"]]
meta_info: dict[str, Any] = await source.get_missing_metadata(Entry(**data))
await sio.emit("meta-info", {"uuid": data["uuid"], "meta": meta_info})
async def preview(entry: Entry) -> None:
"""
Generate and play a preview for a given :py:class:`Entry`.
This function shows a black screen and prints the artist, title and
performer of the entry for a duration.
This is done by creating a black png file, and showing subtitles in the
middle of the screen.... don't ask, it works
:param entry: The entry to preview
:type entry: :py:class:`Entry`
:rtype: None
"""
background = Image.new("RGB", (1280, 720))
subtitle: str = f"""1
00:00:00,00 --> 00:05:00,00
@ -97,7 +204,7 @@ async def preview(entry: Entry) -> None:
process = await asyncio.create_subprocess_exec(
"mpv",
tmpfile.name,
"--image-display-duration=3",
f"--image-display-duration={state.preview_duration}",
"--sub-pos=50",
"--sub-file=-",
"--fullscreen",
@ -110,28 +217,62 @@ async def preview(entry: Entry) -> None:
@sio.on("play")
async def handle_play(data: dict[str, Any]) -> None:
"""
Handle the "play" message.
Plays the :py:class:`Entry`, that is encoded in the `data` parameter. If a
:py:attr:`State.preview_duration` is set, it shows a small preview before
that.
:param data: A dictionary encoding the entry
:type data: dict[str, Any]
:rtype: None
"""
entry: Entry = Entry(**data)
print(
f"Playing: {entry.artist} - {entry.title} [{entry.album}] ({entry.source}) for {entry.performer}"
)
try:
state.current_source = sources[entry.source]
if state.preview_duration > 0:
await preview(entry)
await sources[entry.source].play(entry)
except Exception:
print_exc()
state.current_source = None
await sio.emit("pop-then-get-next")
@sio.on("client-registered")
async def handle_register(data: dict[str, Any]) -> None:
async def handle_client_registered(data: dict[str, Any]) -> None:
"""
Handle the "client-registered" massage.
If the registration was successfull (`data["success"]` == `True`), store
the room code in the global :py:class:`State` and print out a link to join
the webclient.
Start listing all configured :py:class:`syng.sources.source.Source` to the
server via a "sources" message. This message will be handled by the
:py:func:`server.handle_sources` function and may request additional
configuration for each source.
If there is no song playing, start requesting the first song of the queue
with a "get-first" message. This will be handled on the server by the
:py:func:`server.handle_get_first` function.
:param data: A dictionary containing a `success` and a `room` entry.
:type data: dict[str, Any]
:rtype: None
"""
if data["success"]:
logging.info("Registered")
print(f"Join here: {state.server}/{data['room']}")
print(pyqrcode.create(f"{state.server}/{data['room']}").terminal(quiet_zone=1))
print(pyqrcode.create(
f"{state.server}/{data['room']}").terminal(quiet_zone=1))
state.room = data["room"]
await sio.emit("sources", {"sources": list(sources.keys())})
if state.current_source is None:
if state.current_source is None: # A possible race condition can occur here
await sio.emit("get-first")
else:
logging.warning("Registration failed")
@ -140,6 +281,21 @@ async def handle_register(data: dict[str, Any]) -> None:
@sio.on("request-config")
async def handle_request_config(data: dict[str, Any]) -> None:
"""
Handle the "request-config" message.
Sends the specific server side configuration for a given
:py:class:`syng.sources.source.Source`.
A Source can decide, that the config will be split up in multiple Parts.
If this is the case, multiple "config-chunk" messages will be send with a
running enumerator. Otherwise a singe "config" message will be send.
:param data: A dictionary with the entry `source` and a string, that
corresponds to the name of a source.
:type data: dict[str, Any]
:rtype: None
"""
if data["source"] in sources:
config: dict[str, Any] | list[dict[str, Any]] = await sources[
data["source"]
@ -161,6 +317,17 @@ async def handle_request_config(data: dict[str, Any]) -> None:
async def aiomain() -> None:
"""
Async main function.
Parses the arguments, reads a config file and sets default values. Then
connects to a specified server.
If no secret is given, a random secret will be generated and presented to
the user.
:rtype: None
"""
parser: ArgumentParser = ArgumentParser()
parser.add_argument("--room", "-r")
@ -199,9 +366,5 @@ async def aiomain() -> None:
await sio.wait()
def main() -> None:
asyncio.run(aiomain())
if __name__ == "__main__":
main()
asyncio.run(aiomain())

View file

@ -1,58 +1,93 @@
from __future__ import annotations
from collections import deque
from typing import Any, Callable, Optional
import asyncio
from dataclasses import dataclass
import string
import random
import logging
from argparse import ArgumentParser
from uuid import UUID
import datetime
from aiohttp import web
import asyncio
import datetime
import logging
import random
import string
from argparse import ArgumentParser
from collections import deque
from dataclasses import dataclass
from typing import Any
from typing import Callable
from typing import Optional
from uuid import UUID
import socketio
from aiohttp import web
from .entry import Entry
from .sources import Source, available_sources
from .sources import available_sources
from .sources import Source
sio = socketio.AsyncServer(cors_allowed_origins="*", logger=True, engineio_logger=False)
sio = socketio.AsyncServer(cors_allowed_origins="*",
logger=True, engineio_logger=False)
app = web.Application()
sio.attach(app)
async def root_handler(request: Any) -> Any:
"""
Handle the index and favicon requests.
If the path of the request ends with "/favicon.ico" return the favicon,
otherwise the index.html. This way the javascript can read the room code
from the url.
:param request Any: Webrequest from aiohttp
:return: Either the favicon or the index.html
:rtype web.FileResponse:
"""
if request.path.endswith("/favicon.ico"):
return web.FileResponse("syng/static/favicon.ico")
return web.FileResponse("syng/static/index.html")
async def favico_handler(_: Any) -> Any:
return web.FileResponse("syng/static/favicon.ico")
app.add_routes([web.static("/assets/", "syng/static/assets/")])
app.router.add_route("*", "/", root_handler)
app.router.add_route("*", "/{room}", root_handler)
app.router.add_route("*", "/{room}/", root_handler)
app.router.add_route("*", "/favicon.ico", favico_handler)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class Queue:
"""A async queue with synchronization.
This queue keeps track of the amount of entries by using a semaphore.
:param initial_entries: Initial list of entries to add to the queue
:type initial_entries: list[Entry]
"""
def __init__(self, initial_entries: list[Entry]):
"""
Construct the queue. And initialize the internal lock and semaphore.
:param initial_entries: Initial list of entries to add to the queue
:type initial_entries: list[Entry]
"""
self._queue = deque(initial_entries)
self.num_of_entries_sem = asyncio.Semaphore(len(self._queue))
self.readlock = asyncio.Lock()
def append(self, x: Entry) -> None:
self._queue.append(x)
def append(self, entry: Entry) -> None:
"""
Append an entry to the queue, increase the semaphore.
:param entry: The entry to add
:type entry: Entry
:rtype: None
"""
self._queue.append(entry)
self.num_of_entries_sem.release()
async def peek(self) -> Entry:
"""
Return the first entry in the queue.
If the queue is empty, wait until the queue has at least one entry.
:returns: First entry of the queue
:rtype: Entry
"""
async with self.readlock:
await self.num_of_entries_sem.acquire()
item = self._queue[0]
@ -60,33 +95,83 @@ class Queue:
return item
async def popleft(self) -> Entry:
"""
Remove the first entry in the queue and return it.
Decreases the semaphore. If the queue is empty, wait until the queue
has at least one entry.
:returns: First entry of the queue
:rtype: Entry
"""
async with self.readlock:
await self.num_of_entries_sem.acquire()
item = self._queue.popleft()
return item
def to_dict(self) -> list[dict[str, Any]]:
"""
Forward the to_dict request to all entries and return it in a list.
This is done, so that the entries can be converted to a JSON object,
when sending it to the web or playback client.
:returns: A list with dictionaries, that encode the enties in the
queue.
:rtype: list[dict[str, Any]]
"""
return [item.to_dict() for item in self._queue]
def update(
self, locator: Callable[[Entry], Any], updater: Callable[[Entry], None]
) -> None:
def update(self, uuid: UUID | str, updater: Callable[[Entry], None]) -> None:
"""
Update entries in the queue, identified by their uuid.
:param uuid: The uuid of the entry to update
:type uuid: UUID | str
:param updater: A function, that updates the entry
:type updater: Callable[[Entry], None]
:rtype: None
"""
for item in self._queue:
if locator(item):
if item.uuid == uuid or str(item.uuid) == uuid:
updater(item)
def find_by_uuid(self, uuid: UUID | str) -> Optional[Entry]:
"""
Find an entry by its uuid and return it.
:param uuid: The uuid to search for.
:type uuid: UUID | str
:returns: The entry with the uuid or `None` if no such entry exists
:rtype: Optional[Entry]
"""
for item in self._queue:
if item.uuid == uuid or str(item.uuid) == uuid:
return item
return None
async def remove(self, entry: Entry) -> None:
"""
Remove an entry, if it exists. Decrease the semaphore.
:param entry: The entry to remove
:type entry: Entry
:rtype: None
"""
async with self.readlock:
await self.num_of_entries_sem.acquire()
self._queue.remove(entry)
async def moveUp(self, uuid: str) -> None:
async def move_up(self, uuid: str) -> None:
"""
Move an :py:class:`syng.entry.Entry` with the uuid up in the queue.
If it is called on the first two elements, nothing will happen.
:param uuid: The uuid of the entry.
:type uuid: str
:rtype: None
"""
async with self.readlock:
uuid_idx = 0
for idx, item in enumerate(self._queue):
@ -176,7 +261,7 @@ async def handle_append(sid: str, data: dict[str, Any]) -> None:
await send_state(state, room)
await sio.emit(
"buffer",
"get-meta-info",
entry.to_dict(),
room=clients[room].sid,
)
@ -189,7 +274,7 @@ async def handle_meta_info(sid: str, data: dict[str, Any]) -> None:
state = clients[room]
state.queue.update(
lambda item: str(item.uuid) == data["uuid"],
data["uuid"],
lambda item: item.update(**data["meta"]),
)
@ -226,7 +311,8 @@ async def handle_pop_then_get_next(sid: str, data: dict[str, Any] = {}) -> None:
def gen_id(length: int = 4) -> str:
client_id = "".join([random.choice(string.ascii_letters) for _ in range(length)])
client_id = "".join([random.choice(string.ascii_letters)
for _ in range(length)])
if client_id in clients:
client_id = gen_id(length + 1)
return client_id
@ -234,6 +320,13 @@ def gen_id(length: int = 4) -> str:
@sio.on("register-client")
async def handle_register_client(sid: str, data: dict[str, Any]) -> None:
"""
[TODO:description]
:param sid str: [TODO:description]
:param data dict[str, Any]: [TODO:description]
:rtype None: [TODO:description]
"""
room: str = data["room"] if "room" in data and data["room"] else gen_id()
async with sio.session(sid) as session:
session["room"] = room
@ -375,14 +468,14 @@ async def handle_skip_current(sid: str, data: dict[str, Any] = {}) -> None:
await sio.emit("skip-current", room=clients[room].sid)
@sio.on("moveUp")
async def handle_moveUp(sid: str, data: dict[str, Any]) -> None:
@sio.on("move-up")
async def handle_move_up(sid: str, data: dict[str, Any]) -> None:
async with sio.session(sid) as session:
room = session["room"]
is_admin = session["admin"]
state = clients[room]
if is_admin:
await state.queue.moveUp(data["uuid"])
await state.queue.move_up(data["uuid"])
await send_state(state, room)
@ -414,7 +507,6 @@ async def handle_search(sid: str, data: dict[str, str]) -> None:
state = clients[room]
query = data["query"]
result_futures = []
results_list = await asyncio.gather(
*[
state.config.sources[source].search(query)
@ -444,6 +536,12 @@ def main() -> None:
parser.add_argument("--host", "-H", default="localhost")
parser.add_argument("--port", "-p", default="8080")
args = parser.parse_args()
app.add_routes([web.static("/assets/", "syng/static/assets/")])
app.router.add_route("*", "/", root_handler)
app.router.add_route("*", "/{room}", root_handler)
app.router.add_route("*", "/{room}/", root_handler)
web.run_app(app, host=args.host, port=args.port)

View file

@ -90,7 +90,7 @@ class YoutubeSource(Source):
]
def _yt_search(self, query: str) -> list[YouTube]:
results = Search(f"{query} karaoke").results
results: Optional[list[YouTube]] = Search(f"{query} karaoke").results
if results is not None:
return results
return []