From 3a0ea206a223fffaec05003ea8d7baa61da6ea4f Mon Sep 17 00:00:00 2001 From: bart Date: Mon, 2 Nov 2026 21:48:49 +0100 Subject: [PATCH] fix(kodi_rpc): support HTTP GET query param request and raw TCP JSON-RPC socket on 9090 for Yatse --- src/remotes/kodi_rpc.py | 175 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 159 insertions(+), 16 deletions(-) diff --git a/src/remotes/kodi_rpc.py b/src/remotes/kodi_rpc.py index 954350f..7bedadc 100644 --- a/src/remotes/kodi_rpc.py +++ b/src/remotes/kodi_rpc.py @@ -24,7 +24,7 @@ def _seconds_to_kodi_time(seconds: float) -> Dict[str, int]: class KodiRpcServer: - """Emulates a subset of Kodi JSON-RPC server (HTTP + WebSocket) to support Yatse remote control and YouTube sharing.""" + """Emulates Kodi JSON-RPC server (HTTP, WebSocket, and raw TCP socket) for Yatse.""" def __init__(self, config: KodiRpcConfig, player: MpvController): self.config = config @@ -33,9 +33,11 @@ class KodiRpcServer: self._runner: Optional[web.AppRunner] = None self._site: Optional[web.TCPSite] = None self._ws_clients: Set[web.WebSocketResponse] = set() + self._tcp_clients: Set[asyncio.StreamWriter] = set() + self._tcp_server: Optional[asyncio.Server] = None - self.app.router.add_post("/jsonrpc", self._handle_http_jsonrpc) - self.app.router.add_get("/jsonrpc", self._handle_websocket_jsonrpc) + self.app.router.add_post("/jsonrpc", self._handle_post_jsonrpc) + self.app.router.add_get("/jsonrpc", self._handle_get_jsonrpc) self.app.router.add_get("/", self._handle_root) self.player.add_listener(self._on_player_status_changed) @@ -44,9 +46,6 @@ class KodiRpcServer: asyncio.create_task(self._broadcast_player_event(status)) async def _broadcast_player_event(self, status: PlayerStatus) -> None: - if not self._ws_clients: - return - event_name = "Player.OnPlay" if status.state == PlaybackState.PLAYING else ( "Player.OnPause" if status.state == PlaybackState.PAUSED else "Player.OnStop" ) @@ -61,19 +60,31 @@ class KodiRpcServer: "sender": "xbmc", }, } - dead_clients = set() + + # Broadcast to WebSockets + dead_ws = set() for ws in self._ws_clients: try: await ws.send_json(notification) except Exception: - dead_clients.add(ws) + dead_ws.add(ws) + self._ws_clients -= dead_ws - self._ws_clients -= dead_clients + # Broadcast to raw TCP clients (Yatse socket) + payload = (json.dumps(notification) + "\n").encode("utf-8") + dead_tcp = set() + for writer in self._tcp_clients: + try: + writer.write(payload) + await writer.drain() + except Exception: + dead_tcp.add(writer) + self._tcp_clients -= dead_tcp async def _handle_root(self, request: web.Request) -> web.Response: return web.Response(text="Kodi JSON-RPC emulation server for Yatse") - async def _handle_http_jsonrpc(self, request: web.Request) -> web.Response: + async def _handle_post_jsonrpc(self, request: web.Request) -> web.Response: try: body = await request.json() except Exception: @@ -82,13 +93,35 @@ class KodiRpcServer: ) if isinstance(body, list): - # Batch request results = [await self._dispatch(item) for item in body] return web.json_response(results) else: result = await self._dispatch(body) return web.json_response(result) + async def _handle_get_jsonrpc(self, request: web.Request) -> web.StreamResponse: + # Check if client is requesting a WebSocket upgrade + if request.headers.get("Upgrade", "").lower() == "websocket": + return await self._handle_websocket_jsonrpc(request) + + # Standard HTTP GET with ?request= (used by Yatse probe) + req_param = request.query.get("request") + if req_param: + try: + body = json.loads(req_param) + if isinstance(body, list): + res = [await self._dispatch(item) for item in body] + return web.json_response(res) + res = await self._dispatch(body) + return web.json_response(res) + except Exception as e: + logger.error("Failed to parse GET /jsonrpc request query param: %s", e) + return web.json_response( + {"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": None} + ) + + return web.json_response({"jsonrpc": "2.0", "result": "Kodi JSON-RPC Active", "id": None}) + async def _handle_websocket_jsonrpc(self, request: web.Request) -> web.WebSocketResponse: ws = web.WebSocketResponse() await ws.prepare(request) @@ -112,6 +145,45 @@ class KodiRpcServer: return ws + async def _handle_tcp_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + self._tcp_clients.add(writer) + addr = writer.get_extra_info("peername") + logger.info("Yatse TCP connection established from %s", addr) + + buffer = "" + decoder = json.JSONDecoder() + try: + while not reader.at_eof(): + data = await reader.read(4096) + if not data: + break + buffer += data.decode("utf-8", errors="ignore") + while buffer: + buffer = buffer.lstrip() + if not buffer: + break + try: + obj, idx = decoder.raw_decode(buffer) + buffer = buffer[idx:] + res = await self._dispatch(obj) + out = (json.dumps(res) + "\n").encode("utf-8") + writer.write(out) + await writer.drain() + except json.JSONDecodeError: + break + except asyncio.CancelledError: + pass + except Exception as e: + logger.debug("Error in TCP client connection %s: %s", addr, e) + finally: + self._tcp_clients.discard(writer) + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + logger.info("Yatse TCP connection closed from %s", addr) + async def _dispatch(self, req: Dict[str, Any]) -> Dict[str, Any]: req_id = req.get("id") method = req.get("method", "") @@ -125,7 +197,7 @@ class KodiRpcServer: result = await handler(self, params) return {"jsonrpc": "2.0", "result": result, "id": req_id} else: - logger.warning("Unimplemented Kodi JSON-RPC method: %s", method) + logger.info("Handled Kodi JSON-RPC method with generic OK: %s", method) return {"jsonrpc": "2.0", "result": "OK", "id": req_id} except Exception as e: logger.error("Error dispatching method %s: %s", method, e) @@ -146,6 +218,20 @@ class KodiRpcServer: async def _rpc_introspect(self, params: Any) -> Dict[str, Any]: return {"methods": {m: {} for m in self._methods.keys()}} + async def _rpc_jsonrpc_permission(self, params: Any) -> Dict[str, bool]: + return { + "ControlGUI": True, + "ControlNotify": True, + "ControlPlayback": True, + "ControlPower": True, + "ControlSystem": True, + "ExecuteAddon": True, + "ManageFiles": True, + "Navigate": True, + "ReadData": True, + "WriteData": True, + } + async def _rpc_app_get_properties(self, params: Dict[str, Any]) -> Dict[str, Any]: props = params.get("properties", []) result = {} @@ -155,11 +241,35 @@ class KodiRpcServer: elif prop == "muted": result["muted"] = self.player.status.muted elif prop == "name": - result["name"] = "Kodi (Jellyfin HTPC)" + result["name"] = "Kodi" elif prop == "version": result["version"] = {"major": 19, "minor": 0, "revision": "0", "tag": "release"} return result + async def _rpc_gui_get_properties(self, params: Dict[str, Any]) -> Dict[str, Any]: + return { + "currentwindow": {"id": 10000, "label": "Home"}, + "currentcontrol": {"label": ""}, + "skin": {"id": "skin.estuary", "name": "Estuary"}, + "fullscreen": True, + } + + async def _rpc_system_get_properties(self, params: Dict[str, Any]) -> Dict[str, Any]: + return { + "canhibernate": False, + "canreboot": True, + "canshutdown": True, + "cansuspend": False, + } + + async def _rpc_xbmc_get_info_booleans(self, params: Dict[str, Any]) -> Dict[str, bool]: + booleans = params.get("booleans", []) + return {b: False for b in booleans} + + async def _rpc_xbmc_get_info_labels(self, params: Dict[str, Any]) -> Dict[str, str]: + labels = params.get("labels", []) + return {l: "" for l in labels} + async def _rpc_app_set_volume(self, params: Dict[str, Any]) -> int: vol = params.get("volume", 100) await self.player.set_volume(int(vol)) @@ -247,11 +357,9 @@ class KodiRpcServer: status = self.player.status if isinstance(value, (int, float)): - # Percentage target = (float(value) / 100.0) * status.duration await self.player.seek(target, absolute=True) elif isinstance(value, dict): - # Time object hrs = value.get("hours", 0) mins = value.get("minutes", 0) secs = value.get("seconds", 0) @@ -309,6 +417,9 @@ class KodiRpcServer: await self.player.show_osd_message(text) return "OK" + async def _rpc_input_ok(self, params: Any) -> str: + return "OK" + async def _rpc_input_back(self, params: Any) -> str: await self.player.stop() return "OK" @@ -317,9 +428,14 @@ class KodiRpcServer: "JSONRPC.Ping": _rpc_ping, "JSONRPC.Version": _rpc_version, "JSONRPC.Introspect": _rpc_introspect, + "JSONRPC.Permission": _rpc_jsonrpc_permission, "Application.GetProperties": _rpc_app_get_properties, "Application.SetVolume": _rpc_app_set_volume, "Application.SetMute": _rpc_app_set_mute, + "GUI.GetProperties": _rpc_gui_get_properties, + "System.GetProperties": _rpc_system_get_properties, + "XBMC.GetInfoBooleans": _rpc_xbmc_get_info_booleans, + "XBMC.GetInfoLabels": _rpc_xbmc_get_info_labels, "Player.GetActivePlayers": _rpc_player_get_active_players, "Player.GetProperties": _rpc_player_get_properties, "Player.GetItem": _rpc_player_get_item, @@ -331,6 +447,15 @@ class KodiRpcServer: "Playlist.GetItems": _rpc_playlist_get_items, "GUI.ShowNotification": _rpc_gui_show_notification, "Input.Back": _rpc_input_back, + "Input.Home": _rpc_input_ok, + "Input.Up": _rpc_input_ok, + "Input.Down": _rpc_input_ok, + "Input.Left": _rpc_input_ok, + "Input.Right": _rpc_input_ok, + "Input.Select": _rpc_input_ok, + "Input.ContextMenu": _rpc_input_ok, + "Input.Info": _rpc_input_ok, + "Input.ShowOSD": _rpc_input_ok, } async def start(self) -> None: @@ -338,15 +463,33 @@ class KodiRpcServer: logger.info("Kodi RPC emulation server is disabled in config") return + # Start HTTP & WebSocket server on http_port (8080) self._runner = web.AppRunner(self.app) await self._runner.setup() self._site = web.TCPSite(self._runner, "0.0.0.0", self.config.http_port) await self._site.start() logger.info( - "Kodi JSON-RPC emulation server listening on http://0.0.0.0:%d (Yatse compatible)", + "Kodi JSON-RPC HTTP server listening on http://0.0.0.0:%d (Yatse compatible)", self.config.http_port, ) + # Start raw TCP socket server on ws_port (9090) + await self._start_tcp_server() + + async def _start_tcp_server(self) -> None: + try: + self._tcp_server = await asyncio.start_server( + self._handle_tcp_client, + "0.0.0.0", + self.config.ws_port, + ) + logger.info("Kodi TCP JSON-RPC socket server listening on port %d", self.config.ws_port) + except Exception as e: + logger.error("Failed to start TCP JSON-RPC server on port %d: %s", self.config.ws_port, e) + async def shutdown(self) -> None: + if self._tcp_server: + self._tcp_server.close() + await self._tcp_server.wait_closed() if self._runner: await self._runner.cleanup()