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:
2026-05-26 11:37:59 +03:00
parent 8e8acccbb2
commit 97c1784ad4
9 changed files with 158 additions and 16 deletions
@@ -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)