1114 lines
35 KiB
Python
1114 lines
35 KiB
Python
"""
|
|
Module for the Server.
|
|
|
|
Starts a async socketio server, and serves the web client::
|
|
|
|
usage: server.py [-h] [--host HOST] [--port PORT] [--root-folder PATH]
|
|
|
|
options:
|
|
-h, --help show this help message and exit
|
|
--host HOST, -H HOST
|
|
--port PORT, -p PORT
|
|
--root-folder PATH, -r PATH
|
|
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import datetime
|
|
import hashlib
|
|
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
|
|
from typing import Any
|
|
from typing import AsyncGenerator
|
|
from typing import Optional
|
|
|
|
import socketio
|
|
from aiohttp import web
|
|
|
|
from . import jsonencoder
|
|
from .entry import Entry
|
|
from .queue import Queue
|
|
from .sources import available_sources
|
|
from .sources import Source
|
|
|
|
sio = socketio.AsyncServer(
|
|
cors_allowed_origins="*", logger=True, engineio_logger=False, json=jsonencoder
|
|
)
|
|
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:
|
|
"""
|
|
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"):
|
|
return web.FileResponse(os.path.join(app["root_folder"], "favicon.ico"))
|
|
return web.FileResponse(os.path.join(app["root_folder"], "index.html"))
|
|
|
|
|
|
logging.basicConfig(level=logging.WARNING)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
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
|
|
overwritten.
|
|
|
|
:param sources: A dictionary mapping the name of the used sources to their
|
|
instances.
|
|
:type sources: Source
|
|
:param sources_prio: A list defining the order of the search results.
|
|
:type sources_prio: list[str]
|
|
: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:
|
|
- `forced`, 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]
|
|
config: dict[str, Any]
|
|
|
|
|
|
@dataclass
|
|
class State:
|
|
"""This defines the state of one session/room.
|
|
|
|
:param secret: The secret for the room. Used to log in as an admin on the
|
|
webclient or reconnect a playbackclient
|
|
:type secret: str
|
|
:param queue: A queue of :py:class:`syng.entry.Entry` objects. New songs
|
|
are appended to this, and if a playback client requests a song, it is
|
|
taken from the top.
|
|
:type queue: Queue
|
|
:param waiting_room: Contains the Entries, that are hold back, until a
|
|
specific song is finished.
|
|
:type waiting_room: list[Entry]
|
|
:param recent: A list of already played songs in order.
|
|
:type recent: list[Entry]
|
|
:param sid: The socket.io session id of the (unique) playback client. Once
|
|
a new playback client connects to a room (with the correct secret),
|
|
this will be swapped with the new sid.
|
|
:type sid: str
|
|
:param client: The config for the playback client
|
|
:type client: Client
|
|
"""
|
|
|
|
queue: Queue
|
|
waiting_room: list[Entry]
|
|
recent: list[Entry]
|
|
sid: str
|
|
client: Client
|
|
last_seen: datetime.datetime = field(init=False, default_factory=datetime.datetime.now)
|
|
|
|
|
|
clients: dict[str, State] = {}
|
|
|
|
|
|
async def send_state(state: State, sid: str) -> None:
|
|
"""
|
|
Send the current state (queue and recent-list) to sid.
|
|
|
|
This sends a "state" message. This can be received either by the playback
|
|
client, a web client or the whole room.
|
|
|
|
If it is send to a playback client, it will be handled by the
|
|
:py:func:`syng.client.handle_state` function.
|
|
|
|
:param state: The state to send
|
|
:type state: State
|
|
:param sid: The recepient of the "state" message
|
|
:type sid: str:
|
|
:rtype: None
|
|
"""
|
|
|
|
safe_config = {k: v for k, v in state.client.config.items() if k not in ["secret", "key"]}
|
|
print(safe_config)
|
|
|
|
await sio.emit(
|
|
"state",
|
|
{
|
|
"queue": state.queue,
|
|
"recent": state.recent,
|
|
"waiting_room": state.waiting_room,
|
|
"config": safe_config,
|
|
},
|
|
room=sid,
|
|
)
|
|
|
|
|
|
@sio.on("get-state")
|
|
async def handle_state(sid: str) -> None:
|
|
"""
|
|
Handle the "get-state" message.
|
|
|
|
Sends the current state to whoever requests it. This failes if the sender
|
|
is not part of any room.
|
|
|
|
:param sid: The initial sender, and therefore recepient of the "state"
|
|
message
|
|
:type sid: str
|
|
:rtype: None
|
|
"""
|
|
async with sio.session(sid) as session:
|
|
room = session["room"]
|
|
state = clients[room]
|
|
|
|
await send_state(state, sid)
|
|
|
|
|
|
@sio.on("waiting-room-append")
|
|
async def handle_waiting_room_append(sid: str, data: dict[str, Any]) -> None:
|
|
"""
|
|
Append a song to the waiting room.
|
|
|
|
"""
|
|
async with sio.session(sid) as session:
|
|
room = session["room"]
|
|
state = clients[room]
|
|
|
|
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 add to the waiting room: {data['ident']}. Maybe try again?"},
|
|
room=sid,
|
|
)
|
|
return
|
|
|
|
if "uid" not in data or (
|
|
(data["uid"] is not None and len(list(state.queue.find_by_uid(data["uid"]))) == 0)
|
|
or (data["uid"] is None and state.queue.find_by_name(data["performer"]) is None)
|
|
):
|
|
await append_to_queue(room, entry, sid)
|
|
return
|
|
|
|
entry.uid = data["uid"]
|
|
|
|
state.waiting_room.append(entry)
|
|
await send_state(state, room)
|
|
await sio.emit(
|
|
"get-meta-info",
|
|
entry,
|
|
room=clients[room].sid,
|
|
)
|
|
|
|
|
|
async def append_to_queue(room: str, entry: Entry, report_to: Optional[str] = None) -> None:
|
|
"""
|
|
Append a song to the queue for a given session.
|
|
|
|
Checks, if the computed start time is before the configured end time of the
|
|
event, and reports an error, if the end time is exceeded.
|
|
|
|
:param room: The room with the queue.
|
|
:type room: str
|
|
:param entry: The entry that contains the song.
|
|
:type entry: Entry
|
|
:param report_to: If an error occurs, who to report to.
|
|
:type report_to: Optional[str]
|
|
:rtype: None
|
|
"""
|
|
state = clients[room]
|
|
|
|
first_song = state.queue.try_peek()
|
|
if first_song is None or first_song.started_at is None:
|
|
start_time = datetime.datetime.now().timestamp()
|
|
else:
|
|
start_time = first_song.started_at
|
|
|
|
start_time = state.queue.fold(
|
|
lambda item, time: time + item.duration + state.client.config["preview_duration"] + 1,
|
|
start_time,
|
|
)
|
|
|
|
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.client.config["last_song"],
|
|
},
|
|
room=report_to,
|
|
)
|
|
return
|
|
|
|
state.queue.append(entry)
|
|
await send_state(state, room)
|
|
|
|
await sio.emit(
|
|
"get-meta-info",
|
|
entry,
|
|
room=clients[room].sid,
|
|
)
|
|
|
|
|
|
@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 = jsonencoder.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:
|
|
"""
|
|
Handle the "append" message.
|
|
|
|
This should be called from a web client. Appends the entry, that is encoded
|
|
within the data to the room the client is currently connected to. An entry
|
|
constructed this way, will be given a UUID, to differentiate it from other
|
|
entries for the same song. Additionally an id of the web client is saved
|
|
for that entry.
|
|
|
|
If the room is configured to no longer accept songs past a certain time
|
|
(via the :py:attr:`Config.last_song` attribute), it is checked, if the
|
|
start time of the song would exceed this time. If this is the case, the
|
|
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.
|
|
|
|
Since some properties of a song can only be accessed on the playback
|
|
client, a "get-meta-info" message is send to the playback client. This is
|
|
handled there with the :py:func:`syng.client.handle_get_meta_info`
|
|
function.
|
|
|
|
:param sid: The session id of the client sending this request
|
|
:type sid: str
|
|
:param data: A dictionary encoding the entry, that should be added to the
|
|
queue.
|
|
:type data: dict[str, Any]
|
|
:rtype: None
|
|
"""
|
|
async with sio.session(sid) as session:
|
|
room = session["room"]
|
|
state = clients[room]
|
|
|
|
if state.client.config["waiting_room_policy"] and (
|
|
state.client.config["waiting_room_policy"].lower() == "forced"
|
|
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"])
|
|
|
|
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
|
|
|
|
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() == "forced":
|
|
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:
|
|
"""
|
|
Handle the "meta-info" message.
|
|
|
|
Updated a :py:class:syng.entry.Entry`, that is encoded in the data
|
|
parameter, in the queue, that belongs to the room the requesting client
|
|
belongs to, with new meta data, that is send from the playback client.
|
|
|
|
Afterwards send the updated queue to all members of the room.
|
|
|
|
:param sid: The session id of the client sending this request.
|
|
:type sid: str
|
|
:param data: A dictionary encoding the entry to update (already with the
|
|
new metadata)
|
|
:type data: dict[str, Any]
|
|
:rtype: None
|
|
"""
|
|
async with sio.session(sid) as session:
|
|
room = session["room"]
|
|
state = clients[room]
|
|
|
|
state.queue.update(
|
|
data["uuid"],
|
|
lambda item: item.update(**data["meta"]),
|
|
)
|
|
|
|
for entry in state.waiting_room:
|
|
if entry.uuid == data["uuid"] or str(entry.uuid) == data["uuid"]:
|
|
entry.update(**data["meta"])
|
|
|
|
await send_state(state, room)
|
|
|
|
|
|
@sio.on("get-first")
|
|
async def handle_get_first(sid: str) -> None:
|
|
"""
|
|
Handle the "get-first" message.
|
|
|
|
This message is send by the playback client, once it has connected. It
|
|
should only be send for the initial song. Each subsequent song should be
|
|
requestet with a "pop-then-get-next" message (See
|
|
:py:func:`handle_pop_then_get_next`).
|
|
|
|
If no songs are in the queue for this room, this function waits until one
|
|
is available, then notes its starting time and sends it back to the
|
|
playback client in a "play" message. This will be handled by the
|
|
:py:func:`syng.client.handle_play` function.
|
|
|
|
:param sid: The session id of the requesting client
|
|
:type sid: str
|
|
:rtype: None
|
|
"""
|
|
async with sio.session(sid) as session:
|
|
room = session["room"]
|
|
state = clients[room]
|
|
|
|
current = await state.queue.peek()
|
|
current.started_at = datetime.datetime.now().timestamp()
|
|
|
|
await sio.emit("play", current, room=sid)
|
|
|
|
|
|
@sio.on("waiting-room-to-queue")
|
|
async def handle_waiting_room_to_queue(sid: str, data: dict[str, Any]) -> None:
|
|
"""
|
|
Handle the "waiting-room-to-queue" message.
|
|
|
|
If on an admin-connection, removes a song from the waiting room and appends it to
|
|
the queue.
|
|
|
|
:param sid: The session id of the requesting client
|
|
: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:
|
|
entry = next(
|
|
(wr_entry for wr_entry in state.waiting_room if str(wr_entry.uuid) == data["uuid"]),
|
|
None,
|
|
)
|
|
if entry is not None:
|
|
state.waiting_room.remove(entry)
|
|
await append_to_queue(room, entry, sid)
|
|
|
|
|
|
async def add_songs_from_waiting_room(room: str) -> None:
|
|
"""
|
|
Add all songs from the waiting room, that should be added to the queue.
|
|
|
|
A song should be added if none of its performers are already queued.
|
|
|
|
This should be called every time a song leaves the queue.
|
|
|
|
:param room: The room holding the queue.
|
|
:type room: str
|
|
:rtype: None
|
|
"""
|
|
state = clients[room]
|
|
|
|
wrs_to_remove = []
|
|
for wr_entry in state.waiting_room:
|
|
if state.queue.find_by_name(wr_entry.performer) is None:
|
|
await append_to_queue(room, wr_entry)
|
|
wrs_to_remove.append(wr_entry)
|
|
|
|
for wr_entry in wrs_to_remove:
|
|
state.waiting_room.remove(wr_entry)
|
|
|
|
|
|
async def discard_first(room: str) -> Entry:
|
|
"""
|
|
Gets the first element of the queue, handling resulting triggers.
|
|
"""
|
|
state = clients[room]
|
|
|
|
old_entry = await state.queue.popleft()
|
|
|
|
await add_songs_from_waiting_room(room)
|
|
|
|
state.recent.append(old_entry)
|
|
state.last_seen = datetime.datetime.now()
|
|
|
|
return old_entry
|
|
|
|
|
|
@sio.on("pop-then-get-next")
|
|
async def handle_pop_then_get_next(sid: str) -> None:
|
|
"""
|
|
Handle the "pop-then-get-next" message.
|
|
|
|
This function acts similar to the :py:func:`handle_get_first` function. The
|
|
main difference is, that prior to sending a song to the playback client,
|
|
the first element of the queue is discarded.
|
|
|
|
Afterwards it follows the same steps as the handler for the "play" message,
|
|
get the first element of the queue, annotate it with the current time,
|
|
update everyones state and send the entry it to the playback client in a
|
|
"play" message. This will be handled by the
|
|
:py:func:`syng.client.handle_play` function.
|
|
|
|
:param sid: The session id of the requesting playback client
|
|
:type sid: str
|
|
:rtype: None
|
|
"""
|
|
async with sio.session(sid) as session:
|
|
room = session["room"]
|
|
state = clients[room]
|
|
|
|
if sid != state.sid:
|
|
return
|
|
|
|
await discard_first(room)
|
|
await send_state(state, room)
|
|
|
|
current = await state.queue.peek()
|
|
current.started_at = datetime.datetime.now().timestamp()
|
|
await send_state(state, room)
|
|
|
|
await sio.emit("play", current, room=sid)
|
|
|
|
|
|
@sio.on("register-client")
|
|
async def handle_register_client(sid: str, data: dict[str, Any]) -> None:
|
|
"""
|
|
Handle the "register-client" message.
|
|
|
|
The data dictionary should have the following keys:
|
|
- `registration_key` (Optional), a key corresponding to those stored
|
|
in `app["registration-keyfile"]`
|
|
- `room` (Optional), the requested room
|
|
- `config`, an dictionary of initial configurations
|
|
- `queue`, a list of initial entries for the queue. The entries are
|
|
encoded as a dictionary.
|
|
- `recent`, a list of initial entries for the recent list. The entries
|
|
are encoded as a dictionary.
|
|
- `secret`, the secret of the room
|
|
|
|
This will register a new playback client to a specific room. If there
|
|
already exists a playback client registered for this room, this
|
|
playback client will be replaced if and only if, the new playback
|
|
client has the same secret.
|
|
|
|
If registration is restricted, abort, if the given key is not in the
|
|
registration keyfile.
|
|
|
|
If no room is provided, a fresh room id is generated.
|
|
|
|
If the client provides a new room, or a new room id was generated, the
|
|
server will create a new :py:class:`State` object and associate it with
|
|
the room id. The state will be initialized with a queue and recent
|
|
list, an initial config as well as no sources (yet).
|
|
|
|
In any case, the client will be notified of the success or failure, along
|
|
with its assigned room key via a "client-registered" message. This will be
|
|
handled by the :py:func:`syng.client.handle_client_registered` function.
|
|
|
|
If it was successfully registerd, the client will be added to its assigend
|
|
or requested room.
|
|
|
|
Afterwards all clients in the room will be send the current state.
|
|
|
|
:param sid: The session id of the requesting playback client.
|
|
:type sid: str
|
|
:param data: A dictionary with the keys described above
|
|
:type data: dict[str, Any]
|
|
:rtype: None
|
|
"""
|
|
|
|
def gen_id(length: int = 4) -> str:
|
|
client_id = "".join([random.choice(string.ascii_letters) for _ in range(length)])
|
|
if client_id in clients:
|
|
client_id = gen_id(length + 1)
|
|
return client_id
|
|
|
|
if not app["public"]:
|
|
with open(app["registration-keyfile"], encoding="utf8") as f:
|
|
raw_keys = f.readlines()
|
|
keys = [key[:64] for key in raw_keys]
|
|
|
|
if (
|
|
"key" not in data["config"]
|
|
or hashlib.sha256(data["config"]["key"].encode()).hexdigest() not in keys
|
|
):
|
|
await sio.emit(
|
|
"client-registered",
|
|
{"success": False, "room": None},
|
|
room=sid,
|
|
)
|
|
return
|
|
|
|
room: str = (
|
|
data["config"]["room"] if "room" in data["config"] and data["config"]["room"] else gen_id()
|
|
)
|
|
async with sio.session(sid) as session:
|
|
session["room"] = room
|
|
|
|
if room in clients:
|
|
old_state: State = clients[room]
|
|
if data["config"]["secret"] == old_state.client.config["secret"]:
|
|
logger.info("Got new client connection for %s", room)
|
|
old_state.sid = sid
|
|
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("client-registered", {"success": True, "room": room}, room=sid)
|
|
await send_state(clients[room], sid)
|
|
else:
|
|
logger.warning("Got wrong secret for %s", room)
|
|
await sio.emit("client-registered", {"success": False, "room": room}, room=sid)
|
|
else:
|
|
logger.info("Registerd new client %s", room)
|
|
initial_entries = [Entry(**entry) for entry in data["queue"]]
|
|
initial_waiting_room = [Entry(**entry) for entry in data["waiting_room"]]
|
|
initial_recent = [Entry(**entry) for entry in data["recent"]]
|
|
|
|
clients[room] = State(
|
|
queue=Queue(initial_entries),
|
|
waiting_room=initial_waiting_room,
|
|
recent=initial_recent,
|
|
sid=sid,
|
|
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)
|
|
await send_state(clients[room], sid)
|
|
|
|
|
|
@sio.on("sources")
|
|
async def handle_sources(sid: str, data: dict[str, Any]) -> None:
|
|
"""
|
|
Handle the "sources" message.
|
|
|
|
Get the list of sources the client wants to use. Update internal list of
|
|
sources, remove unused sources and query for a config for all uninitialized
|
|
sources by sending a "request-config" message for each such source to the
|
|
playback client. This will be handled by the
|
|
:py:func:`syng.client.request-config` function.
|
|
|
|
This will not yet add the sources to the configuration, rather gather what
|
|
sources need to be configured and request their configuration. The list
|
|
of sources will set the :py:attr:`Config.sources_prio` attribute.
|
|
|
|
:param sid: The session id of the playback client
|
|
:type sid: str
|
|
:param data: A dictionary containing a "sources" key, with the list of
|
|
sources to use.
|
|
:type data: dict[str, Any]
|
|
:rtype: None
|
|
"""
|
|
async with sio.session(sid) as session:
|
|
room = session["room"]
|
|
state = clients[room]
|
|
|
|
if sid != state.sid:
|
|
return
|
|
|
|
unused_sources = state.client.sources.keys() - data["sources"]
|
|
new_sources = data["sources"] - state.client.sources.keys()
|
|
|
|
for source in unused_sources:
|
|
del state.client.sources[source]
|
|
|
|
state.client.sources_prio = data["sources"]
|
|
|
|
for name in new_sources:
|
|
await sio.emit("request-config", {"source": name}, room=sid)
|
|
|
|
|
|
@sio.on("config-chunk")
|
|
async def handle_config_chunk(sid: str, data: dict[str, Any]) -> None:
|
|
"""
|
|
Handle the "config-chunk" message.
|
|
|
|
This is called, when a source wants its configuration transmitted in
|
|
chunks, rather than a single message. If the source already exist
|
|
(e.g. when this is not the first chunk), the config will be added
|
|
to the source, otherwise a source will be created with the given
|
|
configuration.
|
|
|
|
:param sid: The session id of the playback client
|
|
:type sid: str
|
|
:param data: A dictionary with a "source" (str) and a
|
|
"config" (dict[str, Any]) entry. The exact content of the config entry
|
|
depends on the source.
|
|
:rtype: None
|
|
"""
|
|
async with sio.session(sid) as session:
|
|
room = session["room"]
|
|
state = clients[room]
|
|
|
|
if sid != state.sid:
|
|
return
|
|
|
|
if data["source"] not in state.client.sources:
|
|
state.client.sources[data["source"]] = available_sources[data["source"]](data["config"])
|
|
else:
|
|
state.client.sources[data["source"]].add_to_config(data["config"])
|
|
|
|
|
|
@sio.on("config")
|
|
async def handle_config(sid: str, data: dict[str, Any]) -> None:
|
|
"""
|
|
Handle the "config" message.
|
|
|
|
This is called, when a source wants its configuration transmitted in
|
|
a single message, rather than chunks. A source will be created with the
|
|
given configuration.
|
|
|
|
:param sid: The session id of the playback client
|
|
:type sid: str
|
|
:param data: A dictionary with a "source" (str) and a
|
|
"config" (dict[str, Any]) entry. The exact content of the config entry
|
|
depends on the source.
|
|
:type data: dict[str, Any]
|
|
: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"]](data["config"])
|
|
|
|
|
|
@sio.on("register-web")
|
|
async def handle_register_web(sid: str, data: dict[str, Any]) -> bool:
|
|
"""
|
|
Handle a "register-web" message.
|
|
|
|
Adds a web client to a requested room and sends it the initial state of the
|
|
queue and recent list.
|
|
|
|
:param sid: The session id of the web client.
|
|
:type sid: str
|
|
:param data: A dictionary, containing at least a "room" entry.
|
|
:type data: dict[str, Any]
|
|
:returns: True, if the room exist, False otherwise
|
|
:rtype: bool
|
|
"""
|
|
if data["room"] in clients:
|
|
async with sio.session(sid) as session:
|
|
session["room"] = data["room"]
|
|
await sio.enter_room(sid, session["room"])
|
|
state = clients[session["room"]]
|
|
await send_state(state, sid)
|
|
return True
|
|
return False
|
|
|
|
|
|
@sio.on("register-admin")
|
|
async def handle_register_admin(sid: str, data: dict[str, Any]) -> bool:
|
|
"""
|
|
Handle a "register-admin" message.
|
|
|
|
If the client provides the correct secret for its room, the connection is
|
|
upgraded to an admin connection.
|
|
|
|
:param sid: The session id of the client, requesting admin.
|
|
:type sid: str:
|
|
:param data: A dictionary with at least a "secret" entry.
|
|
:type data: dict[str, Any]
|
|
:returns: True, if the secret is correct, False otherwise
|
|
:rtype: bool
|
|
"""
|
|
async with sio.session(sid) as session:
|
|
room = session["room"]
|
|
state = clients[room]
|
|
|
|
is_admin: bool = data["secret"] == state.client.config["secret"]
|
|
async with sio.session(sid) as session:
|
|
session["admin"] = is_admin
|
|
return is_admin
|
|
|
|
|
|
@sio.on("skip-current")
|
|
async def handle_skip_current(sid: str) -> None:
|
|
"""
|
|
Handle a "skip-current" message.
|
|
|
|
If this comes from an admin connection, forward the "skip-current" message
|
|
to the playback client. This will be handled by the
|
|
:py:func:`syng.client.handle_skip_current` function.
|
|
|
|
:param sid: The session id of the client, requesting.
|
|
: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:
|
|
old_entry = await discard_first(room)
|
|
await sio.emit("skip-current", old_entry, room=clients[room].sid)
|
|
await send_state(state, room)
|
|
|
|
|
|
@sio.on("move-up")
|
|
async def handle_move_up(sid: str, data: dict[str, Any]) -> None:
|
|
"""
|
|
Handle the "move-up" message.
|
|
|
|
If on an admin connection, moves up the entry specified in the data by one
|
|
place in the queue.
|
|
|
|
:param sid: The session id of the client requesting.
|
|
:type sid: str
|
|
:param data: A dictionary with at least an "uuid" entry
|
|
:type data: dict[str, Any]
|
|
:rtype: None
|
|
"""
|
|
async with sio.session(sid) as session:
|
|
room = session["room"]
|
|
is_admin = session["admin"]
|
|
state = clients[room]
|
|
if is_admin:
|
|
await state.queue.move_up(data["uuid"])
|
|
await send_state(state, room)
|
|
|
|
|
|
@sio.on("skip")
|
|
async def handle_skip(sid: str, data: dict[str, Any]) -> None:
|
|
"""
|
|
Handle the "skip" message.
|
|
|
|
If on an admin connection, removes the entry specified by data["uuid"]
|
|
from the queue or the waiting room. Triggers the waiting room.
|
|
|
|
:param sid: The session id of the client requesting.
|
|
:type sid: str
|
|
:param data: A dictionary with at least an "uuid" entry.
|
|
:type data: dict[str, Any]
|
|
:rtype: None
|
|
"""
|
|
async with sio.session(sid) as session:
|
|
room = session["room"]
|
|
is_admin = session["admin"]
|
|
state = clients[room]
|
|
|
|
if is_admin:
|
|
entry = state.queue.find_by_uuid(data["uuid"])
|
|
if entry is not None:
|
|
logger.info("Skipping %s", entry)
|
|
|
|
await add_songs_from_waiting_room(room)
|
|
|
|
await state.queue.remove(entry)
|
|
|
|
first_entry_index = None
|
|
for idx, wr_entry in enumerate(state.waiting_room):
|
|
if wr_entry.uuid == data["uuid"]:
|
|
first_entry_index = idx
|
|
break
|
|
|
|
if first_entry_index is not None:
|
|
logger.info(
|
|
"Deleting %s from waiting room",
|
|
state.waiting_room[first_entry_index],
|
|
)
|
|
del state.waiting_room[first_entry_index]
|
|
await send_state(state, room)
|
|
|
|
|
|
@sio.on("disconnect")
|
|
async def handle_disconnect(sid: str) -> None:
|
|
"""
|
|
Handle the "disconnect" message.
|
|
|
|
This message is send automatically, when a client disconnets.
|
|
|
|
Remove the client from its room.
|
|
|
|
:param sid: The session id of the client disconnecting
|
|
:type sid: str
|
|
:rtype: None
|
|
"""
|
|
async with sio.session(sid) as session:
|
|
if "room" in session:
|
|
await sio.leave_room(sid, session["room"])
|
|
|
|
|
|
@sio.on("search")
|
|
async def handle_search(sid: str, data: dict[str, Any]) -> None:
|
|
"""
|
|
Handle the "search" message.
|
|
|
|
Forwards the dict["query"] to the :py:func:`Source.search` method, and
|
|
execute them concurrently. The order is given by the
|
|
:py:attr:`Config.sources_prio` attribute of the state.
|
|
|
|
The result will be send with a "search-results" message to the (web)
|
|
client.
|
|
|
|
:param sid: The session id of the client requesting.
|
|
:type sid: str
|
|
:param data: A dictionary with at least a "query" entry.
|
|
:type data: dict[str, str]
|
|
:rtype: None
|
|
"""
|
|
async with sio.session(sid) as session:
|
|
room = session["room"]
|
|
state = clients[room]
|
|
|
|
query = data["query"]
|
|
results_list = await asyncio.gather(
|
|
*[state.client.sources[source].search(query) for source in state.client.sources_prio]
|
|
)
|
|
|
|
results = [search_result for source_result in results_list for search_result in source_result]
|
|
await sio.emit(
|
|
"search-results",
|
|
{"results": results},
|
|
room=sid,
|
|
)
|
|
|
|
|
|
async def cleanup() -> None:
|
|
"""Clean up the unused playback clients
|
|
|
|
This runs every hour, and removes every client, that did not requested a song for four hours
|
|
"""
|
|
|
|
logger.info("Start Cleanup")
|
|
to_remove: list[str] = []
|
|
for sid, state in clients.items():
|
|
logger.info("Client %s, last seen: %s", sid, str(state.last_seen))
|
|
if state.last_seen + datetime.timedelta(hours=4) < datetime.datetime.now():
|
|
logger.info("No activity for 4 hours, removing %s", sid)
|
|
to_remove.append(sid)
|
|
for sid in to_remove:
|
|
await sio.disconnect(sid)
|
|
del clients[sid]
|
|
logger.info("End Cleanup")
|
|
|
|
# The internal loop counter does not use a regular timestamp, so we need to convert between
|
|
# regular datetime and the async loop time
|
|
now = datetime.datetime.now()
|
|
# today = datetime.datetime(now.year, now.month, now.day)
|
|
# next_run = today + datetime.timedelta(days=1)
|
|
|
|
next_run = now + datetime.timedelta(hours=1)
|
|
offset = next_run.timestamp() - now.timestamp()
|
|
loop_next = asyncio.get_event_loop().time() + offset
|
|
|
|
logger.info("Next Cleanup at %s", str(next))
|
|
asyncio.get_event_loop().call_at(loop_next, lambda: asyncio.create_task(cleanup()))
|
|
|
|
|
|
async def background_tasks(
|
|
iapp: web.Application,
|
|
) -> AsyncGenerator[None, None]:
|
|
"""Create all the background tasks
|
|
|
|
For now, this is only the cleanup task
|
|
"""
|
|
|
|
iapp["repeated_cleanup"] = asyncio.create_task(cleanup())
|
|
|
|
yield
|
|
|
|
iapp["repeated_cleanup"].cancel()
|
|
await iapp["repeated_cleanup"]
|
|
|
|
|
|
def main() -> None:
|
|
"""
|
|
Configure and start the server.
|
|
|
|
Parse the command line arguments, register static routes to serve the web
|
|
client and start the server.
|
|
|
|
:rtype: None
|
|
"""
|
|
parser = ArgumentParser()
|
|
parser.add_argument("--host", "-H", default="localhost")
|
|
parser.add_argument("--port", "-p", type=int, default=8080)
|
|
parser.add_argument("--root-folder", "-r", default="syng/static/")
|
|
parser.add_argument("--registration-keyfile", "-k", default=None)
|
|
args = parser.parse_args()
|
|
|
|
app["public"] = True
|
|
if args.registration_keyfile:
|
|
app["public"] = False
|
|
app["registration-keyfile"] = args.registration_keyfile
|
|
|
|
app["root_folder"] = args.root_folder
|
|
|
|
app.add_routes([web.static("/assets/", os.path.join(app["root_folder"], "assets/"))])
|
|
|
|
app.router.add_route("*", "/", root_handler)
|
|
app.router.add_route("*", "/{room}", root_handler)
|
|
app.router.add_route("*", "/{room}/", root_handler)
|
|
|
|
app.cleanup_ctx.append(background_tasks)
|
|
|
|
web.run_app(app, host=args.host, port=args.port)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|