diff --git a/Dockerfile b/Dockerfile index c36ac89..eef9ef4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ COPY config.example.yaml ./config.example.yaml # If no config.yaml exists at runtime, fallback to config.example.yaml RUN cp config.example.yaml config.yaml -EXPOSE 8080 9090 8097 +EXPOSE 8080 9090 8097 9777/udp ENTRYPOINT ["python3", "-m", "src.main"] CMD ["-c", "config.yaml"] diff --git a/docker-compose.yml b/docker-compose.yml index 6641620..36f86e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,9 +6,10 @@ services: container_name: jellyfin-client restart: unless-stopped ports: - - "8080:8080" # Kodi JSON-RPC (Yatse) - - "9090:9090" # Kodi JSON-RPC WebSocket - - "8097:8097" # Web Remote & REST API + - "8080:8080" # Kodi JSON-RPC (Yatse) + - "9090:9090" # Kodi JSON-RPC WebSocket & TCP Socket + - "9777:9777/udp" # Kodi EventServer (UDP) + - "8097:8097" # Web Remote & REST API environment: - DISPLAY=${DISPLAY:-:0} - MPV_VO=${MPV_VO:-gpu} # Change to 'null' for headless test without window diff --git a/src/config.py b/src/config.py index 142224e..5cfa8c2 100644 --- a/src/config.py +++ b/src/config.py @@ -39,6 +39,7 @@ class KodiRpcConfig: enabled: bool = True http_port: int = 8080 ws_port: int = 9090 + event_port: int = 9777 @dataclass diff --git a/src/main.py b/src/main.py index 21f4fda..3f8d4eb 100644 --- a/src/main.py +++ b/src/main.py @@ -10,6 +10,7 @@ from typing import List from src.config import AppConfig from src.jellyfin.client import JellyfinClient from src.player.mpv_controller import MpvController +from src.remotes.kodi_eventserver import KodiEventServer from src.remotes.kodi_rpc import KodiRpcServer from src.remotes.rest_api import RestApiServer @@ -27,12 +28,14 @@ async def run_app(config: AppConfig) -> None: player = MpvController(config.player) jellyfin = JellyfinClient(config.jellyfin, player) kodi_rpc = KodiRpcServer(config.kodi_rpc, player) + kodi_event = KodiEventServer(config.kodi_rpc.event_port, player) rest_api = RestApiServer(config.web_api, player) # Start services await player.start() await jellyfin.start() await kodi_rpc.start() + await kodi_event.start() await rest_api.start() 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...") await rest_api.shutdown() + await kodi_event.shutdown() await kodi_rpc.shutdown() await jellyfin.shutdown() await player.shutdown() + logger.info("Graceful shutdown complete.") diff --git a/src/remotes/kodi_eventserver.py b/src/remotes/kodi_eventserver.py new file mode 100644 index 0000000..68ab955 --- /dev/null +++ b/src/remotes/kodi_eventserver.py @@ -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() diff --git a/src/remotes/kodi_rpc.py b/src/remotes/kodi_rpc.py index 7bedadc..94d3cef 100644 --- a/src/remotes/kodi_rpc.py +++ b/src/remotes/kodi_rpc.py @@ -264,7 +264,15 @@ class KodiRpcServer: 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} + 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]: labels = params.get("labels", [])