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)
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"]
last_song = (
datetime.datetime.fromisoformat(config["config"]["last_song"])
if "last_song" in config["config"]
else None
)
if "preview_duration" in config["config"]:
state.preview_duration = config["config"]["preview_duration"]
state.config |= config["config"] | {"last_song": last_song}
state.key = args.key if args.key else None

View file

@ -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
]
)