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: 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): def __init__(self, config: KodiRpcConfig, player: MpvController):
self.config = config self.config = config
@@ -33,9 +33,11 @@ class KodiRpcServer:
self._runner: Optional[web.AppRunner] = None self._runner: Optional[web.AppRunner] = None
self._site: Optional[web.TCPSite] = None self._site: Optional[web.TCPSite] = None
self._ws_clients: Set[web.WebSocketResponse] = set() 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_post("/jsonrpc", self._handle_post_jsonrpc)
self.app.router.add_get("/jsonrpc", self._handle_websocket_jsonrpc) self.app.router.add_get("/jsonrpc", self._handle_get_jsonrpc)
self.app.router.add_get("/", self._handle_root) self.app.router.add_get("/", self._handle_root)
self.player.add_listener(self._on_player_status_changed) self.player.add_listener(self._on_player_status_changed)
@@ -44,9 +46,6 @@ class KodiRpcServer:
asyncio.create_task(self._broadcast_player_event(status)) asyncio.create_task(self._broadcast_player_event(status))
async def _broadcast_player_event(self, status: PlayerStatus) -> None: 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 ( event_name = "Player.OnPlay" if status.state == PlaybackState.PLAYING else (
"Player.OnPause" if status.state == PlaybackState.PAUSED else "Player.OnStop" "Player.OnPause" if status.state == PlaybackState.PAUSED else "Player.OnStop"
) )
@@ -61,19 +60,31 @@ class KodiRpcServer:
"sender": "xbmc", "sender": "xbmc",
}, },
} }
dead_clients = set()
# Broadcast to WebSockets
dead_ws = set()
for ws in self._ws_clients: for ws in self._ws_clients:
try: try:
await ws.send_json(notification) await ws.send_json(notification)
except Exception: 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: async def _handle_root(self, request: web.Request) -> web.Response:
return web.Response(text="Kodi JSON-RPC emulation server for Yatse") 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: try:
body = await request.json() body = await request.json()
except Exception: except Exception:
@@ -82,13 +93,35 @@ class KodiRpcServer:
) )
if isinstance(body, list): if isinstance(body, list):
# Batch request
results = [await self._dispatch(item) for item in body] results = [await self._dispatch(item) for item in body]
return web.json_response(results) return web.json_response(results)
else: else:
result = await self._dispatch(body) result = await self._dispatch(body)
return web.json_response(result) 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: async def _handle_websocket_jsonrpc(self, request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse() ws = web.WebSocketResponse()
await ws.prepare(request) await ws.prepare(request)
@@ -112,6 +145,45 @@ class KodiRpcServer:
return ws 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]: async def _dispatch(self, req: Dict[str, Any]) -> Dict[str, Any]:
req_id = req.get("id") req_id = req.get("id")
method = req.get("method", "") method = req.get("method", "")
@@ -125,7 +197,7 @@ class KodiRpcServer:
result = await handler(self, params) result = await handler(self, params)
return {"jsonrpc": "2.0", "result": result, "id": req_id} return {"jsonrpc": "2.0", "result": result, "id": req_id}
else: 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} return {"jsonrpc": "2.0", "result": "OK", "id": req_id}
except Exception as e: except Exception as e:
logger.error("Error dispatching method %s: %s", method, 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]: async def _rpc_introspect(self, params: Any) -> Dict[str, Any]:
return {"methods": {m: {} for m in self._methods.keys()}} 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]: async def _rpc_app_get_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
props = params.get("properties", []) props = params.get("properties", [])
result = {} result = {}
@@ -155,11 +241,35 @@ class KodiRpcServer:
elif prop == "muted": elif prop == "muted":
result["muted"] = self.player.status.muted result["muted"] = self.player.status.muted
elif prop == "name": elif prop == "name":
result["name"] = "Kodi (Jellyfin HTPC)" result["name"] = "Kodi"
elif prop == "version": elif prop == "version":
result["version"] = {"major": 19, "minor": 0, "revision": "0", "tag": "release"} result["version"] = {"major": 19, "minor": 0, "revision": "0", "tag": "release"}
return result 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: async def _rpc_app_set_volume(self, params: Dict[str, Any]) -> int:
vol = params.get("volume", 100) vol = params.get("volume", 100)
await self.player.set_volume(int(vol)) await self.player.set_volume(int(vol))
@@ -247,11 +357,9 @@ class KodiRpcServer:
status = self.player.status status = self.player.status
if isinstance(value, (int, float)): if isinstance(value, (int, float)):
# Percentage
target = (float(value) / 100.0) * status.duration target = (float(value) / 100.0) * status.duration
await self.player.seek(target, absolute=True) await self.player.seek(target, absolute=True)
elif isinstance(value, dict): elif isinstance(value, dict):
# Time object
hrs = value.get("hours", 0) hrs = value.get("hours", 0)
mins = value.get("minutes", 0) mins = value.get("minutes", 0)
secs = value.get("seconds", 0) secs = value.get("seconds", 0)
@@ -309,6 +417,9 @@ class KodiRpcServer:
await self.player.show_osd_message(text) await self.player.show_osd_message(text)
return "OK" return "OK"
async def _rpc_input_ok(self, params: Any) -> str:
return "OK"
async def _rpc_input_back(self, params: Any) -> str: async def _rpc_input_back(self, params: Any) -> str:
await self.player.stop() await self.player.stop()
return "OK" return "OK"
@@ -317,9 +428,14 @@ class KodiRpcServer:
"JSONRPC.Ping": _rpc_ping, "JSONRPC.Ping": _rpc_ping,
"JSONRPC.Version": _rpc_version, "JSONRPC.Version": _rpc_version,
"JSONRPC.Introspect": _rpc_introspect, "JSONRPC.Introspect": _rpc_introspect,
"JSONRPC.Permission": _rpc_jsonrpc_permission,
"Application.GetProperties": _rpc_app_get_properties, "Application.GetProperties": _rpc_app_get_properties,
"Application.SetVolume": _rpc_app_set_volume, "Application.SetVolume": _rpc_app_set_volume,
"Application.SetMute": _rpc_app_set_mute, "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.GetActivePlayers": _rpc_player_get_active_players,
"Player.GetProperties": _rpc_player_get_properties, "Player.GetProperties": _rpc_player_get_properties,
"Player.GetItem": _rpc_player_get_item, "Player.GetItem": _rpc_player_get_item,
@@ -331,6 +447,15 @@ class KodiRpcServer:
"Playlist.GetItems": _rpc_playlist_get_items, "Playlist.GetItems": _rpc_playlist_get_items,
"GUI.ShowNotification": _rpc_gui_show_notification, "GUI.ShowNotification": _rpc_gui_show_notification,
"Input.Back": _rpc_input_back, "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: async def start(self) -> None:
@@ -338,15 +463,33 @@ class KodiRpcServer:
logger.info("Kodi RPC emulation server is disabled in config") logger.info("Kodi RPC emulation server is disabled in config")
return return
# Start HTTP & WebSocket server on http_port (8080)
self._runner = web.AppRunner(self.app) self._runner = web.AppRunner(self.app)
await self._runner.setup() await self._runner.setup()
self._site = web.TCPSite(self._runner, "0.0.0.0", self.config.http_port) self._site = web.TCPSite(self._runner, "0.0.0.0", self.config.http_port)
await self._site.start() await self._site.start()
logger.info( 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, 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: async def shutdown(self) -> None:
if self._tcp_server:
self._tcp_server.close()
await self._tcp_server.wait_closed()
if self._runner: if self._runner:
await self._runner.cleanup() await self._runner.cleanup()