fix(kodi_rpc): support HTTP GET query param request and raw TCP JSON-RPC socket on 9090 for Yatse

This commit is contained in:
bart
2026-11-02 21:48:49 +01:00
parent 0aed952728
commit 3a0ea206a2
+159 -16
View File
@@ -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=<url-encoded-json> (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()