Add WebSocket support for real-time media status updates
Replace HTTP polling with WebSocket push notifications for instant state change responses. Server broadcasts updates only when significant changes occur (state, track, volume, etc.) while letting Home Assistant interpolate position during playback. Includes seek detection for timeline updates and automatic fallback to HTTP polling if WebSocket disconnects. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -265,3 +268,140 @@ class MediaServerClient:
|
||||
endpoint = f"{API_SCRIPTS_EXECUTE}/{script_name}"
|
||||
json_data = {"args": args or []}
|
||||
return await self._request("POST", endpoint, json_data)
|
||||
|
||||
|
||||
class MediaServerWebSocket:
|
||||
"""WebSocket client for real-time media status updates."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
token: str,
|
||||
on_status_update: Callable[[dict[str, Any]], None],
|
||||
on_disconnect: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the WebSocket client.
|
||||
|
||||
Args:
|
||||
host: Server hostname or IP
|
||||
port: Server port
|
||||
token: API authentication token
|
||||
on_status_update: Callback when status update received
|
||||
on_disconnect: Callback when connection lost
|
||||
"""
|
||||
self._host = host
|
||||
self._port = int(port)
|
||||
self._token = token
|
||||
self._on_status_update = on_status_update
|
||||
self._on_disconnect = on_disconnect
|
||||
self._ws_url = f"ws://{host}:{self._port}/api/media/ws?token={token}"
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
||||
self._receive_task: asyncio.Task | None = None
|
||||
self._running = False
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Establish WebSocket connection.
|
||||
|
||||
Returns:
|
||||
True if connection successful
|
||||
"""
|
||||
try:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession()
|
||||
|
||||
self._ws = await self._session.ws_connect(
|
||||
self._ws_url,
|
||||
heartbeat=30,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
)
|
||||
self._running = True
|
||||
|
||||
# Start receive loop
|
||||
self._receive_task = asyncio.create_task(self._receive_loop())
|
||||
|
||||
_LOGGER.info("WebSocket connected to %s:%s", self._host, self._port)
|
||||
return True
|
||||
|
||||
except Exception as err:
|
||||
_LOGGER.warning("WebSocket connection failed: %s", err)
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Close WebSocket connection."""
|
||||
self._running = False
|
||||
|
||||
if self._receive_task:
|
||||
self._receive_task.cancel()
|
||||
try:
|
||||
await self._receive_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._receive_task = None
|
||||
|
||||
if self._ws and not self._ws.closed:
|
||||
await self._ws.close()
|
||||
self._ws = None
|
||||
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
_LOGGER.debug("WebSocket disconnected")
|
||||
|
||||
async def _receive_loop(self) -> None:
|
||||
"""Background loop to receive WebSocket messages."""
|
||||
while self._running and self._ws and not self._ws.closed:
|
||||
try:
|
||||
msg = await self._ws.receive(timeout=60)
|
||||
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
data = msg.json()
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type in ("status", "status_update"):
|
||||
status_data = data.get("data", {})
|
||||
# Convert album art URL to absolute
|
||||
if (
|
||||
status_data.get("album_art_url")
|
||||
and status_data["album_art_url"].startswith("/")
|
||||
):
|
||||
track_id = f"{status_data.get('title', '')}-{status_data.get('artist', '')}"
|
||||
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
|
||||
status_data["album_art_url"] = (
|
||||
f"http://{self._host}:{self._port}"
|
||||
f"{status_data['album_art_url']}?token={self._token}&t={track_hash}"
|
||||
)
|
||||
self._on_status_update(status_data)
|
||||
elif msg_type == "pong":
|
||||
_LOGGER.debug("Received pong")
|
||||
|
||||
elif msg.type == aiohttp.WSMsgType.CLOSED:
|
||||
_LOGGER.warning("WebSocket closed by server")
|
||||
break
|
||||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||
_LOGGER.error("WebSocket error: %s", self._ws.exception())
|
||||
break
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# Send ping to keep connection alive
|
||||
if self._ws and not self._ws.closed:
|
||||
try:
|
||||
await self._ws.send_json({"type": "ping"})
|
||||
except Exception:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as err:
|
||||
_LOGGER.error("WebSocket receive error: %s", err)
|
||||
break
|
||||
|
||||
# Connection lost, notify callback
|
||||
if self._on_disconnect:
|
||||
self._on_disconnect()
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return True if WebSocket is connected."""
|
||||
return self._ws is not None and not self._ws.closed
|
||||
|
||||
Reference in New Issue
Block a user