diff --git a/syng/client.py b/syng/client.py index 7625fda..f3a6e42 100644 --- a/syng/client.py +++ b/syng/client.py @@ -43,14 +43,6 @@ from .sources import configure_sources, Source from .log import logger -sio: socketio.AsyncClient = socketio.AsyncClient(json=jsonencoder) -# logger: logging.Logger = logging"Syng"er(__name__) -sources: dict[str, Source] = {} - - -currentLock: asyncio.Semaphore = asyncio.Semaphore(0) - - def default_config() -> dict[str, Optional[int | str]]: """ Return a default configuration for the client. @@ -119,374 +111,380 @@ class State: config: dict[str, Any] = field(default_factory=default_config) -state: State = State() +# state: State = State() -@sio.on("update_config") -async def handle_update_config(data: dict[str, Any]) -> None: - """ - Handle the "update_config" message. +class Client: + def __init__(self, config: dict[str, Any]): + self.sio = socketio.AsyncClient(json=jsonencoder) + self.config = config + self.sources = configure_sources(config["sources"]) + self.state = State() + self.currentLock = asyncio.Semaphore(0) + self.register_handlers() - Currently, this function is untested and should be considered dangerous. + def register_handlers(self) -> None: + self.sio.on("update_config", self.handle_update_config) + self.sio.on("skip-current", self.handle_skip_current) + self.sio.on("state", self.handle_state) + self.sio.on("connect", self.handle_connect) + 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) - :param data: A dictionary with the new configuration. - :type data: dict[str, Any] - :rtype: None - """ - state.config = default_config() | data + async def handle_update_config(self, data: dict[str, Any]) -> None: + """ + Handle the "update_config" message. + Currently, this function is untested and should be considered dangerous. -@sio.on("skip-current") -async def handle_skip_current(data: dict[str, Any]) -> None: - """ - Handle the "skip-current" message. + :param data: A dictionary with the new configuration. + :type data: dict[str, Any] + :rtype: None + """ + self.state.config = default_config() | data - Skips the song, that is currently played. If playback currently waits for - buffering, the buffering is also aborted. + async def handle_skip_current(self, data: dict[str, Any]) -> None: + """ + Handle the "skip-current" message. - Since the ``queue`` could already be updated, when this evaluates, the - first entry in the queue is send explicitly. + Skips the song, that is currently played. If playback currently waits for + buffering, the buffering is also aborted. - :param data: An entry, that should be equivalent to the first entry of the - queue. - :rtype: None - """ - logger.info("Skipping current") - if state.current_source is not None: - await state.current_source.skip_current(Entry(**data)) + Since the ``queue`` could already be updated, when this evaluates, the + first entry in the queue is send explicitly. + :param data: An entry, that should be equivalent to the first entry of the + queue. + :rtype: None + """ + logger.info("Skipping current") + if self.state.current_source is not None: + await self.state.current_source.skip_current(Entry(**data)) -@sio.on("state") -async def handle_state(data: dict[str, Any]) -> None: - """ - Handle the "state" message. + async def handle_state(self, data: dict[str, Any]) -> None: + """ + Handle the "state" message. - The "state" message forwards the current queue and recent list from the - server. This function saves a copy of both in the global - :py:class:`State`:. + The "state" message forwards the current queue and recent list from the + server. This function saves a copy of both in the global + :py:class:`State`:. - After recieving the new state, a buffering task for the first elements of - the queue is started. + After recieving the new state, a buffering task for the first elements of + the queue is started. - :param data: A dictionary with the `queue` and `recent` list. - :type data: dict[str, Any] - :rtype: None - """ - state.queue = [Entry(**entry) for entry in data["queue"]] - state.waiting_room = [Entry(**entry) for entry in data["waiting_room"]] - state.recent = [Entry(**entry) for entry in data["recent"]] + :param data: A dictionary with the `queue` and `recent` list. + :type data: dict[str, Any] + :rtype: None + """ + self.state.queue = [Entry(**entry) for entry in data["queue"]] + self.state.waiting_room = [Entry(**entry) for entry in data["waiting_room"]] + self.state.recent = [Entry(**entry) for entry in data["recent"]] - for entry in state.queue[:2]: - logger.info("Buffering: %s", entry.title) - await sources[entry.source].buffer(entry) + for entry in self.state.queue[:2]: + logger.info("Buffering: %s", entry.title) + await self.sources[entry.source].buffer(entry) + async def handle_connect(self) -> None: + """ + Handle the "connect" message. -@sio.on("connect") -async def handle_connect() -> None: - """ - 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. - 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. + If the room code is `None`, the server will issue a room code. - If the room code is `None`, the server will issue a room code. + This message will be handled by the + :py:func:`syng.server.handle_register_client` function of the server. - This message will be handled by the - :py:func:`syng.server.handle_register_client` function of the server. + :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, + } + await self.sio.emit("register-client", data) - :rtype: None - """ - logger.info("Connected to server") - data = { - "queue": state.queue, - "waiting_room": state.waiting_room, - "recent": state.recent, - "config": state.config, - } - await sio.emit("register-client", data) + async def handle_get_meta_info(self, data: dict[str, Any]) -> None: + """ + Handle a "get-meta-info" message. + Collects the metadata for a given :py:class:`Entry`, from its source, and + sends them back to the server in a "meta-info" message. On the server side + a :py:func:`syng.server.handle_meta_info` function is called. -@sio.on("get-meta-info") -async def handle_get_meta_info(data: dict[str, Any]) -> None: - """ - Handle a "get-meta-info" message. + :param data: A dictionary encoding the entry + :type data: dict[str, Any] + :rtype: None + """ + source: Source = self.sources[data["source"]] + meta_info: dict[str, Any] = await source.get_missing_metadata(Entry(**data)) + await self.sio.emit("meta-info", {"uuid": data["uuid"], "meta": meta_info}) - Collects the metadata for a given :py:class:`Entry`, from its source, and - sends them back to the server in a "meta-info" message. On the server side - a :py:func:`syng.server.handle_meta_info` function is called. + async def preview(self, entry: Entry) -> None: + """ + Generate and play a preview for a given :py:class:`Entry`. - :param data: A dictionary encoding the entry - :type data: dict[str, Any] - :rtype: None - """ - source: Source = sources[data["source"]] - meta_info: dict[str, Any] = await source.get_missing_metadata(Entry(**data)) - await sio.emit("meta-info", {"uuid": data["uuid"], "meta": meta_info}) + This function shows a black screen and prints the artist, title and + performer of the entry for a duration. + This is done by creating a black png file, and showing subtitles in the + middle of the screen.... don't ask, it works -async def preview(entry: Entry) -> None: - """ - Generate and play a preview for a given :py:class:`Entry`. - - This function shows a black screen and prints the artist, title and - performer of the entry for a duration. - - This is done by creating a black png file, and showing subtitles in the - middle of the screen.... don't ask, it works - - :param entry: The entry to preview - :type entry: :py:class:`Entry` - :rtype: None - """ - background = Image.new("RGB", (1280, 720)) - subtitle: str = f"""1 + :param entry: The entry to preview + :type entry: :py:class:`Entry` + :rtype: None + """ + background = Image.new("RGB", (1280, 720)) + subtitle: str = f"""1 00:00:00,00 --> 00:05:00,00 {entry.artist} - {entry.title} {entry.performer}""" - with tempfile.NamedTemporaryFile() as tmpfile: - background.save(tmpfile, "png") - process = await asyncio.create_subprocess_exec( - "mpv", - tmpfile.name, - f"--image-display-duration={state.config['preview_duration']}", - "--sub-pos=50", - "--sub-file=-", - "--fullscreen", - state.config["mpv_options"], - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + with tempfile.NamedTemporaryFile() as tmpfile: + background.save(tmpfile, "png") + process = await asyncio.create_subprocess_exec( + "mpv", + tmpfile.name, + f"--image-display-duration={self.state.config['preview_duration']}", + "--sub-pos=50", + "--sub-file=-", + "--fullscreen", + self.state.config["mpv_options"], + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await process.communicate(subtitle.encode()) + + async def handle_play(self, data: dict[str, Any]) -> None: + """ + Handle the "play" message. + + Plays the :py:class:`Entry`, that is encoded in the `data` parameter. If a + :py:attr:`State.preview_duration` is set, it shows a small preview before + that. + + When the playback is done, the next song is requested from the server with + a "pop-then-get-next" message. This is handled by the + :py:func:`syng.server.handle_pop_then_get_next` function on the server. + + If the entry is marked as skipped, emit a "get-first" message instead, + because the server already handled the removal of the first entry. + + :param data: A dictionary encoding the entry + :type data: dict[str, Any] + :rtype: None + """ + entry: Entry = Entry(**data) + print( + f"Playing: {entry.artist} - {entry.title} [{entry.album}] " + f"({entry.source}) for {entry.performer}" ) - await process.communicate(subtitle.encode()) - - -@sio.on("play") -async def handle_play(data: dict[str, Any]) -> None: - """ - Handle the "play" message. - - Plays the :py:class:`Entry`, that is encoded in the `data` parameter. If a - :py:attr:`State.preview_duration` is set, it shows a small preview before - that. - - When the playback is done, the next song is requested from the server with - a "pop-then-get-next" message. This is handled by the - :py:func:`syng.server.handle_pop_then_get_next` function on the server. - - If the entry is marked as skipped, emit a "get-first" message instead, - because the server already handled the removal of the first entry. - - :param data: A dictionary encoding the entry - :type data: dict[str, Any] - :rtype: None - """ - entry: Entry = Entry(**data) - print( - f"Playing: {entry.artist} - {entry.title} [{entry.album}] " - f"({entry.source}) for {entry.performer}" - ) - try: - state.current_source = sources[entry.source] - if state.config["preview_duration"] > 0: - await preview(entry) - await sources[entry.source].play(entry, state.config["mpv_options"]) - except Exception: # pylint: disable=broad-except - print_exc() - state.current_source = None - if entry.skip: - await sio.emit("get-first") - else: - await sio.emit("pop-then-get-next") - - -@sio.on("search") -async def handle_search(data: dict[str, Any]) -> None: - """ - Handle the "search" message. - - This handles client side search requests. It sends a search request to all - configured :py:class:`syng.sources.source.Source` and collects the results. - - The results are then send back to the server in a "search-results" message, - including the `sid` of the corresponding webclient. - - :param data: A dictionary with the `query` and `sid` entry. - :type data: dict[str, Any] - :rtype: None - """ - logger.info(f"Searching for: {data['query']}") - query = data["query"] - sid = data["sid"] - results_list = await asyncio.gather(*[source.search(query) for source in sources.values()]) - - results = [ - search_result.to_dict() for source_result in results_list for search_result in source_result - ] - - await sio.emit("search-results", {"results": results, "sid": sid}) - - -@sio.on("client-registered") -async def handle_client_registered(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"]: - logger.info("Registered") - - # this is borked on windows - if os.name != "nt": - print(f"Join here: {state.config['server']}/{data['room']}") - qr = QRCode(box_size=20, border=2) - qr.add_data(f"{state.config['server']}/{data['room']}") - qr.make() - qr.print_ascii() - state.config["room"] = data["room"] - await sio.emit("sources", {"sources": list(sources.keys())}) - if state.current_source is None: # A possible race condition can occur here - await sio.emit("get-first") - else: - logger.warning("Registration failed") - await sio.disconnect() - - -@sio.on("request-config") -async def handle_request_config(data: dict[str, Any]) -> None: - """ - Handle the "request-config" message. - - Sends the specific server side configuration for a given - :py:class:`syng.sources.source.Source`. - - A Source can decide, that the config will be split up in multiple Parts. - If this is the case, multiple "config-chunk" messages will be send with a - running enumerator. Otherwise a single "config" message will be send. - - After the configuration is send, the source is asked to update its - configuration. This can also be split up in multiple parts. - - :param data: A dictionary with the entry `source` and a string, that - corresponds to the name of a source. - :type data: dict[str, Any] - :rtype: None - """ - if data["source"] in sources: - config: dict[str, Any] | list[dict[str, Any]] = await sources[data["source"]].get_config() - if isinstance(config, list): - num_chunks: int = len(config) - for current, chunk in enumerate(config): - await sio.emit( - "config-chunk", - { - "source": data["source"], - "config": chunk, - "number": current, - "total": num_chunks, - }, - ) + try: + self.state.current_source = self.sources[entry.source] + if self.state.config["preview_duration"] > 0: + await self.preview(entry) + await self.sources[entry.source].play(entry, self.state.config["mpv_options"]) + except Exception: # pylint: disable=broad-except + print_exc() + self.state.current_source = None + if entry.skip: + await self.sio.emit("get-first") else: - await sio.emit("config", {"source": data["source"], "config": config}) + await self.sio.emit("pop-then-get-next") - updated_config = await sources[data["source"]].update_config() - if isinstance(updated_config, list): - num_chunks = len(updated_config) - for current, chunk in enumerate(updated_config): - await sio.emit( - "config-chunk", - { - "source": data["source"], - "config": chunk, - "number": current, - "total": num_chunks, - }, - ) - elif updated_config is not None: - await sio.emit("config", {"source": data["source"], "config": updated_config}) + async def handle_search(self, data: dict[str, Any]) -> None: + """ + Handle the "search" message. + This handles client side search requests. It sends a search request to all + configured :py:class:`syng.sources.source.Source` and collects the results. -def signal_handler() -> None: - """ - Signal handler for the client. + The results are then send back to the server in a "search-results" message, + including the `sid` of the corresponding webclient. - This function is called when the client receives a signal to terminate. It - will disconnect from the server and kill the current player. - - :rtype: None - """ - engineio.async_client.async_signal_handler() - if state.current_source is not None: - if state.current_source.player is not None: - state.current_source.player.kill() - - -async def start_client(config: dict[str, Any]) -> None: - """ - Initialize the client and connect to the server. - - :param config: Config options for the client - :type config: dict[str, Any] - :rtype: None - """ - - sources.update(configure_sources(config["sources"])) - - if "config" in config: - last_song = ( - datetime.datetime.fromisoformat(config["config"]["last_song"]).timestamp() - if "last_song" in config["config"] and config["config"]["last_song"] - else None + :param data: A dictionary with the `query` and `sid` entry. + :type data: dict[str, Any] + :rtype: None + """ + logger.info(f"Searching for: {data['query']}") + query = data["query"] + sid = data["sid"] + results_list = await asyncio.gather( + *[source.search(query) for source in self.sources.values()] ) - state.config |= config["config"] | {"last_song": last_song} - if not ("secret" in state.config and state.config["secret"]): - state.config["secret"] = "".join( - secrets.choice(string.ascii_letters + string.digits) for _ in range(8) - ) - print(f"Generated secret: {state.config['secret']}") + results = [ + search_result.to_dict() + for source_result in results_list + for search_result in source_result + ] - if not ("key" in state.config and state.config["key"]): - state.config["key"] = "" + await self.sio.emit("search-results", {"results": results, "sid": sid}) - try: - await sio.connect(state.config["server"]) - except ConnectionError: - logger.error("Could not connect to server") - return + async def handle_client_registered(self, data: dict[str, Any]) -> None: + """ + Handle the "client-registered" message. - # this is not supported under windows - if os.name != "nt": - asyncio.get_event_loop().add_signal_handler(signal.SIGINT, signal_handler) - asyncio.get_event_loop().add_signal_handler(signal.SIGTERM, signal_handler) + 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. - try: - await sio.wait() - except asyncio.CancelledError: - pass - finally: - if state.current_source is not None: - if state.current_source.player is not None: - state.current_source.player.kill() + 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"]: + logger.info("Registered") + + # this is borked on windows + if os.name != "nt": + print(f"Join here: {self.state.config['server']}/{data['room']}") + qr = QRCode(box_size=20, border=2) + qr.add_data(f"{self.state.config['server']}/{data['room']}") + 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: + logger.warning("Registration failed") + await self.sio.disconnect() + + async def handle_request_config(self, data: dict[str, Any]) -> None: + """ + Handle the "request-config" message. + + Sends the specific server side configuration for a given + :py:class:`syng.sources.source.Source`. + + A Source can decide, that the config will be split up in multiple Parts. + If this is the case, multiple "config-chunk" messages will be send with a + running enumerator. Otherwise a single "config" message will be send. + + After the configuration is send, the source is asked to update its + configuration. This can also be split up in multiple parts. + + :param data: A dictionary with the entry `source` and a string, that + corresponds to the name of a source. + :type data: dict[str, Any] + :rtype: None + """ + if data["source"] in self.sources: + config: dict[str, Any] | list[dict[str, Any]] = await self.sources[ + data["source"] + ].get_config() + if isinstance(config, list): + num_chunks: int = len(config) + for current, chunk in enumerate(config): + await self.sio.emit( + "config-chunk", + { + "source": data["source"], + "config": chunk, + "number": current, + "total": num_chunks, + }, + ) + else: + await self.sio.emit("config", {"source": data["source"], "config": config}) + + updated_config = await self.sources[data["source"]].update_config() + if isinstance(updated_config, list): + num_chunks = len(updated_config) + for current, chunk in enumerate(updated_config): + await self.sio.emit( + "config-chunk", + { + "source": data["source"], + "config": chunk, + "number": current, + "total": num_chunks, + }, + ) + elif updated_config is not None: + await self.sio.emit("config", {"source": data["source"], "config": updated_config}) + + def signal_handler(self) -> None: + """ + Signal handler for the client. + + This function is called when the client receives a signal to terminate. It + will disconnect from the server and kill the current player. + + :rtype: None + """ + engineio.async_client.async_signal_handler() + if self.state.current_source is not None: + if self.state.current_source.player is not None: + self.state.current_source.player.kill() + + async def start_client(self, config: dict[str, Any]) -> None: + """ + Initialize the client and connect to the server. + + :param config: Config options for the client + :type config: dict[str, Any] + :rtype: None + """ + + self.sources.update(configure_sources(config["sources"])) + + if "config" in config: + last_song = ( + datetime.datetime.fromisoformat(config["config"]["last_song"]).timestamp() + if "last_song" in config["config"] and config["config"]["last_song"] + else None + ) + self.state.config |= config["config"] | {"last_song": last_song} + + if not ("secret" in self.state.config and self.state.config["secret"]): + self.state.config["secret"] = "".join( + secrets.choice(string.ascii_letters + string.digits) for _ in range(8) + ) + print(f"Generated secret: {self.state.config['secret']}") + + if not ("key" in self.state.config and self.state.config["key"]): + self.state.config["key"] = "" + + try: + await self.sio.connect(self.state.config["server"]) + except ConnectionError: + logger.error("Could not connect to server") + return + + # this is not supported under windows + if os.name != "nt": + asyncio.get_event_loop().add_signal_handler(signal.SIGINT, self.signal_handler) + asyncio.get_event_loop().add_signal_handler(signal.SIGTERM, self.signal_handler) + + try: + await self.sio.wait() + except asyncio.CancelledError: + pass + finally: + if self.state.current_source is not None: + if self.state.current_source.player is not None: + self.state.current_source.player.kill() def create_async_and_start_client( @@ -507,7 +505,9 @@ def create_async_and_start_client( if queue is not None: logger.addHandler(QueueHandler(queue)) - asyncio.run(start_client(config)) + client = Client(config) + + asyncio.run(client.start_client(config)) def run_client(args: Namespace) -> None: diff --git a/typings/socketio/__init__.pyi b/typings/socketio/__init__.pyi index ef78900..37bece2 100644 --- a/typings/socketio/__init__.pyi +++ b/typings/socketio/__init__.pyi @@ -34,7 +34,9 @@ class AsyncServer: class AsyncClient: def __init__(self, json: Any = None): ... - def on(self, event: str) -> Callable[[ClientHandler], ClientHandler]: ... + def on( + 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 disconnect(self) -> None: ...