Reworked registering the clients into the connection
All checks were successful
Check / mypy (push) Successful in 34s
Check / ruff (push) Successful in 5s

This commit is contained in:
Christoph Stahl 2025-06-12 23:16:31 +02:00
parent 988992bc74
commit 292f45ccba
4 changed files with 268 additions and 78 deletions

View file

@ -202,17 +202,51 @@ class Client:
self.sio.on("get-meta-info", self.handle_get_meta_info) self.sio.on("get-meta-info", self.handle_get_meta_info)
self.sio.on("play", self.handle_play) self.sio.on("play", self.handle_play)
self.sio.on("search", self.handle_search) 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("request-config", self.handle_request_config)
self.sio.on("msg", self.handle_msg) self.sio.on("msg", self.handle_msg)
self.sio.on("disconnect", self.handle_disconnect) self.sio.on("disconnect", self.handle_disconnect)
self.sio.on("room-removed", self.handle_room_removed) 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: async def handle_disconnect(self) -> None:
self.connection_state.set_disconnected() self.connection_state.set_disconnected()
await self.ensure_disconnect() await self.ensure_disconnect()
async def ensure_disconnect(self) -> None: 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("Disconnecting from server")
logger.info(f"Connection: {self.connection_state.is_connected()}") logger.info(f"Connection: {self.connection_state.is_connected()}")
logger.info(f"MPV: {self.connection_state.is_mpv_running()}") logger.info(f"MPV: {self.connection_state.is_mpv_running()}")
@ -324,29 +358,40 @@ class Client:
""" """
Handle the "connect" message. Handle the "connect" message.
Called when the client successfully connects or reconnects to the server. This is called when the client successfully connects to the server
Sends a `register-client` message to the server with the initial state and and starts the player.
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.
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 If there is no song playing, start requesting the first song of the queue
:py:func:`syng.server.handle_register_client` function of the server. with a "get-first" message. This will be handled on the server by the
:py:func:`syng.server.handle_get_first` function.
:rtype: None :rtype: None
""" """
logger.info("Connected to server") logger.info("Connected to server: %s", self.state.config["server"])
data = { self.player.start()
"queue": self.state.queue, room = self.state.config["room"]
"waiting_room": self.state.waiting_room, server = self.state.config["server"]
"recent": self.state.recent,
"config": self.state.config, logger.info("Connected to room: %s", room)
"version": SYNG_VERSION, qr_string = f"{server}/{room}"
} self.player.update_qr(qr_string)
await self.sio.emit("register-client", data) # 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: 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} "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: async def handle_request_config(self, data: dict[str, Any]) -> None:
""" """
Handle the "request-config" message. Handle the "request-config" message.
@ -606,7 +605,7 @@ class Client:
Handle the "room-removed" message. Handle the "room-removed" message.
This is called when the server removes the room, that this client is 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. :param data: A dictionary with the `room` entry.
:type data: dict[str, Any] :type data: dict[str, Any]
@ -645,7 +644,15 @@ class Client:
self.state.config["key"] = "" self.state.config["key"] = ""
try: 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 # this is not supported under windows
if os.name != "nt": if os.name != "nt":
@ -656,8 +663,8 @@ class Client:
await self.sio.wait() await self.sio.wait()
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
except ConnectionError: except ConnectionError as e:
logger.critical("Could not connect to server") logger.warning("Could not connect to server: %s", e.args[0])
finally: finally:
await self.ensure_disconnect() await self.ensure_disconnect()

View file

@ -28,6 +28,7 @@ from dataclasses import field
from typing import Any, Callable, Literal, AsyncGenerator, Optional, cast from typing import Any, Callable, Literal, AsyncGenerator, Optional, cast
import socketio import socketio
from socketio.exceptions import ConnectionRefusedError
from aiohttp import web from aiohttp import web
try: 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 def wrapper(self: Server, sid: str, *args: Any, **kwargs: Any) -> Any:
async with self.sio.session(sid) as session: async with self.sio.session(sid) as session:
room = session["room"] 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) await self.sio.emit("err", {"type": "NO_ADMIN"}, sid)
return return
return await handler(self, sid, *args, **kwargs) 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("move-up", self.handle_move_up)
self.sio.on("skip", self.handle_skip) self.sio.on("skip", self.handle_skip)
self.sio.on("disconnect", self.handle_disconnect) 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", self.handle_search)
self.sio.on("search-results", self.handle_search_results) self.sio.on("search-results", self.handle_search_results)
@ -928,7 +930,7 @@ class Server:
{"success": False, "room": None, "reason": "PROTOCOL_VERSION"}, {"success": False, "room": None, "reason": "PROTOCOL_VERSION"},
room=sid, room=sid,
) )
return False raise ConnectionRefusedError("Client is incompatible and outdated. Please update.")
if client_version > SYNG_VERSION: if client_version > SYNG_VERSION:
await self.sio.emit( await self.sio.emit(
@ -936,12 +938,7 @@ class Server:
{"type": "error", "msg": "Server is outdated. Please update."}, {"type": "error", "msg": "Server is outdated. Please update."},
room=sid, room=sid,
) )
await self.sio.emit( raise ConnectionRefusedError("Server is outdated. Please update.")
"client-registered",
{"success": False, "room": None, "reason": "PROTOCOL_VERSION"},
room=sid,
)
return False
if client_version < SYNG_VERSION: if client_version < SYNG_VERSION:
await self.sio.emit( await self.sio.emit(
@ -983,6 +980,8 @@ class Server:
async def handle_register_client(self, sid: str, data: dict[str, Any]) -> None: 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. Handle the "register-client" message.
The data dictionary should have the following keys: 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"]) 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: 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. Handle a "register-web" message.
Adds a web client to a requested room and sends it the initial state of the Adds a web client to a requested room and sends it the initial state of the
@ -1232,6 +1412,8 @@ class Server:
@with_state @with_state
async def handle_register_admin(self, state: State, sid: str, data: dict[str, Any]) -> bool: 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. Handle a "register-admin" message.
If the client provides the correct secret for its room, the connection is If the client provides the correct secret for its room, the connection is

View file

@ -47,6 +47,6 @@ class AsyncClient:
self, event: str, handler: Optional[Callable[..., Any]] = None self, event: str, handler: Optional[Callable[..., Any]] = None
) -> Callable[[ClientHandler], ClientHandler]: ... ) -> Callable[[ClientHandler], ClientHandler]: ...
async def wait(self) -> None: ... 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 disconnect(self) -> None: ...
async def emit(self, message: str, data: Any = None) -> None: ... async def emit(self, message: str, data: Any = None) -> None: ...

View file

@ -1,2 +1,3 @@
class ConnectionError(Exception): ... class ConnectionError(Exception): ...
class ConnectionRefusedError(ConnectionError): ...
class BadNamespaceError(Exception): ... class BadNamespaceError(Exception): ...