Reworked registering the clients into the connection
This commit is contained in:
parent
988992bc74
commit
292f45ccba
4 changed files with 268 additions and 78 deletions
145
syng/client.py
145
syng/client.py
|
@ -202,17 +202,51 @@ class Client:
|
|||
self.sio.on("get-meta-info", self.handle_get_meta_info)
|
||||
self.sio.on("play", self.handle_play)
|
||||
self.sio.on("search", self.handle_search)
|
||||
self.sio.on("client-registered", self.handle_client_registered)
|
||||
self.sio.on("request-config", self.handle_request_config)
|
||||
self.sio.on("msg", self.handle_msg)
|
||||
self.sio.on("disconnect", self.handle_disconnect)
|
||||
self.sio.on("room-removed", self.handle_room_removed)
|
||||
self.sio.on("*", self.handle_unknown_message)
|
||||
self.sio.on("connect_error", self.handle_connect_error)
|
||||
|
||||
async def handle_connect_error(self, data: dict[str, Any]) -> None:
|
||||
"""
|
||||
Handle the "connect_error" message.
|
||||
|
||||
This function is called when the client fails to connect to the server.
|
||||
It will log the error and disconnect from the server.
|
||||
|
||||
:param data: A dictionary with the error message.
|
||||
:type data: dict[str, Any]
|
||||
:rtype: None
|
||||
"""
|
||||
logger.critical("Connection error: %s", data["message"])
|
||||
await self.ensure_disconnect()
|
||||
|
||||
async def handle_unknown_message(self, event: str, data: dict[str, Any]) -> None:
|
||||
"""
|
||||
Handle unknown messages.
|
||||
|
||||
This function is called when the client receives a message, that is not
|
||||
handled by any of the other handlers. It will log the event and data.
|
||||
|
||||
:param event: The name of the event
|
||||
:type event: str
|
||||
:param data: The data of the event
|
||||
:type data: dict[str, Any]
|
||||
:rtype: None
|
||||
"""
|
||||
logger.warning(f"Unknown message: {event} with data: {data}")
|
||||
|
||||
async def handle_disconnect(self) -> None:
|
||||
self.connection_state.set_disconnected()
|
||||
await self.ensure_disconnect()
|
||||
|
||||
async def ensure_disconnect(self) -> None:
|
||||
"""
|
||||
Ensure that the client is disconnected from the server and the player is
|
||||
terminated.
|
||||
"""
|
||||
logger.info("Disconnecting from server")
|
||||
logger.info(f"Connection: {self.connection_state.is_connected()}")
|
||||
logger.info(f"MPV: {self.connection_state.is_mpv_running()}")
|
||||
|
@ -324,29 +358,40 @@ class Client:
|
|||
"""
|
||||
Handle the "connect" message.
|
||||
|
||||
Called when the client successfully connects or reconnects to the server.
|
||||
Sends a `register-client` message to the server with the initial state and
|
||||
configuration of the client, consiting of the currently saved
|
||||
:py:attr:`State.queue` and :py:attr:`State.recent` field of the global
|
||||
:py:class:`State`, as well a room code the client wants to connect to, a
|
||||
secret to secure the access to the room and a config dictionary.
|
||||
This is called when the client successfully connects to the server
|
||||
and starts the player.
|
||||
|
||||
If the room code is `None`, the server will issue a room code.
|
||||
Start listing all configured :py:class:`syng.sources.source.Source` to the
|
||||
server via a "sources" message. This message will be handled by the
|
||||
:py:func:`syng.server.handle_sources` function and may request additional
|
||||
configuration for each source.
|
||||
|
||||
This message will be handled by the
|
||||
:py:func:`syng.server.handle_register_client` function of the server.
|
||||
If there is no song playing, start requesting the first song of the queue
|
||||
with a "get-first" message. This will be handled on the server by the
|
||||
:py:func:`syng.server.handle_get_first` function.
|
||||
|
||||
:rtype: None
|
||||
"""
|
||||
logger.info("Connected to server")
|
||||
data = {
|
||||
"queue": self.state.queue,
|
||||
"waiting_room": self.state.waiting_room,
|
||||
"recent": self.state.recent,
|
||||
"config": self.state.config,
|
||||
"version": SYNG_VERSION,
|
||||
}
|
||||
await self.sio.emit("register-client", data)
|
||||
logger.info("Connected to server: %s", self.state.config["server"])
|
||||
self.player.start()
|
||||
room = self.state.config["room"]
|
||||
server = self.state.config["server"]
|
||||
|
||||
logger.info("Connected to room: %s", room)
|
||||
qr_string = f"{server}/{room}"
|
||||
self.player.update_qr(qr_string)
|
||||
# this is borked on windows
|
||||
|
||||
if os.name != "nt":
|
||||
print(f"Join here: {server}/{room}")
|
||||
qr = QRCode(box_size=20, border=2)
|
||||
qr.add_data(qr_string)
|
||||
qr.make()
|
||||
qr.print_ascii()
|
||||
|
||||
await self.sio.emit("sources", {"sources": list(self.sources.keys())})
|
||||
if self.state.current_source is None: # A possible race condition can occur here
|
||||
await self.sio.emit("get-first")
|
||||
|
||||
async def handle_get_meta_info(self, data: dict[str, Any]) -> None:
|
||||
"""
|
||||
|
@ -457,52 +502,6 @@ class Client:
|
|||
"search-results", {"results": results, "sid": sid, "search_id": search_id}
|
||||
)
|
||||
|
||||
async def handle_client_registered(self, data: dict[str, Any]) -> None:
|
||||
"""
|
||||
Handle the "client-registered" message.
|
||||
|
||||
If the registration was successfull (`data["success"]` == `True`), store
|
||||
the room code in the global :py:class:`State` and print out a link to join
|
||||
the webclient.
|
||||
|
||||
Start listing all configured :py:class:`syng.sources.source.Source` to the
|
||||
server via a "sources" message. This message will be handled by the
|
||||
:py:func:`syng.server.handle_sources` function and may request additional
|
||||
configuration for each source.
|
||||
|
||||
If there is no song playing, start requesting the first song of the queue
|
||||
with a "get-first" message. This will be handled on the server by the
|
||||
:py:func:`syng.server.handle_get_first` function.
|
||||
|
||||
:param data: A dictionary containing a `success` and a `room` entry.
|
||||
:type data: dict[str, Any]
|
||||
:rtype: None
|
||||
"""
|
||||
if data["success"]:
|
||||
self.player.start()
|
||||
|
||||
logger.info("Connected to room: %s", data["room"])
|
||||
qr_string = f"{self.state.config['server']}/{data['room']}"
|
||||
self.player.update_qr(qr_string)
|
||||
# this is borked on windows
|
||||
|
||||
await self.handle_state(data)
|
||||
if os.name != "nt":
|
||||
print(f"Join here: {self.state.config['server']}/{data['room']}")
|
||||
qr = QRCode(box_size=20, border=2)
|
||||
qr.add_data(qr_string)
|
||||
qr.make()
|
||||
qr.print_ascii()
|
||||
|
||||
self.state.config["room"] = data["room"]
|
||||
await self.sio.emit("sources", {"sources": list(self.sources.keys())})
|
||||
if self.state.current_source is None: # A possible race condition can occur here
|
||||
await self.sio.emit("get-first")
|
||||
else:
|
||||
reason = data.get("reason", "Unknown")
|
||||
logger.critical(f"Registration failed: {reason}")
|
||||
await self.sio.disconnect()
|
||||
|
||||
async def handle_request_config(self, data: dict[str, Any]) -> None:
|
||||
"""
|
||||
Handle the "request-config" message.
|
||||
|
@ -606,7 +605,7 @@ class Client:
|
|||
Handle the "room-removed" message.
|
||||
|
||||
This is called when the server removes the room, that this client is
|
||||
connected to. It will disconnect from the server and terminate the player.
|
||||
connected to. We simply log this event.
|
||||
|
||||
:param data: A dictionary with the `room` entry.
|
||||
:type data: dict[str, Any]
|
||||
|
@ -645,7 +644,15 @@ class Client:
|
|||
self.state.config["key"] = ""
|
||||
|
||||
try:
|
||||
await self.sio.connect(self.state.config["server"])
|
||||
data = {
|
||||
"type": "playback",
|
||||
"queue": self.state.queue,
|
||||
"waiting_room": self.state.waiting_room,
|
||||
"recent": self.state.recent,
|
||||
"config": self.state.config,
|
||||
"version": SYNG_VERSION,
|
||||
}
|
||||
await self.sio.connect(self.state.config["server"], auth=data)
|
||||
|
||||
# this is not supported under windows
|
||||
if os.name != "nt":
|
||||
|
@ -656,8 +663,8 @@ class Client:
|
|||
await self.sio.wait()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except ConnectionError:
|
||||
logger.critical("Could not connect to server")
|
||||
except ConnectionError as e:
|
||||
logger.warning("Could not connect to server: %s", e.args[0])
|
||||
finally:
|
||||
await self.ensure_disconnect()
|
||||
|
||||
|
|
198
syng/server.py
198
syng/server.py
|
@ -28,6 +28,7 @@ from dataclasses import field
|
|||
from typing import Any, Callable, Literal, AsyncGenerator, Optional, cast
|
||||
|
||||
import socketio
|
||||
from socketio.exceptions import ConnectionRefusedError
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
|
@ -92,7 +93,7 @@ def admin(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|||
async def wrapper(self: Server, sid: str, *args: Any, **kwargs: Any) -> Any:
|
||||
async with self.sio.session(sid) as session:
|
||||
room = session["room"]
|
||||
if room not in self.clients or not self.is_admin(self.clients[room], sid):
|
||||
if room not in self.clients or not await self.is_admin(self.clients[room], sid):
|
||||
await self.sio.emit("err", {"type": "NO_ADMIN"}, sid)
|
||||
return
|
||||
return await handler(self, sid, *args, **kwargs)
|
||||
|
@ -226,6 +227,7 @@ class Server:
|
|||
self.sio.on("move-up", self.handle_move_up)
|
||||
self.sio.on("skip", self.handle_skip)
|
||||
self.sio.on("disconnect", self.handle_disconnect)
|
||||
self.sio.on("connect", self.handle_connect)
|
||||
self.sio.on("search", self.handle_search)
|
||||
self.sio.on("search-results", self.handle_search_results)
|
||||
|
||||
|
@ -928,7 +930,7 @@ class Server:
|
|||
{"success": False, "room": None, "reason": "PROTOCOL_VERSION"},
|
||||
room=sid,
|
||||
)
|
||||
return False
|
||||
raise ConnectionRefusedError("Client is incompatible and outdated. Please update.")
|
||||
|
||||
if client_version > SYNG_VERSION:
|
||||
await self.sio.emit(
|
||||
|
@ -936,12 +938,7 @@ class Server:
|
|||
{"type": "error", "msg": "Server is outdated. Please update."},
|
||||
room=sid,
|
||||
)
|
||||
await self.sio.emit(
|
||||
"client-registered",
|
||||
{"success": False, "room": None, "reason": "PROTOCOL_VERSION"},
|
||||
room=sid,
|
||||
)
|
||||
return False
|
||||
raise ConnectionRefusedError("Server is outdated. Please update.")
|
||||
|
||||
if client_version < SYNG_VERSION:
|
||||
await self.sio.emit(
|
||||
|
@ -983,6 +980,8 @@ class Server:
|
|||
|
||||
async def handle_register_client(self, sid: str, data: dict[str, Any]) -> None:
|
||||
"""
|
||||
THIS IS DEPRECATED, REGISTRATION IS NOW DONE VIA THE CONNECT EVENT.
|
||||
|
||||
Handle the "register-client" message.
|
||||
|
||||
The data dictionary should have the following keys:
|
||||
|
@ -1206,8 +1205,189 @@ class Server:
|
|||
"""
|
||||
state.client.sources[data["source"]] = available_sources[data["source"]](data["config"])
|
||||
|
||||
async def handle_connect(
|
||||
self, sid: str, environ: dict[str, Any], auth: None | dict[str, Any] = None
|
||||
) -> None:
|
||||
"""
|
||||
Handle the "connect" message.
|
||||
|
||||
This is called, when a client connects to the server. It will register the
|
||||
client and send the initial state of the room to the client.
|
||||
|
||||
:param sid: The session id of the requesting client.
|
||||
:type sid: str
|
||||
:param data: A dictionary with the keys described in
|
||||
:py:func:`handle_register_client`.
|
||||
:type data: dict[str, Any]
|
||||
:rtype: None
|
||||
"""
|
||||
logger.debug("Client %s connected", sid)
|
||||
logger.debug("Data: %s", auth)
|
||||
if auth is None or "type" not in auth:
|
||||
logger.warning("Client %s connected without auth data", sid)
|
||||
raise ConnectionRefusedError("No authentication data provided. Please register first.")
|
||||
|
||||
match auth["type"]:
|
||||
case "playback":
|
||||
await self.register_playback_client(sid, auth)
|
||||
case "web":
|
||||
await self.register_web_client(sid, auth)
|
||||
|
||||
async def register_web_client(self, sid: str, auth: dict[str, Any]) -> None:
|
||||
if auth["room"] in self.clients:
|
||||
logger.info("Client %s registered for room %s", sid, auth["room"])
|
||||
async with self.sio.session(sid) as session:
|
||||
session["room"] = auth["room"]
|
||||
await self.sio.enter_room(sid, session["room"])
|
||||
state = self.clients[session["room"]]
|
||||
await self.send_state(state, sid)
|
||||
is_admin = False
|
||||
if "secret" in auth:
|
||||
is_admin = auth["secret"] == state.client.config["secret"]
|
||||
async with self.sio.session(sid) as session:
|
||||
session["admin"] = is_admin
|
||||
await self.sio.emit("admin", is_admin, room=sid)
|
||||
else:
|
||||
logger.warning(
|
||||
"Client %s tried to register for non-existing room %s", sid, auth["room"]
|
||||
)
|
||||
raise ConnectionRefusedError(
|
||||
f"Room {auth['room']} does not exist. Please register first."
|
||||
)
|
||||
|
||||
async def register_playback_client(self, sid: str, data: dict[str, Any]) -> None:
|
||||
"""
|
||||
Register a new playback client and create a new room if necessary.
|
||||
|
||||
The data dictionary should have the following keys:
|
||||
- `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
|
||||
- `version`, the version of the client as a triple of integers
|
||||
- `key`, a registration key given out by the server administrator
|
||||
|
||||
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
|
||||
"""
|
||||
if "version" not in data:
|
||||
pass
|
||||
# TODO: Fallback to old registration method
|
||||
|
||||
# await self.sio.emit(
|
||||
# "client-registered",
|
||||
# {"success": False, "room": None, "reason": "NO_VERSION"},
|
||||
# room=sid,
|
||||
# )
|
||||
return
|
||||
|
||||
client_version = tuple(data["version"])
|
||||
if not await self.check_client_version(client_version, sid):
|
||||
return
|
||||
|
||||
def gen_id(length: int = 4) -> str:
|
||||
client_id = "".join([random.choice(string.ascii_letters) for _ in range(length)])
|
||||
if client_id in self.clients:
|
||||
client_id = gen_id(length + 1)
|
||||
return client_id
|
||||
|
||||
if "key" in data["config"]:
|
||||
data["config"]["key"] = hashlib.sha256(data["config"]["key"].encode()).hexdigest()
|
||||
|
||||
if self.app["type"] == "private" and (
|
||||
"key" not in data["config"] or not self.check_registration(data["config"]["key"])
|
||||
):
|
||||
await self.sio.emit(
|
||||
"client-registered",
|
||||
{
|
||||
"success": False,
|
||||
"room": None,
|
||||
"reason": "PRIVATE",
|
||||
},
|
||||
room=sid,
|
||||
)
|
||||
raise ConnectionRefusedError(
|
||||
"Private server, registration key not provided or invalid."
|
||||
)
|
||||
|
||||
room: str = (
|
||||
data["config"]["room"]
|
||||
if "room" in data["config"] and data["config"]["room"]
|
||||
else gen_id()
|
||||
)
|
||||
async with self.sio.session(sid) as session:
|
||||
session["room"] = room
|
||||
|
||||
if room in self.clients:
|
||||
old_state: State = self.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 self.sio.enter_room(sid, room)
|
||||
await self.send_state(self.clients[room], sid)
|
||||
else:
|
||||
logger.warning("Got wrong secret for %s", room)
|
||||
raise ConnectionRefusedError(f"Wrong secret for room {room}.")
|
||||
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"]]
|
||||
|
||||
self.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 self.sio.enter_room(sid, room)
|
||||
await self.send_state(self.clients[room], sid)
|
||||
|
||||
async def handle_register_web(self, sid: str, data: dict[str, Any]) -> bool:
|
||||
"""
|
||||
THIS IS DEPRECATED, REGISTRATION IS NOW DONE VIA THE CONNECT EVENT.
|
||||
|
||||
Handle a "register-web" message.
|
||||
|
||||
Adds a web client to a requested room and sends it the initial state of the
|
||||
|
@ -1232,6 +1412,8 @@ class Server:
|
|||
@with_state
|
||||
async def handle_register_admin(self, state: State, sid: str, data: dict[str, Any]) -> bool:
|
||||
"""
|
||||
THIS IS DEPRECATED, REGISTRATION IS NOW DONE VIA THE CONNECT EVENT.
|
||||
|
||||
Handle a "register-admin" message.
|
||||
|
||||
If the client provides the correct secret for its room, the connection is
|
||||
|
|
|
@ -47,6 +47,6 @@ class AsyncClient:
|
|||
self, event: str, handler: Optional[Callable[..., Any]] = None
|
||||
) -> Callable[[ClientHandler], ClientHandler]: ...
|
||||
async def wait(self) -> None: ...
|
||||
async def connect(self, server: str) -> None: ...
|
||||
async def connect(self, server: str, auth: dict[str, Any]) -> None: ...
|
||||
async def disconnect(self) -> None: ...
|
||||
async def emit(self, message: str, data: Any = None) -> None: ...
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
class ConnectionError(Exception): ...
|
||||
class ConnectionRefusedError(ConnectionError): ...
|
||||
class BadNamespaceError(Exception): ...
|
||||
|
|
Loading…
Add table
Reference in a new issue