fix(kodi_rpc): support HTTP GET query param request and raw TCP JSON-RPC socket on 9090 for Yatse
This commit is contained in:
+159
-16
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user