feat(eventserver): implement Kodi UDP EventServer on port 9777 for Yatse
This commit is contained in:
+1
-1
@@ -29,7 +29,7 @@ COPY config.example.yaml ./config.example.yaml
|
|||||||
# If no config.yaml exists at runtime, fallback to config.example.yaml
|
# If no config.yaml exists at runtime, fallback to config.example.yaml
|
||||||
RUN cp config.example.yaml config.yaml
|
RUN cp config.example.yaml config.yaml
|
||||||
|
|
||||||
EXPOSE 8080 9090 8097
|
EXPOSE 8080 9090 8097 9777/udp
|
||||||
|
|
||||||
ENTRYPOINT ["python3", "-m", "src.main"]
|
ENTRYPOINT ["python3", "-m", "src.main"]
|
||||||
CMD ["-c", "config.yaml"]
|
CMD ["-c", "config.yaml"]
|
||||||
|
|||||||
+4
-3
@@ -6,9 +6,10 @@ services:
|
|||||||
container_name: jellyfin-client
|
container_name: jellyfin-client
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080" # Kodi JSON-RPC (Yatse)
|
- "8080:8080" # Kodi JSON-RPC (Yatse)
|
||||||
- "9090:9090" # Kodi JSON-RPC WebSocket
|
- "9090:9090" # Kodi JSON-RPC WebSocket & TCP Socket
|
||||||
- "8097:8097" # Web Remote & REST API
|
- "9777:9777/udp" # Kodi EventServer (UDP)
|
||||||
|
- "8097:8097" # Web Remote & REST API
|
||||||
environment:
|
environment:
|
||||||
- DISPLAY=${DISPLAY:-:0}
|
- DISPLAY=${DISPLAY:-:0}
|
||||||
- MPV_VO=${MPV_VO:-gpu} # Change to 'null' for headless test without window
|
- MPV_VO=${MPV_VO:-gpu} # Change to 'null' for headless test without window
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class KodiRpcConfig:
|
|||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
http_port: int = 8080
|
http_port: int = 8080
|
||||||
ws_port: int = 9090
|
ws_port: int = 9090
|
||||||
|
event_port: int = 9777
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from typing import List
|
|||||||
from src.config import AppConfig
|
from src.config import AppConfig
|
||||||
from src.jellyfin.client import JellyfinClient
|
from src.jellyfin.client import JellyfinClient
|
||||||
from src.player.mpv_controller import MpvController
|
from src.player.mpv_controller import MpvController
|
||||||
|
from src.remotes.kodi_eventserver import KodiEventServer
|
||||||
from src.remotes.kodi_rpc import KodiRpcServer
|
from src.remotes.kodi_rpc import KodiRpcServer
|
||||||
from src.remotes.rest_api import RestApiServer
|
from src.remotes.rest_api import RestApiServer
|
||||||
|
|
||||||
@@ -27,12 +28,14 @@ async def run_app(config: AppConfig) -> None:
|
|||||||
player = MpvController(config.player)
|
player = MpvController(config.player)
|
||||||
jellyfin = JellyfinClient(config.jellyfin, player)
|
jellyfin = JellyfinClient(config.jellyfin, player)
|
||||||
kodi_rpc = KodiRpcServer(config.kodi_rpc, player)
|
kodi_rpc = KodiRpcServer(config.kodi_rpc, player)
|
||||||
|
kodi_event = KodiEventServer(config.kodi_rpc.event_port, player)
|
||||||
rest_api = RestApiServer(config.web_api, player)
|
rest_api = RestApiServer(config.web_api, player)
|
||||||
|
|
||||||
# Start services
|
# Start services
|
||||||
await player.start()
|
await player.start()
|
||||||
await jellyfin.start()
|
await jellyfin.start()
|
||||||
await kodi_rpc.start()
|
await kodi_rpc.start()
|
||||||
|
await kodi_event.start()
|
||||||
await rest_api.start()
|
await rest_api.start()
|
||||||
|
|
||||||
logger.info("Jellyfin HTPC Client is fully initialized and ready.")
|
logger.info("Jellyfin HTPC Client is fully initialized and ready.")
|
||||||
@@ -55,9 +58,11 @@ async def run_app(config: AppConfig) -> None:
|
|||||||
|
|
||||||
logger.info("Shutting down services...")
|
logger.info("Shutting down services...")
|
||||||
await rest_api.shutdown()
|
await rest_api.shutdown()
|
||||||
|
await kodi_event.shutdown()
|
||||||
await kodi_rpc.shutdown()
|
await kodi_rpc.shutdown()
|
||||||
await jellyfin.shutdown()
|
await jellyfin.shutdown()
|
||||||
await player.shutdown()
|
await player.shutdown()
|
||||||
|
|
||||||
logger.info("Graceful shutdown complete.")
|
logger.info("Graceful shutdown complete.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from src.player.mpv_controller import MpvController
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Kodi EventServer Packet Types
|
||||||
|
PT_HELO = 0x01
|
||||||
|
PT_BYE = 0x02
|
||||||
|
PT_BUTTON = 0x03
|
||||||
|
PT_MOUSE = 0x04
|
||||||
|
PT_PING = 0x05
|
||||||
|
PT_NOTIFICATION = 0x06
|
||||||
|
PT_BLOB = 0x07
|
||||||
|
PT_LOG = 0x08
|
||||||
|
PT_ACTION = 0x09
|
||||||
|
|
||||||
|
HEADER_FORMAT = "!4sBBHIIHI10s"
|
||||||
|
HEADER_SIZE = struct.calcsize(HEADER_FORMAT) # 32 bytes
|
||||||
|
|
||||||
|
|
||||||
|
class KodiEventServerProtocol(asyncio.DatagramProtocol):
|
||||||
|
"""UDP protocol handler for Kodi EventServer packets from Yatse."""
|
||||||
|
|
||||||
|
def __init__(self, player: MpvController):
|
||||||
|
self.player = player
|
||||||
|
self.transport: Optional[asyncio.DatagramTransport] = None
|
||||||
|
|
||||||
|
def connection_made(self, transport: asyncio.DatagramTransport) -> None:
|
||||||
|
self.transport = transport
|
||||||
|
logger.info("Kodi EventServer UDP protocol initialized")
|
||||||
|
|
||||||
|
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
||||||
|
if len(data) < HEADER_SIZE:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
sig, majver, minver, pkttype, seq, maxseq, paylen, client_token, _ = struct.unpack(
|
||||||
|
HEADER_FORMAT, data[:HEADER_SIZE]
|
||||||
|
)
|
||||||
|
if sig != b"XBMC":
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = data[HEADER_SIZE : HEADER_SIZE + paylen]
|
||||||
|
self._handle_packet(pkttype, payload, addr)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Error parsing EventServer packet: %s", e)
|
||||||
|
|
||||||
|
def _handle_packet(self, pkttype: int, payload: bytes, addr: tuple[str, int]) -> None:
|
||||||
|
if pkttype == PT_HELO:
|
||||||
|
# Client registration
|
||||||
|
devname = payload.split(b"\x00")[0].decode("utf-8", errors="ignore")
|
||||||
|
logger.info("Yatse EventServer connected from %s (Device: %s)", addr, devname)
|
||||||
|
elif pkttype == PT_PING:
|
||||||
|
logger.debug("Yatse EventServer PING from %s", addr)
|
||||||
|
elif pkttype == PT_BUTTON:
|
||||||
|
self._handle_button(payload)
|
||||||
|
elif pkttype == PT_ACTION:
|
||||||
|
self._handle_action(payload)
|
||||||
|
elif pkttype == PT_BYE:
|
||||||
|
logger.info("Yatse EventServer disconnected from %s", addr)
|
||||||
|
|
||||||
|
def _handle_button(self, payload: bytes) -> None:
|
||||||
|
# Format: code(2B) + map_name(null-terminated) + button_name(null-terminated) + flags(2B) + amount(2B)
|
||||||
|
if len(payload) < 6:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
parts = payload[2:].split(b"\x00")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
map_name = parts[0].decode("utf-8", errors="ignore")
|
||||||
|
button_name = parts[1].decode("utf-8", errors="ignore").lower()
|
||||||
|
|
||||||
|
# Check flags: 0x01 is KEY_DOWN
|
||||||
|
tail = b"\x00".join(parts[2:])
|
||||||
|
if len(tail) >= 2:
|
||||||
|
flags = struct.unpack("!H", tail[:2])[0]
|
||||||
|
if not (flags & 0x01): # Ignore key up events
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("EventServer button pressed: map=%s, btn=%s", map_name, button_name)
|
||||||
|
self._dispatch_button(button_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Error handling EventServer button: %s", e)
|
||||||
|
|
||||||
|
def _handle_action(self, payload: bytes) -> None:
|
||||||
|
# Format: action_type(1B) + action_name(null-terminated string)
|
||||||
|
if len(payload) < 2:
|
||||||
|
return
|
||||||
|
action = payload[1:].split(b"\x00")[0].decode("utf-8", errors="ignore").lower()
|
||||||
|
logger.info("EventServer action received: %s", action)
|
||||||
|
if "playpause" in action or "play" in action:
|
||||||
|
asyncio.create_task(self.player.play_pause())
|
||||||
|
elif "stop" in action:
|
||||||
|
asyncio.create_task(self.player.stop())
|
||||||
|
|
||||||
|
def _dispatch_button(self, btn: str) -> None:
|
||||||
|
if btn in ("left", "stepback"):
|
||||||
|
asyncio.create_task(self.player.seek(-10))
|
||||||
|
elif btn in ("right", "stepforward"):
|
||||||
|
asyncio.create_task(self.player.seek(10))
|
||||||
|
elif btn in ("up", "bigstepforward"):
|
||||||
|
asyncio.create_task(self.player.seek(60))
|
||||||
|
elif btn in ("down", "bigstepback"):
|
||||||
|
asyncio.create_task(self.player.seek(-60))
|
||||||
|
elif btn in ("select", "enter", "play", "pause", "playpause"):
|
||||||
|
asyncio.create_task(self.player.play_pause())
|
||||||
|
elif btn in ("back", "stop"):
|
||||||
|
asyncio.create_task(self.player.stop())
|
||||||
|
elif btn in ("volumeup", "volup"):
|
||||||
|
asyncio.create_task(self.player.set_volume(self.player.status.volume + 5))
|
||||||
|
elif btn in ("volumedown", "voldown"):
|
||||||
|
asyncio.create_task(self.player.set_volume(self.player.status.volume - 5))
|
||||||
|
|
||||||
|
|
||||||
|
class KodiEventServer:
|
||||||
|
"""Manages the UDP socket for Kodi EventServer."""
|
||||||
|
|
||||||
|
def __init__(self, port: int, player: MpvController):
|
||||||
|
self.port = port
|
||||||
|
self.player = player
|
||||||
|
self.transport: Optional[asyncio.DatagramTransport] = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
transport, _ = await loop.create_datagram_endpoint(
|
||||||
|
lambda: KodiEventServerProtocol(self.player),
|
||||||
|
local_addr=("0.0.0.0", self.port),
|
||||||
|
)
|
||||||
|
self.transport = transport
|
||||||
|
logger.info("Kodi EventServer UDP listener started on 0.0.0.0:%d (Yatse compatible)", self.port)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to start Kodi EventServer on UDP port %d: %s", self.port, e)
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
if self.transport:
|
||||||
|
self.transport.close()
|
||||||
@@ -264,7 +264,15 @@ class KodiRpcServer:
|
|||||||
|
|
||||||
async def _rpc_xbmc_get_info_booleans(self, params: Dict[str, Any]) -> Dict[str, bool]:
|
async def _rpc_xbmc_get_info_booleans(self, params: Dict[str, Any]) -> Dict[str, bool]:
|
||||||
booleans = params.get("booleans", [])
|
booleans = params.get("booleans", [])
|
||||||
return {b: False for b in booleans}
|
result = {}
|
||||||
|
for b in booleans:
|
||||||
|
if b in ("Services.EventServer", "Services.Webserver"):
|
||||||
|
result[b] = True
|
||||||
|
elif b == "System.HasModalDialog":
|
||||||
|
result[b] = False
|
||||||
|
else:
|
||||||
|
result[b] = True
|
||||||
|
return result
|
||||||
|
|
||||||
async def _rpc_xbmc_get_info_labels(self, params: Dict[str, Any]) -> Dict[str, str]:
|
async def _rpc_xbmc_get_info_labels(self, params: Dict[str, Any]) -> Dict[str, str]:
|
||||||
labels = params.get("labels", [])
|
labels = params.get("labels", [])
|
||||||
|
|||||||
Reference in New Issue
Block a user