feat(client): v0.3.0 server compat — WS subprotocol auth, 429 retry, HTTPS, X-Request-ID
Aligns the integration with the four wire-level changes shipped in
media-server v0.3.0/0.3.1 without breaking back-compat with older
server versions or pre-existing config entries.
- WebSocket auth via Sec-WebSocket-Protocol: media-server.token.<T>
(preferred by server v0.3.0+). The ?token= query is still sent so
older servers and unauthenticated mode both keep working — aiohttp
completes the handshake even when the server doesn't echo the
subprotocol back.
- 429 Too Many Requests surfaced as MediaServerRateLimitError with
Retry-After parsed; execute_script() sleeps min(retry_after, 30)
and retries once before falling through to the caller.
- Optional HTTPS/WSS (CONF_USE_SSL) + optional certificate verification
toggle (CONF_VERIFY_SSL) wired through the config flow, client, and
WebSocket. Defaults preserve http+verified behaviour, so existing
config entries are unchanged.
- X-Request-ID header (uuid4 hex) on every HTTP call so HA-side issues
can be cross-referenced with the server's access/audit logs. The
format matches the server's ^[A-Za-z0-9._-]{1,128}\$ allow-list so
the id is preserved verbatim instead of being replaced server-side.
Bumps manifest version to 0.3.3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,10 @@ from .const import (
|
||||
CONF_HOST,
|
||||
CONF_PORT,
|
||||
CONF_TOKEN,
|
||||
CONF_USE_SSL,
|
||||
CONF_VERIFY_SSL,
|
||||
DEFAULT_USE_SSL,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
DOMAIN,
|
||||
SERVICE_EXECUTE_SCRIPT,
|
||||
SERVICE_PLAY_MEDIA_FILE,
|
||||
@@ -144,11 +148,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""
|
||||
_LOGGER.debug("Setting up Remote Media Player: %s", entry.entry_id)
|
||||
|
||||
# Create API client
|
||||
# Create API client. ``use_ssl`` / ``verify_ssl`` were added in v0.3.3;
|
||||
# ``.get(..., default)`` keeps pre-existing config entries (which lack the
|
||||
# keys entirely) working at the old http+verify defaults.
|
||||
client = MediaServerClient(
|
||||
host=entry.data[CONF_HOST],
|
||||
port=entry.data[CONF_PORT],
|
||||
token=entry.data[CONF_TOKEN],
|
||||
use_ssl=entry.data.get(CONF_USE_SSL, DEFAULT_USE_SSL),
|
||||
verify_ssl=entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
|
||||
)
|
||||
|
||||
# Verify connection
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
@@ -55,6 +56,18 @@ class MediaServerAuthError(MediaServerError):
|
||||
"""Exception for authentication errors."""
|
||||
|
||||
|
||||
class MediaServerRateLimitError(MediaServerError):
|
||||
"""Raised when the server replies with HTTP 429.
|
||||
|
||||
The media server's in-process token-bucket limiter (v0.3.0+) returns 429
|
||||
with a ``Retry-After`` header — capture it so callers can back off.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, retry_after: float | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class MediaServerClient:
|
||||
"""Client for the Media Server REST API."""
|
||||
|
||||
@@ -64,6 +77,8 @@ class MediaServerClient:
|
||||
port: int,
|
||||
token: str,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
use_ssl: bool = False,
|
||||
verify_ssl: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the client.
|
||||
|
||||
@@ -72,13 +87,22 @@ class MediaServerClient:
|
||||
port: Server port
|
||||
token: API authentication token
|
||||
session: Optional aiohttp session (will create one if not provided)
|
||||
use_ssl: If True, talk HTTPS instead of HTTP. The media server v0.3.0+
|
||||
supports ``ssl_certfile`` / ``ssl_keyfile`` in ``config.yaml``.
|
||||
verify_ssl: If False, skip TLS certificate verification (only needed
|
||||
for self-signed certs on a trusted LAN).
|
||||
"""
|
||||
self._host = host
|
||||
self._port = int(port) # Ensure port is an integer
|
||||
self._token = token
|
||||
self._session = session
|
||||
self._own_session = session is None
|
||||
self._base_url = f"http://{host}:{self._port}"
|
||||
self._use_ssl = use_ssl
|
||||
# aiohttp accepts ``ssl=False`` to disable verification; ``None`` keeps
|
||||
# the default verifying SSLContext.
|
||||
self._ssl: bool | None = False if (use_ssl and not verify_ssl) else None
|
||||
scheme = "https" if use_ssl else "http"
|
||||
self._base_url = f"{scheme}://{host}:{self._port}"
|
||||
|
||||
async def _ensure_session(self) -> aiohttp.ClientSession:
|
||||
"""Ensure we have an aiohttp session."""
|
||||
@@ -98,8 +122,18 @@ class MediaServerClient:
|
||||
When no token is configured the media server runs in anonymous mode
|
||||
(``auth.auth_enabled()`` returns False), so we omit the Authorization
|
||||
header entirely rather than sending ``Bearer `` with an empty value.
|
||||
|
||||
Every request carries a per-call ``X-Request-ID`` that the media server
|
||||
echoes back into its log lines (audit log + access log) so a problem
|
||||
in HA can be correlated to the matching server-side entry. The id is
|
||||
a UUID4 hex (32 chars) which fits the server's ``[A-Za-z0-9._-]{1,128}``
|
||||
allow-list and is therefore preserved verbatim instead of being
|
||||
replaced by a fresh server-side id.
|
||||
"""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Request-ID": uuid.uuid4().hex,
|
||||
}
|
||||
if self._token:
|
||||
headers["Authorization"] = f"Bearer {self._token}"
|
||||
return headers
|
||||
@@ -129,17 +163,38 @@ class MediaServerClient:
|
||||
"""
|
||||
session = await self._ensure_session()
|
||||
url = f"{self._base_url}{endpoint}"
|
||||
headers = self._get_headers() if auth_required else {}
|
||||
# Always send X-Request-ID, even on unauthenticated calls — it's the
|
||||
# observability hook, not an auth token, and the health endpoint
|
||||
# benefits from being log-correlated just like every other.
|
||||
headers = self._get_headers() if auth_required else {
|
||||
"Content-Type": "application/json",
|
||||
"X-Request-ID": uuid.uuid4().hex,
|
||||
}
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
async with session.request(
|
||||
method, url, headers=headers, json=json_data, timeout=timeout
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
timeout=timeout,
|
||||
ssl=self._ssl,
|
||||
) as response:
|
||||
if response.status == 401:
|
||||
raise MediaServerAuthError("Invalid API token")
|
||||
if response.status == 403:
|
||||
raise MediaServerAuthError("Access forbidden")
|
||||
if response.status == 429:
|
||||
retry_after_raw = response.headers.get("Retry-After", "")
|
||||
try:
|
||||
retry_after = float(retry_after_raw) if retry_after_raw else None
|
||||
except ValueError:
|
||||
retry_after = None
|
||||
raise MediaServerRateLimitError(
|
||||
f"Rate limited by server (retry after {retry_after}s)",
|
||||
retry_after=retry_after,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
@@ -307,6 +362,11 @@ class MediaServerClient:
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a script on the server.
|
||||
|
||||
The server (v0.3.0+) rate-limits ``/api/scripts/execute`` at 10/min per
|
||||
peer. If we hit 429 we wait for ``Retry-After`` (capped at 30 s) and
|
||||
retry once — enough for a brief HA-side burst without masking a real
|
||||
sustained overload, which falls through as ``MediaServerRateLimitError``.
|
||||
|
||||
Args:
|
||||
script_name: Name of the script to execute
|
||||
params: Optional named parameters (validated against script schema)
|
||||
@@ -316,7 +376,17 @@ class MediaServerClient:
|
||||
"""
|
||||
endpoint = f"{API_SCRIPTS_EXECUTE}/{script_name}"
|
||||
json_data = {"params": params or {}}
|
||||
return await self._request("POST", endpoint, json_data)
|
||||
try:
|
||||
return await self._request("POST", endpoint, json_data)
|
||||
except MediaServerRateLimitError as err:
|
||||
wait = min(err.retry_after or 5.0, 30.0)
|
||||
_LOGGER.warning(
|
||||
"execute_script(%s) rate-limited, retrying after %.1fs",
|
||||
script_name,
|
||||
wait,
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
return await self._request("POST", endpoint, json_data)
|
||||
|
||||
async def get_media_folders(self) -> dict[str, dict[str, Any]]:
|
||||
"""Get configured media folders.
|
||||
@@ -448,6 +518,8 @@ class MediaServerWebSocket:
|
||||
on_disconnect: Callable[[], None] | None = None,
|
||||
on_scripts_changed: Callable[[], None] | None = None,
|
||||
on_foreground_update: Callable[[dict[str, Any]], None] | None = None,
|
||||
use_ssl: bool = False,
|
||||
verify_ssl: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the WebSocket client.
|
||||
|
||||
@@ -459,6 +531,8 @@ class MediaServerWebSocket:
|
||||
on_disconnect: Callback when connection lost
|
||||
on_scripts_changed: Callback when scripts have changed
|
||||
on_foreground_update: Callback when foreground process changes
|
||||
use_ssl: If True, talk WSS instead of WS.
|
||||
verify_ssl: If False, skip TLS certificate verification.
|
||||
"""
|
||||
self._host = host
|
||||
self._port = int(port)
|
||||
@@ -467,11 +541,18 @@ class MediaServerWebSocket:
|
||||
self._on_disconnect = on_disconnect
|
||||
self._on_scripts_changed = on_scripts_changed
|
||||
self._on_foreground_update = on_foreground_update
|
||||
self._use_ssl = use_ssl
|
||||
self._ssl: bool | None = False if (use_ssl and not verify_ssl) else None
|
||||
# The server's WS endpoint accepts an unauthenticated connection when
|
||||
# api_tokens is empty (see media.py:websocket_endpoint), so we only
|
||||
# append ?token=... when one was configured.
|
||||
# append ?token=... when one was configured. Pre-0.3.0 servers only
|
||||
# know the query path; 0.3.0+ servers prefer the ``Sec-WebSocket-Protocol``
|
||||
# subprotocol (which keeps the token out of URLs / Referer / logs) but
|
||||
# still accept the query as a documented back-compat fallback. We send
|
||||
# both so the integration works against either server version.
|
||||
token_query = f"?token={token}" if token else ""
|
||||
self._ws_url = f"ws://{host}:{self._port}/api/media/ws{token_query}"
|
||||
scheme = "wss" if use_ssl else "ws"
|
||||
self._ws_url = f"{scheme}://{host}:{self._port}/api/media/ws{token_query}"
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
||||
self._receive_task: asyncio.Task | None = None
|
||||
@@ -487,11 +568,21 @@ class MediaServerWebSocket:
|
||||
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),
|
||||
)
|
||||
ws_kwargs: dict[str, Any] = {
|
||||
"heartbeat": 30,
|
||||
"timeout": aiohttp.ClientTimeout(total=10),
|
||||
}
|
||||
if self._token:
|
||||
# Subprotocol-based auth (preferred by media server v0.3.0+).
|
||||
# aiohttp negotiates this header; if the server doesn't echo
|
||||
# it back (older versions), aiohttp still completes the
|
||||
# handshake — at which point the ?token= query in the URL
|
||||
# takes over. Safe across both server generations.
|
||||
ws_kwargs["protocols"] = [f"media-server.token.{self._token}"]
|
||||
if self._ssl is not None:
|
||||
ws_kwargs["ssl"] = self._ssl
|
||||
|
||||
self._ws = await self._session.ws_connect(self._ws_url, **ws_kwargs)
|
||||
self._running = True
|
||||
|
||||
# Start receive loop
|
||||
@@ -546,8 +637,9 @@ class MediaServerWebSocket:
|
||||
track_id = f"{status_data.get('title', '')}-{status_data.get('artist', '')}"
|
||||
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
|
||||
token_param = f"token={self._token}&" if self._token else ""
|
||||
http_scheme = "https" if self._use_ssl else "http"
|
||||
status_data["album_art_url"] = (
|
||||
f"http://{self._host}:{self._port}"
|
||||
f"{http_scheme}://{self._host}:{self._port}"
|
||||
f"{status_data['album_art_url']}?{token_param}t={track_hash}"
|
||||
)
|
||||
self._on_status_update(status_data)
|
||||
|
||||
@@ -22,9 +22,13 @@ from .const import (
|
||||
DOMAIN,
|
||||
CONF_TOKEN,
|
||||
CONF_POLL_INTERVAL,
|
||||
CONF_USE_SSL,
|
||||
CONF_VERIFY_SSL,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_POLL_INTERVAL,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_USE_SSL,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -52,6 +56,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
host=data[CONF_HOST],
|
||||
port=data[CONF_PORT],
|
||||
token=data.get(CONF_TOKEN, "") or "",
|
||||
use_ssl=data.get(CONF_USE_SSL, DEFAULT_USE_SSL),
|
||||
verify_ssl=data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -134,6 +140,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
type=selector.TextSelectorType.PASSWORD
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_USE_SSL, default=DEFAULT_USE_SSL
|
||||
): selector.BooleanSelector(),
|
||||
vol.Optional(
|
||||
CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL
|
||||
): selector.BooleanSelector(),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
||||
),
|
||||
|
||||
@@ -9,6 +9,8 @@ CONF_TOKEN = "token"
|
||||
CONF_POLL_INTERVAL = "poll_interval"
|
||||
CONF_NAME = "name"
|
||||
CONF_USE_WEBSOCKET = "use_websocket"
|
||||
CONF_USE_SSL = "use_ssl"
|
||||
CONF_VERIFY_SSL = "verify_ssl"
|
||||
|
||||
# Default values
|
||||
DEFAULT_PORT = 8765
|
||||
@@ -16,6 +18,8 @@ DEFAULT_POLL_INTERVAL = 5
|
||||
DEFAULT_NAME = "Remote Media Player"
|
||||
DEFAULT_USE_WEBSOCKET = True
|
||||
DEFAULT_RECONNECT_INTERVAL = 5
|
||||
DEFAULT_USE_SSL = False
|
||||
DEFAULT_VERIFY_SSL = True
|
||||
# Displays change rarely (brightness/contrast/input source via physical buttons
|
||||
# or external automations), so a slow shared poll is plenty. The previous
|
||||
# per-entity polling produced ~9 calls every 30 s for a single monitor.
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["aiohttp>=3.8.0"],
|
||||
"version": "0.3.2"
|
||||
"version": "0.3.3"
|
||||
}
|
||||
|
||||
@@ -36,11 +36,15 @@ from .const import (
|
||||
CONF_PORT,
|
||||
CONF_TOKEN,
|
||||
CONF_POLL_INTERVAL,
|
||||
CONF_USE_SSL,
|
||||
CONF_USE_WEBSOCKET,
|
||||
CONF_VERIFY_SSL,
|
||||
DEFAULT_POLL_INTERVAL,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_USE_WEBSOCKET,
|
||||
DEFAULT_RECONNECT_INTERVAL,
|
||||
DEFAULT_USE_SSL,
|
||||
DEFAULT_USE_WEBSOCKET,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -87,6 +91,8 @@ async def async_setup_entry(
|
||||
port=entry.data[CONF_PORT],
|
||||
token=entry.data[CONF_TOKEN],
|
||||
use_websocket=use_websocket,
|
||||
use_ssl=entry.data.get(CONF_USE_SSL, DEFAULT_USE_SSL),
|
||||
verify_ssl=entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
|
||||
entry=entry,
|
||||
)
|
||||
|
||||
@@ -124,6 +130,8 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
port: int,
|
||||
token: str,
|
||||
use_websocket: bool = True,
|
||||
use_ssl: bool = DEFAULT_USE_SSL,
|
||||
verify_ssl: bool = DEFAULT_VERIFY_SSL,
|
||||
entry: ConfigEntry | None = None,
|
||||
) -> None:
|
||||
"""Initialize the coordinator.
|
||||
@@ -136,6 +144,8 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
port: Server port
|
||||
token: API token
|
||||
use_websocket: Whether to use WebSocket for updates
|
||||
use_ssl: Talk WSS instead of WS
|
||||
verify_ssl: Verify TLS cert (off for self-signed)
|
||||
entry: Config entry (for integration reload on scripts change)
|
||||
"""
|
||||
super().__init__(
|
||||
@@ -149,6 +159,8 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._port = port
|
||||
self._token = token
|
||||
self._use_websocket = use_websocket
|
||||
self._use_ssl = use_ssl
|
||||
self._verify_ssl = verify_ssl
|
||||
self._entry = entry
|
||||
self._ws_client: MediaServerWebSocket | None = None
|
||||
self._ws_connected = False
|
||||
@@ -173,6 +185,8 @@ class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
on_disconnect=self._handle_ws_disconnect,
|
||||
on_scripts_changed=self._handle_ws_scripts_changed,
|
||||
on_foreground_update=self._handle_ws_foreground_update,
|
||||
use_ssl=self._use_ssl,
|
||||
verify_ssl=self._verify_ssl,
|
||||
)
|
||||
|
||||
if await self._ws_client.connect():
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"token": "API Token",
|
||||
"use_ssl": "Use HTTPS",
|
||||
"verify_ssl": "Verify TLS certificate",
|
||||
"name": "Name",
|
||||
"poll_interval": "Poll Interval"
|
||||
},
|
||||
@@ -15,6 +17,8 @@
|
||||
"host": "Hostname or IP address of the Media Server",
|
||||
"port": "Port number (default: 8765)",
|
||||
"token": "API authentication token from the server configuration. Leave blank if the server runs without authentication.",
|
||||
"use_ssl": "Talk to the server over HTTPS/WSS. The server must be configured with ssl_certfile and ssl_keyfile in config.yaml.",
|
||||
"verify_ssl": "Verify the server's TLS certificate chain. Turn off only if the server uses a self-signed certificate on a trusted LAN.",
|
||||
"name": "Display name for this media player",
|
||||
"poll_interval": "How often to poll for status updates (seconds)"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"token": "API Token",
|
||||
"use_ssl": "Use HTTPS",
|
||||
"verify_ssl": "Verify TLS certificate",
|
||||
"name": "Name",
|
||||
"poll_interval": "Poll Interval"
|
||||
},
|
||||
@@ -15,6 +17,8 @@
|
||||
"host": "Hostname or IP address of the Media Server",
|
||||
"port": "Port number (default: 8765)",
|
||||
"token": "API authentication token from the server configuration. Leave blank if the server runs without authentication.",
|
||||
"use_ssl": "Talk to the server over HTTPS/WSS. The server must be configured with ssl_certfile and ssl_keyfile in config.yaml.",
|
||||
"verify_ssl": "Verify the server's TLS certificate chain. Turn off only if the server uses a self-signed certificate on a trusted LAN.",
|
||||
"name": "Display name for this media player",
|
||||
"poll_interval": "How often to poll for status updates (seconds)"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"host": "Хост",
|
||||
"port": "Порт",
|
||||
"token": "API токен",
|
||||
"use_ssl": "Использовать HTTPS",
|
||||
"verify_ssl": "Проверять TLS-сертификат",
|
||||
"name": "Название",
|
||||
"poll_interval": "Интервал опроса"
|
||||
},
|
||||
@@ -15,6 +17,8 @@
|
||||
"host": "Имя хоста или IP-адрес Media Server",
|
||||
"port": "Номер порта (по умолчанию: 8765)",
|
||||
"token": "Токен аутентификации из конфигурации сервера. Оставьте пустым, если сервер работает без аутентификации.",
|
||||
"use_ssl": "Подключаться к серверу по HTTPS/WSS. Сервер должен быть настроен с параметрами ssl_certfile и ssl_keyfile в config.yaml.",
|
||||
"verify_ssl": "Проверять цепочку TLS-сертификатов сервера. Отключайте только если сервер использует самоподписанный сертификат в доверенной локальной сети.",
|
||||
"name": "Отображаемое имя медиаплеера",
|
||||
"poll_interval": "Частота опроса статуса (в секундах)"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user