diff --git a/syng/client.py b/syng/client.py index f8439a8..9762cb5 100644 --- a/syng/client.py +++ b/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() diff --git a/syng/server.py b/syng/server.py index 508a694..99e2bd9 100644 --- a/syng/server.py +++ b/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 diff --git a/typings/socketio/__init__.pyi b/typings/socketio/__init__.pyi index 460a2d4..b136415 100644 --- a/typings/socketio/__init__.pyi +++ b/typings/socketio/__init__.pyi @@ -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: ... diff --git a/typings/socketio/exceptions.pyi b/typings/socketio/exceptions.pyi index 14d5feb..94d6abf 100644 --- a/typings/socketio/exceptions.pyi +++ b/typings/socketio/exceptions.pyi @@ -1,2 +1,3 @@ class ConnectionError(Exception): ... +class ConnectionRefusedError(ConnectionError): ... class BadNamespaceError(Exception): ...