From 97c1784ad4807586ad423c8f66da4ed085ca51d2 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 26 May 2026 11:37:59 +0300 Subject: [PATCH] =?UTF-8?q?feat(client):=20v0.3.0=20server=20compat=20?= =?UTF-8?q?=E2=80=94=20WS=20subprotocol=20auth,=20429=20retry,=20HTTPS,=20?= =?UTF-8?q?X-Request-ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. (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) --- .../remote_media_player/__init__.py | 10 +- .../remote_media_player/api_client.py | 118 ++++++++++++++++-- .../remote_media_player/config_flow.py | 12 ++ .../remote_media_player/const.py | 4 + .../remote_media_player/manifest.json | 2 +- .../remote_media_player/media_player.py | 16 ++- .../remote_media_player/strings.json | 4 + .../remote_media_player/translations/en.json | 4 + .../remote_media_player/translations/ru.json | 4 + 9 files changed, 158 insertions(+), 16 deletions(-) diff --git a/custom_components/remote_media_player/__init__.py b/custom_components/remote_media_player/__init__.py index e4d8a0b..94a5a62 100644 --- a/custom_components/remote_media_player/__init__.py +++ b/custom_components/remote_media_player/__init__.py @@ -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 diff --git a/custom_components/remote_media_player/api_client.py b/custom_components/remote_media_player/api_client.py index d2b1236..1c7e936 100644 --- a/custom_components/remote_media_player/api_client.py +++ b/custom_components/remote_media_player/api_client.py @@ -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) diff --git a/custom_components/remote_media_player/config_flow.py b/custom_components/remote_media_player/config_flow.py index a87483c..555573d 100644 --- a/custom_components/remote_media_player/config_flow.py +++ b/custom_components/remote_media_player/config_flow.py @@ -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) ), diff --git a/custom_components/remote_media_player/const.py b/custom_components/remote_media_player/const.py index 83d39b1..2fd2c83 100644 --- a/custom_components/remote_media_player/const.py +++ b/custom_components/remote_media_player/const.py @@ -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. diff --git a/custom_components/remote_media_player/manifest.json b/custom_components/remote_media_player/manifest.json index 6022bcc..b41b7ed 100644 --- a/custom_components/remote_media_player/manifest.json +++ b/custom_components/remote_media_player/manifest.json @@ -8,5 +8,5 @@ "integration_type": "device", "iot_class": "local_push", "requirements": ["aiohttp>=3.8.0"], - "version": "0.3.2" + "version": "0.3.3" } diff --git a/custom_components/remote_media_player/media_player.py b/custom_components/remote_media_player/media_player.py index 46f6677..0ddcaca 100644 --- a/custom_components/remote_media_player/media_player.py +++ b/custom_components/remote_media_player/media_player.py @@ -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(): diff --git a/custom_components/remote_media_player/strings.json b/custom_components/remote_media_player/strings.json index 58ff1de..355e0f0 100644 --- a/custom_components/remote_media_player/strings.json +++ b/custom_components/remote_media_player/strings.json @@ -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)" } diff --git a/custom_components/remote_media_player/translations/en.json b/custom_components/remote_media_player/translations/en.json index 58ff1de..355e0f0 100644 --- a/custom_components/remote_media_player/translations/en.json +++ b/custom_components/remote_media_player/translations/en.json @@ -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)" } diff --git a/custom_components/remote_media_player/translations/ru.json b/custom_components/remote_media_player/translations/ru.json index 5460efe..fcc0ad8 100644 --- a/custom_components/remote_media_player/translations/ru.json +++ b/custom_components/remote_media_player/translations/ru.json @@ -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": "Частота опроса статуса (в секундах)" }