Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
2415689184 More documentation 2024-01-02 14:18:04 +01:00
b31276d818 Protocol and other stuff 2024-01-02 10:06:37 +01:00
8 changed files with 207 additions and 26 deletions

131
Protocol.md Normal file
View file

@ -0,0 +1,131 @@
Protocol
========
This document describes the workflows of the software.
Preliminaries
-------------
- **Song**: A reference to the file containing the audio and video. Can be separated into two files (e.g. mp3+cdg) or a link (e.g. a YouTube link)
- **Source**: A collection of _songs_, that can be searched and played back from. (e.g. a folder, a s3 storage or YouTube)
- **Performer**: The person(s) doing the actual singing
- **Entry**: A _song_ together with a _performer_
- **Queue**: A list of _entries_. Once the first entry is completely played back, the next entry is played.
- **Waiting Room**: A list of _entries_. To limit one performer filling the entire _queue_, a waiting room can be configured. If so, each performer can only have one entry in the queue. Each additional entry is put in the waiting room. Once the last entry of a performer left the queue, the first entry of that performer in the waiting room is added at the end of the queue.
- **Recents**: A list of _entries_. Once an entry successfully leaves the _queue_, it is added to the recents.
- **Playback client**: Part of the software, that does the actual playback, usually hooked to a video output device like a monitor or a projector. This needs to have access to the configured sources.
- **Web client**: User facing part of the software. Used to search and add _entries_ to the _queue_. Has an admin view to manipulate the queue and the _waiting room_.
- **Room**: One specific karaoke event, consisting of one _queue_, one _recents_, up to one _waiting room_, one _playback client_ and several _web clients_. It has an identifier and a _secret_, used to authenticate as an admin.
- **Server**: Manages all _rooms_.
- **State**: The state of a _room_ consists of its _queue_, _waiting room_, _recents_ and the configuration values of the _playback client_
We will use the abbreviations _P_, _W_, and _S_ when talking about the _playback client_, _web client_ and the _server_.
Communication usually happens between P ↔ S and W ↔ S and as messages on top of web sockets, using [socket.io](https://socket.io/docs/v4/client-api/).
### Entry
Entries are regularly sent between all participants and are encoded in JSON as follows:
| Key | Type | Description | Optional |
|----------|-------|----------------------------------------------------------------------------------|------------------------------------------|
| ident | `str` | Identifier for the entry in its given source. E.g. a file name or a YouTube Link | No |
| source | `str` | Name of the source (`files`, `s3`, `youtube`, etc.) | No |
| duration | `int` | Duration of the song | No |
| title | `str` | Name of the song | No |
| artist | `str` | Artist of the original song | No |
| album | `str` | Name of the collection this song belongs to | No |
| uuid | `str` | A UUID for this entry | Yes (generated automatically if omitted) |
### Client Config
A client config specifies the knowlege the server has of a specific playback client.
| Key | Type | Description | Optional | Default |
|---------------------|------|----------------------------------------------------------------------------------------------------------|----------|--------------------------|
| server | str | URL of the server | Yes | `https://localhost:8080` |
| room | str | Identifier of the room the client wants to connect to | Yes | Generated by the server |
| secret | str | The secret for the room | No | |
| preview_duration | int | Time between songs, where a preview is shown for the next song | Yes | 3 |
| last_song | int | Unix timestamp of the last song allowed to be played | Yes | None |
| waiting_room_policy | str | `forced` if waiting room is forced, `optional` if performers are given the choice, `None` if deactivated | Yes | None |
Workflow
--------
### Connect P ↔ S
When a playback client connects (or reconnects) to a server, it can provide a room identifier and a room secret.
If none are given, the server will generate both and send them to the client.
If the server does not know a room with that identifier, a new room is created with the given secret.
If the server has already registered a room with the given identifier, if the secret is the same, the connection to the new playback client is stored and the old connection is forgotten.
In case of a reconnect, client and server agree on a state. First the client sends its state (meaning Queue, Waiting Room, Recents and configuration) to the server.
Configuration is merged and if Queue, Waiting Room and Recents are each non-empty, the respective value on the server-side is overwritten.
Then the server returns its (possible) new Queue, Waiting Room and Recents to the Client.
The following messages are exchanged during connection:
| Communication | Message | Params | Notes |
|---------------|---------------------|-------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| P → S | `connect` | -- | Socket.io connect |
| S → P | `connect` | -- | Socket.io connect |
| P → S | `register-client` | `{ queue: list[Entry], waiting_room: list[Entry], recents: list[Entry], config: Config }` | The playback client can push an initial state to the server. | |
| S -> P | `client-registered` | `{ success: bool, room: str }` | success is `true` if requested room is not in use or secrets match, otherwise `false`. The server confirms the room name, if it was requested in `register-client`, otherwise a new room name is returned |
| S -> P | `state` | `{ queue: list[Entry], waiting_room: list[Entry], recents: list[Entry], config: Config}` | The server returns its updated state (without the secret) |
| P -> S | `sources` | `{ sources: list[str] }` | sources are the names of the configured sources, the server updates its list |
| P -> S | `get-first` | -- | See playback workflow. This is only sent if no song is currently playing |
| S -> P | `request-config` | `{ source: str, update: True }` | This messsage is sent for each newly added source |
| P -> S | `config-chunk` | `{ source: str, config: dict[str, Any], number: int, total: int }` | Configuration for each configured source. Potentially uses cached values on the client side. Can optionally be sent in chunks, will be merged on the server side |
| P -> S | `request-resend-config` | `{ source: str }` | Cached values are should be updated before sending this message |
| S -> P | `request-config` | `{ source: str, update: False }` | Old config on the server-side is discarded |
| P -> S | `config-chunk` | see above |
### Connect W <-> S
When a web client connects to a server, it adds itself to a room.
Optionally it can upgrade its connection to an admin connection, that allows manipulation messages for the queue and the waiting_room.
| Communication | Message | Params | Returns | Notes |
|---------------|---------|--------|---------|-------|
| W -> S | `connect` | -- | -- | Socket.io connect |
| S -> W | `connect` | -- | -- | Socket.io connect |
| W -> S | `register-web` | `{ room: str}` | bool | Connect to a room, server returns true if room exists |
| S -> W | `state` | `{ queue: list[Entry], waiting_room: list[Entry], recents: list[Entry], config: Config}` | -- | The server returns its initial state (without the secret) |
| W -> S | `register-admin` | `{ secret: str }` | bool | Optional, enables admin mode, if secret matches configured room secret |
### Playback
While the playback client handles the playback and is aware of the queue, the client must always explicitly request the next song from the server.
| Communication | Message | Params | Notes |
|---------------|---------|--------|-------|
| P -> S | `get-first` | -- | This blocks until an entry is added to the queue |
| S -> P | `play` | Entry | A field `started_at` is added to the entries |
| P -> S | `pop-then-get-next` | -- | This should be sent after a song is completed |
| S -> P,W | `state` | see above | All web clients and the playback client are notified of the new state |
| S -> P | `play` | see above | see above |
### Search
| Communication | Message | Params | Notes |
|---------------|---------|--------|-------|
| W -> S | `search` | `{ query: str} ` | -- |
| S -> W | `search-results` | `{ results: list[Result]}` | A _Result_ is an entry only consiting of `ident`, `source`, `title`, `artist`, `album` |
### Append
When appending, the web client does not get direct feedback in the success case, but the server sends a `state` message after each change in state.
| Communication | Message | Params | Notes |
|---------------|---------|--------|-------|
| W -> S | `append` | `{ident: str, performer: str, source: str, uid: str}` | `ident` and `source` identify the song. `uid` is currently unused. |
| S -> P,W | `state` | see above | All web clients and the playback client are notified of the new state |
| S -> W | `msg` | `{ msg: "Unable to append `ident`. Maybe try again?" }` | When something goes wrong |
| S -> W | `ask_for_waiting` | `{ current_entry: Entry, old_entry: Entry }` | Response if waitingroom is configured and already in queue |
| W -> S | `append-anyway` | `{ident: str, performer: str, source: str, uid: str}` | Append it anyway. Will be ignored, if `waiting_room_policy` is set to `forced` |
| W -> S | `waiting-room-append` | `{ident: str, performer: str, source: str, uid: str}` | Append to the waiting room |

View file

@ -353,22 +353,32 @@ async def handle_request_config(data: dict[str, Any]) -> None:
:type data: dict[str, Any] :type data: dict[str, Any]
:rtype: None :rtype: None
""" """
if data["source"] in sources:
config: dict[str, Any] | list[dict[str, Any]] = await sources[data["source"]].get_config() async def send_config(source: str, update: bool) -> None:
config: dict[str, Any] | list[dict[str, Any]] = await sources[source].get_config(update)
if isinstance(config, list): if isinstance(config, list):
num_chunks: int = len(config) num_chunks: int = len(config)
for current, chunk in enumerate(config): for current, chunk in enumerate(config):
await sio.emit( await sio.emit(
"config-chunk", "config-chunk",
{ {
"source": data["source"], "source": source,
"config": chunk, "config": chunk,
"number": current + 1, "number": current + 1,
"total": num_chunks, "total": num_chunks,
}, },
) )
else: else:
await sio.emit("config", {"source": data["source"], "config": config}) await sio.emit(
"config-chunk", {"source": source, "config": config, "number": 1, "total": 1}
)
if data["source"] in sources:
await send_config(data["source"], False)
if data["update"]:
await sources[data["source"]].get_config(True)
await sio.emit("request-resend-config", {"source": data["source"]})
def signal_handler() -> None: def signal_handler() -> None:

View file

@ -234,7 +234,8 @@ class OptionFrame(customtkinter.CTkScrollableFrame): # type:ignore
def get_config(self) -> dict[str, Any]: def get_config(self) -> dict[str, Any]:
config: dict[str, Any] = {} config: dict[str, Any] = {}
for name, textbox in self.string_options.items(): for name, textbox in self.string_options.items():
config[name] = textbox.get("0.0", "end").strip() value = textbox.get("0.0", "end").strip()
config[name] = value if value else None
for name, optionmenu in self.choose_options.items(): for name, optionmenu in self.choose_options.items():
config[name] = optionmenu.get().strip() config[name] = optionmenu.get().strip()
@ -338,7 +339,9 @@ class SyngGui(customtkinter.CTk): # type:ignore
config: dict[str, dict[str, Any]] = {"sources": {}, "config": default_config()} config: dict[str, dict[str, Any]] = {"sources": {}, "config": default_config()}
try: try:
config["config"] |= loaded_config["config"] for option, value in loaded_config["config"].items():
if value:
config["config"][option] = value
except (KeyError, TypeError): except (KeyError, TypeError):
print("Could not load config") print("Could not load config")
@ -400,8 +403,11 @@ class SyngGui(customtkinter.CTk): # type:ignore
self.tabs = {} self.tabs = {}
for source_name in available_sources: for source_name in available_sources:
source_config = {}
try: try:
source_config = loaded_config["sources"][source_name] for option, value in loaded_config["sources"][source_name].items():
if value:
source_config[option] = value
except (KeyError, TypeError): except (KeyError, TypeError):
source_config = {} source_config = {}

View file

@ -454,8 +454,6 @@ async def handle_append_anyway(sid: str, data: dict[str, Any]) -> None:
entry.uid = data["uid"] if "uid" in data else None entry.uid = data["uid"] if "uid" in data else None
print(entry)
await append_to_queue(room, entry, sid) await append_to_queue(room, entry, sid)
@ -637,9 +635,10 @@ async def handle_register_client(sid: str, data: dict[str, Any]) -> None:
- `config`, an dictionary of initial configurations - `config`, an dictionary of initial configurations
- `queue`, a list of initial entries for the queue. The entries are - `queue`, a list of initial entries for the queue. The entries are
encoded as a dictionary. encoded as a dictionary.
- `waiting_room`, a list of initial entries for the waiting room. The
entries are encoded as a dictionary.
- `recent`, a list of initial entries for the recent list. The entries - `recent`, a list of initial entries for the recent list. The entries
are encoded as a dictionary. are encoded as a dictionary.
- `secret`, the secret of the room
This will register a new playback client to a specific room. If there This will register a new playback client to a specific room. If there
already exists a playback client registered for this room, this already exists a playback client registered for this room, this
@ -777,7 +776,7 @@ async def handle_sources(sid: str, data: dict[str, Any]) -> None:
state.client.sources_prio = data["sources"] state.client.sources_prio = data["sources"]
for name in new_sources: for name in new_sources:
await sio.emit("request-config", {"source": name}, room=sid) await sio.emit("request-config", {"source": name, "update": True}, room=sid)
@sio.on("config-chunk") @sio.on("config-chunk")
@ -811,6 +810,32 @@ async def handle_config_chunk(sid: str, data: dict[str, Any]) -> None:
state.client.sources[data["source"]].add_to_config(data["config"]) state.client.sources[data["source"]].add_to_config(data["config"])
@sio.on("request-resend-config")
async def handle_request_resend_config(sid: str, data: dict[str, Any]) -> None:
"""
Handle the "request-resend-config" message.
Clears the config for a given source and requests a resend of the config
from the playback client.
:param sid: The session id of the playback client
:type sid: str
:param data: A dictionary with the "source" (str) entry
:rtype: None
"""
async with sio.session(sid) as session:
room = session["room"]
state = clients[room]
if sid != state.sid:
return
state.client.sources[data["source"]] = available_sources[data["source"]]({})
print(f"Rerequesting {data['source']}")
await sio.emit("request-config", {"source": data["source"], "update": False}, sid)
@sio.on("config") @sio.on("config")
async def handle_config(sid: str, data: dict[str, Any]) -> None: async def handle_config(sid: str, data: dict[str, Any]) -> None:
""" """

View file

@ -19,7 +19,8 @@ class FilesSource(FileBasedSource):
source_name = "files" source_name = "files"
config_schema = FileBasedSource.config_schema | { config_schema = FileBasedSource.config_schema | {
"dir": (str, "Directory to index", "."), "dir": (str, "Directory to index", "."),
"index_file": (str, "Index file", "files-index"), # "index_file": (str, "Index file", str(user_cache_path("syng") / "files" / "index")),
# "recreate_index": (bool, "Recreate index file", False),
} }
def __init__(self, config: dict[str, Any]): def __init__(self, config: dict[str, Any]):
@ -29,7 +30,7 @@ class FilesSource(FileBasedSource):
self.dir = config["dir"] if "dir" in config else "." self.dir = config["dir"] if "dir" in config else "."
self.extra_mpv_arguments = ["--scale=oversample"] self.extra_mpv_arguments = ["--scale=oversample"]
async def get_file_list(self) -> list[str]: async def get_file_list(self, update: bool = False) -> list[str]:
"""Collect all files in ``dir``, that have the correct filename extension""" """Collect all files in ``dir``, that have the correct filename extension"""
def _get_file_list() -> list[str]: def _get_file_list() -> list[str]:

View file

@ -8,6 +8,8 @@ import os
from json import dump, load from json import dump, load
from typing import Any, Optional, Tuple, cast from typing import Any, Optional, Tuple, cast
from platformdirs import user_cache_path
try: try:
from minio import Minio from minio import Minio
@ -31,6 +33,7 @@ class S3Source(FileBasedSource):
- ``index_file``: If the file does not exist, saves the paths of - ``index_file``: If the file does not exist, saves the paths of
files from the s3 instance to this file. If it exists, loads files from the s3 instance to this file. If it exists, loads
the list of files from this file. the list of files from this file.
-``recreate_index``, rebuild index even if it exists
""" """
source_name = "s3" source_name = "s3"
@ -41,7 +44,7 @@ class S3Source(FileBasedSource):
"secure": (bool, "Use SSL", True), "secure": (bool, "Use SSL", True),
"bucket": (str, "Bucket of the s3", ""), "bucket": (str, "Bucket of the s3", ""),
"tmp_dir": (str, "Folder for\ntemporary download", "/tmp/syng"), "tmp_dir": (str, "Folder for\ntemporary download", "/tmp/syng"),
"index_file": (str, "Index file", "s3-index"), "index_file": (str, "Index file", str(user_cache_path("syng") / "s3" / "index")),
} }
def __init__(self, config: dict[str, Any]): def __init__(self, config: dict[str, Any]):
@ -66,7 +69,7 @@ class S3Source(FileBasedSource):
self.index_file: Optional[str] = config["index_file"] if "index_file" in config else None self.index_file: Optional[str] = config["index_file"] if "index_file" in config else None
self.extra_mpv_arguments = ["--scale=oversample"] self.extra_mpv_arguments = ["--scale=oversample"]
async def get_file_list(self) -> list[str]: async def get_file_list(self, update: bool = False) -> list[str]:
""" """
Return the list of files on the s3 instance, according to the extensions. Return the list of files on the s3 instance, according to the extensions.
@ -78,8 +81,8 @@ class S3Source(FileBasedSource):
:rtype: list[str] :rtype: list[str]
""" """
def _get_file_list() -> list[str]: def _get_file_list(update: bool) -> list[str]:
if self.index_file is not None and os.path.isfile(self.index_file): if not update and self.index_file is not None and os.path.isfile(self.index_file):
with open(self.index_file, "r", encoding="utf8") as index_file_handle: with open(self.index_file, "r", encoding="utf8") as index_file_handle:
return cast(list[str], load(index_file_handle)) return cast(list[str], load(index_file_handle))
@ -89,11 +92,12 @@ class S3Source(FileBasedSource):
if self.has_correct_extension(obj.object_name) if self.has_correct_extension(obj.object_name)
] ]
if self.index_file is not None and not os.path.isfile(self.index_file): if self.index_file is not None and not os.path.isfile(self.index_file):
os.makedirs(os.path.dirname(self.index_file), exist_ok=True)
with open(self.index_file, "w", encoding="utf8") as index_file_handle: with open(self.index_file, "w", encoding="utf8") as index_file_handle:
dump(file_list, index_file_handle) dump(file_list, index_file_handle)
return file_list return file_list
return await asyncio.to_thread(_get_file_list) return await asyncio.to_thread(_get_file_list, update)
async def get_missing_metadata(self, entry: Entry) -> dict[str, Any]: async def get_missing_metadata(self, entry: Entry) -> dict[str, Any]:
""" """

View file

@ -145,7 +145,7 @@ class Source(ABC):
""" """
args = ["--fullscreen", *options, video] + ([f"--audio-file={audio}"] if audio else []) args = ["--fullscreen", *options, video] + ([f"--audio-file={audio}"] if audio else [])
print(f"File is {video=} and {audio=}") # print(f"File is {video=} and {audio=}")
mpv_process = asyncio.create_subprocess_exec( mpv_process = asyncio.create_subprocess_exec(
"mpv", "mpv",
@ -372,19 +372,21 @@ class Source(ABC):
splitquery = shlex.split(query) splitquery = shlex.split(query)
return [element for element in data if contains_all_words(splitquery, element)] return [element for element in data if contains_all_words(splitquery, element)]
async def get_file_list(self) -> list[str]: async def get_file_list(self, update: bool = False) -> list[str]:
""" """
Gather a list of all files belonging to the source. Gather a list of all files belonging to the source.
This list will be send to the server. When the server searches, this This list will be send to the server. When the server searches, this
list will be searched. list will be searched.
:param update: If true, regenerates caches
:type: bool
:return: List of filenames belonging to the source :return: List of filenames belonging to the source
:rtype: list[str] :rtype: list[str]
""" """
return [] return []
async def get_config(self) -> dict[str, Any] | list[dict[str, Any]]: async def get_config(self, update: bool = False) -> dict[str, Any] | list[dict[str, Any]]:
""" """
Return the part of the config, that should be send to the server. Return the part of the config, that should be send to the server.
@ -399,14 +401,16 @@ class Source(ABC):
But this can be any other values, as long as the respective source can But this can be any other values, as long as the respective source can
handle that data. handle that data.
:param update: If true, forces an update of caches
:type update: bool
:return: The part of the config, that should be sended to the server. :return: The part of the config, that should be sended to the server.
:rtype: dict[str, Any] | list[dict[str, Any]] :rtype: dict[str, Any] | list[dict[str, Any]]
""" """
if not self._index: if update or not self._index:
self._index = [] self._index = []
print(f"{self.source_name}: generating index") # print(f"{self.source_name}: generating index")
self._index = await self.get_file_list() self._index = await self.get_file_list(update)
print(f"{self.source_name}: done") # print(f"{self.source_name}: done")
chunked = zip_longest(*[iter(self._index)] * 1000, fillvalue="") chunked = zip_longest(*[iter(self._index)] * 1000, fillvalue="")
return [{"index": list(filter(lambda x: x != "", chunk))} for chunk in chunked] return [{"index": list(filter(lambda x: x != "", chunk))} for chunk in chunked]

View file

@ -87,7 +87,7 @@ class YoutubeSource(Source):
} }
) )
async def get_config(self) -> dict[str, Any] | list[dict[str, Any]]: async def get_config(self, update: bool = False) -> dict[str, Any] | list[dict[str, Any]]:
""" """
Return the list of channels in a dictionary with key ``channels``. Return the list of channels in a dictionary with key ``channels``.