From b6ec01491441768ffab099208bb2c9bb497858ce Mon Sep 17 00:00:00 2001 From: Christoph Stahl Date: Thu, 1 Dec 2022 20:41:58 +0100 Subject: [PATCH] Docstrings and documentation --- docs/Makefile | 20 ++++ docs/make.bat | 35 +++++++ docs/source/client.rst | 6 ++ docs/source/conf.py | 30 ++++++ docs/source/entry.rst | 5 + docs/source/index.rst | 18 ++++ docs/source/server.rst | 6 ++ syng/client.py | 209 +++++++++++++++++++++++++++++++++++----- syng/server.py | 172 ++++++++++++++++++++++++++------- syng/sources/youtube.py | 2 +- 10 files changed, 442 insertions(+), 61 deletions(-) create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/source/client.rst create mode 100644 docs/source/conf.py create mode 100644 docs/source/entry.rst create mode 100644 docs/source/index.rst create mode 100644 docs/source/server.rst 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/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -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 diff --git a/docs/source/client.rst b/docs/source/client.rst new file mode 100644 index 0000000..193dd6c --- /dev/null +++ b/docs/source/client.rst @@ -0,0 +1,6 @@ +Client +====== + +.. automodule:: syng.client + :members: + diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..3246357 --- /dev/null +++ b/docs/source/conf.py @@ -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"] diff --git a/docs/source/entry.rst b/docs/source/entry.rst new file mode 100644 index 0000000..ff5b7ab --- /dev/null +++ b/docs/source/entry.rst @@ -0,0 +1,5 @@ +Entry +===== + +.. automodule:: syng.entry + :members: diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..3fa4ad0 --- /dev/null +++ b/docs/source/index.rst @@ -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` diff --git a/docs/source/server.rst b/docs/source/server.rst new file mode 100644 index 0000000..11731c5 --- /dev/null +++ b/docs/source/server.rst @@ -0,0 +1,6 @@ +Server +====== + +.. automodule:: syng.server + :members: + diff --git a/syng/client.py b/syng/client.py index 8cf80f1..b06595f 100644 --- a/syng/client.py +++ b/syng/client.py @@ -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] - await preview(entry) + 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()) diff --git a/syng/server.py b/syng/server.py index ef3073f..9d9b7cc 100644 --- a/syng/server.py +++ b/syng/server.py @@ -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) diff --git a/syng/sources/youtube.py b/syng/sources/youtube.py index fca5b29..2d408c3 100644 --- a/syng/sources/youtube.py +++ b/syng/sources/youtube.py @@ -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 []