From 31c45e3fe41172fa8cbb3c862c700f96226f739d Mon Sep 17 00:00:00 2001 From: Christoph Stahl Date: Wed, 1 Nov 2023 13:03:37 +0100 Subject: [PATCH] Changes to internal config handling, server side checking of waiting room policy, updatable config --- syng/client.py | 71 ++++++++--------- syng/server.py | 210 +++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 212 insertions(+), 69 deletions(-) diff --git a/syng/client.py b/syng/client.py index 9090010..e421ae5 100644 --- a/syng/client.py +++ b/syng/client.py @@ -62,6 +62,14 @@ sources: dict[str, Source] = {} currentLock: asyncio.Semaphore = asyncio.Semaphore(0) +def default_config(): + return { + "preview_duration": 3, + "last_song": None, + "waiting_room_policy": None, + } + + @dataclass class State: """This captures the current state of the playback client. @@ -87,12 +95,19 @@ class State: :type secret: str :param key: An optional key, if registration on the server is limited. :type key: Optional[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] + :param config: Various configuration options for the client: + * `preview_duration` (`Optional[int]`): The duration in seconds the + playback client shows a preview for the next song. This is accounted for + in the calculation of the ETA for songs later in the queue. + * `last_song` (`Optional[datetime.datetime]`): A timestamp, defining the end of + the queue. + * `waiting_room_policy` (Optional[str]): One of: + - `force`, if a performer is already in the queue, they are put in the + waiting room. + - `optional`, if a performer is already in the queue, they have the option + to be put in the waiting room. + - `None`, performers are always added to the queue. + :type config: dict[str, Any]: """ # pylint: disable=too-many-instance-attributes @@ -105,31 +120,17 @@ class State: server: str = "" secret: str = "" key: Optional[str] = None - preview_duration: int = 3 - 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, - } + config: dict[str, Any] = field(default_factory=default_config) state: State = State() +@sio.on("update_config") +async def handle_update_config(data: dict[str, Any]) -> None: + state.config = default_config() | data + + @sio.on("skip-current") async def handle_skip_current(data: dict[str, Any]) -> None: """ @@ -201,7 +202,7 @@ async def handle_connect() -> None: "recent": state.recent, "room": state.room, "secret": state.secret, - "config": state.get_config(), + "config": state.config, } if state.key: data["registration-key"] = state.key @@ -252,7 +253,7 @@ async def preview(entry: Entry) -> None: process = await asyncio.create_subprocess_exec( "mpv", tmpfile.name, - f"--image-display-duration={state.preview_duration}", + f"--image-display-duration={state.config['preview_duration']}", "--sub-pos=50", "--sub-file=-", "--fullscreen", @@ -290,7 +291,7 @@ async def handle_play(data: dict[str, Any]) -> None: ) try: state.current_source = sources[entry.source] - if state.preview_duration > 0: + if state.config["preview_duration"] > 0: await preview(entry) await sources[entry.source].play(entry) except Exception: # pylint: disable=broad-except @@ -409,12 +410,12 @@ async def aiomain() -> None: sources.update(configure_sources(config["sources"])) if "config" in config: - if "last_song" in config["config"]: - state.last_song = datetime.datetime.fromisoformat( - config["config"]["last_song"] - ) - if "preview_duration" in config["config"]: - state.preview_duration = config["config"]["preview_duration"] + last_song = ( + datetime.datetime.fromisoformat(config["config"]["last_song"]) + if "last_song" in config["config"] + else None + ) + state.config |= config["config"] | {"last_song": last_song} state.key = args.key if args.key else None diff --git a/syng/server.py b/syng/server.py index 8286af2..f1f37f9 100644 --- a/syng/server.py +++ b/syng/server.py @@ -21,6 +21,7 @@ import logging import os import random import string +from json.decoder import JSONDecodeError from argparse import ArgumentParser from dataclasses import dataclass from dataclasses import field @@ -43,6 +44,12 @@ sio = socketio.AsyncServer( app = web.Application() sio.attach(app) +DEFAULT_CONFIG = { + "preview_duration": 3, + "waiting_room_policy": None, + "last_song": None, +} + async def root_handler(request: Any) -> Any: """ @@ -68,7 +75,7 @@ logger = logging.getLogger(__name__) @dataclass -class Config: +class Client: """This stores the configuration of a specific playback client. In case a new playback client connects to a room, these values can be @@ -79,18 +86,23 @@ class Config: :type sources: Source :param sources_prio: A list defining the order of the search results. :type sources_prio: list[str] - :param preview_duration: The duration in seconds the playbackclients shows - a preview for the next song. This is accounted for in the calculation - of the ETA for songs later in the queue. - :type preview_duration: int - :param last_song: A timestamp, defining the end of the queue. - :type last_song: Optional[float] + :param config: Various configuration options for the client: + * `preview_duration` (`Optional[int]`): The duration in seconds the + playback client shows a preview for the next song. This is accounted for + in the calculation of the ETA for songs later in the queue. + * `last_song` (`Optional[float]`): A timestamp, defining the end of the queue. + * `waiting_room_policy` (Optional[str]): One of: + - `force`, if a performer is already in the queue, they are put in the + waiting room. + - `optional`, if a performer is already in the queue, they have the option + to be put in the waiting room. + - `None`, performers are always added to the queue. + :type config: dict[str, Any]: """ sources: dict[str, Source] sources_prio: list[str] - preview_duration: int - last_song: Optional[float] + config: dict[str, Any] @dataclass @@ -113,8 +125,8 @@ class State: a new playback client connects to a room (with the correct secret), this will be swapped with the new sid. :type sid: str - :param config: The config for the client - :type config: Config + :param client: The config for the playback client + :type client: Client """ secret: str @@ -122,7 +134,7 @@ class State: waiting_room: list[Entry] recent: list[Entry] sid: str - config: Config + client: Client last_seen: datetime.datetime = field( init=False, default_factory=datetime.datetime.now ) @@ -153,6 +165,7 @@ async def send_state(state: State, sid: str) -> None: "queue": state.queue, "recent": state.recent, "waiting_room": state.waiting_room, + "config": state.client.config, }, room=sid, ) @@ -188,7 +201,7 @@ async def handle_waiting_room_append(sid: str, data: dict[str, Any]) -> None: room = session["room"] state = clients[room] - source_obj = state.config.sources[data["source"]] + source_obj = state.client.sources[data["source"]] entry = await source_obj.get_entry(data["performer"], data["ident"]) @@ -254,18 +267,23 @@ async def append_to_queue( start_time = state.queue.fold( lambda item, time: time + item.duration - + state.config.preview_duration + + state.client.config["preview_duration"] + 1, start_time, ) - if state.config.last_song: - if state.config.last_song < start_time: - end_time = datetime.datetime.fromtimestamp(state.config.last_song) + if state.client.config["last_song"]: + if state.client.config["last_song"] < start_time: + # end_time = datetime.datetime.fromtimestamp( + # state.client.config["last_song"] + # ) if report_to is not None: await sio.emit( "err", - {"type": "QUEUE_FULL", "end_time": state.config.last_song}, + { + "type": "QUEUE_FULL", + "end_time": state.client.config["last_song"], + }, room=report_to, ) return @@ -280,6 +298,57 @@ async def append_to_queue( ) +@sio.on("show_config") +async def handle_show_config(sid: str) -> None: + """ + Sends public config to webclient. + + This will only be send if the client is on an admin connection. + + :param sid: The session id of the client sending this request + :type sid: str + :rtype: None + """ + + async with sio.session(sid) as session: + room = session["room"] + is_admin = session["admin"] + state = clients[room] + + if is_admin: + await sio.emit( + "config", + state.client.config, + sid, + ) + else: + await sio.emit("err", {"type": "NO_ADMIN"}, sid) + + +@sio.on("update_config") +async def handle_update_config(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: + try: + config = json.loads(data["config"]) + await sio.emit( + "update_config", + DEFAULT_CONFIG | config, + state.sid, + ) + state.client.config = DEFAULT_CONFIG | config + await sio.emit("update_config", config, room) + except JSONDecodeError: + await sio.emit("err", {"type": "JSON_MALFORMED"}) + + else: + await sio.emit("err", {"type": "NO_ADMIN"}, sid) + + @sio.on("append") async def handle_append(sid: str, data: dict[str, Any]) -> None: """ @@ -297,6 +366,10 @@ async def handle_append(sid: str, data: dict[str, Any]) -> None: request is denied and a "msg" message is send to the client, detailing this. + If a waitingroom is forced or optional, it is checked, if one of the performers is + already in queue. In that case, a "ask_for_waitingroom" message is send to the + client. + Otherwise the song is added to the queue. And all connected clients (web and playback client) are informed of the new state with a "state" message. @@ -316,7 +389,30 @@ async def handle_append(sid: str, data: dict[str, Any]) -> None: room = session["room"] state = clients[room] - source_obj = state.config.sources[data["source"]] + if state.client.config["waiting_room_policy"] and ( + state.client.config["waiting_room_policy"].lower() == "force" + or state.client.config["waiting_room_policy"].lower() == "optional" + ): + old_entry = state.queue.find_by_name(data["performer"]) + if old_entry is not None: + await sio.emit( + "ask_for_waitingroom", + { + "current_entry": { + "source": data["source"], + "performer": data["performer"], + "ident": data["ident"], + }, + "old_entry": { + "artist": old_entry.artist, + "title": old_entry.title, + "performer": old_entry.performer, + }, + }, + ) + return + + source_obj = state.client.sources[data["source"]] entry = await source_obj.get_entry(data["performer"], data["ident"]) @@ -333,6 +429,47 @@ async def handle_append(sid: str, data: dict[str, Any]) -> None: await append_to_queue(room, entry, sid) +@sio.on("append-anyway") +async def handle_append_anyway(sid: str, data: dict[str, Any]) -> None: + """ + Appends a song to the queue, even if the performer is already in queue. + + Works the same as handle_append, but without the check if the performer is already + in queue. + + Only if the waiting_room_policy is not configured as forced. + """ + async with sio.session(sid) as session: + room = session["room"] + state = clients[room] + + if state.client.config["waiting_room_policy"].lower() == "force": + await sio.emit( + "err", + {"type": "WAITING_ROOM_FORCED"}, + room=sid, + ) + return + + source_obj = state.client.sources[data["source"]] + + entry = await source_obj.get_entry(data["performer"], data["ident"]) + + if entry is None: + await sio.emit( + "msg", + {"msg": f"Unable to append {data['ident']}. Maybe try again?"}, + room=sid, + ) + return + + entry.uid = data["uid"] if "uid" in data else None + + print(entry) + + await append_to_queue(room, entry, sid) + + @sio.on("meta-info") async def handle_meta_info(sid: str, data: dict[str, Any]) -> None: """ @@ -586,10 +723,10 @@ async def handle_register_client(sid: str, data: dict[str, Any]) -> None: if data["secret"] == old_state.secret: logger.info("Got new client connection for %s", room) old_state.sid = sid - old_state.config = Config( - sources=old_state.config.sources, - sources_prio=old_state.config.sources_prio, - **data["config"], + old_state.client = Client( + sources=old_state.client.sources, + sources_prio=old_state.client.sources_prio, + config=DEFAULT_CONFIG | data["config"], ) await sio.enter_room(sid, room) await sio.emit( @@ -615,8 +752,13 @@ async def handle_register_client(sid: str, data: dict[str, Any]) -> None: waiting_room=initial_waiting_room, recent=initial_recent, sid=sid, - config=Config(sources={}, sources_prio=[], **data["config"]), + client=Client( + sources={}, + sources_prio=[], + config=DEFAULT_CONFIG | data["config"], + ), ) + await sio.enter_room(sid, room) await sio.emit( "client-registered", {"success": True, "room": room}, room=sid @@ -653,13 +795,13 @@ async def handle_sources(sid: str, data: dict[str, Any]) -> None: if sid != state.sid: return - unused_sources = state.config.sources.keys() - data["sources"] - new_sources = data["sources"] - state.config.sources.keys() + unused_sources = state.client.sources.keys() - data["sources"] + new_sources = data["sources"] - state.client.sources.keys() for source in unused_sources: - del state.config.sources[source] + del state.client.sources[source] - state.config.sources_prio = data["sources"] + state.client.sources_prio = data["sources"] for name in new_sources: await sio.emit("request-config", {"source": name}, room=sid) @@ -690,12 +832,12 @@ async def handle_config_chunk(sid: str, data: dict[str, Any]) -> None: if sid != state.sid: return - if not data["source"] in state.config.sources: - state.config.sources[data["source"]] = available_sources[ + if data["source"] not in state.client.sources: + state.client.sources[data["source"]] = available_sources[ data["source"] ](data["config"]) else: - state.config.sources[data["source"]].add_to_config(data["config"]) + state.client.sources[data["source"]].add_to_config(data["config"]) @sio.on("config") @@ -722,7 +864,7 @@ async def handle_config(sid: str, data: dict[str, Any]) -> None: if sid != state.sid: return - state.config.sources[data["source"]] = available_sources[data["source"]]( + state.client.sources[data["source"]] = available_sources[data["source"]]( data["config"] ) @@ -910,8 +1052,8 @@ async def handle_search(sid: str, data: dict[str, Any]) -> None: query = data["query"] results_list = await asyncio.gather( *[ - state.config.sources[source].search(query) - for source in state.config.sources_prio + state.client.sources[source].search(query) + for source in state.client.sources_prio ] )