Docstrings and documentation
This commit is contained in:
parent
37e09d10da
commit
b6ec014914
10 changed files with 442 additions and 61 deletions
20
docs/Makefile
Normal file
20
docs/Makefile
Normal 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
35
docs/make.bat
Normal 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
6
docs/source/client.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
Client
|
||||||
|
======
|
||||||
|
|
||||||
|
.. automodule:: syng.client
|
||||||
|
:members:
|
||||||
|
|
30
docs/source/conf.py
Normal file
30
docs/source/conf.py
Normal 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
5
docs/source/entry.rst
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
Entry
|
||||||
|
=====
|
||||||
|
|
||||||
|
.. automodule:: syng.entry
|
||||||
|
:members:
|
18
docs/source/index.rst
Normal file
18
docs/source/index.rst
Normal 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
6
docs/source/server.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
Server
|
||||||
|
======
|
||||||
|
|
||||||
|
.. automodule:: syng.server
|
||||||
|
:members:
|
||||||
|
|
207
syng/client.py
207
syng/client.py
|
@ -1,21 +1,24 @@
|
||||||
import asyncio
|
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 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 pyqrcode
|
||||||
|
import socketio
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from .sources import Source, configure_sources
|
|
||||||
from .entry import Entry
|
from .entry import Entry
|
||||||
|
from .sources import configure_sources
|
||||||
|
from .sources import Source
|
||||||
|
|
||||||
|
|
||||||
sio: socketio.AsyncClient = socketio.AsyncClient()
|
sio: socketio.AsyncClient = socketio.AsyncClient()
|
||||||
|
@ -28,6 +31,33 @@ currentLock: asyncio.Semaphore = asyncio.Semaphore(0)
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class State:
|
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
|
current_source: Optional[Source] = None
|
||||||
queue: list[Entry] = field(default_factory=list)
|
queue: list[Entry] = field(default_factory=list)
|
||||||
recent: 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
|
last_song: Optional[datetime.datetime] = None
|
||||||
|
|
||||||
def get_config(self) -> dict[str, Any]:
|
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 {
|
return {
|
||||||
"preview_duration": self.preview_duration,
|
"preview_duration": self.preview_duration,
|
||||||
"last_song": self.last_song.timestamp() if self.last_song else None,
|
"last_song": self.last_song.timestamp() if self.last_song else None,
|
||||||
|
@ -49,6 +89,16 @@ state: State = State()
|
||||||
|
|
||||||
@sio.on("skip-current")
|
@sio.on("skip-current")
|
||||||
async def handle_skip_current(_: dict[str, Any] = {}) -> None:
|
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")
|
logger.info("Skipping current")
|
||||||
if state.current_source is not None:
|
if state.current_source is not None:
|
||||||
await state.current_source.skip_current(state.queue[0])
|
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")
|
@sio.on("state")
|
||||||
async def handle_state(data: dict[str, Any]) -> None:
|
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.queue = [Entry(**entry) for entry in data["queue"]]
|
||||||
state.recent = [Entry(**entry) for entry in data["recent"]]
|
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")
|
@sio.on("connect")
|
||||||
async def handle_connect(_: dict[str, Any] = {}) -> None:
|
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")
|
logging.info("Connected to server")
|
||||||
await sio.emit(
|
await sio.emit(
|
||||||
"register-client",
|
"register-client",
|
||||||
|
@ -79,14 +162,38 @@ async def handle_connect(_: dict[str, Any] = {}) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@sio.on("buffer")
|
@sio.on("get-meta-info")
|
||||||
async def handle_buffer(data: dict[str, Any]) -> None:
|
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"]]
|
source: Source = sources[data["source"]]
|
||||||
meta_info: dict[str, Any] = await source.get_missing_metadata(Entry(**data))
|
meta_info: dict[str, Any] = await source.get_missing_metadata(Entry(**data))
|
||||||
await sio.emit("meta-info", {"uuid": data["uuid"], "meta": meta_info})
|
await sio.emit("meta-info", {"uuid": data["uuid"], "meta": meta_info})
|
||||||
|
|
||||||
|
|
||||||
async def preview(entry: Entry) -> None:
|
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))
|
background = Image.new("RGB", (1280, 720))
|
||||||
subtitle: str = f"""1
|
subtitle: str = f"""1
|
||||||
00:00:00,00 --> 00:05:00,00
|
00:00:00,00 --> 00:05:00,00
|
||||||
|
@ -97,7 +204,7 @@ async def preview(entry: Entry) -> None:
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
"mpv",
|
"mpv",
|
||||||
tmpfile.name,
|
tmpfile.name,
|
||||||
"--image-display-duration=3",
|
f"--image-display-duration={state.preview_duration}",
|
||||||
"--sub-pos=50",
|
"--sub-pos=50",
|
||||||
"--sub-file=-",
|
"--sub-file=-",
|
||||||
"--fullscreen",
|
"--fullscreen",
|
||||||
|
@ -110,28 +217,62 @@ async def preview(entry: Entry) -> None:
|
||||||
|
|
||||||
@sio.on("play")
|
@sio.on("play")
|
||||||
async def handle_play(data: dict[str, Any]) -> None:
|
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)
|
entry: Entry = Entry(**data)
|
||||||
print(
|
print(
|
||||||
f"Playing: {entry.artist} - {entry.title} [{entry.album}] ({entry.source}) for {entry.performer}"
|
f"Playing: {entry.artist} - {entry.title} [{entry.album}] ({entry.source}) for {entry.performer}"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
state.current_source = sources[entry.source]
|
state.current_source = sources[entry.source]
|
||||||
|
if state.preview_duration > 0:
|
||||||
await preview(entry)
|
await preview(entry)
|
||||||
await sources[entry.source].play(entry)
|
await sources[entry.source].play(entry)
|
||||||
except Exception:
|
except Exception:
|
||||||
print_exc()
|
print_exc()
|
||||||
|
state.current_source = None
|
||||||
await sio.emit("pop-then-get-next")
|
await sio.emit("pop-then-get-next")
|
||||||
|
|
||||||
|
|
||||||
@sio.on("client-registered")
|
@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"]:
|
if data["success"]:
|
||||||
logging.info("Registered")
|
logging.info("Registered")
|
||||||
print(f"Join here: {state.server}/{data['room']}")
|
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"]
|
state.room = data["room"]
|
||||||
await sio.emit("sources", {"sources": list(sources.keys())})
|
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")
|
await sio.emit("get-first")
|
||||||
else:
|
else:
|
||||||
logging.warning("Registration failed")
|
logging.warning("Registration failed")
|
||||||
|
@ -140,6 +281,21 @@ async def handle_register(data: dict[str, Any]) -> None:
|
||||||
|
|
||||||
@sio.on("request-config")
|
@sio.on("request-config")
|
||||||
async def handle_request_config(data: dict[str, Any]) -> None:
|
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:
|
if data["source"] in sources:
|
||||||
config: dict[str, Any] | list[dict[str, Any]] = await sources[
|
config: dict[str, Any] | list[dict[str, Any]] = await sources[
|
||||||
data["source"]
|
data["source"]
|
||||||
|
@ -161,6 +317,17 @@ async def handle_request_config(data: dict[str, Any]) -> None:
|
||||||
|
|
||||||
|
|
||||||
async def aiomain() -> 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: ArgumentParser = ArgumentParser()
|
||||||
|
|
||||||
parser.add_argument("--room", "-r")
|
parser.add_argument("--room", "-r")
|
||||||
|
@ -199,9 +366,5 @@ async def aiomain() -> None:
|
||||||
await sio.wait()
|
await sio.wait()
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
asyncio.run(aiomain())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
asyncio.run(aiomain())
|
||||||
|
|
172
syng/server.py
172
syng/server.py
|
@ -1,58 +1,93 @@
|
||||||
from __future__ import annotations
|
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
|
import socketio
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
from .entry import Entry
|
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()
|
app = web.Application()
|
||||||
sio.attach(app)
|
sio.attach(app)
|
||||||
|
|
||||||
|
|
||||||
async def root_handler(request: Any) -> Any:
|
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"):
|
if request.path.endswith("/favicon.ico"):
|
||||||
return web.FileResponse("syng/static/favicon.ico")
|
return web.FileResponse("syng/static/favicon.ico")
|
||||||
return web.FileResponse("syng/static/index.html")
|
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)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Queue:
|
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]):
|
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._queue = deque(initial_entries)
|
||||||
|
|
||||||
self.num_of_entries_sem = asyncio.Semaphore(len(self._queue))
|
self.num_of_entries_sem = asyncio.Semaphore(len(self._queue))
|
||||||
self.readlock = asyncio.Lock()
|
self.readlock = asyncio.Lock()
|
||||||
|
|
||||||
def append(self, x: Entry) -> None:
|
def append(self, entry: Entry) -> None:
|
||||||
self._queue.append(x)
|
"""
|
||||||
|
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()
|
self.num_of_entries_sem.release()
|
||||||
|
|
||||||
async def peek(self) -> Entry:
|
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:
|
async with self.readlock:
|
||||||
await self.num_of_entries_sem.acquire()
|
await self.num_of_entries_sem.acquire()
|
||||||
item = self._queue[0]
|
item = self._queue[0]
|
||||||
|
@ -60,33 +95,83 @@ class Queue:
|
||||||
return item
|
return item
|
||||||
|
|
||||||
async def popleft(self) -> Entry:
|
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:
|
async with self.readlock:
|
||||||
await self.num_of_entries_sem.acquire()
|
await self.num_of_entries_sem.acquire()
|
||||||
item = self._queue.popleft()
|
item = self._queue.popleft()
|
||||||
return item
|
return item
|
||||||
|
|
||||||
def to_dict(self) -> list[dict[str, Any]]:
|
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]
|
return [item.to_dict() for item in self._queue]
|
||||||
|
|
||||||
def update(
|
def update(self, uuid: UUID | str, updater: Callable[[Entry], None]) -> None:
|
||||||
self, locator: Callable[[Entry], Any], 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:
|
for item in self._queue:
|
||||||
if locator(item):
|
if item.uuid == uuid or str(item.uuid) == uuid:
|
||||||
updater(item)
|
updater(item)
|
||||||
|
|
||||||
def find_by_uuid(self, uuid: UUID | str) -> Optional[Entry]:
|
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:
|
for item in self._queue:
|
||||||
if item.uuid == uuid or str(item.uuid) == uuid:
|
if item.uuid == uuid or str(item.uuid) == uuid:
|
||||||
return item
|
return item
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def remove(self, entry: Entry) -> 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:
|
async with self.readlock:
|
||||||
await self.num_of_entries_sem.acquire()
|
await self.num_of_entries_sem.acquire()
|
||||||
self._queue.remove(entry)
|
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:
|
async with self.readlock:
|
||||||
uuid_idx = 0
|
uuid_idx = 0
|
||||||
for idx, item in enumerate(self._queue):
|
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 send_state(state, room)
|
||||||
|
|
||||||
await sio.emit(
|
await sio.emit(
|
||||||
"buffer",
|
"get-meta-info",
|
||||||
entry.to_dict(),
|
entry.to_dict(),
|
||||||
room=clients[room].sid,
|
room=clients[room].sid,
|
||||||
)
|
)
|
||||||
|
@ -189,7 +274,7 @@ async def handle_meta_info(sid: str, data: dict[str, Any]) -> None:
|
||||||
state = clients[room]
|
state = clients[room]
|
||||||
|
|
||||||
state.queue.update(
|
state.queue.update(
|
||||||
lambda item: str(item.uuid) == data["uuid"],
|
data["uuid"],
|
||||||
lambda item: item.update(**data["meta"]),
|
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:
|
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:
|
if client_id in clients:
|
||||||
client_id = gen_id(length + 1)
|
client_id = gen_id(length + 1)
|
||||||
return client_id
|
return client_id
|
||||||
|
@ -234,6 +320,13 @@ def gen_id(length: int = 4) -> str:
|
||||||
|
|
||||||
@sio.on("register-client")
|
@sio.on("register-client")
|
||||||
async def handle_register_client(sid: str, data: dict[str, Any]) -> None:
|
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()
|
room: str = data["room"] if "room" in data and data["room"] else gen_id()
|
||||||
async with sio.session(sid) as session:
|
async with sio.session(sid) as session:
|
||||||
session["room"] = room
|
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)
|
await sio.emit("skip-current", room=clients[room].sid)
|
||||||
|
|
||||||
|
|
||||||
@sio.on("moveUp")
|
@sio.on("move-up")
|
||||||
async def handle_moveUp(sid: str, data: dict[str, Any]) -> None:
|
async def handle_move_up(sid: str, data: dict[str, Any]) -> None:
|
||||||
async with sio.session(sid) as session:
|
async with sio.session(sid) as session:
|
||||||
room = session["room"]
|
room = session["room"]
|
||||||
is_admin = session["admin"]
|
is_admin = session["admin"]
|
||||||
state = clients[room]
|
state = clients[room]
|
||||||
if is_admin:
|
if is_admin:
|
||||||
await state.queue.moveUp(data["uuid"])
|
await state.queue.move_up(data["uuid"])
|
||||||
await send_state(state, room)
|
await send_state(state, room)
|
||||||
|
|
||||||
|
|
||||||
|
@ -414,7 +507,6 @@ async def handle_search(sid: str, data: dict[str, str]) -> None:
|
||||||
state = clients[room]
|
state = clients[room]
|
||||||
|
|
||||||
query = data["query"]
|
query = data["query"]
|
||||||
result_futures = []
|
|
||||||
results_list = await asyncio.gather(
|
results_list = await asyncio.gather(
|
||||||
*[
|
*[
|
||||||
state.config.sources[source].search(query)
|
state.config.sources[source].search(query)
|
||||||
|
@ -444,6 +536,12 @@ def main() -> None:
|
||||||
parser.add_argument("--host", "-H", default="localhost")
|
parser.add_argument("--host", "-H", default="localhost")
|
||||||
parser.add_argument("--port", "-p", default="8080")
|
parser.add_argument("--port", "-p", default="8080")
|
||||||
args = parser.parse_args()
|
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)
|
web.run_app(app, host=args.host, port=args.port)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -90,7 +90,7 @@ class YoutubeSource(Source):
|
||||||
]
|
]
|
||||||
|
|
||||||
def _yt_search(self, query: str) -> list[YouTube]:
|
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:
|
if results is not None:
|
||||||
return results
|
return results
|
||||||
return []
|
return []
|
||||||
|
|
Loading…
Add table
Reference in a new issue