Changes to internal config handling, server side checking of waiting room policy, updatable config

This commit is contained in:
Christoph Stahl 2023-11-01 13:03:37 +01:00
parent a983c74de8
commit 31c45e3fe4
2 changed files with 212 additions and 69 deletions

View file

@ -62,6 +62,14 @@ sources: dict[str, Source] = {}
currentLock: asyncio.Semaphore = asyncio.Semaphore(0) currentLock: asyncio.Semaphore = asyncio.Semaphore(0)
def default_config():
return {
"preview_duration": 3,
"last_song": None,
"waiting_room_policy": None,
}
@dataclass @dataclass
class State: class State:
"""This captures the current state of the playback client. """This captures the current state of the playback client.
@ -87,12 +95,19 @@ class State:
:type secret: str :type secret: str
:param key: An optional key, if registration on the server is limited. :param key: An optional key, if registration on the server is limited.
:type key: Optional[str] :type key: Optional[str]
:param preview_duration: Amount of seconds the preview before a song be :param config: Various configuration options for the client:
displayed. * `preview_duration` (`Optional[int]`): The duration in seconds the
:type preview_duration: int playback client shows a preview for the next song. This is accounted for
:param last_song: At what time should the server not accept any more songs. in the calculation of the ETA for songs later in the queue.
`None` if no such limit should exist. * `last_song` (`Optional[datetime.datetime]`): A timestamp, defining the end of
:type last_song: Optional[datetime.datetime] 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 # pylint: disable=too-many-instance-attributes
@ -105,31 +120,17 @@ class State:
server: str = "" server: str = ""
secret: str = "" secret: str = ""
key: Optional[str] = None key: Optional[str] = None
preview_duration: int = 3 config: dict[str, Any] = field(default_factory=default_config)
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,
}
state: State = State() 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") @sio.on("skip-current")
async def handle_skip_current(data: dict[str, Any]) -> None: async def handle_skip_current(data: dict[str, Any]) -> None:
""" """
@ -201,7 +202,7 @@ async def handle_connect() -> None:
"recent": state.recent, "recent": state.recent,
"room": state.room, "room": state.room,
"secret": state.secret, "secret": state.secret,
"config": state.get_config(), "config": state.config,
} }
if state.key: if state.key:
data["registration-key"] = state.key data["registration-key"] = state.key
@ -252,7 +253,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,
f"--image-display-duration={state.preview_duration}", f"--image-display-duration={state.config['preview_duration']}",
"--sub-pos=50", "--sub-pos=50",
"--sub-file=-", "--sub-file=-",
"--fullscreen", "--fullscreen",
@ -290,7 +291,7 @@ async def handle_play(data: dict[str, Any]) -> None:
) )
try: try:
state.current_source = sources[entry.source] state.current_source = sources[entry.source]
if state.preview_duration > 0: if state.config["preview_duration"] > 0:
await preview(entry) await preview(entry)
await sources[entry.source].play(entry) await sources[entry.source].play(entry)
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
@ -409,12 +410,12 @@ async def aiomain() -> None:
sources.update(configure_sources(config["sources"])) sources.update(configure_sources(config["sources"]))
if "config" in config: if "config" in config:
if "last_song" in config["config"]: last_song = (
state.last_song = datetime.datetime.fromisoformat( datetime.datetime.fromisoformat(config["config"]["last_song"])
config["config"]["last_song"] if "last_song" in config["config"]
else None
) )
if "preview_duration" in config["config"]: state.config |= config["config"] | {"last_song": last_song}
state.preview_duration = config["config"]["preview_duration"]
state.key = args.key if args.key else None state.key = args.key if args.key else None

View file

@ -21,6 +21,7 @@ import logging
import os import os
import random import random
import string import string
from json.decoder import JSONDecodeError
from argparse import ArgumentParser from argparse import ArgumentParser
from dataclasses import dataclass from dataclasses import dataclass
from dataclasses import field from dataclasses import field
@ -43,6 +44,12 @@ sio = socketio.AsyncServer(
app = web.Application() app = web.Application()
sio.attach(app) sio.attach(app)
DEFAULT_CONFIG = {
"preview_duration": 3,
"waiting_room_policy": None,
"last_song": None,
}
async def root_handler(request: Any) -> Any: async def root_handler(request: Any) -> Any:
""" """
@ -68,7 +75,7 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class Config: class Client:
"""This stores the configuration of a specific playback client. """This stores the configuration of a specific playback client.
In case a new playback client connects to a room, these values can be In case a new playback client connects to a room, these values can be
@ -79,18 +86,23 @@ class Config:
:type sources: Source :type sources: Source
:param sources_prio: A list defining the order of the search results. :param sources_prio: A list defining the order of the search results.
:type sources_prio: list[str] :type sources_prio: list[str]
:param preview_duration: The duration in seconds the playbackclients shows :param config: Various configuration options for the client:
a preview for the next song. This is accounted for in the calculation * `preview_duration` (`Optional[int]`): The duration in seconds the
of the ETA for songs later in the queue. playback client shows a preview for the next song. This is accounted for
:type preview_duration: int in the calculation of the ETA for songs later in the queue.
:param last_song: A timestamp, defining the end of the queue. * `last_song` (`Optional[float]`): A timestamp, defining the end of the queue.
:type last_song: Optional[float] * `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: dict[str, Source]
sources_prio: list[str] sources_prio: list[str]
preview_duration: int config: dict[str, Any]
last_song: Optional[float]
@dataclass @dataclass
@ -113,8 +125,8 @@ class State:
a new playback client connects to a room (with the correct secret), a new playback client connects to a room (with the correct secret),
this will be swapped with the new sid. this will be swapped with the new sid.
:type sid: str :type sid: str
:param config: The config for the client :param client: The config for the playback client
:type config: Config :type client: Client
""" """
secret: str secret: str
@ -122,7 +134,7 @@ class State:
waiting_room: list[Entry] waiting_room: list[Entry]
recent: list[Entry] recent: list[Entry]
sid: str sid: str
config: Config client: Client
last_seen: datetime.datetime = field( last_seen: datetime.datetime = field(
init=False, default_factory=datetime.datetime.now init=False, default_factory=datetime.datetime.now
) )
@ -153,6 +165,7 @@ async def send_state(state: State, sid: str) -> None:
"queue": state.queue, "queue": state.queue,
"recent": state.recent, "recent": state.recent,
"waiting_room": state.waiting_room, "waiting_room": state.waiting_room,
"config": state.client.config,
}, },
room=sid, room=sid,
) )
@ -188,7 +201,7 @@ async def handle_waiting_room_append(sid: str, data: dict[str, Any]) -> None:
room = session["room"] room = session["room"]
state = clients[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"]) entry = await source_obj.get_entry(data["performer"], data["ident"])
@ -254,18 +267,23 @@ async def append_to_queue(
start_time = state.queue.fold( start_time = state.queue.fold(
lambda item, time: time lambda item, time: time
+ item.duration + item.duration
+ state.config.preview_duration + state.client.config["preview_duration"]
+ 1, + 1,
start_time, start_time,
) )
if state.config.last_song: if state.client.config["last_song"]:
if state.config.last_song < start_time: if state.client.config["last_song"] < start_time:
end_time = datetime.datetime.fromtimestamp(state.config.last_song) # end_time = datetime.datetime.fromtimestamp(
# state.client.config["last_song"]
# )
if report_to is not None: if report_to is not None:
await sio.emit( await sio.emit(
"err", "err",
{"type": "QUEUE_FULL", "end_time": state.config.last_song}, {
"type": "QUEUE_FULL",
"end_time": state.client.config["last_song"],
},
room=report_to, room=report_to,
) )
return 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") @sio.on("append")
async def handle_append(sid: str, data: dict[str, Any]) -> None: 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 request is denied and a "msg" message is send to the client, detailing
this. 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 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. 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"] room = session["room"]
state = clients[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"]) 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) 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") @sio.on("meta-info")
async def handle_meta_info(sid: str, data: dict[str, Any]) -> None: 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: if data["secret"] == old_state.secret:
logger.info("Got new client connection for %s", room) logger.info("Got new client connection for %s", room)
old_state.sid = sid old_state.sid = sid
old_state.config = Config( old_state.client = Client(
sources=old_state.config.sources, sources=old_state.client.sources,
sources_prio=old_state.config.sources_prio, sources_prio=old_state.client.sources_prio,
**data["config"], config=DEFAULT_CONFIG | data["config"],
) )
await sio.enter_room(sid, room) await sio.enter_room(sid, room)
await sio.emit( await sio.emit(
@ -615,8 +752,13 @@ async def handle_register_client(sid: str, data: dict[str, Any]) -> None:
waiting_room=initial_waiting_room, waiting_room=initial_waiting_room,
recent=initial_recent, recent=initial_recent,
sid=sid, 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.enter_room(sid, room)
await sio.emit( await sio.emit(
"client-registered", {"success": True, "room": room}, room=sid "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: if sid != state.sid:
return return
unused_sources = state.config.sources.keys() - data["sources"] unused_sources = state.client.sources.keys() - data["sources"]
new_sources = data["sources"] - state.config.sources.keys() new_sources = data["sources"] - state.client.sources.keys()
for source in unused_sources: 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: for name in new_sources:
await sio.emit("request-config", {"source": name}, room=sid) 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: if sid != state.sid:
return return
if not data["source"] in state.config.sources: if data["source"] not in state.client.sources:
state.config.sources[data["source"]] = available_sources[ state.client.sources[data["source"]] = available_sources[
data["source"] data["source"]
](data["config"]) ](data["config"])
else: else:
state.config.sources[data["source"]].add_to_config(data["config"]) state.client.sources[data["source"]].add_to_config(data["config"])
@sio.on("config") @sio.on("config")
@ -722,7 +864,7 @@ async def handle_config(sid: str, data: dict[str, Any]) -> None:
if sid != state.sid: if sid != state.sid:
return return
state.config.sources[data["source"]] = available_sources[data["source"]]( state.client.sources[data["source"]] = available_sources[data["source"]](
data["config"] data["config"]
) )
@ -910,8 +1052,8 @@ async def handle_search(sid: str, data: dict[str, Any]) -> None:
query = data["query"] query = data["query"]
results_list = await asyncio.gather( results_list = await asyncio.gather(
*[ *[
state.config.sources[source].search(query) state.client.sources[source].search(query)
for source in state.config.sources_prio for source in state.client.sources_prio
] ]
) )