Moved away from this awful global client construction.

Client is now a class, that can be instantiated and contains its state.
This commit is contained in:
Christoph Stahl 2024-10-09 16:17:55 +02:00
parent f2e04ab95e
commit fb12bdedd8
2 changed files with 338 additions and 336 deletions

View file

@ -43,14 +43,6 @@ from .sources import configure_sources, Source
from .log import logger 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]]: def default_config() -> dict[str, Optional[int | str]]:
""" """
Return a default configuration for the client. Return a default configuration for the client.
@ -119,11 +111,30 @@ class State:
config: dict[str, Any] = field(default_factory=default_config) config: dict[str, Any] = field(default_factory=default_config)
state: State = State() # state: State = State()
@sio.on("update_config") class Client:
async def handle_update_config(data: dict[str, Any]) -> None: 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()
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)
async def handle_update_config(self, data: dict[str, Any]) -> None:
""" """
Handle the "update_config" message. Handle the "update_config" message.
@ -133,11 +144,9 @@ async def handle_update_config(data: dict[str, Any]) -> None:
:type data: dict[str, Any] :type data: dict[str, Any]
:rtype: None :rtype: None
""" """
state.config = default_config() | data self.state.config = default_config() | data
async def handle_skip_current(self, data: dict[str, Any]) -> None:
@sio.on("skip-current")
async def handle_skip_current(data: dict[str, Any]) -> None:
""" """
Handle the "skip-current" message. Handle the "skip-current" message.
@ -152,12 +161,10 @@ async def handle_skip_current(data: dict[str, Any]) -> None:
:rtype: None :rtype: None
""" """
logger.info("Skipping current") logger.info("Skipping current")
if state.current_source is not None: if self.state.current_source is not None:
await state.current_source.skip_current(Entry(**data)) await self.state.current_source.skip_current(Entry(**data))
async def handle_state(self, data: dict[str, Any]) -> None:
@sio.on("state")
async def handle_state(data: dict[str, Any]) -> None:
""" """
Handle the "state" message. Handle the "state" message.
@ -172,17 +179,15 @@ async def handle_state(data: dict[str, Any]) -> None:
:type data: dict[str, Any] :type data: dict[str, Any]
:rtype: None :rtype: None
""" """
state.queue = [Entry(**entry) for entry in data["queue"]] self.state.queue = [Entry(**entry) for entry in data["queue"]]
state.waiting_room = [Entry(**entry) for entry in data["waiting_room"]] self.state.waiting_room = [Entry(**entry) for entry in data["waiting_room"]]
state.recent = [Entry(**entry) for entry in data["recent"]] self.state.recent = [Entry(**entry) for entry in data["recent"]]
for entry in state.queue[:2]: for entry in self.state.queue[:2]:
logger.info("Buffering: %s", entry.title) logger.info("Buffering: %s", entry.title)
await sources[entry.source].buffer(entry) await self.sources[entry.source].buffer(entry)
async def handle_connect(self) -> None:
@sio.on("connect")
async def handle_connect() -> None:
""" """
Handle the "connect" message. Handle the "connect" message.
@ -202,16 +207,14 @@ async def handle_connect() -> None:
""" """
logger.info("Connected to server") logger.info("Connected to server")
data = { data = {
"queue": state.queue, "queue": self.state.queue,
"waiting_room": state.waiting_room, "waiting_room": self.state.waiting_room,
"recent": state.recent, "recent": self.state.recent,
"config": state.config, "config": self.state.config,
} }
await sio.emit("register-client", data) await self.sio.emit("register-client", data)
async def handle_get_meta_info(self, data: dict[str, Any]) -> None:
@sio.on("get-meta-info")
async def handle_get_meta_info(data: dict[str, Any]) -> None:
""" """
Handle a "get-meta-info" message. Handle a "get-meta-info" message.
@ -223,12 +226,11 @@ async def handle_get_meta_info(data: dict[str, Any]) -> None:
:type data: dict[str, Any] :type data: dict[str, Any]
:rtype: None :rtype: None
""" """
source: Source = sources[data["source"]] source: Source = self.sources[data["source"]]
meta_info: dict[str, Any] = await source.get_missing_metadata(Entry(**data)) meta_info: dict[str, Any] = await source.get_missing_metadata(Entry(**data))
await sio.emit("meta-info", {"uuid": data["uuid"], "meta": meta_info}) await self.sio.emit("meta-info", {"uuid": data["uuid"], "meta": meta_info})
async def preview(self, entry: Entry) -> None:
async def preview(entry: Entry) -> None:
""" """
Generate and play a preview for a given :py:class:`Entry`. Generate and play a preview for a given :py:class:`Entry`.
@ -252,20 +254,18 @@ async def preview(entry: Entry) -> None:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
"mpv", "mpv",
tmpfile.name, tmpfile.name,
f"--image-display-duration={state.config['preview_duration']}", f"--image-display-duration={self.state.config['preview_duration']}",
"--sub-pos=50", "--sub-pos=50",
"--sub-file=-", "--sub-file=-",
"--fullscreen", "--fullscreen",
state.config["mpv_options"], self.state.config["mpv_options"],
stdin=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
await process.communicate(subtitle.encode()) await process.communicate(subtitle.encode())
async def handle_play(self, data: dict[str, Any]) -> None:
@sio.on("play")
async def handle_play(data: dict[str, Any]) -> None:
""" """
Handle the "play" message. Handle the "play" message.
@ -290,21 +290,19 @@ async def handle_play(data: dict[str, Any]) -> None:
f"({entry.source}) for {entry.performer}" f"({entry.source}) for {entry.performer}"
) )
try: try:
state.current_source = sources[entry.source] self.state.current_source = self.sources[entry.source]
if state.config["preview_duration"] > 0: if self.state.config["preview_duration"] > 0:
await preview(entry) await self.preview(entry)
await sources[entry.source].play(entry, state.config["mpv_options"]) await self.sources[entry.source].play(entry, self.state.config["mpv_options"])
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
print_exc() print_exc()
state.current_source = None self.state.current_source = None
if entry.skip: if entry.skip:
await sio.emit("get-first") await self.sio.emit("get-first")
else: else:
await sio.emit("pop-then-get-next") await self.sio.emit("pop-then-get-next")
async def handle_search(self, data: dict[str, Any]) -> None:
@sio.on("search")
async def handle_search(data: dict[str, Any]) -> None:
""" """
Handle the "search" message. Handle the "search" message.
@ -321,17 +319,19 @@ async def handle_search(data: dict[str, Any]) -> None:
logger.info(f"Searching for: {data['query']}") logger.info(f"Searching for: {data['query']}")
query = data["query"] query = data["query"]
sid = data["sid"] sid = data["sid"]
results_list = await asyncio.gather(*[source.search(query) for source in sources.values()]) results_list = await asyncio.gather(
*[source.search(query) for source in self.sources.values()]
)
results = [ results = [
search_result.to_dict() for source_result in results_list for search_result in source_result 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}) await self.sio.emit("search-results", {"results": results, "sid": sid})
async def handle_client_registered(self, data: dict[str, Any]) -> None:
@sio.on("client-registered")
async def handle_client_registered(data: dict[str, Any]) -> None:
""" """
Handle the "client-registered" message. Handle the "client-registered" message.
@ -357,22 +357,20 @@ async def handle_client_registered(data: dict[str, Any]) -> None:
# this is borked on windows # this is borked on windows
if os.name != "nt": if os.name != "nt":
print(f"Join here: {state.config['server']}/{data['room']}") print(f"Join here: {self.state.config['server']}/{data['room']}")
qr = QRCode(box_size=20, border=2) qr = QRCode(box_size=20, border=2)
qr.add_data(f"{state.config['server']}/{data['room']}") qr.add_data(f"{self.state.config['server']}/{data['room']}")
qr.make() qr.make()
qr.print_ascii() qr.print_ascii()
state.config["room"] = data["room"] self.state.config["room"] = data["room"]
await sio.emit("sources", {"sources": list(sources.keys())}) await self.sio.emit("sources", {"sources": list(self.sources.keys())})
if state.current_source is None: # A possible race condition can occur here if self.state.current_source is None: # A possible race condition can occur here
await sio.emit("get-first") await self.sio.emit("get-first")
else: else:
logger.warning("Registration failed") logger.warning("Registration failed")
await sio.disconnect() await self.sio.disconnect()
async def handle_request_config(self, data: dict[str, Any]) -> None:
@sio.on("request-config")
async def handle_request_config(data: dict[str, Any]) -> None:
""" """
Handle the "request-config" message. Handle the "request-config" message.
@ -391,12 +389,14 @@ async def handle_request_config(data: dict[str, Any]) -> None:
:type data: dict[str, Any] :type data: dict[str, Any]
:rtype: None :rtype: None
""" """
if data["source"] in sources: if data["source"] in self.sources:
config: dict[str, Any] | list[dict[str, Any]] = await sources[data["source"]].get_config() config: dict[str, Any] | list[dict[str, Any]] = await self.sources[
data["source"]
].get_config()
if isinstance(config, list): if isinstance(config, list):
num_chunks: int = len(config) num_chunks: int = len(config)
for current, chunk in enumerate(config): for current, chunk in enumerate(config):
await sio.emit( await self.sio.emit(
"config-chunk", "config-chunk",
{ {
"source": data["source"], "source": data["source"],
@ -406,13 +406,13 @@ async def handle_request_config(data: dict[str, Any]) -> None:
}, },
) )
else: else:
await sio.emit("config", {"source": data["source"], "config": config}) await self.sio.emit("config", {"source": data["source"], "config": config})
updated_config = await sources[data["source"]].update_config() updated_config = await self.sources[data["source"]].update_config()
if isinstance(updated_config, list): if isinstance(updated_config, list):
num_chunks = len(updated_config) num_chunks = len(updated_config)
for current, chunk in enumerate(updated_config): for current, chunk in enumerate(updated_config):
await sio.emit( await self.sio.emit(
"config-chunk", "config-chunk",
{ {
"source": data["source"], "source": data["source"],
@ -422,10 +422,9 @@ async def handle_request_config(data: dict[str, Any]) -> None:
}, },
) )
elif updated_config is not None: elif updated_config is not None:
await sio.emit("config", {"source": data["source"], "config": updated_config}) await self.sio.emit("config", {"source": data["source"], "config": updated_config})
def signal_handler(self) -> None:
def signal_handler() -> None:
""" """
Signal handler for the client. Signal handler for the client.
@ -435,12 +434,11 @@ def signal_handler() -> None:
:rtype: None :rtype: None
""" """
engineio.async_client.async_signal_handler() engineio.async_client.async_signal_handler()
if state.current_source is not None: if self.state.current_source is not None:
if state.current_source.player is not None: if self.state.current_source.player is not None:
state.current_source.player.kill() self.state.current_source.player.kill()
async def start_client(self, config: dict[str, Any]) -> None:
async def start_client(config: dict[str, Any]) -> None:
""" """
Initialize the client and connect to the server. Initialize the client and connect to the server.
@ -449,7 +447,7 @@ async def start_client(config: dict[str, Any]) -> None:
:rtype: None :rtype: None
""" """
sources.update(configure_sources(config["sources"])) self.sources.update(configure_sources(config["sources"]))
if "config" in config: if "config" in config:
last_song = ( last_song = (
@ -457,36 +455,36 @@ async def start_client(config: dict[str, Any]) -> None:
if "last_song" in config["config"] and config["config"]["last_song"] if "last_song" in config["config"] and config["config"]["last_song"]
else None else None
) )
state.config |= config["config"] | {"last_song": last_song} self.state.config |= config["config"] | {"last_song": last_song}
if not ("secret" in state.config and state.config["secret"]): if not ("secret" in self.state.config and self.state.config["secret"]):
state.config["secret"] = "".join( self.state.config["secret"] = "".join(
secrets.choice(string.ascii_letters + string.digits) for _ in range(8) secrets.choice(string.ascii_letters + string.digits) for _ in range(8)
) )
print(f"Generated secret: {state.config['secret']}") print(f"Generated secret: {self.state.config['secret']}")
if not ("key" in state.config and state.config["key"]): if not ("key" in self.state.config and self.state.config["key"]):
state.config["key"] = "" self.state.config["key"] = ""
try: try:
await sio.connect(state.config["server"]) await self.sio.connect(self.state.config["server"])
except ConnectionError: except ConnectionError:
logger.error("Could not connect to server") logger.error("Could not connect to server")
return return
# this is not supported under windows # this is not supported under windows
if os.name != "nt": if os.name != "nt":
asyncio.get_event_loop().add_signal_handler(signal.SIGINT, signal_handler) asyncio.get_event_loop().add_signal_handler(signal.SIGINT, self.signal_handler)
asyncio.get_event_loop().add_signal_handler(signal.SIGTERM, signal_handler) asyncio.get_event_loop().add_signal_handler(signal.SIGTERM, self.signal_handler)
try: try:
await sio.wait() await self.sio.wait()
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
finally: finally:
if state.current_source is not None: if self.state.current_source is not None:
if state.current_source.player is not None: if self.state.current_source.player is not None:
state.current_source.player.kill() self.state.current_source.player.kill()
def create_async_and_start_client( def create_async_and_start_client(
@ -507,7 +505,9 @@ def create_async_and_start_client(
if queue is not None: if queue is not None:
logger.addHandler(QueueHandler(queue)) logger.addHandler(QueueHandler(queue))
asyncio.run(start_client(config)) client = Client(config)
asyncio.run(client.start_client(config))
def run_client(args: Namespace) -> None: def run_client(args: Namespace) -> None:

View file

@ -34,7 +34,9 @@ class AsyncServer:
class AsyncClient: class AsyncClient:
def __init__(self, json: Any = None): ... 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 wait(self) -> None: ...
async def connect(self, server: str) -> None: ... async def connect(self, server: str) -> None: ...
async def disconnect(self) -> None: ... async def disconnect(self) -> None: ...